Skip to content

Commit 0c8e34e

Browse files
authored
Merge pull request #45566 from nextcloud/backport/45222/stable29
2 parents bdf1d2c + 8330825 commit 0c8e34e

11 files changed

Lines changed: 450 additions & 53 deletions

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 122 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
namespace OCA\DAV\CalDAV;
4141

4242
use DateTime;
43+
use DateTimeImmutable;
4344
use DateTimeInterface;
4445
use OCA\DAV\AppInfo\Application;
4546
use OCA\DAV\CalDAV\Sharing\Backend;
@@ -1920,15 +1921,34 @@ public function search(
19201921
$this->db->escapeLikeParameter($pattern) . '%')));
19211922
}
19221923

1923-
if (isset($options['timerange'])) {
1924-
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
1925-
$outerQuery->andWhere($outerQuery->expr()->gt('lastoccurence',
1926-
$outerQuery->createNamedParameter($options['timerange']['start']->getTimeStamp())));
1927-
}
1928-
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
1929-
$outerQuery->andWhere($outerQuery->expr()->lt('firstoccurence',
1930-
$outerQuery->createNamedParameter($options['timerange']['end']->getTimeStamp())));
1931-
}
1924+
$start = null;
1925+
$end = null;
1926+
1927+
$hasLimit = is_int($limit);
1928+
$hasTimeRange = false;
1929+
1930+
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
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;
1940+
}
1941+
1942+
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
1943+
/** @var DateTimeInterface $end */
1944+
$end = $options['timerange']['end'];
1945+
$outerQuery->andWhere(
1946+
$outerQuery->expr()->lt(
1947+
'firstoccurence',
1948+
$outerQuery->createNamedParameter($end->getTimestamp())
1949+
)
1950+
);
1951+
$hasTimeRange = true;
19321952
}
19331953

