From d335b1b60ecf3023cf4853655b559175e392b441 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 7 May 2024 15:55:47 +0200 Subject: [PATCH] fix(caldav): event search with limit and timerange --- apps/dav/lib/CalDAV/CalDavBackend.php | 152 ++++++++++++++++++-------- 1 file changed, 105 insertions(+), 47 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 1424ee4f9be2a..ccdf3f833f8b5 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1914,14 +1914,34 @@ public function search( $this->db->escapeLikeParameter($pattern) . '%'))); } + $start = null; + $end = null; + + $hasLimit = is_int($limit); + $hasTimeRange = false; + if (isset($options['timerange'])) { if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { - $outerQuery->andWhere($outerQuery->expr()->gt('lastoccurence', - $outerQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()))); + /** @var DateTimeInterface $start */ + $start = $options['timerange']['start']; + $outerQuery->andWhere( + $outerQuery->expr()->gt( + 'lastoccurence', + $outerQuery->createNamedParameter($start->getTimestamp()) + ) + ); + $hasTimeRange = true; } if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { - $outerQuery->andWhere($outerQuery->expr()->lt('firstoccurence', - $outerQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()))); + /** @var DateTimeInterface $end */ + $end = $options['timerange']['end']; + $outerQuery->andWhere( + $outerQuery->expr()->lt( + 'firstoccurence', + $outerQuery->createNamedParameter($end->getTimestamp()) + ) + ); + $hasTimeRange = true; } } @@ -1940,52 +1960,43 @@ public function search( $outerQuery->andWhere($outerQuery->expr()->in('c.id', $outerQuery->createFunction($innerQuery->getSQL()))); - if ($offset) { - $outerQuery->setFirstResult($offset); - } - if ($limit) { - $outerQuery->setMaxResults($limit); + if ($offset === null) { + $offset = 0; + } + + if ($hasLimit && $hasTimeRange) { + /** + * Event recurrences are evaluated at runtime because the database only knows the first and last occurrence. + * + * Given, a user created 8 events with a yearly reoccurrence and two for events tomorrow. + * The upcoming event widget asks the CalDAV backend for 7 events within the next 14 days. + * + * If limit 7 is applied to the SQL query, we find the 7 events with a yearly reoccurrence + * and discard the events after evaluating the reoccurrence rules because they are not due within + * the next 14 days and end up with an empty result even if there are two events to show. + * + * The workaround for search requests with limit and time range is ask for more row than requested + * and retry if we have not reached the limit. + * + * 25 rows and 3 retries is entirely arbitrary. + * Send us a patch if you have something different in mind. + */ + $maxResults = max($limit, 25); + $attempts = 3; + } else { + $maxResults = $limit; + $attempts = 1; } - $result = $outerQuery->executeQuery(); - $calendarObjects = []; - while (($row = $result->fetch()) !== false) { - $start = $options['timerange']['start'] ?? null; - $end = $options['timerange']['end'] ?? null; - - if ($start === null || !($start instanceof DateTimeInterface) || $end === null || !($end instanceof DateTimeInterface)) { - // No filter required - $calendarObjects[] = $row; - continue; - } + $outerQuery->setFirstResult($offset); + $outerQuery->setMaxResults($maxResults); - $isValid = $this->validateFilterForObject($row, [ - 'name' => 'VCALENDAR', - 'comp-filters' => [ - [ - 'name' => 'VEVENT', - 'comp-filters' => [], - 'prop-filters' => [], - 'is-not-defined' => false, - 'time-range' => [ - 'start' => $start, - 'end' => $end, - ], - ], - ], - 'prop-filters' => [], - 'is-not-defined' => false, - 'time-range' => null, - ]); - if (is_resource($row['calendardata'])) { - // Put the stream back to the beginning so it can be read another time - rewind($row['calendardata']); - } - if ($isValid) { - $calendarObjects[] = $row; - } - } - $result->closeCursor(); + $calendarObjects = []; + do { + $objectsCount = array_push($calendarObjects, ...$this->searchCalendarObjectsByQuery($outerQuery, $start, $end)); + $outerQuery->setFirstResult($offset += $maxResults); + --$attempts; + } while ($attempts > 0 && $objectsCount < $limit); return array_map(function ($o) use ($options) { $calendarData = Reader::read($o['calendardata']); @@ -2025,6 +2036,53 @@ public function search( }, $calendarObjects); } + private function searchCalendarObjectsByQuery(IQueryBuilder $query, DateTimeInterface|null $start, DateTimeInterface|null $end): array { + $calendarObjects = []; + $filterByTimeRange = ($start instanceof DateTimeInterface) || ($end instanceof DateTimeInterface); + + $result = $query->executeQuery(); + + while (($row = $result->fetch()) !== false) { + if ($filterByTimeRange === false) { + // No filter required + $calendarObjects[] = $row; + continue; + } + + $isValid = $this->validateFilterForObject($row, [ + 'name' => 'VCALENDAR', + 'comp-filters' => [ + [ + 'name' => 'VEVENT', + 'comp-filters' => [], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => [ + 'start' => $start, + 'end' => $end, + ], + ], + ], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => null, + ]); + + if (is_resource($row['calendardata'])) { + // Put the stream back to the beginning so it can be read another time + rewind($row['calendardata']); + } + + if ($isValid) { + $calendarObjects[] = $row; + } + } + + $result->closeCursor(); + + return $calendarObjects; + } + /** * @param Component $comp * @return array