Skip to content

Commit d2dd88b

Browse files
committed
fix(caldav): event search with limit and timerange
Signed-off-by: Daniel Kesselberg <[email protected]>
1 parent c60de5b commit d2dd88b

8 files changed

Lines changed: 252 additions & 45 deletions

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 98 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,14 +1920,34 @@ public function search(
19201920
$this->db->escapeLikeParameter($pattern) . '%')));
19211921
}
19221922

1923+
$start = null;
1924+
$end = null;
1925+
1926+
$hasLimit = is_int($limit);
1927+
$hasTimeRange = false;
1928+
19231929
if (isset($options['timerange'])) {
19241930
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
1925-
$outerQuery->andWhere($outerQuery->expr()->gt('lastoccurence',
1926-
$outerQuery->createNamedParameter($options['timerange']['start']->getTimeStamp())));
1931+
/** @var DateTimeInterface $start */
1932+
$start = $options['timerange']['start'];
1933+
$outerQuery->andWhere(
1934+
$outerQuery->expr()->gt(
1935+
'lastoccurence',
1936+
$outerQuery->createNamedParameter($start->getTimestamp())
1937+
)
1938+
);
1939+
$hasTimeRange = true;
19271940
}
19281941
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
1929-
$outerQuery->andWhere($outerQuery->expr()->lt('firstoccurence',
1930-
$outerQuery->createNamedParameter($options['timerange']['end']->getTimeStamp())));
1942+
/** @var DateTimeInterface $end */
1943+
$end = $options['timerange']['end'];
1944+
$outerQuery->andWhere(
1945+
$outerQuery->expr()->lt(
1946+
'firstoccurence',
1947+
$outerQuery->createNamedParameter($end->getTimestamp())
1948+
)
1949+
);
1950+
$hasTimeRange = true;
19311951
}
19321952
}
19331953

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

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

1949-
if ($offset) {
1950-
$outerQuery->setFirstResult($offset);
1951-
}
1952-
if ($limit) {
1953-
$outerQuery->setMaxResults($limit);
1954-
}
1969+
$offset = (int)$offset;
1970+
$outerQuery->setFirstResult($offset);
19551971

