From ac97f10b44f253bad920176c0af577f8cdb74397 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Fri, 10 Oct 2025 13:24:51 +0200 Subject: [PATCH] perf(dav): push carddav report filtering to the db Signed-off-by: Christoph Wurst --- apps/dav/lib/CardDAV/AddressBook.php | 11 +++- apps/dav/lib/CardDAV/CardDavBackend.php | 81 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php index 4d30d507a7d..63223eb06f1 100644 --- a/apps/dav/lib/CardDAV/AddressBook.php +++ b/apps/dav/lib/CardDAV/AddressBook.php @@ -13,6 +13,8 @@ use OCP\IL10N; use OCP\Server; use Psr\Log\LoggerInterface; use Sabre\CardDAV\Backend\BackendInterface; +use Sabre\CardDAV\IAddressBookObjectContainer; +use Sabre\CardDAV\Xml\Request\AddressBookQueryReport; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\IMoveTarget; @@ -25,7 +27,7 @@ use Sabre\DAV\PropPatch; * @package OCA\DAV\CardDAV * @property CardDavBackend $carddavBackend */ -class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMoveTarget { +class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMoveTarget, IAddressBookObjectContainer { /** * AddressBook constructor. * @@ -258,4 +260,11 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMov return false; } } + + public function addressBookQuery(AddressBookQueryReport $report): array { + return $this->carddavBackend->addressBookReport( + $this->addressBookInfo['id'], + $report, + ); + } } diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index 4829bdf4e76..45f2c9afd6a 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -30,9 +30,12 @@ use PDO; use Sabre\CardDAV\Backend\BackendInterface; use Sabre\CardDAV\Backend\SyncSupport; use Sabre\CardDAV\Plugin; +use Sabre\CardDAV\Xml\Request\AddressBookQueryReport; use Sabre\DAV\Exception\BadRequest; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; +use function in_array; +use function strtoupper; class CardDavBackend implements BackendInterface, SyncSupport { use TTransactional; @@ -1533,4 +1536,82 @@ class CardDavBackend implements BackendInterface, SyncSupport { // should already be handled, but just in case throw new BadRequest('vCard can not be empty'); } + + public function addressBookReport(int $addressBookId, AddressBookQueryReport $report): array { + $selectQuery = $this->db->getQueryBuilder(); + $selectQuery->select('c.uri') + ->from($this->dbCardsPropertiesTable, 'cp') + ->join('cp', $this->dbCardsTable, 'c', $selectQuery->expr()->eq('c.id', 'cp.cardid', IQueryBuilder::PARAM_INT)) + ->where($selectQuery->expr()->eq('c.addressbookid', $selectQuery->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + + $needsPostFilter = false; + $dbFilters = []; + foreach ($report->filters as $filter) { + if (!in_array(strtoupper($filter['name']), self::$indexProperties, true)) { + // We can't push the filter down to the DB + $needsPostFilter = true; + } + if (!empty($filter['param-filter'])) { + // Parameters are not indexed + $needsPostFilter = true; + } + if ($filter['is-not-defined'] === true) { + // Can't easily look for this with a query + $needsPostFilter = true; + } + + foreach ($filter['text-matches'] as $matchFilter) { + // TODO: handle negate-condition + $dbFilters[] = $selectQuery->expr()->andX( + $selectQuery->expr()->eq( + 'cp.name', + $selectQuery->createNamedParameter($filter['name']), + ), + match ($matchFilter['match-type']) { + 'equals' => $selectQuery->expr()->eq( + 'cp.value', + $selectQuery->createNamedParameter($matchFilter['value'], IQueryBuilder::PARAM_STR) + ), + 'contains' => $selectQuery->expr()->like( + 'cp.value', + $selectQuery->createNamedParameter('%' . $matchFilter['value'] . '%', IQueryBuilder::PARAM_STR) // TODO: escaping + ), + 'starts-with' => $selectQuery->expr()->eq( + 'cp.value', + $selectQuery->createNamedParameter($matchFilter['value'] . '%', IQueryBuilder::PARAM_STR) // TODO: escaping + ), + 'ends-with' => $selectQuery->expr()->eq( + 'cp.value', + $selectQuery->createNamedParameter('%' . $matchFilter['value'], IQueryBuilder::PARAM_STR) // TODO: escaping + ), + default => throw new \InvalidArgumentException('Invalid filter match'), + } + ); + } + } + + if ($dbFilters !== []) { + $selectQuery->andWhere( + match ($report->test) { + 'allof' => $selectQuery->expr()->andX(...$dbFilters), + 'anyof' => $selectQuery->expr()->orX(...$dbFilters), + default => throw new \InvalidArgumentException('Invalid filter test'), + } + ); + } + + $selectQuery->groupBy('c.uri'); // deduplicate + $result = $selectQuery->executeQuery(); + $uris = []; + while (($uri = $result->fetchOne()) !== false) { + $uris[] = $uri; + } + $result->closeCursor(); + + if ($needsPostFilter) { + // TODO implement + } + + return $uris; + } }