19341954
if (isset($options['uid'])) {
@@ -1946,54 +1966,46 @@ 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+
// Without explicit order by its undefined in which order the SQL server returns the events.
1970+
// For the pagination with hasLimit and hasTimeRange, a stable ordering is helpful.
1971+
$outerQuery->addOrderBy('id');
19551972

1956-
$result = $outerQuery->executeQuery();
1957-
$calendarObjects = [];
1958-
while (($row = $result->fetch()) !== false) {
1959-
$start = $options['timerange']['start'] ?? null;
1960-
$end = $options['timerange']['end'] ?? null;
1973+
$offset = (int)$offset;
1974+
$outerQuery->setFirstResult($offset);
19611975

1962-
if ($start === null || !($start instanceof DateTimeInterface) || $end === null || !($end instanceof DateTimeInterface)) {
1963-
// No filter required
1964-
$calendarObjects[] = $row;
1965-
continue;
1966-
}
1976+
$calendarObjects = [];
19671977

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;
1992-
}
1978+
if ($hasLimit && $hasTimeRange) {
1979+
/**
1980+
* Event recurrences are evaluated at runtime because the database only knows the first and last occurrence.
1981+
*
1982+
* Given, a user created 8 events with a yearly reoccurrence and two for events tomorrow.
1983+
* The upcoming event widget asks the CalDAV backend for 7 events within the next 14 days.
1984+
*
1985+
* If limit 7 is applied to the SQL query, we find the 7 events with a yearly reoccurrence
1986+
* and discard the events after evaluating the reoccurrence rules because they are not due within
1987+
* the next 14 days and end up with an empty result even if there are two events to show.
1988+
*
1989+
* The workaround for search requests with a limit and time range is asking for more row than requested
1990+
* and retrying if we have not reached the limit.
1991+
*
1992+
* 25 rows and 3 retries is entirely arbitrary.
1993+
*/
1994+
$maxResults = (int)max($limit, 25);
1995+
$outerQuery->setMaxResults($maxResults);
1996+
1997+
for ($attempt = $objectsCount = 0; $attempt < 3 && $objectsCount < $limit; $attempt++) {
1998+
$objectsCount = array_push($calendarObjects, ...$this->searchCalendarObjects($outerQuery, $start, $end));
1999+
$outerQuery->setFirstResult($offset += $maxResults);
2000+
}
2001+
2002+
$calendarObjects = array_slice($calendarObjects, 0, $limit, false);
2003+
} else {
2004+
$outerQuery->setMaxResults($limit);
2005+
$calendarObjects = $this->searchCalendarObjects($outerQuery, $start, $end);
19932006
}
1994-
$result->closeCursor();
19952007

1996-
return array_map(function ($o) use ($options) {
2008+
$calendarObjects = array_map(function ($o) use ($options) {
19972009
$calendarData = Reader::read($o['calendardata']);
19982010

19992011
// Expand recurrences if an explicit time range is requested
@@ -2029,6 +2041,64 @@ public function search(
20292041
}, $timezones),
20302042
];
20312043
}, $calendarObjects);
2044+
2045+
usort($calendarObjects, function (array $a, array $b) {
2046+
/** @var DateTimeImmutable $startA */
2047+
$startA = $a['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE);
2048+
/** @var DateTimeImmutable $startB */
2049+
$startB = $b['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE);
2050+
2051+
return $startA->getTimestamp() <=> $startB->getTimestamp();
2052+
});
2053+
2054+
return $calendarObjects;
2055+
}
2056+
2057+
private function searchCalendarObjects(IQueryBuilder $query, DateTimeInterface|null $start, DateTimeInterface|null $end): array {
2058+
$calendarObjects = [];
2059+
$filterByTimeRange = ($start instanceof DateTimeInterface) || ($end instanceof DateTimeInterface);
2060+
2061+
$result = $query->executeQuery();
2062+
2063+
while (($row = $result->fetch()) !== false) {
2064+
if ($filterByTimeRange === false) {
2065+
// No filter required
2066+
$calendarObjects[] = $row;
2067+
continue;
2068+
}
2069+
2070+
$isValid = $this->validateFilterForObject($row, [
2071+
'name' => 'VCALENDAR',
2072+
'comp-filters' => [
2073+
[
2074+
'name' => 'VEVENT',
2075+
'comp-filters' => [],
2076+
'prop-filters' => [],
2077+
'is-not-defined' => false,
2078+
'time-range' => [
2079+
'start' => $start,
2080+
'end' => $end,
2081+
],
2082+
],
2083+
],
2084+
'prop-filters' => [],
2085+
'is-not-defined' => false,
2086+
'time-range' => null,
2087+
]);
2088+
2089+
if (is_resource($row['calendardata'])) {
2090+
// Put the stream back to the beginning so it can be read another time
2091+
rewind($row['calendardata']);
2092+
}
2093+
2094+
if ($isValid) {
2095+
$calendarObjects[] = $row;
2096+
}
2097+
}
2098+
2099+
$result->closeCursor();
2100+
2101+
return $calendarObjects;
20322102
}
20332103

20342104
/**
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: 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: 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: 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
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:20240507T122246Z
6+
LAST-MODIFIED:20240507T175258Z
7+
DTSTAMP:20240507T175258Z
8+
UID:39e1b04f-d1cc-4622-bf97-11c38e070f43
9+
SUMMARY:Missing DTSTART 1
10+
DTEND;TZID=Europe/Berlin:20240514T133000
11+
TRANSP:OPAQUE
12+
X-MOZ-GENERATION:2
13+
END:VEVENT
14+
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:20240507T122246Z
6+
LAST-MODIFIED:20240507T175258Z
7+
DTSTAMP:20240507T175258Z
8+
UID:12413feb-4b8c-4e95-ae7f-9ec4f42f3348
9+
SUMMARY:Missing DTSTART 2
10+
DTEND;TZID=Europe/Berlin:20240514T133000
11+
TRANSP:OPAQUE
12+
X-MOZ-GENERATION:2
13+
END:VEVENT
14+
END:VCALENDAR

0 commit comments

Comments
 (0)