324 lines
12 KiB
PHP
324 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Repository;
|
|
|
|
use App\Entity\Event;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\DBAL\ParameterType;
|
|
use Doctrine\ORM\QueryBuilder;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
class EventRepository extends ServiceEntityRepository
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, Event::class);
|
|
}
|
|
|
|
public function findOneNotDeleted(int $id): ?Event
|
|
{
|
|
return $this->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<int, float>
|
|
*/
|
|
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');
|
|
}
|
|
}
|