Skip to content

Commit

Permalink
fix(caldav): event search with limit and timerange
Browse files Browse the repository at this point in the history
Signed-off-by: Daniel Kesselberg <[email protected]>
  • Loading branch information
kesselb committed May 7, 2024
1 parent c60de5b commit b7c3704
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 45 deletions.
143 changes: 98 additions & 45 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -1920,14 +1920,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;
}
}

Expand All @@ -1946,52 +1966,38 @@ public function search(

$outerQuery->andWhere($outerQuery->expr()->in('c.id', $outerQuery->createFunction($innerQuery->getSQL())));

if ($offset) {
$outerQuery->setFirstResult($offset);
}
if ($limit) {
$outerQuery->setMaxResults($limit);
}
$offset = (int)$offset;
$outerQuery->setFirstResult($offset);

$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;
}

$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;
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 a limit and time range is asking for more row than requested
* and retrying if we have not reached the limit.
*
* 25 rows and 3 retries is entirely arbitrary.
*/
$maxResults = (int)max($limit, 25);
$outerQuery->setMaxResults($maxResults);

for ($attempt = $objectsCount = 0; $attempt < 3 && $objectsCount < $limit; $attempt++) {
$objectsCount = array_push($calendarObjects, ...$this->searchCalendarObjectsByQuery($outerQuery, $start, $end));
$outerQuery->setFirstResult($offset += $maxResults);
}
} else {
$outerQuery->setMaxResults($limit);
$calendarObjects = $this->searchCalendarObjectsByQuery($outerQuery, $start, $end);
}
$result->closeCursor();

return array_map(function ($o) use ($options) {
$calendarData = Reader::read($o['calendardata']);
Expand Down Expand Up @@ -2031,6 +2037,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
Expand Down
17 changes: 17 additions & 0 deletions apps/dav/tests/misc/caldav-search-limit-timerange-event-a.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20240507T105946Z
LAST-MODIFIED:20240507T121113Z
DTSTAMP:20240507T121113Z
UID:07514c7b-1014-425c-b1b8-2c35ab0eea1d
SUMMARY:Event A
RRULE:FREQ=YEARLY
DTSTART;TZID=Europe/Berlin:20240101T101500
DTEND;TZID=Europe/Berlin:20240101T111500
TRANSP:OPAQUE
X-MOZ-GENERATION:4
SEQUENCE:2
END:VEVENT
END:VCALENDAR
17 changes: 17 additions & 0 deletions apps/dav/tests/misc/caldav-search-limit-timerange-event-b.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20240507T110122Z
LAST-MODIFIED:20240507T121120Z
DTSTAMP:20240507T121120Z
UID:67cf8134-ff10-49a7-913d-acfeda463db6
SUMMARY:Event B
RRULE:FREQ=YEARLY
DTSTART;TZID=Europe/Berlin:20240101T123000
DTEND;TZID=Europe/Berlin:20240101T133000
TRANSP:OPAQUE
X-MOZ-GENERATION:4
SEQUENCE:2
END:VEVENT
END:VCALENDAR
17 changes: 17 additions & 0 deletions apps/dav/tests/misc/caldav-search-limit-timerange-event-c.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20240507T120352Z
LAST-MODIFIED:20240507T121128Z
DTSTAMP:20240507T121128Z
UID:59090ca1-e52b-447f-8e08-491d1da729fa
SUMMARY:Event C
RRULE:FREQ=YEARLY
DTSTART;TZID=Europe/Berlin:20240101T151000
DTEND;TZID=Europe/Berlin:20240101T161000
TRANSP:OPAQUE
X-MOZ-GENERATION:2
SEQUENCE:1
END:VEVENT
END:VCALENDAR
14 changes: 14 additions & 0 deletions apps/dav/tests/misc/caldav-search-limit-timerange-event-cake.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20240507T122221Z
LAST-MODIFIED:20240507T122237Z
DTSTAMP:20240507T122237Z
UID:19c4e049-0b09-4101-a2ad-061a837e6a5e
SUMMARY:Cake Tasting
DTSTART;TZID=Europe/Berlin:20240509T151500
DTEND;TZID=Europe/Berlin:20240509T171500
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
17 changes: 17 additions & 0 deletions apps/dav/tests/misc/caldav-search-limit-timerange-event-d.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20240507T120414Z
LAST-MODIFIED:20240507T121134Z
DTSTAMP:20240507T121134Z
UID:b1814d32-9adf-4518-8535-37f2c037f423
SUMMARY:Event D
RRULE:FREQ=YEARLY
DTSTART;TZID=Europe/Berlin:20240101T164500
DTEND;TZID=Europe/Berlin:20240101T171500
TRANSP:OPAQUE
SEQUENCE:2
X-MOZ-GENERATION:3
END:VEVENT
END:VCALENDAR
15 changes: 15 additions & 0 deletions apps/dav/tests/misc/caldav-search-limit-timerange-event-pasta.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20240507T122246Z
LAST-MODIFIED:20240507T175258Z
DTSTAMP:20240507T175258Z
UID:60a7d310-aa7b-4974-8a8a-ff9339367e1d
SUMMARY:Pasta Day
DTSTART;TZID=Europe/Berlin:20240514T123000
DTEND;TZID=Europe/Berlin:20240514T133000
TRANSP:OPAQUE
X-MOZ-GENERATION:2
END:VEVENT
END:VCALENDAR
57 changes: 57 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1598,4 +1598,61 @@ public function testRestoreChanges(): void {
self::assertEqualsCanonicalizing([$uri1, $uri3], $changesAfter['modified']);
self::assertEquals([$uri2], $changesAfter['deleted']);
}

public function testSearchWithLimitAndTimeRange() {
$calendarId = $this->createTestCalendar();
$calendarInfo = [
'id' => $calendarId,
'principaluri' => 'user1',
'{http://owncloud.org/ns}owner-principal' => 'user1',
];

$testFiles = [
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-a.ics',
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-b.ics',
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-c.ics',
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-d.ics',
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-cake.ics',
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-pasta.ics',
];

foreach ($testFiles as $testFile) {
$objectUri = static::getUniqueID('search-limit-timerange-');
$calendarData = \file_get_contents($testFile);
$this->backend->createCalendarObject($calendarId, $objectUri, $calendarData);
}

$start = new DateTimeImmutable('2024-05-06T00:00:00Z');
$end = $start->add(new DateInterval('P14D'));

$results = $this->backend->search(
$calendarInfo,
'',
[],
[
'timerange' => [
'start' => $start,
'end' => $end,
]
],
4,
null,
);

$this->assertCount(2, $results);

$this->assertEquals('Cake Tasting', $results[0]['objects'][0]['SUMMARY'][0]);
$this->assertGreaterThanOrEqual(
$start->getTimestamp(),
$results[0]['objects'][0]['DTSTART'][0]->getTimestamp(),
'Recurrence starting before requested start',
);

$this->assertEquals('Pasta Day', $results[1]['objects'][0]['SUMMARY'][0]);
$this->assertGreaterThanOrEqual(
$start->getTimestamp(),
$results[1]['objects'][0]['DTSTART'][0]->getTimestamp(),
'Recurrence starting before requested start',
);
}
}

0 comments on commit b7c3704

Please sign in to comment.