notDeleted() ->andWhere('e.id = :id') ->setParameter('id', $id) ->getQuery() ->getOneOrNullResult(); } /** @return Event[] */ public function findRecent(int $limit): array { return $this->orderByStartDate($this->notEnded($this->notDeleted())) ->setMaxResults($limit) ->getQuery() ->getResult(); } /** @return Event[] */ public function findWithCoordinates(int $limit = 500): array { return $this->notEnded($this->notDeleted()) ->andWhere('e.latitude IS NOT NULL AND e.longitude IS NOT NULL') ->andWhere('e.latitude <> 0 OR e.longitude <> 0') ->setMaxResults($limit) ->getQuery() ->getResult(); } /** * Distance in km to every event within `radiusKm` of (lat, lon), * nearest first, keyed by event id — the location+range based * replacement for a flat "first N" cap. No PostGIS here, so distance * is a plain Haversine formula in raw SQL (DQL has no trig * functions); a computed column can't be filtered in WHERE directly, * hence the subquery. * * @return array */ public function distancesWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array { // end_date comparison mirrors notEnded()/filterByDateRange(): day-level // lexicographic comparison against the raw ISO-ish string column, // in Europe/Berlin — this is raw SQL (DBAL, not the QueryBuilder), // so it can't just call that helper directly. $today = (new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin')))->format('Y-m-d'); $sql = 'SELECT id, distance_km FROM ( SELECT e.id, (6371 * acos(least(1, greatest(-1, cos(radians(:lat)) * cos(radians(e.latitude)) * cos(radians(e.longitude) - radians(:lon)) + sin(radians(:lat)) * sin(radians(e.latitude)) )))) AS distance_km FROM events e WHERE e.deleted_at IS NULL AND e.latitude IS NOT NULL AND e.longitude IS NOT NULL AND (e.latitude <> 0 OR e.longitude <> 0) AND (e.end_date >= :today OR e.end_date = \'\') ) sub WHERE distance_km <= :radius ORDER BY distance_km ASC LIMIT :limit'; $rows = $this->getEntityManager()->getConnection()->executeQuery( $sql, ['lat' => $lat, 'lon' => $lon, 'radius' => $radiusKm, 'limit' => $limit, 'today' => $today], ['lat' => ParameterType::STRING, 'lon' => ParameterType::STRING, 'radius' => ParameterType::STRING, 'limit' => ParameterType::INTEGER, 'today' => ParameterType::STRING] )->fetchAllAssociative(); $distances = []; foreach ($rows as $row) { $distances[(int) $row['id']] = (float) $row['distance_km']; } return $distances; } /** * Events within `radiusKm` of (lat, lon), nearest first. * * @return Event[] */ public function findWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array { return $this->hydrateInOrder(array_keys($this->distancesWithinRadius($lat, $lon, $radiusKm, $limit))); } /** * Text/category search, paginated, optionally restricted to (and * ordered by) a pre-computed set of location-filtered ids — e.g. from * distancesWithinRadius() — so "markets near Düsseldorf" combines a * normal LIKE/category filter with the radius query rather than * needing a second, separate search path. Without $locationIds this * behaves like a plain text/category search, ordered chronologically. * * Distance order only exists in PHP (the $locationIds array), not as * a sortable DB column, so paging has to slice that ordered id list * *after* intersecting it with the text/category matches — pushing * LIMIT/OFFSET into SQL here would paginate an arbitrary DB-ordered * subset instead of the correct "25th-50th nearest" slice. That trick * only applies to $sort === 'nearest' though; the other sorts are real * DB columns, so they go through a normal ORDER BY + LIMIT/OFFSET * query instead, restricted to $locationIds the same way but without * needing to preserve that array's own order. * * @param int[]|null $locationIds * * @return Event[] */ public function search(?string $q, ?string $category, ?string $dateFrom, ?string $dateTo, int $limit, int $offset, ?array $locationIds = null, string $sort = 'soonest'): array { // "Nearest" only means anything once a location narrowed the pool. if ('nearest' === $sort && null === $locationIds) { $sort = 'soonest'; } if ('nearest' === $sort) { if (!$locationIds) { return []; } $matchingIds = array_map('intval', array_column( $this->baseSearchQb($q, $category, $dateFrom, $dateTo) ->select('e.id') ->andWhere('e.id IN (:locationIds)') ->setParameter('locationIds', $locationIds) ->getQuery() ->getScalarResult(), 'id' )); $matchingIdSet = array_flip($matchingIds); $orderedIds = array_values(array_filter($locationIds, static fn ($id) => isset($matchingIdSet[$id]))); return $this->hydrateInOrder(array_slice($orderedIds, $offset, $limit)); } if (null !== $locationIds && !$locationIds) { return []; } $qb = $this->baseSearchQb($q, $category, $dateFrom, $dateTo); if (null !== $locationIds) { $qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds); } match ($sort) { 'az' => $qb->orderBy('e.title', 'ASC'), 'za' => $qb->orderBy('e.title', 'DESC'), default => $this->orderByStartDate($qb), }; return $qb->setMaxResults($limit) ->setFirstResult($offset) ->getQuery() ->getResult(); } /** * Total count matching the same filters as search(), for pagination. * * @param int[]|null $locationIds */ public function countSearch(?string $q, ?string $category, ?string $dateFrom, ?string $dateTo, ?array $locationIds = null): int { if (null !== $locationIds && !$locationIds) { return 0; } $qb = $this->baseSearchQb($q, $category, $dateFrom, $dateTo)->select('COUNT(DISTINCT e.id)'); if (null !== $locationIds) { $qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds); } return (int) $qb->getQuery()->getSingleScalarResult(); } private function baseSearchQb(?string $q, ?string $category, ?string $dateFrom, ?string $dateTo): QueryBuilder { $qb = $this->notEnded($this->notDeleted()); if ($q) { $qb->andWhere('LOWER(e.title) LIKE :q OR LOWER(e.city) LIKE :q OR LOWER(e.street) LIKE :q') ->setParameter('q', '%'.mb_strtolower($q).'%'); } $this->filterByCategory($qb, $category); $this->filterByDateRange($qb, $dateFrom, $dateTo); return $qb; } /** * Keeps events that are happening at some point within the inclusive * day range [$dateFrom, $dateTo] ("Y-m-d", either end optional) — i.e. * the event overlaps the window, so a multi-day exhibition shows up * for any day it's running, not just the day it opened. * * start_date/end_date are raw ISO-8601-ish strings * ("2026-09-12T18:00:00+02:00"), consistently formatted across the * imported dataset, so a plain lexicographic comparison against a * "Y-m-d" boundary sorts correctly without parsing them as real * date/time columns. The upper bound compares against the *next* day * with "<", since "2026-09-12T18:00" > "2026-09-12" as strings. * Applied in SQL (not PHP, post-fetch) so it composes correctly with * pagination and COUNT — filtering after LIMIT/OFFSET would make the * page size and total count lie. Undated events (blank start_date) * are excluded once a range is active; a blank end_date falls back to * the start alone. */ private function filterByDateRange(QueryBuilder $qb, ?string $dateFrom, ?string $dateTo): void { if (!$dateFrom && !$dateTo) { return; } $qb->andWhere("e.startDate <> ''"); if ($dateTo) { $dayAfterTo = (new \DateTimeImmutable($dateTo, new \DateTimeZone('Europe/Berlin')))->modify('+1 day'); $qb->andWhere('e.startDate < :rangeToExclusive') ->setParameter('rangeToExclusive', $dayAfterTo->format('Y-m-d')); } if ($dateFrom) { $qb->andWhere("e.endDate >= :rangeFrom OR (e.endDate = '' AND e.startDate >= :rangeFrom)") ->setParameter('rangeFrom', $dateFrom); } } /** * Hydrates entities for the given ids and returns them in that exact * order — Doctrine's IN(...) doesn't preserve it. * * @param int[] $ids * * @return Event[] */ private function hydrateInOrder(array $ids): array { if (!$ids) { return []; } $events = $this->createQueryBuilder('e') ->andWhere('e.id IN (:ids)') ->setParameter('ids', $ids) ->getQuery() ->getResult(); $order = array_flip($ids); usort($events, static fn (Event $a, Event $b) => $order[$a->getId()] <=> $order[$b->getId()]); return $events; } private function filterByCategory(QueryBuilder $qb, ?string $category): void { if (!$category) { return; } $qb->join('e.categories', 'c') ->andWhere('c.name = :category') ->setParameter('category', $category); } private function notDeleted(): QueryBuilder { return $this->createQueryBuilder('e') ->andWhere('e.deletedAt IS NULL'); } /** * Hides events that have already ended — compared by calendar day * (Europe/Berlin), the same precision filterByDateRange() uses, * not exact time (end_date is a raw ISO-ish string, * not a real date/time column). Blank end_date is treated as "still * current" rather than excluded, matching orderByStartDate()'s * defensive handling of blank date strings. * * Deliberately NOT applied to findOneNotDeleted() — a bookmarked or * shared link to a single past event should still resolve, only the * listing/discovery queries should hide it. */ private function notEnded(QueryBuilder $qb): QueryBuilder { $today = new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin')); return $qb->andWhere("e.endDate >= :notEndedBoundary OR e.endDate = ''") ->setParameter('notEndedBoundary', $today->format('Y-m-d')); } /** * startDate is a raw ISO-ish string column (may be empty when the * source had no time interval); push undated events to the end * instead of letting '' sort first. */ private function orderByStartDate(QueryBuilder $qb): QueryBuilder { return $qb->addSelect("CASE WHEN e.startDate = '' THEN 1 ELSE 0 END AS HIDDEN has_no_date") ->orderBy('has_no_date', 'ASC') ->addOrderBy('e.startDate', 'ASC'); } }