1956-
$result = $outerQuery->executeQuery();
19571972
$calendarObjects = [];
1958-
while (($row = $result->fetch()) !== false) {
1959-
$start = $options['timerange']['start'] ?? null;
1960-
$end = $options['timerange']['end'] ?? null;
1961-
1962-
if ($start === null || !($start instanceof DateTimeInterface) || $end === null || !($end instanceof DateTimeInterface)) {
1963-
// No filter required
1964-
$calendarObjects[] = $row;
1965-
continue;
1966-
}
19671973

1968-
$isValid = $this->validateFilterForObject($row, [
1969-
'name' => 'VCALENDAR',
1970-
'comp-filters' => [
1971-
[
1972-
'name' => 'VEVENT',
1973-
'comp-filters' => [],
1974-
'prop-filters' => [],
1975-
'is-not-defined' => false,
1976-
'time-range' => [
1977-
'start' => $start,
1978-
'end' => $end,
1979-
],
1980-
],
1981-
],
1982-
'prop-filters' => [],
1983-
'is-not-defined' => false,
1984-
'time-range' => null,
1985-
]);
1986-
if (is_resource($row['calendardata'])) {
1987-
// Put the stream back to the beginning so it can be read another time
1988-
rewind($row['calendardata']);
1989-
}
1990-
if ($isValid) {
1991-
$calendarObjects[] = $row;
1974+
if ($hasLimit && $hasTimeRange) {
1975+
/**
1976+
* Event recurrences are evaluated at runtime because the database only knows the first and last occurrence.
1977+
*
1978+
* Given, a user created 8 events with a yearly reoccurrence and two for events tomorrow.
1979+
* The upcoming event widget asks the CalDAV backend for 7 events within the next 14 days.
1980+
*
1981+
* If limit 7 is applied to the SQL query, we find the 7 events with a yearly reoccurrence
1982+
* and discard the events after evaluating the reoccurrence rules because they are not due within
1983+
* the next 14 days and end up with an empty result even if there are two events to show.
1984+
*
1985+
* The workaround for search requests with limit and time range is ask for more row than requested
1986+
* and retry if we have not reached the limit.
1987+
*
1988+
* 25 rows and 3 retries is entirely arbitrary.
1989+
*/
1990+
$maxResults = (int)max($limit, 25);
1991+
$outerQuery->setMaxResults($maxResults);
1992+
1993+
for ($attempt = $objectsCount = 0; $attempt < 3 && $objectsCount < $limit; $attempt++) {
1994+
$objectsCount = array_push($calendarObjects, ...$this->searchCalendarObjectsByQuery($outerQuery, $start, $end));
1995+
$outerQuery->setFirstResult($offset += $maxResults);
19921996
}
1997+
} else {
1998+
$outerQuery->setMaxResults($limit);
1999+
$calendarObjects = $this->searchCalendarObjectsByQuery($outerQuery, $start, $end);
19932000
}
1994-
$result->closeCursor();
19952001

19962002
return array_map(function ($o) use ($options) {
19972003
$calendarData = Reader::read($o['calendardata']);
@@ -2031,6 +2037,53 @@ public function search(
20312037
}, $calendarObjects);
20322038
}
20332039

2040+
private function searchCalendarObjectsByQuery(IQueryBuilder $query, DateTimeInterface|null $start, DateTimeInterface|null $end): array {
2041+
$calendarObjects = [];
2042+
$filterByTimeRange = ($start instanceof DateTimeInterface) || ($end instanceof DateTimeInterface);
2043+
2044+
$result = $query->executeQuery();
2045+
2046+
while (($row = $result->fetch()) !== false) {
2047+
if ($filterByTimeRange === false) {
2048+
// No filter required
2049+
$calendarObjects[] = $row;
2050+
continue;
2051+
}
2052+
2053+
$isValid = $this->validateFilterForObject($row, [
2054+
'name' => 'VCALENDAR',
2055+
'comp-filters' => [
2056+
[
2057+
'name' => 'VEVENT',
2058+
'comp-filters' => [],
2059+
'prop-filters' => [],
2060+
'is-not-defined' => false,
2061+
'time-range' => [
2062+
'start' => $start,
2063+
'end' => $end,
2064+
],
2065+
],
2066+
],
2067+
'prop-filters' => [],
2068+
'is-not-defined' => false,
2069+
'time-range' => null,
2070+
]);
2071+
2072+
if (is_resource($row['calendardata'])) {
2073+
// Put the stream back to the beginning so it can be read another time
2074+
rewind($row['calendardata']);
2075+
}
2076+
2077+
if ($isValid) {
2078+
$calendarObjects[] = $row;
2079+
}
2080+
}
2081+
2082+
$result->closeCursor();
2083+
2084+
return $calendarObjects;
2085+
}
2086+
20342087
/**
20352088
* @param Component $comp
20362089
* @return array
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
3+
VERSION:2.0
4+
BEGIN:VEVENT
5+
CREATED:20240507T105946Z
6+
LAST-MODIFIED:20240507T121113Z
7+
DTSTAMP:20240507T121113Z
8+
UID:07514c7b-1014-425c-b1b8-2c35ab0eea1d
9+
SUMMARY:Event A
10+
RRULE:FREQ=YEARLY
11+
DTSTART;TZID=Europe/Berlin:20240101T101500
12+
DTEND;TZID=Europe/Berlin:20240101T111500
13+
TRANSP:OPAQUE
14+
X-MOZ-GENERATION:4
15+
SEQUENCE:2
16+
END:VEVENT
17+
END:VCALENDAR
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
3+
VERSION:2.0
4+
BEGIN:VEVENT
5+
CREATED:20240507T110122Z
6+
LAST-MODIFIED:20240507T121120Z
7+
DTSTAMP:20240507T121120Z
8+
UID:67cf8134-ff10-49a7-913d-acfeda463db6
9+
SUMMARY:Event B
10+
RRULE:FREQ=YEARLY
11+
DTSTART;TZID=Europe/Berlin:20240101T123000
12+
DTEND;TZID=Europe/Berlin:20240101T133000
13+
TRANSP:OPAQUE
14+
X-MOZ-GENERATION:4
15+
SEQUENCE:2
16+
END:VEVENT
17+
END:VCALENDAR
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
3+
VERSION:2.0
4+
BEGIN:VEVENT
5+
CREATED:20240507T120352Z
6+
LAST-MODIFIED:20240507T121128Z
7+
DTSTAMP:20240507T121128Z
8+
UID:59090ca1-e52b-447f-8e08-491d1da729fa
9+
SUMMARY:Event C
10+
RRULE:FREQ=YEARLY
11+
DTSTART;TZID=Europe/Berlin:20240101T151000
12+
DTEND;TZID=Europe/Berlin:20240101T161000
13+
TRANSP:OPAQUE
14+
X-MOZ-GENERATION:2
15+
SEQUENCE:1
16+
END:VEVENT
17+
END:VCALENDAR
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
3+
VERSION:2.0
4+
BEGIN:VEVENT
5+
CREATED:20240507T122221Z
6+
LAST-MODIFIED:20240507T122237Z
7+
DTSTAMP:20240507T122237Z
8+
UID:19c4e049-0b09-4101-a2ad-061a837e6a5e
9+
SUMMARY:Cake Tasting
10+
DTSTART;TZID=Europe/Berlin:20240509T151500
11+
DTEND;TZID=Europe/Berlin:20240509T171500
12+
TRANSP:OPAQUE
13+
END:VEVENT
14+
END:VCALENDAR
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
3+
VERSION:2.0
4+
BEGIN:VEVENT
5+
CREATED:20240507T120414Z
6+
LAST-MODIFIED:20240507T121134Z
7+
DTSTAMP:20240507T121134Z
8+
UID:b1814d32-9adf-4518-8535-37f2c037f423
9+
SUMMARY:Event D
10+
RRULE:FREQ=YEARLY
11+
DTSTART;TZID=Europe/Berlin:20240101T164500
12+
DTEND;TZID=Europe/Berlin:20240101T171500
13+
TRANSP:OPAQUE
14+
SEQUENCE:2
15+
X-MOZ-GENERATION:3
16+
END:VEVENT
17+
END:VCALENDAR
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
BEGIN:VCALENDAR
2+
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
3+
VERSION:2.0
4+
BEGIN:VEVENT
5+
CREATED:20240507T122246Z
6+
LAST-MODIFIED:20240507T175258Z
7+
DTSTAMP:20240507T175258Z
8+
UID:60a7d310-aa7b-4974-8a8a-ff9339367e1d
9+
SUMMARY:Pasta Day
10+
DTSTART;TZID=Europe/Berlin:20240514T123000
11+
DTEND;TZID=Europe/Berlin:20240514T133000
12+
TRANSP:OPAQUE
13+
X-MOZ-GENERATION:2
14+
END:VEVENT
15+
END:VCALENDAR

apps/dav/tests/unit/CalDAV/CalDavBackendTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,4 +1598,61 @@ public function testRestoreChanges(): void {
15981598
self::assertEqualsCanonicalizing([$uri1, $uri3], $changesAfter['modified']);
15991599
self::assertEquals([$uri2], $changesAfter['deleted']);
16001600
}
1601+
1602+
public function testSearchWithLimitAndTimeRange() {
1603+
$calendarId = $this->createTestCalendar();
1604+
$calendarInfo = [
1605+
'id' => $calendarId,
1606+
'principaluri' => 'user1',
1607+
'{http://owncloud.org/ns}owner-principal' => 'user1',
1608+
];
1609+
1610+
$testFiles = [
1611+
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-a.ics',
1612+
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-b.ics',
1613+
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-c.ics',
1614+
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-d.ics',
1615+
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-cake.ics',
1616+
__DIR__ . '/../../misc/caldav-search-limit-timerange-event-pasta.ics',
1617+
];
1618+
1619+
foreach ($testFiles as $testFile) {
1620+
$objectUri = static::getUniqueID('search-limit-timerange-');
1621+
$calendarData = \file_get_contents($testFile);
1622+
$this->backend->createCalendarObject($calendarId, $objectUri, $calendarData);
1623+
}
1624+
1625+
$start = new DateTimeImmutable('2024-05-06T00:00:00Z');
1626+
$end = $start->add(new DateInterval('P14D'));
1627+
1628+
$results = $this->backend->search(
1629+
$calendarInfo,
1630+
'',
1631+
[],
1632+
[
1633+
'timerange' => [
1634+
'start' => $start,
1635+
'end' => $end,
1636+
]
1637+
],
1638+
4,
1639+
null,
1640+
);
1641+
1642+
$this->assertCount(2, $results);
1643+
1644+
$this->assertEquals('Cake Tasting', $results[0]['objects'][0]['SUMMARY'][0]);
1645+
$this->assertGreaterThanOrEqual(
1646+
$start->getTimestamp(),
1647+
$results[0]['objects'][0]['DTSTART'][0]->getTimestamp(),
1648+
'Recurrence starting before requested start',
1649+
);
1650+
1651+
$this->assertEquals('Pasta Day', $results[1]['objects'][0]['SUMMARY'][0]);
1652+
$this->assertGreaterThanOrEqual(
1653+
$start->getTimestamp(),
1654+
$results[1]['objects'][0]['DTSTART'][0]->getTimestamp(),
1655+
'Recurrence starting before requested start',
1656+
);
1657+
}
16011658
}

0 commit comments

Comments
 (0)