From 1f80f4449a4d268ad7ca41463d960b266f2c91fe Mon Sep 17 00:00:00 2001 From: MateuszKolankowski Date: Mon, 5 Jan 2026 15:33:47 +0100 Subject: [PATCH 1/4] Add limit parameter to count and subtree size methods in various services --- phpstan-baseline.neon | 20 ++---- .../Persistence/Content/Location/Handler.php | 2 +- .../Persistence/Filter/Content/Handler.php | 2 +- .../Persistence/Filter/Location/Handler.php | 2 +- .../Filter/Query/CountQueryBuilder.php | 16 +++++ src/contracts/Repository/ContentService.php | 5 +- .../Decorator/ContentServiceDecorator.php | 4 +- .../Decorator/LocationServiceDecorator.php | 12 ++-- src/contracts/Repository/LocationService.php | 11 ++- src/lib/Persistence/Cache/LocationHandler.php | 5 +- .../Legacy/Content/Location/Gateway.php | 2 +- .../Location/Gateway/DoctrineDatabase.php | 15 +++- .../Location/Gateway/ExceptionConversion.php | 4 +- .../Legacy/Content/Location/Handler.php | 4 +- .../Content/Doctrine/DoctrineGateway.php | 15 +++- .../Legacy/Filter/Gateway/Gateway.php | 4 +- .../Location/Doctrine/DoctrineGateway.php | 15 +++- .../Handler/ContentFilteringHandler.php | 4 +- .../Handler/LocationFilteringHandler.php | 4 +- .../Filter/Query/LimitedCountQueryBuilder.php | 65 ++++++++++++++++++ src/lib/Repository/ContentService.php | 4 +- src/lib/Repository/LocationService.php | 16 +++-- .../SiteAccessAware/ContentService.php | 5 +- .../SiteAccessAware/LocationService.php | 16 +++-- .../Resources/settings/repository/inner.yml | 2 + .../storage_engines/legacy/filter.yaml | 7 ++ .../storage_engines/legacy/location.yml | 1 + .../Core/Repository/LocationServiceTest.php | 67 +++++++++++++++++- .../Location/Gateway/DoctrineDatabaseTest.php | 3 +- .../Gateway/DoctrineDatabaseTrashTest.php | 3 +- .../Content/UrlAlias/UrlAliasHandlerTest.php | 3 +- .../Query/LimitedCountQueryBuilderTest.php | 68 +++++++++++++++++++ tests/lib/Persistence/Legacy/TestCase.php | 6 ++ .../LocationServiceDecoratorTest.php | 2 +- 34 files changed, 345 insertions(+), 69 deletions(-) create mode 100644 src/contracts/Persistence/Filter/Query/CountQueryBuilder.php create mode 100644 src/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilder.php create mode 100644 tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5bc082fb71..accdd364d3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -32145,7 +32145,7 @@ parameters: - message: '#^Cannot access property \$id on Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Location\|null\.$#' identifier: property.nonObject - count: 11 + count: 9 path: tests/integration/Core/Repository/LocationServiceTest.php - @@ -32352,12 +32352,6 @@ parameters: count: 1 path: tests/integration/Core/Repository/LocationServiceTest.php - - - message: '#^Method Ibexa\\Tests\\Integration\\Core\\Repository\\LocationServiceTest\:\:testGetSubtreeSize\(\) should return Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Location but returns Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Location\|null\.$#' - identifier: return.type - count: 1 - path: tests/integration/Core/Repository/LocationServiceTest.php - - message: '#^Method Ibexa\\Tests\\Integration\\Core\\Repository\\LocationServiceTest\:\:testHideLocation\(\) has no return type specified\.$#' identifier: missingType.return @@ -32610,12 +32604,6 @@ parameters: count: 1 path: tests/integration/Core/Repository/LocationServiceTest.php - - - message: '#^Parameter \#1 \$location of method Ibexa\\Contracts\\Core\\Repository\\LocationService\:\:getSubtreeSize\(\) expects Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Location, Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Location\|null given\.$#' - identifier: argument.type - count: 2 - path: tests/integration/Core/Repository/LocationServiceTest.php - - message: '#^Parameter \#1 \$location of method Ibexa\\Contracts\\Core\\Repository\\LocationService\:\:moveSubtree\(\) expects Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Location, Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Location\|null given\.$#' identifier: argument.type @@ -52482,6 +52470,12 @@ parameters: count: 1 path: tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php + - + message: '#^Parameter \#3 \$limit of method Ibexa\\Core\\Persistence\\Legacy\\Filter\\Query\\LimitedCountQueryBuilder\:\:wrap\(\) expects int\<1, max\>\|null, 0 given\.$#' + identifier: argument.type + count: 1 + path: tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php + - message: '#^Call to an undefined method Ibexa\\Contracts\\Core\\Container\:\:get\(\)\.$#' identifier: method.notFound diff --git a/src/contracts/Persistence/Content/Location/Handler.php b/src/contracts/Persistence/Content/Location/Handler.php index 39fb16793f..8aae165397 100644 --- a/src/contracts/Persistence/Content/Location/Handler.php +++ b/src/contracts/Persistence/Content/Location/Handler.php @@ -111,7 +111,7 @@ public function loadParentLocationsForDraftContent($contentId); */ public function copySubtree($sourceId, $destinationParentId); - public function getSubtreeSize(string $path): int; + public function getSubtreeSize(string $path, ?int $limit = null): int; /** * Moves location identified by $sourceId into new parent identified by $destinationParentId. diff --git a/src/contracts/Persistence/Filter/Content/Handler.php b/src/contracts/Persistence/Filter/Content/Handler.php index fde4d03fef..2becc01547 100644 --- a/src/contracts/Persistence/Filter/Content/Handler.php +++ b/src/contracts/Persistence/Filter/Content/Handler.php @@ -22,5 +22,5 @@ interface Handler */ public function find(Filter $filter): iterable; - public function count(Filter $filter): int; + public function count(Filter $filter, ?int $limit = null): int; } diff --git a/src/contracts/Persistence/Filter/Location/Handler.php b/src/contracts/Persistence/Filter/Location/Handler.php index cfece4b4d1..4f81c49de9 100644 --- a/src/contracts/Persistence/Filter/Location/Handler.php +++ b/src/contracts/Persistence/Filter/Location/Handler.php @@ -22,5 +22,5 @@ interface Handler */ public function find(Filter $filter): iterable; - public function count(Filter $filter): int; + public function count(Filter $filter, ?int $limit = null): int; } diff --git a/src/contracts/Persistence/Filter/Query/CountQueryBuilder.php b/src/contracts/Persistence/Filter/Query/CountQueryBuilder.php new file mode 100644 index 0000000000..7c8fbf4df4 --- /dev/null +++ b/src/contracts/Persistence/Filter/Query/CountQueryBuilder.php @@ -0,0 +1,16 @@ + */ - public function count(Filter $filter, ?array $languages = null): int; + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int; } diff --git a/src/contracts/Repository/Decorator/ContentServiceDecorator.php b/src/contracts/Repository/Decorator/ContentServiceDecorator.php index c9d6c65038..760091598d 100644 --- a/src/contracts/Repository/Decorator/ContentServiceDecorator.php +++ b/src/contracts/Repository/Decorator/ContentServiceDecorator.php @@ -284,8 +284,8 @@ public function find(Filter $filter, ?array $languages = null): ContentList return $this->innerService->find($filter, $languages); } - public function count(Filter $filter, ?array $languages = null): int + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int { - return $this->innerService->count($filter, $languages); + return $this->innerService->count($filter, $languages, $limit); } } diff --git a/src/contracts/Repository/Decorator/LocationServiceDecorator.php b/src/contracts/Repository/Decorator/LocationServiceDecorator.php index 9cdf51d075..259779aebb 100644 --- a/src/contracts/Repository/Decorator/LocationServiceDecorator.php +++ b/src/contracts/Repository/Decorator/LocationServiceDecorator.php @@ -81,14 +81,14 @@ public function loadParentLocationsForDraftContent( return $this->innerService->loadParentLocationsForDraftContent($versionInfo, $prioritizedLanguages); } - public function getLocationChildCount(Location $location): int + public function getLocationChildCount(Location $location, ?int $limit = null): int { - return $this->innerService->getLocationChildCount($location); + return $this->innerService->getLocationChildCount($location, $limit); } - public function getSubtreeSize(Location $location): int + public function getSubtreeSize(Location $location, ?int $limit = null): int { - return $this->innerService->getSubtreeSize($location); + return $this->innerService->getSubtreeSize($location, $limit); } public function createLocation( @@ -159,8 +159,8 @@ public function find(Filter $filter, ?array $languages = null): LocationList return $this->innerService->find($filter, $languages); } - public function count(Filter $filter, ?array $languages = null): int + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int { - return $this->innerService->count($filter, $languages); + return $this->innerService->count($filter, $languages, $limit); } } diff --git a/src/contracts/Repository/LocationService.php b/src/contracts/Repository/LocationService.php index 79136f9841..deb40da35d 100644 --- a/src/contracts/Repository/LocationService.php +++ b/src/contracts/Repository/LocationService.php @@ -123,17 +123,20 @@ public function loadParentLocationsForDraftContent(VersionInfo $versionInfo, ?ar * Returns the number of children which are readable by the current user of a location object. * * @param \Ibexa\Contracts\Core\Repository\Values\Content\Location $location + * @param int|null $limit If set, the count will be limited to first $limit items found. * * @return int */ - public function getLocationChildCount(Location $location): int; + public function getLocationChildCount(Location $location, ?int $limit = null): int; /** * Return the subtree size of a given location. * * Warning! This method is not permission aware by design. + * + * @param int|null $limit */ - public function getSubtreeSize(Location $location): int; + public function getSubtreeSize(Location $location, ?int $limit = null): int; /** * Creates the new $location in the content repository for the given content. @@ -280,6 +283,8 @@ public function find(Filter $filter, ?array $languages = null): LocationList; * @param array|null $languages a list of language codes to be added as additional constraints. * If skipped, by default, unless SiteAccessAware layer has been disabled, languages set * for a SiteAccess in a current context will be used. + * @param int|null $limit If set, the count will be limited to first $limit items found. + * In some cases it can significantly speed up a count operation for more complex filters. */ - public function count(Filter $filter, ?array $languages = null): int; + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int; } diff --git a/src/lib/Persistence/Cache/LocationHandler.php b/src/lib/Persistence/Cache/LocationHandler.php index 52091c2eb7..da55c46387 100644 --- a/src/lib/Persistence/Cache/LocationHandler.php +++ b/src/lib/Persistence/Cache/LocationHandler.php @@ -257,13 +257,14 @@ public function copySubtree($sourceId, $destinationParentId, $newOwnerId = null) return $this->persistenceHandler->locationHandler()->copySubtree($sourceId, $destinationParentId, $newOwnerId); } - public function getSubtreeSize(string $path): int + public function getSubtreeSize(string $path, ?int $limit = null): int { $this->logger->logCall(__METHOD__, [ 'path' => $path, + 'limit' => $limit, ]); - return $this->persistenceHandler->locationHandler()->getSubtreeSize($path); + return $this->persistenceHandler->locationHandler()->getSubtreeSize($path, $limit); } /** diff --git a/src/lib/Persistence/Legacy/Content/Location/Gateway.php b/src/lib/Persistence/Legacy/Content/Location/Gateway.php index 5616d64f2f..87dcfbe5cc 100644 --- a/src/lib/Persistence/Legacy/Content/Location/Gateway.php +++ b/src/lib/Persistence/Legacy/Content/Location/Gateway.php @@ -128,7 +128,7 @@ abstract public function getSubtreeNodeIdToContentIdMap(int $sourceId): array; */ abstract public function getSubtreeChildrenDraftContentIds(int $sourceId): array; - abstract public function getSubtreeSize(string $path): int; + abstract public function getSubtreeSize(string $path, ?int $limit = null): int; /** * Returns data for the first level children of the location identified by given $locationId. diff --git a/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php index bfdfab4534..8891c71043 100644 --- a/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php @@ -17,6 +17,7 @@ use Ibexa\Contracts\Core\Persistence\Content\Location; use Ibexa\Contracts\Core\Persistence\Content\Location\CreateStruct; use Ibexa\Contracts\Core\Persistence\Content\Location\UpdateStruct; +use Ibexa\Contracts\Core\Persistence\Filter\Query\CountQueryBuilder; use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException; use Ibexa\Contracts\Core\Repository\Values\Content\Query\CriterionInterface; use Ibexa\Core\Base\Exceptions\DatabaseException; @@ -44,7 +45,8 @@ public function __construct( private readonly Connection $connection, private readonly MaskGenerator $languageMaskGenerator, private readonly CriteriaConverter $trashCriteriaConverter, - private readonly SortClauseConverter $trashSortClauseConverter + private readonly SortClauseConverter $trashSortClauseConverter, + private readonly CountQueryBuilder $countQueryBuilder ) { } @@ -249,7 +251,10 @@ public function getSubtreeChildrenDraftContentIds(int $sourceId): array return $query->executeQuery()->fetchFirstColumn(); } - public function getSubtreeSize(string $path): int + /** + * @phpstan-param positive-int $limit + */ + public function getSubtreeSize(string $path, ?int $limit = null): int { $query = $this->createNodeQueryBuilder(['COUNT(node_id)']); $query->andWhere( @@ -261,6 +266,12 @@ public function getSubtreeSize(string $path): int ) ); + $query = $this->countQueryBuilder->wrap( + $query, + 't.node_id', + $limit + ); + return (int) $query->executeQuery()->fetchOne(); } diff --git a/src/lib/Persistence/Legacy/Content/Location/Gateway/ExceptionConversion.php b/src/lib/Persistence/Legacy/Content/Location/Gateway/ExceptionConversion.php index 4208552cc7..f16d1fe069 100644 --- a/src/lib/Persistence/Legacy/Content/Location/Gateway/ExceptionConversion.php +++ b/src/lib/Persistence/Legacy/Content/Location/Gateway/ExceptionConversion.php @@ -128,10 +128,10 @@ public function getSubtreeChildrenDraftContentIds(int $sourceId): array } } - public function getSubtreeSize(string $path): int + public function getSubtreeSize(string $path, ?int $limit = null): int { try { - return $this->innerGateway->getSubtreeSize($path); + return $this->innerGateway->getSubtreeSize($path, $limit); } catch (DBALException | PDOException $e) { throw DatabaseException::wrap($e); } diff --git a/src/lib/Persistence/Legacy/Content/Location/Handler.php b/src/lib/Persistence/Legacy/Content/Location/Handler.php index 078f42daa0..8459ae94da 100644 --- a/src/lib/Persistence/Legacy/Content/Location/Handler.php +++ b/src/lib/Persistence/Legacy/Content/Location/Handler.php @@ -327,9 +327,9 @@ public function copySubtree($sourceId, $destinationParentId, $newOwnerId = null) return $copiedSubtreeRootLocation; } - public function getSubtreeSize(string $path): int + public function getSubtreeSize(string $path, ?int $limit = null): int { - return $this->locationGateway->getSubtreeSize($path); + return $this->locationGateway->getSubtreeSize($path, $limit); } /** diff --git a/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php b/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php index 06ee29f6ab..edc5cad885 100644 --- a/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php +++ b/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php @@ -13,6 +13,7 @@ use Doctrine\DBAL\FetchMode; use Ibexa\Contracts\Core\Persistence\Filter\CriterionVisitor; use Ibexa\Contracts\Core\Persistence\Filter\Doctrine\FilteringQueryBuilder; +use Ibexa\Contracts\Core\Persistence\Filter\Query\CountQueryBuilder; use Ibexa\Contracts\Core\Persistence\Filter\SortClauseVisitor; use Ibexa\Contracts\Core\Repository\Values\Filter\FilteringCriterion; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; @@ -57,17 +58,27 @@ final class DoctrineGateway implements Gateway public function __construct( private readonly Connection $connection, private readonly CriterionVisitor $criterionVisitor, - private readonly SortClauseVisitor $sortClauseVisitor + private readonly SortClauseVisitor $sortClauseVisitor, + private readonly CountQueryBuilder $countQueryBuilder ) { } - public function count(FilteringCriterion $criterion): int + /** + * @phpstan-param positive-int $limit + */ + public function count(FilteringCriterion $criterion, ?int $limit = null): int { $query = $this->buildQuery( ['COUNT(DISTINCT content.id)'], $criterion ); + $query = $this->countQueryBuilder->wrap( + $query, + 'content.id', + $limit + ); + return (int)$query->executeQuery()->fetch(FetchMode::COLUMN); } diff --git a/src/lib/Persistence/Legacy/Filter/Gateway/Gateway.php b/src/lib/Persistence/Legacy/Filter/Gateway/Gateway.php index c76a604349..e3deea4310 100644 --- a/src/lib/Persistence/Legacy/Filter/Gateway/Gateway.php +++ b/src/lib/Persistence/Legacy/Filter/Gateway/Gateway.php @@ -18,9 +18,9 @@ interface Gateway { /** - * Return number of matched rows for the given Criteria (a total count w/o pagination constraints). + * Return number of matched rows for the given Criteria (a total count w/o pagination constraints, Unless a limit is passed). */ - public function count(FilteringCriterion $criterion): int; + public function count(FilteringCriterion $criterion, ?int $limit = null): int; /** * Return iterator for raw Repository data for the given Query result filtered by the given Criteria, diff --git a/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php b/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php index 49557211b8..84ad3d38dd 100644 --- a/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php +++ b/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php @@ -12,6 +12,7 @@ use Doctrine\DBAL\FetchMode; use Ibexa\Contracts\Core\Persistence\Filter\CriterionVisitor; use Ibexa\Contracts\Core\Persistence\Filter\Doctrine\FilteringQueryBuilder; +use Ibexa\Contracts\Core\Persistence\Filter\Query\CountQueryBuilder; use Ibexa\Contracts\Core\Persistence\Filter\SortClauseVisitor; use Ibexa\Contracts\Core\Repository\Values\Filter\FilteringCriterion; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; @@ -26,16 +27,26 @@ public function __construct( private Connection $connection, private CriterionVisitor $criterionVisitor, - private SortClauseVisitor $sortClauseVisitor + private SortClauseVisitor $sortClauseVisitor, + private CountQueryBuilder $countQueryBuilder ) { } - public function count(FilteringCriterion $criterion): int + /** + * @phpstan-param positive-int $limit + */ + public function count(FilteringCriterion $criterion, ?int $limit = null): int { $query = $this->buildQuery($criterion); $query->select('COUNT(DISTINCT location.node_id)'); + $query = $this->countQueryBuilder->wrap( + $query, + 'location.node_id', + $limit + ); + return (int)$query->executeQuery()->fetch(FetchMode::COLUMN); } diff --git a/src/lib/Persistence/Legacy/Filter/Handler/ContentFilteringHandler.php b/src/lib/Persistence/Legacy/Filter/Handler/ContentFilteringHandler.php index be975019d2..ef6118f295 100644 --- a/src/lib/Persistence/Legacy/Filter/Handler/ContentFilteringHandler.php +++ b/src/lib/Persistence/Legacy/Filter/Handler/ContentFilteringHandler.php @@ -73,8 +73,8 @@ function (array $row): ContentItem { return $list; } - public function count(Filter $filter): int + public function count(Filter $filter, ?int $limit = null): int { - return $this->gateway->count($filter->getCriterion()); + return $this->gateway->count($filter->getCriterion(), $limit); } } diff --git a/src/lib/Persistence/Legacy/Filter/Handler/LocationFilteringHandler.php b/src/lib/Persistence/Legacy/Filter/Handler/LocationFilteringHandler.php index da28228501..dd03786797 100644 --- a/src/lib/Persistence/Legacy/Filter/Handler/LocationFilteringHandler.php +++ b/src/lib/Persistence/Legacy/Filter/Handler/LocationFilteringHandler.php @@ -69,8 +69,8 @@ function (array $row): LocationWithContentInfo { return $list; } - public function count(Filter $filter): int + public function count(Filter $filter, ?int $limit = null): int { - return $this->gateway->count($filter->getCriterion()); + return $this->gateway->count($filter->getCriterion(), $limit); } } diff --git a/src/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilder.php b/src/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilder.php new file mode 100644 index 0000000000..9b337ab06e --- /dev/null +++ b/src/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilder.php @@ -0,0 +1,65 @@ +select($countableField) + ->setMaxResults($limit) + ->getSQL(); + + $countQuery = $this->connection->createQueryBuilder(); + + return $countQuery + ->select( + 'COUNT(1)' + ) + ->from('(' . $querySql . ')', 'csub') + ->setParameters($queryBuilder->getParameters(), $queryBuilder->getParameterTypes()); + } +} diff --git a/src/lib/Repository/ContentService.php b/src/lib/Repository/ContentService.php index 6c6065f7f3..37efab7f59 100644 --- a/src/lib/Repository/ContentService.php +++ b/src/lib/Repository/ContentService.php @@ -2693,7 +2693,7 @@ public function find(Filter $filter, ?array $languages = null): ContentList return new ContentList($contentItemsIterator->getTotalCount(), $contentItems); } - public function count(Filter $filter, ?array $languages = null): int + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int { $filter = clone $filter; if (!empty($languages)) { @@ -2713,6 +2713,6 @@ public function count(Filter $filter, ?array $languages = null): int $filter->andWithCriterion($permissionCriterion); } - return $this->contentFilteringHandler->count($filter); + return $this->contentFilteringHandler->count($filter, $limit); } } diff --git a/src/lib/Repository/LocationService.php b/src/lib/Repository/LocationService.php index 6dcc99fa2a..24dbfad2ce 100644 --- a/src/lib/Repository/LocationService.php +++ b/src/lib/Repository/LocationService.php @@ -363,17 +363,21 @@ public function loadParentLocationsForDraftContent(VersionInfo $versionInfo, ?ar /** * Returns the number of children which are readable by the current user of a Location object. */ - public function getLocationChildCount(APILocation $location): int + public function getLocationChildCount(APILocation $location, ?int $limit = null): int { $filter = $this->buildLocationChildrenFilter($location); - return $this->count($filter); + return $this->count($filter, null, $limit); } - public function getSubtreeSize(APILocation $location): int + /** + * @param int|null $limit + */ + public function getSubtreeSize(APILocation $location, ?int $limit = null): int { return $this->persistenceHandler->locationHandler()->getSubtreeSize( - $location->getPathString() + $location->getPathString(), + $limit ); } @@ -931,7 +935,7 @@ public function find(Filter $filter, ?array $languages = null): LocationList ); } - public function count(Filter $filter, ?array $languages = null): int + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int { $filter = clone $filter; if (!empty($languages)) { @@ -951,7 +955,7 @@ public function count(Filter $filter, ?array $languages = null): int $filter->andWithCriterion($permissionCriterion); } - return $this->locationFilteringHandler->count($filter); + return $this->locationFilteringHandler->count($filter, $limit); } /** diff --git a/src/lib/Repository/SiteAccessAware/ContentService.php b/src/lib/Repository/SiteAccessAware/ContentService.php index 1155d2fe9e..67f6d3c6f3 100644 --- a/src/lib/Repository/SiteAccessAware/ContentService.php +++ b/src/lib/Repository/SiteAccessAware/ContentService.php @@ -293,11 +293,12 @@ public function find(Filter $filter, ?array $languages = null): ContentList ); } - public function count(Filter $filter, ?array $languages = null): int + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int { return $this->service->count( $filter, - $this->languageResolver->getPrioritizedLanguages($languages) + $this->languageResolver->getPrioritizedLanguages($languages), + $limit ); } } diff --git a/src/lib/Repository/SiteAccessAware/LocationService.php b/src/lib/Repository/SiteAccessAware/LocationService.php index 3dc1d8ddae..d7e3e5fad0 100644 --- a/src/lib/Repository/SiteAccessAware/LocationService.php +++ b/src/lib/Repository/SiteAccessAware/LocationService.php @@ -104,14 +104,14 @@ public function loadParentLocationsForDraftContent(VersionInfo $versionInfo, ?ar ); } - public function getLocationChildCount(Location $location): int + public function getLocationChildCount(Location $location, ?int $limit = null): int { - return $this->service->getLocationChildCount($location); + return $this->service->getLocationChildCount($location, $limit); } - public function getSubtreeSize(Location $location): int + public function getSubtreeSize(Location $location, ?int $limit = null): int { - return $this->service->getSubtreeSize($location); + return $this->service->getSubtreeSize($location, $limit); } public function createLocation(ContentInfo $contentInfo, LocationCreateStruct $locationCreateStruct): Location @@ -192,11 +192,15 @@ public function find(Filter $filter, ?array $languages = null): LocationList ); } - public function count(Filter $filter, ?array $languages = null): int + /** + * @param int|null $limit + */ + public function count(Filter $filter, ?array $languages = null, ?int $limit = null): int { return $this->service->count( $filter, - $this->languageResolver->getPrioritizedLanguages($languages) + $this->languageResolver->getPrioritizedLanguages($languages), + $limit ); } } diff --git a/src/lib/Resources/settings/repository/inner.yml b/src/lib/Resources/settings/repository/inner.yml index 2c10e30ac5..3d79114c80 100644 --- a/src/lib/Resources/settings/repository/inner.yml +++ b/src/lib/Resources/settings/repository/inner.yml @@ -105,6 +105,8 @@ services: class: Ibexa\Core\Repository\LocationService factory: ['@Ibexa\Core\Repository\Repository', getLocationService] lazy: true + tags: + - { name: 'proxy', interface: 'Ibexa\Contracts\Core\Future\Repository\FutureLocationService' } Ibexa\Core\Repository\LanguageService: class: Ibexa\Core\Repository\LanguageService diff --git a/src/lib/Resources/settings/storage_engines/legacy/filter.yaml b/src/lib/Resources/settings/storage_engines/legacy/filter.yaml index 4ac9426423..5b23edfb25 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/filter.yaml +++ b/src/lib/Resources/settings/storage_engines/legacy/filter.yaml @@ -22,6 +22,9 @@ services: Ibexa\Contracts\Core\Persistence\Filter\SortClauseVisitor: alias: Ibexa\Core\Persistence\Legacy\Filter\SortClauseVisitor + + Ibexa\Contracts\Core\Persistence\Filter\Query\CountQueryBuilder: + alias: Ibexa\Core\Persistence\Legacy\Filter\Query\LimitedCountQueryBuilder # implementations: Ibexa\Core\Persistence\Legacy\Filter\Gateway\Content\Mapper\DoctrineGatewayDataMapper: @@ -56,3 +59,7 @@ services: arguments: $gateway: '@Ibexa\Core\Persistence\Legacy\Filter\Gateway\Location\Doctrine\DoctrineGateway' $locationMapper: '@Ibexa\Core\Persistence\Legacy\Content\Location\Mapper' + + Ibexa\Core\Persistence\Legacy\Filter\Query\LimitedCountQueryBuilder: + arguments: + $connection: '@ibexa.persistence.connection' diff --git a/src/lib/Resources/settings/storage_engines/legacy/location.yml b/src/lib/Resources/settings/storage_engines/legacy/location.yml index 3b0ba0cde5..d6e86033d5 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/location.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/location.yml @@ -6,6 +6,7 @@ services: - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' - '@ibexa.core.trash.search.legacy.gateway.criteria_converter' - '@ibexa.core.trash.search.legacy.gateway.sort_clause_converter' + - '@Ibexa\Contracts\Core\Persistence\Filter\Query\CountQueryBuilder' Ibexa\Core\Persistence\Legacy\Content\Location\Gateway\ExceptionConversion: class: Ibexa\Core\Persistence\Legacy\Content\Location\Gateway\ExceptionConversion diff --git a/tests/integration/Core/Repository/LocationServiceTest.php b/tests/integration/Core/Repository/LocationServiceTest.php index 172b4b05df..76b38051f4 100644 --- a/tests/integration/Core/Repository/LocationServiceTest.php +++ b/tests/integration/Core/Repository/LocationServiceTest.php @@ -1127,8 +1127,6 @@ public function testLoadParentLocationsForDraftContentThrowsBadStateException(Co * Test for the getLocationChildCount() method. * * @covers \Ibexa\Contracts\Core\Repository\LocationService::getLocationChildCount() - * - * @depends testLoadLocation */ public function testGetLocationChildCount() { @@ -1143,6 +1141,27 @@ public function testGetLocationChildCount() ); } + /** + * Test for the getLocationChildCount() method with a limitation on the number of children. + * + * @covers \Ibexa\Contracts\Core\Repository\LocationService::getLocationChildCount() + * + * @depends testLoadLocation + */ + public function testGetLocationChildCountWithLimitation(): void + { + // $locationId is the ID of an existing location + $locationService = $this->getRepository()->getLocationService(); + $location = $locationService->loadLocation($this->generateId('location', 5)); + $this->assertSame( + 2, + $locationService->getLocationChildCount( + $location, + 2 + ) + ); + } + /** * Test for the loadLocationChildren() method. * @@ -3604,6 +3623,9 @@ public function testGetSubtreeSize(): Location $folder = $this->createFolder(['eng-GB' => 'Parent Folder'], 2); $location = $folder->getVersionInfo()->getContentInfo()->getMainLocation(); + self::assertNotNull($location); + + // phpstan-ignore-next-line self::assertSame(1, $locationService->getSubtreeSize($location)); $this->createFolder(['eng-GB' => 'Child 1'], $location->id); @@ -3614,6 +3636,47 @@ public function testGetSubtreeSize(): Location return $location; } + public function testGetSubtreeSizeWithLimit(): Location + { + $repository = $this->getRepository(); + $locationService = $repository->getLocationService(); + + $folder = $this->createFolder(['eng-GB' => 'Parent Folder'], 2); + $location = $folder->getVersionInfo()->getContentInfo()->getMainLocation(); + self::assertNotNull($location); + + for ($i = 1; $i <= 10; ++$i) { + $this->createFolder(['eng-GB' => 'Child ' . $i], $location->id); + } + + self::assertSame(3, $locationService->getSubtreeSize($location, 3)); + + return $location; + } + + public function testGetSubtreeSizeWithInvalidLimitThrowsExpectedError(): Location + { + $repository = $this->getRepository(); + $locationService = $repository->getLocationService(); + + $folder = $this->createFolder(['eng-GB' => 'Parent Folder'], 2); + $location = $folder->getVersionInfo()->getContentInfo()->getMainLocation(); + self::assertNotNull($location); + + self::assertSame(1, $locationService->getSubtreeSize($location)); + + for ($i = 1; $i <= 10; ++$i) { + $this->createFolder(['eng-GB' => 'Child ' . $i], $location->id); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Limit must be greater than 0/'); + + self::assertSame(3, $locationService->getSubtreeSize($location, -42)); + + return $location; + } + /** * Loads properties from all locations in the $location's subtree. * diff --git a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php index e05e15bbcb..a45774ba49 100644 --- a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php @@ -30,7 +30,8 @@ protected function getLocationGateway() $this->getDatabaseConnection(), $this->getLanguageMaskGenerator(), $this->getTrashCriteriaConverterDependency(), - $this->getTrashSortClauseConverterDependency() + $this->getTrashSortClauseConverterDependency(), + $this->getLimitedCountQueryBuilderDependency() ); } diff --git a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php index 0aa8113c3f..e73158b309 100644 --- a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php +++ b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php @@ -26,7 +26,8 @@ protected function getLocationGateway() $this->getDatabaseConnection(), $this->getLanguageMaskGenerator(), $this->getTrashCriteriaConverterDependency(), - $this->getTrashSortClauseConverterDependency() + $this->getTrashSortClauseConverterDependency(), + $this->getLimitedCountQueryBuilderDependency(), ); } diff --git a/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php b/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php index 2be4fcd1d2..216cf15741 100644 --- a/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php +++ b/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php @@ -5447,7 +5447,8 @@ protected function getLocationGateway() $this->getDatabaseConnection(), $this->getLanguageMaskGenerator(), $this->getTrashCriteriaConverterDependency(), - $this->getTrashSortClauseConverterDependency() + $this->getTrashSortClauseConverterDependency(), + $this->getLimitedCountQueryBuilderDependency() ); } diff --git a/tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php b/tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php new file mode 100644 index 0000000000..76b3ca8c63 --- /dev/null +++ b/tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php @@ -0,0 +1,68 @@ +limitedCountQueryBuilder = new LimitedCountQueryBuilder($this->getDatabaseConnection()); + } + + public function testWrapThrowsExceptionOnZeroLimit(): void + { + $qb = $this->getDatabaseConnection()->createQueryBuilder(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Limit must be greater than 0/'); + + $this->limitedCountQueryBuilder->wrap($qb, 'someField', 0); + } + + /** + * @covers \Ibexa\Core\Persistence\Legacy\Filter\Query\LimitedCountQueryBuilder::wrap + */ + public function testWrapDoesNotChangeQueryBuilderIfLimitIsNull(): void + { + $qb = $this->getDatabaseConnection()->createQueryBuilder(); + $qb->select('DISTINCT someField') + ->from('someTable') + ->where('someCondition = :condition') + ->setParameter('condition', 'value'); + + $wrappedQueryBuilder = $this->limitedCountQueryBuilder->wrap($qb, 'someField', null); + + // The original query should remain unchanged + self::assertEquals($qb->getSQL(), $wrappedQueryBuilder->getSQL()); + } + + public function testWrapWrapsQueryBuilderCorrectly(): void + { + $qb = $this->getDatabaseConnection()->createQueryBuilder(); + $qb->select('DISTINCT someField') + ->from('someTable') + ->where('someCondition = :condition') + ->setParameter('condition', 'value'); + + $wrappedQueryBuilder = $this->limitedCountQueryBuilder->wrap($qb, 'someField', 10); + + $expectedSql = 'SELECT COUNT(1) FROM (SELECT someField FROM someTable WHERE someCondition = :condition LIMIT 10) csub'; + self::assertEquals($expectedSql, $wrappedQueryBuilder->getSQL()); + self::assertEquals($qb->getParameters(), $wrappedQueryBuilder->getParameters()); + } +} diff --git a/tests/lib/Persistence/Legacy/TestCase.php b/tests/lib/Persistence/Legacy/TestCase.php index f3e006b230..3e942177d4 100644 --- a/tests/lib/Persistence/Legacy/TestCase.php +++ b/tests/lib/Persistence/Legacy/TestCase.php @@ -17,6 +17,7 @@ use Ibexa\Contracts\Core\Test\Persistence\Fixture\FixtureImporter; use Ibexa\Contracts\Core\Test\Persistence\Fixture\YamlFixture; use Ibexa\Contracts\Core\Test\Repository\SetupFactory\Legacy; +use Ibexa\Core\Persistence\Legacy\Filter\Query\LimitedCountQueryBuilder; use Ibexa\Core\Persistence\Legacy\SharedGateway; use Ibexa\Core\Search\Legacy\Content; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriteriaConverter; @@ -347,4 +348,9 @@ protected function getTrashSortClauseConverterDependency(): SortClauseConverter ] ); } + + protected function getLimitedCountQueryBuilderDependency(): LimitedCountQueryBuilder + { + return new LimitedCountQueryBuilder($this->getDatabaseConnection()); + } } diff --git a/tests/lib/Repository/Decorator/LocationServiceDecoratorTest.php b/tests/lib/Repository/Decorator/LocationServiceDecoratorTest.php index 906c9575ac..cd175b1402 100644 --- a/tests/lib/Repository/Decorator/LocationServiceDecoratorTest.php +++ b/tests/lib/Repository/Decorator/LocationServiceDecoratorTest.php @@ -151,7 +151,7 @@ public function testGetLocationChildCountDecorator() $serviceMock = $this->createServiceMock(); $decoratedService = $this->createDecorator($serviceMock); - $parameters = [$this->createMock(Location::class)]; + $parameters = [$this->createMock(Location::class), 8]; $serviceMock->expects(self::once())->method('getLocationChildCount')->with(...$parameters); From 2ae6e1f8c359a3a38672cb22ee8c331865dc89f6 Mon Sep 17 00:00:00 2001 From: MateuszKolankowski Date: Mon, 5 Jan 2026 15:51:10 +0100 Subject: [PATCH 2/4] Remove proxy tag from LocationService definition in inner.yml --- src/lib/Resources/settings/repository/inner.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/Resources/settings/repository/inner.yml b/src/lib/Resources/settings/repository/inner.yml index 3d79114c80..2c10e30ac5 100644 --- a/src/lib/Resources/settings/repository/inner.yml +++ b/src/lib/Resources/settings/repository/inner.yml @@ -105,8 +105,6 @@ services: class: Ibexa\Core\Repository\LocationService factory: ['@Ibexa\Core\Repository\Repository', getLocationService] lazy: true - tags: - - { name: 'proxy', interface: 'Ibexa\Contracts\Core\Future\Repository\FutureLocationService' } Ibexa\Core\Repository\LanguageService: class: Ibexa\Core\Repository\LanguageService From 38956acb7520e63aee94eaae2bb0c2b495b66714 Mon Sep 17 00:00:00 2001 From: MateuszKolankowski Date: Wed, 7 Jan 2026 10:06:28 +0100 Subject: [PATCH 3/4] Remove dependency from getLocationChildCount test --- tests/integration/Core/Repository/LocationServiceTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/integration/Core/Repository/LocationServiceTest.php b/tests/integration/Core/Repository/LocationServiceTest.php index 76b38051f4..704d045ef4 100644 --- a/tests/integration/Core/Repository/LocationServiceTest.php +++ b/tests/integration/Core/Repository/LocationServiceTest.php @@ -1145,8 +1145,6 @@ public function testGetLocationChildCount() * Test for the getLocationChildCount() method with a limitation on the number of children. * * @covers \Ibexa\Contracts\Core\Repository\LocationService::getLocationChildCount() - * - * @depends testLoadLocation */ public function testGetLocationChildCountWithLimitation(): void { From 10b0d648495d9cf9f74680fa1a8ba33f27b72e72 Mon Sep 17 00:00:00 2001 From: MateuszKolankowski Date: Wed, 7 Jan 2026 13:30:08 +0100 Subject: [PATCH 4/4] Ignored type error for limit parameter in LimitedCountQueryBuilder wrap method --- phpstan-baseline.neon | 6 ------ .../Legacy/Filter/Query/LimitedCountQueryBuilderTest.php | 1 + 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index accdd364d3..39e7665ac5 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -52470,12 +52470,6 @@ parameters: count: 1 path: tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php - - - message: '#^Parameter \#3 \$limit of method Ibexa\\Core\\Persistence\\Legacy\\Filter\\Query\\LimitedCountQueryBuilder\:\:wrap\(\) expects int\<1, max\>\|null, 0 given\.$#' - identifier: argument.type - count: 1 - path: tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php - - message: '#^Call to an undefined method Ibexa\\Contracts\\Core\\Container\:\:get\(\)\.$#' identifier: method.notFound diff --git a/tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php b/tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php index 76b3ca8c63..ac9e666a08 100644 --- a/tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php +++ b/tests/lib/Persistence/Legacy/Filter/Query/LimitedCountQueryBuilderTest.php @@ -31,6 +31,7 @@ public function testWrapThrowsExceptionOnZeroLimit(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/Limit must be greater than 0/'); + /** @phpstan-ignore argument.type */ $this->limitedCountQueryBuilder->wrap($qb, 'someField', 0); }