diff --git a/eZ/Bundle/EzPublishCoreBundle/ApiLoader/RepositoryFactory.php b/eZ/Bundle/EzPublishCoreBundle/ApiLoader/RepositoryFactory.php
index 0a2a1e80ef..4ae7d0d05b 100644
--- a/eZ/Bundle/EzPublishCoreBundle/ApiLoader/RepositoryFactory.php
+++ b/eZ/Bundle/EzPublishCoreBundle/ApiLoader/RepositoryFactory.php
@@ -17,6 +17,8 @@
use eZ\Publish\Core\Repository\Helper\RelationProcessor;
use eZ\Publish\Core\Repository\Mapper;
use eZ\Publish\Core\Search\Common\BackgroundIndexer;
+use eZ\Publish\SPI\Persistence\Filter\Content\Handler as ContentFilteringHandler;
+use eZ\Publish\SPI\Persistence\Filter\Location\Handler as LocationFilteringHandler;
use eZ\Publish\SPI\Persistence\Handler as PersistenceHandler;
use eZ\Publish\SPI\Repository\Strategy\ContentThumbnail\ThumbnailStrategy;
use eZ\Publish\SPI\Repository\Validator\ContentValidator;
@@ -84,7 +86,9 @@ public function buildRepository(
Mapper\ContentMapper $contentMapper,
ContentValidator $contentValidator,
LimitationService $limitationService,
- PermissionService $permissionService
+ PermissionService $permissionService,
+ ContentFilteringHandler $contentFilteringHandler,
+ LocationFilteringHandler $locationFilteringHandler
): Repository {
$config = $this->container->get('ezpublish.api.repository_configuration_provider')->getRepositoryConfig();
@@ -105,6 +109,8 @@ public function buildRepository(
$limitationService,
$this->languageResolver,
$permissionService,
+ $contentFilteringHandler,
+ $locationFilteringHandler,
[
'role' => [
'policyMap' => $this->policyMap,
diff --git a/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php b/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php
index 15c7675d9b..2c5800a4ea 100644
--- a/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php
+++ b/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php
@@ -18,6 +18,8 @@
use eZ\Publish\Core\MVC\Symfony\MVCEvents;
use eZ\Publish\Core\QueryType\QueryType;
use eZ\Publish\SPI\MVC\EventSubscriber\ConfigScopeChangeSubscriber;
+use eZ\Publish\SPI\Repository\Values\Filter\CriterionQueryBuilder as FilteringCriterionQueryBuilder;
+use eZ\Publish\SPI\Repository\Values\Filter\SortClauseQueryBuilder as FilteringSortClauseQueryBuilder;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\Filesystem\Filesystem;
@@ -141,18 +143,7 @@ public function load(array $configs, ContainerBuilder $container)
$this->buildPolicyMap($container);
- $container->registerForAutoconfiguration(QueryType::class)
- ->addTag(QueryTypePass::QUERY_TYPE_SERVICE_TAG);
-
- $container->registerForAutoconfiguration(ConfigScopeChangeSubscriber::class)
- ->addTag(
- 'kernel.event_listener',
- ['method' => 'onConfigScopeChange', 'event' => MVCEvents::CONFIG_SCOPE_CHANGE]
- )
- ->addTag(
- 'kernel.event_listener',
- ['method' => 'onConfigScopeChange', 'event' => MVCEvents::CONFIG_SCOPE_RESTORE]
- );
+ $this->registerForAutoConfiguration($container);
}
/**
@@ -579,4 +570,26 @@ private function handleUrlWildcards(array $config, ContainerBuilder $container,
$loader->load('url_wildcard.yml');
}
}
+
+ private function registerForAutoConfiguration(ContainerBuilder $container): void
+ {
+ $container->registerForAutoconfiguration(QueryType::class)
+ ->addTag(QueryTypePass::QUERY_TYPE_SERVICE_TAG);
+
+ $container->registerForAutoconfiguration(ConfigScopeChangeSubscriber::class)
+ ->addTag(
+ 'kernel.event_listener',
+ ['method' => 'onConfigScopeChange', 'event' => MVCEvents::CONFIG_SCOPE_CHANGE]
+ )
+ ->addTag(
+ 'kernel.event_listener',
+ ['method' => 'onConfigScopeChange', 'event' => MVCEvents::CONFIG_SCOPE_RESTORE]
+ );
+
+ $container->registerForAutoconfiguration(FilteringCriterionQueryBuilder::class)
+ ->addTag(ServiceTags::FILTERING_CRITERION_QUERY_BUILDER);
+
+ $container->registerForAutoconfiguration(FilteringSortClauseQueryBuilder::class)
+ ->addTag(ServiceTags::FILTERING_SORT_CLAUSE_QUERY_BUILDER);
+ }
}
diff --git a/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/ServiceTags.php b/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/ServiceTags.php
new file mode 100644
index 0000000000..2c9381c875
--- /dev/null
+++ b/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/ServiceTags.php
@@ -0,0 +1,27 @@
+setAutoconfigured(true);
+ $this->setDefinition($classFQCN, $definition);
+
+ $this->load();
+
+ $this->compileCoreContainer();
+
+ $this->assertContainerBuilderHasServiceDefinitionWithTag(
+ $classFQCN,
+ $tagName
+ );
+ }
+
+ /**
+ * Data provider for {@see testFilteringQueryBuildersAutomaticConfiguration}.
+ */
+ public function getFilteringQueryBuilderData(): iterable
+ {
+ yield Filter\CriterionQueryBuilder::class => [
+ CustomCriterionQueryBuilder::class,
+ ServiceTags::FILTERING_CRITERION_QUERY_BUILDER,
+ ];
+
+ yield Filter\SortClauseQueryBuilder::class => [
+ CustomSortClauseQueryBuilder::class,
+ ServiceTags::FILTERING_SORT_CLAUSE_QUERY_BUILDER,
+ ];
+ }
+
/**
* Prepare Core Container for compilation by mocking required parameters and compile it.
*/
diff --git a/eZ/Bundle/EzPublishCoreBundle/Tests/DependencyInjection/Stub/Filter/CustomCriterionQueryBuilder.php b/eZ/Bundle/EzPublishCoreBundle/Tests/DependencyInjection/Stub/Filter/CustomCriterionQueryBuilder.php
new file mode 100644
index 0000000000..55a4a8fbfb
--- /dev/null
+++ b/eZ/Bundle/EzPublishCoreBundle/Tests/DependencyInjection/Stub/Filter/CustomCriterionQueryBuilder.php
@@ -0,0 +1,32 @@
+['<language_code>' => '<name>']
- * @param int $parentLocationId
+ * @param int|null $parentLocationId
*
* @return \eZ\Publish\API\Repository\Values\Content\Content published Content
*
@@ -721,8 +729,11 @@ protected function assertAllValidationErrorsOccur(
* @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
* @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
*/
- protected function createFolder(array $names, $parentLocationId)
- {
+ public function createFolder(
+ array $names,
+ ?int $parentLocationId = null,
+ ?string $remoteId = null
+ ): Content {
$repository = $this->getRepository(false);
$contentService = $repository->getContentService();
$contentTypeService = $repository->getContentTypeService();
@@ -737,12 +748,21 @@ protected function createFolder(array $names, $parentLocationId)
$contentTypeService->loadContentTypeByIdentifier('folder'),
$mainLanguageCode
);
+ $struct->remoteId = $remoteId;
foreach ($names as $languageCode => $translatedName) {
$struct->setField('name', $translatedName, $languageCode);
}
+
+ $locationCreateStructList = [];
+ if (null !== $parentLocationId) {
+ $locationCreateStructList[] = $locationService->newLocationCreateStruct(
+ $parentLocationId
+ );
+ }
+
$contentDraft = $contentService->createContent(
$struct,
- [$locationService->newLocationCreateStruct($parentLocationId)]
+ $locationCreateStructList
);
return $contentService->publishVersion($contentDraft->versionInfo);
diff --git a/eZ/Publish/API/Repository/Tests/Filtering/BaseRepositoryFilteringTestCase.php b/eZ/Publish/API/Repository/Tests/Filtering/BaseRepositoryFilteringTestCase.php
new file mode 100644
index 0000000000..22bae23a9e
--- /dev/null
+++ b/eZ/Publish/API/Repository/Tests/Filtering/BaseRepositoryFilteringTestCase.php
@@ -0,0 +1,381 @@
+buildFilter(
+ $filterFactory,
+ $this->contentProvider->createSharedContentStructure()
+ );
+ if ([] === $filter->getSortClauses()) {
+ // there has to be a sort clause to compare results with search engine
+ $filter->withSortClause($this->getDefaultSortClause());
+ }
+
+ // validate the result using search service
+ $list = $this->find($filter);
+ /** @var \IteratorAggregate $list */
+ $this->compareWithSearchResults($filter, $list);
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->contentProvider = new TestContentProvider($this->getRepository(false), $this);
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function testFindDoesNotFindDrafts(): void
+ {
+ $contentDraft = $this->contentProvider->createContentDraft(
+ 'folder',
+ ['name' => [TestContentProvider::ENG_US => 'Draft Folder']],
+ );
+
+ $filter = new Filter();
+ $filter
+ ->withCriterion(new Criterion\ContentId($contentDraft->id));
+
+ $list = $this->find($filter, []);
+
+ self::assertCount(0, $list);
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\ContentService::find
+ *
+ * @dataProvider getUserLimitationData
+ *
+ * @param \eZ\Publish\API\Repository\Values\User\Limitation[] $limitations
+ * @param string[] $expectedContentRemoteIds
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function testFindByUserWithLimitations(
+ array $limitations,
+ array $expectedContentRemoteIds
+ ): void {
+ $repository = $this->getRepository();
+ $parentFolder = $this->contentProvider->createSharedContentStructure();
+ $login = uniqid('editor', true);
+ $user = $this->createUserWithPolicies(
+ $login,
+ [
+ ['module' => 'content', 'function' => 'read', 'limitations' => $limitations],
+ ],
+ new Limitation\SubtreeLimitation(
+ ['limitationValues' => [$parentFolder->contentInfo->getMainLocation()->pathString]]
+ )
+ );
+ $repository->getPermissionResolver()->setCurrentUserReference($user);
+
+ $filter = new Filter();
+ $filter->withCriterion(new ParentLocationId($parentFolder->contentInfo->mainLocationId));
+
+ $this->assertFoundContentItemsByRemoteIds($this->find($filter), $expectedContentRemoteIds);
+ }
+
+ /**
+ * Data provider consumed by children implementations.
+ */
+ public function getFilterFactories(): iterable
+ {
+ // Note: Filter relying on database data cannot be instantiated here
+ // because database data is not available yet
+ yield 'ParentLocationID' => [
+ static function (Content $parentFolder): Filter {
+ return (new Filter())
+ ->withCriterion(
+ new Criterion\ParentLocationId($parentFolder->contentInfo->mainLocationId)
+ );
+ },
+ 5,
+ ];
+
+ yield 'ParentLocationID for a single Translation' => [
+ static function (Content $parentFolder): Filter {
+ return (new Filter())
+ ->withCriterion(
+ new Criterion\ParentLocationId($parentFolder->contentInfo->mainLocationId)
+ )
+ ->andWithCriterion(new Criterion\LanguageCode(['eng-GB']));
+ },
+ 5,
+ ];
+
+ yield 'ParentLocationID with Sort Clauses' => [
+ static function (Content $parentFolder): Filter {
+ return (new Filter())
+ ->withCriterion(
+ new Criterion\ParentLocationId($parentFolder->contentInfo->mainLocationId)
+ )
+ ->withSortClause(new SortClause\DatePublished(Query::SORT_ASC))
+ ->withSortClause(new SortClause\ContentId(Query::SORT_ASC));
+ },
+ // expected total count
+ 5,
+ ];
+
+ foreach ($this->getCriteriaForInitialData() as $dataSetName => $filter) {
+ yield $dataSetName => [
+ static function (Content $parentFolder) use ($filter): Filter {
+ return new Filter($filter);
+ },
+ // for those rely on search result count
+ null,
+ ];
+ }
+ }
+
+ /**
+ * A list of Criteria which arguments rely on initial test data to work.
+ *
+ * Note: this is a quick attempt to cover all supported Filtering Criteria. In the future it
+ * should be refactored to rely on shared data structure created at runtime.
+ *
+ * @return \eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion[]
+ *
+ * @see getFilterFactories
+ */
+ public function getCriteriaForInitialData(): iterable
+ {
+ yield 'Ancestor=/1/5/44/45/' => new Criterion\Ancestor('/1/5/44/45/');
+ yield 'ContentId=57' => new Criterion\ContentId(57);
+ yield 'ContentTypeGroupId=1' => new Criterion\ContentTypeGroupId(1);
+ yield 'ContentTypeId=1' => new Criterion\ContentTypeId(1);
+ yield 'ContentTypeIdentifier=folder' => new Criterion\ContentTypeIdentifier('folder');
+ yield 'DateMetadata=BETWEEN 1080220197 AND 1448889046' => new Criterion\DateMetadata(
+ Criterion\DateMetadata::CREATED,
+ Criterion\Operator::BETWEEN,
+ [1080220197, 1448889046]
+ );
+ yield 'IsUserBased=true' => new Criterion\IsUserBased(true);
+ yield 'IsUserBased=false' => new Criterion\IsUserBased(false);
+ yield 'IsUserEnabled=true' => new Criterion\IsUserEnabled();
+ yield 'LanguageCode=eng-GB' => new Criterion\LanguageCode(TestContentProvider::ENG_GB);
+ yield 'LocationId=2' => new Criterion\LocationId(2);
+ yield 'LocationRemoteId=f3e90596361e31d496d4026eb624c983' => new Criterion\LocationRemoteId(
+ 'f3e90596361e31d496d4026eb624c983'
+ );
+ yield 'MatchAll' => new Criterion\MatchAll();
+ yield 'MatchNone' => new Criterion\MatchNone();
+ yield 'ObjectStateId=1' => new Criterion\ObjectStateId(1);
+ yield 'ObjectStateIdentifier=not_locked' => new Criterion\ObjectStateIdentifier(
+ 'not_locked'
+ );
+ yield 'ObjectStateIdentifier=ez_lock(not_locked)' => new Criterion\ObjectStateIdentifier(
+ ['not_locked'], 'ez_lock'
+ );
+ yield 'ParentLocationId=1' => new Criterion\ParentLocationId(1);
+ yield 'RemoteId=8a9c9c761004866fb458d89910f52bee' => new Criterion\RemoteId(
+ '8a9c9c761004866fb458d89910f52bee'
+ );
+ yield 'SectionId=1' => new Criterion\SectionId(1);
+ yield 'SectionIdentifier=standard' => new Criterion\SectionIdentifier('standard');
+ yield 'Sibling IN 2, 1]' => new Criterion\Sibling(2, 1);
+ yield 'Subtree=/1/2/' => new Criterion\Subtree('/1/2/');
+ yield 'UserEmail=nospam@ez.no' => new Criterion\UserEmail('nospam@ez.no');
+ yield 'UserEmail=nospam@*' => new Criterion\UserEmail('*@ez.no', Criterion\Operator::LIKE);
+ yield 'UserId=14' => new Criterion\UserId(14);
+ yield 'UserLogin=admin' => new Criterion\UserLogin('admin');
+ yield 'UserLogin=a*' => new Criterion\UserLogin('a*', Criterion\Operator::LIKE);
+ yield 'UserMetadata=OWNER IN (10, 14)' => new Criterion\UserMetadata(
+ Criterion\UserMetadata::OWNER, Criterion\Operator::IN, [10, 14]
+ );
+ yield 'UserMetadata=GROUP IN (12)' => new Criterion\UserMetadata(
+ Criterion\UserMetadata::GROUP, Criterion\Operator::EQ, 12
+ );
+ yield 'UserMetadata=MODIFIER IN (14)' => new Criterion\UserMetadata(
+ Criterion\UserMetadata::MODIFIER, Criterion\Operator::EQ,
+ 14
+ );
+ yield 'Visibility=VISIBLE' => new Criterion\Visibility(Criterion\Visibility::VISIBLE);
+ }
+
+ protected function assertTotalCount(FilteringCriterion $criterion, int $searchTotalCount): void
+ {
+ if (!$criterion instanceof Criterion\MatchNone) {
+ self::assertGreaterThan(
+ 0,
+ $searchTotalCount,
+ sprintf(
+ 'There is no corresponding data to test the "%s" Criterion',
+ get_class($criterion)
+ )
+ );
+ } else {
+ // special case for a single criterion (not worth to make test impl. cleaner)
+ self::assertSame(
+ 0,
+ $searchTotalCount,
+ sprintf('MatchNone is expected to return 0 rows, %d returned', $searchTotalCount)
+ );
+ }
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function getUserLimitationData(): iterable
+ {
+ $repository = $this->getRepository(false);
+
+ // Content Type Limitations
+ $contentTypeService = $repository->getContentTypeService();
+ $articleType = $contentTypeService->loadContentTypeByIdentifier('article');
+ $folderType = $contentTypeService->loadContentTypeByIdentifier('folder');
+ yield 'ContentTypeLimitation IN (article)' => [
+ [new Limitation\ContentTypeLimitation(['limitationValues' => [$articleType->id]])],
+ ['remote-id-article-1', 'remote-id-article-2', 'remote-id-article-3'],
+ ];
+ yield 'ContentTypeLimitation IN (folder)' => [
+ [new Limitation\ContentTypeLimitation(['limitationValues' => [$folderType->id]])],
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['folder1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2'],
+ ],
+ ];
+ yield 'ContentTypeLimitation IN (article, folder)' => [
+ [
+ new Limitation\ContentTypeLimitation(
+ ['limitationValues' => [$articleType->id, $folderType->id]]
+ ),
+ ],
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['folder1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article2'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article3'],
+ ],
+ ];
+
+ // Section Limitation
+ $sectionService = $repository->getSectionService();
+ $standardSection = $sectionService->loadSectionByIdentifier('standard');
+ yield 'SectionLimitation IN (standard)' => [
+ [new Limitation\SectionLimitation(['limitationValues' => [$standardSection->id]])],
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['folder1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article2'],
+ ],
+ ];
+
+ // User-related Limitations
+ yield 'OwnerLimitation = self' => [
+ [new Limitation\OwnerLimitation(['limitationValues' => [1]])],
+ [],
+ ];
+
+ yield 'UserGroupLimitation = self' => [
+ [new Limitation\UserGroupLimitation(['limitationValues' => [1]])],
+ [],
+ ];
+
+ // Location-related Limitations
+ yield 'LocationLimitation IN (administrator users)' => [
+ [new Limitation\LocationLimitation(['limitationValues' => [2]])],
+ [],
+ ];
+
+ yield 'SubtreeLimitation IN (/1/2/)' => [
+ [new Limitation\SubtreeLimitation(['limitationValues' => ['/1/2/']])],
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['folder1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article2'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article3'],
+ ],
+ ];
+
+ // Object State Limitation
+ yield 'ObjectStateLimitation IN (locked, not_locked)' => [
+ [new Limitation\ObjectStateLimitation(['limitationValues' => [1, 2]])],
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['folder1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article2'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article3'],
+ ],
+ ];
+
+ yield 'ContentTypeLimitation AND SectionLimitation' => [
+ [
+ new Limitation\ContentTypeLimitation(['limitationValues' => [$articleType->id]]),
+ new Limitation\SectionLimitation(['limitationValues' => [$standardSection->id]]),
+ ],
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['article1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['article2'],
+ ],
+ ];
+ }
+
+ /**
+ * Build Repository Filter from a callable factory accepting Content item as a container for
+ * all items under test.
+ */
+ protected function buildFilter(callable $filterFactory, Content $parentFolder): Filter
+ {
+ return $filterFactory($parentFolder);
+ }
+}
diff --git a/eZ/Publish/API/Repository/Tests/Filtering/ContentFilteringTest.php b/eZ/Publish/API/Repository/Tests/Filtering/ContentFilteringTest.php
new file mode 100644
index 0000000000..7221073612
--- /dev/null
+++ b/eZ/Publish/API/Repository/Tests/Filtering/ContentFilteringTest.php
@@ -0,0 +1,374 @@
+getRepository(false);
+ $locationService = $repository->getLocationService();
+ $contentService = $repository->getContentService();
+
+ $this->contentProvider->createSharedContentStructure();
+
+ // sanity check
+ $locations = $locationService->loadLocations(
+ $contentService->loadContentInfoByRemoteId(
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2']
+ )
+ );
+ self::assertCount(2, $locations);
+ [$location1, $location2] = $locations;
+ self::assertNotEquals($location1->depth, $location2->depth);
+
+ $sortClause = new SortClause\Location\Depth(Query::SORT_ASC);
+
+ $filter = new Filter();
+ $filter
+ ->withCriterion(
+ new Criterion\RemoteId(TestContentProvider::CONTENT_REMOTE_IDS['folder2'])
+ )
+ ->orWithCriterion(
+ new Criterion\RemoteId(TestContentProvider::CONTENT_REMOTE_IDS['folder1'])
+ )
+ ->withSortClause(new SortClause\ContentName(Query::SORT_ASC))
+ ->withSortClause($sortClause);
+
+ $contentList = $contentService->find($filter);
+ $this->assertFoundContentItemsByRemoteIds(
+ $contentList,
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['folder1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2'],
+ ]
+ );
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ * @throws \Exception
+ */
+ protected function compareWithSearchResults(Filter $filter, IteratorAggregate $filteredContentList): void
+ {
+ $query = $this->buildSearchQueryFromFilter($filter);
+ $contentListFromSearch = $this->findUsingContentSearch($query);
+ self::assertCount($contentListFromSearch->getTotalCount(), $filteredContentList);
+ $filteredContentListIterator = $filteredContentList->getIterator();
+ foreach ($contentListFromSearch as $pos => $expectedContentItem) {
+ $this->assertContentItemEquals(
+ $expectedContentItem,
+ $filteredContentListIterator->offsetGet($pos),
+ "Content items at the position {$pos} are not the same"
+ );
+ }
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ */
+ protected function findUsingContentSearch(Query $query): ContentList
+ {
+ $repository = $this->getRepository(false);
+ $searchService = $repository->getSearchService();
+ $searchResults = $searchService->findContent($query);
+
+ return new ContentList(
+ $searchResults->totalCount,
+ array_map(
+ static function (SearchHit $searchHit) {
+ return $searchHit->valueObject;
+ },
+ $searchResults->searchHits
+ )
+ );
+ }
+
+ protected function getDefaultSortClause(): FilteringSortClause
+ {
+ return new SortClause\ContentId();
+ }
+
+ public function getFilterFactories(): iterable
+ {
+ yield from parent::getFilterFactories();
+
+ yield 'Content remote ID for an item without any Location' => [
+ static function (Content $parentFolder): Filter {
+ return (new Filter())
+ ->withCriterion(
+ new Criterion\RemoteId(TestContentProvider::CONTENT_REMOTE_IDS['no-location'])
+ );
+ },
+ // expected total count
+ 1,
+ ];
+ }
+
+ /**
+ * Create Folder and sub-folders matching expected paginator page size (creates `$pageSize` * `$noOfPages` items).
+ *
+ * @param int $pageSize
+ * @param int $noOfPages
+ *
+ * @return int parent Folder Location ID
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ private function createMultiplePagesOfContentItems(int $pageSize, int $noOfPages): int
+ {
+ $parentFolder = $this->createFolder(['eng-GB' => 'Parent Folder'], 2);
+ $parentFolderMainLocationId = $parentFolder->contentInfo->mainLocationId;
+
+ $noOfItems = $pageSize * $noOfPages;
+ for ($itemNo = 1; $itemNo <= $noOfItems; ++$itemNo) {
+ $this->createFolder(['eng-GB' => "Child no #{$itemNo}"], $parentFolderMainLocationId);
+ }
+
+ return $parentFolderMainLocationId;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function testPagination(): void
+ {
+ $pageSize = 10;
+ $noOfPages = 4;
+ $parentLocationId = $this->createMultiplePagesOfContentItems($pageSize, $noOfPages);
+
+ $collectedContentIDs = [];
+ $filter = new Filter(new Criterion\ParentLocationId($parentLocationId));
+ for ($offset = 0; $offset < $noOfPages; $offset += $pageSize) {
+ $filter->sliceBy($pageSize, 0);
+ $contentList = $this->find($filter);
+
+ // a total count reflects a total number of items, not a number of items on a current page
+ self::assertSame($pageSize * $noOfPages, $contentList->getTotalCount());
+
+ // an actual number of items on a current page
+ self::assertCount($pageSize, $contentList);
+
+ // check if results are not duplicated across multiple pages
+ foreach ($contentList as $contentItem) {
+ self::assertNotContains(
+ $contentItem->id,
+ $collectedContentIDs,
+ "Content item ID={$contentItem->id} exists on multiple pages"
+ );
+ $collectedContentIDs[] = $contentItem->id;
+ }
+ }
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function testFindContentWithExternalStorageFields(): void
+ {
+ $repository = $this->getRepository();
+ $contentService = $repository->getContentService();
+ $contentTypeService = $repository->getContentTypeService();
+
+ $blogType = $contentTypeService->loadContentTypeByIdentifier('blog');
+ $contentCreate = $contentService->newContentCreateStruct($blogType, 'eng-GB');
+ $contentCreate->setField('name', 'British Blog');
+ $contentCreate->setField('tags', new Keyword\Value(['British', 'posts']));
+ $contentDraft = $contentService->createContent($contentCreate);
+ $contentService->publishVersion($contentDraft->getVersionInfo());
+
+ $filter = new Filter(new Criterion\ContentTypeIdentifier('blog'));
+ $contentList = $this->find($filter, []);
+
+ self::assertSame(1, $contentList->getTotalCount());
+ self::assertCount(1, $contentList);
+
+ foreach ($contentList as $content) {
+ $legacyLoadedContent = $contentService->loadContent($content->id, []);
+ self::assertEquals($legacyLoadedContent, $content);
+ }
+ }
+
+ /**
+ * @dataProvider getDataForTestFindContentWithLocationCriterion
+ *
+ * @param string[] $expectedContentRemoteIds
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function testFindContentUsingLocationCriterion(
+ callable $filterFactory,
+ array $expectedContentRemoteIds
+ ): void {
+ $parentFolder = $this->contentProvider->createSharedContentStructure();
+ $filter = $this->buildFilter($filterFactory, $parentFolder);
+ $this->assertFoundContentItemsByRemoteIds(
+ $this->find($filter, []),
+ $expectedContentRemoteIds
+ );
+ }
+
+ public function getDataForTestFindContentWithLocationCriterion(): iterable
+ {
+ yield 'Content items with secondary Location, sorted by Content ID' => [
+ static function (Content $parentFolder): Filter {
+ return (new Filter())
+ ->withCriterion(
+ new Criterion\Location\IsMainLocation(
+ Criterion\Location\IsMainLocation::NOT_MAIN
+ )
+ )
+ ->withSortClause(new SortClause\ContentId(Query::SORT_ASC));
+ },
+ [TestContentProvider::CONTENT_REMOTE_IDS['folder2']],
+ ];
+
+ yield 'Folders with Location, sorted by Content ID' => [
+ static function (Content $parentFolder): Filter {
+ return (new Filter())
+ ->withCriterion(
+ new Criterion\Location\IsMainLocation(
+ Criterion\Location\IsMainLocation::MAIN
+ )
+ )
+ ->andWithCriterion(
+ new Criterion\ParentLocationId($parentFolder->contentInfo->mainLocationId)
+ )
+ ->andWithCriterion(
+ new Criterion\ContentTypeIdentifier('folder')
+ )
+ ->withSortClause(new SortClause\ContentId(Query::SORT_ASC));
+ },
+ [
+ TestContentProvider::CONTENT_REMOTE_IDS['folder1'],
+ TestContentProvider::CONTENT_REMOTE_IDS['folder2'],
+ ],
+ ];
+ }
+
+ protected function assertFoundContentItemsByRemoteIds(
+ iterable $list,
+ array $expectedContentRemoteIds
+ ): void {
+ self::assertCount(count($expectedContentRemoteIds), $list);
+ foreach ($list as $content) {
+ /** @var \eZ\Publish\API\Repository\Values\Content\Content $content */
+ self::assertContainsEquals(
+ $content->contentInfo->remoteId,
+ $expectedContentRemoteIds,
+ sprintf(
+ 'Content %d (%s) was not supposed to be found',
+ $content->id,
+ $content->contentInfo->remoteId
+ )
+ );
+ }
+ }
+
+ /**
+ * @dataProvider getListOfSupportedSortClauses
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function testFindWithSortClauses(string $sortClauseFQCN): void
+ {
+ $this->performAndAssertSimpleSortClauseQuery(new $sortClauseFQCN(Query::SORT_ASC));
+ $this->performAndAssertSimpleSortClauseQuery(new $sortClauseFQCN(Query::SORT_DESC));
+ }
+
+ /**
+ * Simple test to check each sort clause validity on a database integration level.
+ *
+ * Note: It should be expanded in the future to check validity of the sorting logic itself
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ private function performAndAssertSimpleSortClauseQuery(FilteringSortClause $sortClause): void
+ {
+ $filter = new Filter(new Criterion\ContentId(57), [$sortClause]);
+ $contentList = $this->find($filter, []);
+ self::assertCount(1, $contentList);
+ self::assertSame(57, $contentList->getIterator()[0]->id);
+ }
+
+ public function getListOfSupportedSortClauses(): iterable
+ {
+ yield 'Content\\Id' => [SortClause\ContentId::class];
+ yield 'ContentName' => [SortClause\ContentName::class];
+ yield 'DateModified' => [SortClause\DateModified::class];
+ yield 'DatePublished' => [SortClause\DatePublished::class];
+ yield 'SectionIdentifier' => [SortClause\SectionIdentifier::class];
+ yield 'SectionName' => [SortClause\SectionName::class];
+ yield 'Location\\Depth' => [SortClause\Location\Depth::class];
+ yield 'Location\\Id' => [SortClause\Location\Id::class];
+ yield 'Location\\Path' => [SortClause\Location\Path::class];
+ yield 'Location\\Priority' => [SortClause\Location\Priority::class];
+ yield 'Location\\Visibility' => [SortClause\Location\Visibility::class];
+ }
+
+ /**
+ * @return \eZ\Publish\API\Repository\Values\Content\ContentList
+ */
+ protected function find(Filter $filter, ?array $contextLanguages = null): iterable
+ {
+ $repository = $this->getRepository(false);
+ $contentService = $repository->getContentService();
+
+ return $contentService->find($filter, $contextLanguages);
+ }
+
+ private function buildSearchQueryFromFilter(Filter $filter): Query
+ {
+ $limit = $filter->getLimit();
+
+ return new Query(
+ [
+ 'filter' => $filter->getCriterion(),
+ 'sortClauses' => $filter->getSortClauses(),
+ 'offset' => $filter->getOffset(),
+ 'limit' => $limit > 0 ? $limit : 999,
+ ]
+ );
+ }
+}
diff --git a/eZ/Publish/API/Repository/Tests/Filtering/LocationFilteringTest.php b/eZ/Publish/API/Repository/Tests/Filtering/LocationFilteringTest.php
new file mode 100644
index 0000000000..20c21291c2
--- /dev/null
+++ b/eZ/Publish/API/Repository/Tests/Filtering/LocationFilteringTest.php
@@ -0,0 +1,119 @@
+buildSearchQueryFromFilter($filter);
+ $locationListFromSearch = $this->findUsingLocationSearch($query);
+ self::assertEquals($locationListFromSearch, $filteredLocationList);
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ */
+ private function findUsingLocationSearch(LocationQuery $query): LocationList
+ {
+ $repository = $this->getRepository(false);
+ $searchService = $repository->getSearchService();
+ $searchResults = $searchService->findLocations($query);
+
+ return new LocationList(
+ [
+ 'totalCount' => $searchResults->totalCount,
+ 'locations' => array_map(
+ static function (SearchHit $searchHit) {
+ return $searchHit->valueObject;
+ },
+ $searchResults->searchHits
+ ),
+ ]
+ );
+ }
+
+ protected function getDefaultSortClause(): FilteringSortClause
+ {
+ return new Query\SortClause\Location\Id();
+ }
+
+ public function getCriteriaForInitialData(): iterable
+ {
+ yield 'Location\\Depth=2' => new Criterion\Location\Depth(Criterion\Operator::EQ, 2);
+ yield 'Location\\IsMainLocation' => new Criterion\Location\IsMainLocation(
+ Criterion\Location\IsMainLocation::MAIN
+ );
+ yield 'Location\\Priority>0' => new Criterion\Location\Priority(Criterion\Operator::GT, 0);
+
+ yield from parent::getCriteriaForInitialData();
+ }
+
+ /**
+ * @return \eZ\Publish\API\Repository\Values\Content\LocationList
+ */
+ protected function find(Filter $filter, ?array $contextLanguages = null): iterable
+ {
+ $repository = $this->getRepository(false);
+ $locationService = $repository->getLocationService();
+
+ return $locationService->find($filter, $contextLanguages);
+ }
+
+ protected function assertFoundContentItemsByRemoteIds(
+ iterable $list,
+ array $expectedContentRemoteIds
+ ): void {
+ foreach ($list as $location) {
+ /** @var \eZ\Publish\API\Repository\Values\Content\Location $location */
+ $contentInfo = $location->getContentInfo();
+ self::assertContainsEquals(
+ $contentInfo->remoteId,
+ $expectedContentRemoteIds,
+ sprintf(
+ 'Content %d (%s) at Location %d was not supposed to be found',
+ $location->id,
+ $contentInfo->id,
+ $contentInfo->remoteId
+ )
+ );
+ }
+ }
+
+ private function buildSearchQueryFromFilter(Filter $filter): LocationQuery
+ {
+ $limit = $filter->getLimit();
+
+ return new LocationQuery(
+ [
+ 'filter' => $filter->getCriterion(),
+ 'sortClauses' => $filter->getSortClauses(),
+ 'offset' => $filter->getOffset(),
+ 'limit' => $limit > 0 ? $limit : 999,
+ ]
+ );
+ }
+}
diff --git a/eZ/Publish/API/Repository/Tests/Filtering/TestContentProvider.php b/eZ/Publish/API/Repository/Tests/Filtering/TestContentProvider.php
new file mode 100644
index 0000000000..73a989bc39
--- /dev/null
+++ b/eZ/Publish/API/Repository/Tests/Filtering/TestContentProvider.php
@@ -0,0 +1,198 @@
+ 'content-remote-id-parent-folder',
+ 'folder1' => 'content-remote-id-folder1',
+ 'folder2' => 'content-remote-id-folder2',
+ 'no-location' => 'content-remote-id-folder-without-location',
+ 'article1' => 'remote-id-article-1',
+ 'article2' => 'remote-id-article-2',
+ 'article3' => 'remote-id-article-3',
+ ];
+
+ /** @var \eZ\Publish\API\Repository\Repository */
+ private $repository;
+
+ /** @var \eZ\Publish\API\Repository\Tests\BaseTest */
+ private $testInstance;
+
+ public function __construct(Repository $repository, BaseTest $testInstance)
+ {
+ $this->repository = $repository;
+ $this->testInstance = $testInstance;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function createSharedContentStructure(): Content
+ {
+ $locationService = $this->repository->getLocationService();
+ $contentService = $this->repository->getContentService();
+
+ try {
+ // see if the data is already there
+ return $contentService->loadContentByRemoteId(self::CONTENT_REMOTE_IDS['parent']);
+ } catch (NotFoundException $e) {
+ // don't do anything
+ }
+
+ $parentFolder = $this->testInstance->createFolder(
+ [
+ self::ENG_GB => 'English Folder',
+ self::ENG_US => 'American Folder',
+ ],
+ 2,
+ self::CONTENT_REMOTE_IDS['parent'],
+ );
+ $rootLocationId = $parentFolder->contentInfo->mainLocationId;
+ $this->testInstance->createFolder(
+ [
+ self::ENG_GB => 'Nested English Folder 1',
+ self::ENG_US => 'Nested American Folder 1',
+ ],
+ $rootLocationId,
+ self::CONTENT_REMOTE_IDS['folder1'],
+ );
+ $folder2 = $this->testInstance->createFolder(
+ [
+ self::ENG_GB => 'Nested English Folder 2',
+ self::ENG_US => 'Nested American Folder 2',
+ ],
+ $rootLocationId,
+ self::CONTENT_REMOTE_IDS['folder2']
+ );
+ // create extra Location for 2nd folder
+ $locationService->createLocation(
+ $folder2->contentInfo,
+ $locationService->newLocationCreateStruct(2)
+ );
+
+ $this->testInstance->createFolder(
+ [
+ self::ENG_US => 'Folder w/o Location',
+ ],
+ null,
+ self::CONTENT_REMOTE_IDS['no-location']
+ );
+
+ $this->createArticle('Article 1', $rootLocationId, self::CONTENT_REMOTE_IDS['article1']);
+ $this->createArticle('Article 2', $rootLocationId, self::CONTENT_REMOTE_IDS['article2']);
+ $this->createArticle(
+ 'Article 3',
+ $rootLocationId,
+ self::CONTENT_REMOTE_IDS['article3'],
+ 'new_section'
+ );
+
+ return $parentFolder;
+ }
+
+ /**
+ * @param string $contentTypeIdentifier
+ * @param array $multilingualFields structure:
+ *
+ * [
+ * '<field_definition_identifier>' =>
+ * [
+ * '<language_code>' => <value>
+ * ]
+ * ]
+ *
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ public function createContentDraft(
+ string $contentTypeIdentifier,
+ array $multilingualFields,
+ ?int $parentLocationId = null
+ ): Content {
+ $contentTypeService = $this->repository->getContentTypeService();
+ $contentService = $this->repository->getContentService();
+
+ $locationCreateStructList = [];
+ if (null !== $parentLocationId) {
+ $locationCreateStructList = [
+ $this->repository->getLocationService()->newLocationCreateStruct($parentLocationId),
+ ];
+ }
+
+ $folderType = $contentTypeService->loadContentTypeByIdentifier($contentTypeIdentifier);
+ // first language of first Field is to be main one
+ $mainLanguageCode = array_keys(array_values($multilingualFields)[0])[0];
+ $contentCreate = $contentService->newContentCreateStruct($folderType, $mainLanguageCode);
+ foreach ($multilingualFields as $fieldDefinitionIdentifier => $translations) {
+ foreach ($translations as $languageCode => $value) {
+ $contentCreate->setField($fieldDefinitionIdentifier, $value, $languageCode);
+ }
+ }
+
+ return $contentService->createContent($contentCreate, $locationCreateStructList);
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ private function createArticle(
+ string $title,
+ int $parentLocationId,
+ string $remoteId,
+ ?string $sectionName = null
+ ): Content {
+ $contentTypeService = $this->repository->getContentTypeService();
+ $contentService = $this->repository->getContentService();
+ $locationService = $this->repository->getLocationService();
+
+ $articleType = $contentTypeService->loadContentTypeByIdentifier('article');
+ $articleCreate = $contentService->newContentCreateStruct($articleType, self::ENG_GB);
+ $articleCreate->remoteId = $remoteId;
+ if (null !== $sectionName) {
+ $articleCreate->sectionId = $this->createSection($sectionName)->id;
+ }
+ $articleCreate->setField('title', $title);
+ $contentDraft = $contentService->createContent(
+ $articleCreate,
+ [$locationService->newLocationCreateStruct($parentLocationId)]
+ );
+
+ return $contentService->publishVersion($contentDraft->getVersionInfo());
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException
+ */
+ private function createSection(string $sectionIdentifier): Section
+ {
+ $sectionService = $this->repository->getSectionService();
+ $sectionCreate = $sectionService->newSectionCreateStruct();
+ $sectionCreate->identifier = $sectionIdentifier;
+ $sectionCreate->name = ucfirst($sectionIdentifier);
+
+ return $sectionService->createSection($sectionCreate);
+ }
+}
diff --git a/eZ/Publish/API/Repository/Tests/PHPUnitConstraint/ContentItemEquals.php b/eZ/Publish/API/Repository/Tests/PHPUnitConstraint/ContentItemEquals.php
new file mode 100644
index 0000000000..9b8f715d98
--- /dev/null
+++ b/eZ/Publish/API/Repository/Tests/PHPUnitConstraint/ContentItemEquals.php
@@ -0,0 +1,136 @@
+expectedContent = $expectedContent;
+ }
+
+ public function evaluate($content, string $description = '', bool $returnResult = false): bool
+ {
+ if (!$content instanceof Content) {
+ return false;
+ }
+ if ($this->expectedContent === $content) {
+ return true;
+ }
+
+ $comparatorFactory = ComparatorFactory::getInstance();
+
+ try {
+ // Note: intentionally didn't implement custom comparator, to re-use built-in ones
+ // for chosen properties
+ $this->compareValueObjects(
+ $comparatorFactory,
+ $this->expectedContent->getContentType(),
+ $content->getContentType()
+ );
+ $this->compareValueObjects(
+ $comparatorFactory,
+ $this->expectedContent->getVersionInfo(),
+ $content->getVersionInfo()
+ );
+ $this->compareValueObjects(
+ $comparatorFactory,
+ $this->expectedContent->getThumbnail(),
+ $content->getThumbnail()
+ );
+ $this->compareArrays(
+ $comparatorFactory,
+ $this->expectedContent->fields,
+ $content->fields
+ );
+ } catch (ComparisonFailure $f) {
+ if ($returnResult) {
+ return false;
+ }
+
+ $msg = sprintf(
+ "%s\nContent item [%d] \"%s\" is not the same as [%d] \"%s\"\n%s",
+ $description,
+ $this->expectedContent->id,
+ $this->expectedContent->getName(),
+ $content->id,
+ $content->getName(),
+ $f->getMessage()
+ );
+
+ throw new ExpectationFailedException(trim($msg), $f);
+ }
+
+ return true;
+ }
+
+ public function toString(): string
+ {
+ return sprintf(
+ 'is the same as Content item [%d] "%s"',
+ $this->expectedContent->id,
+ $this->expectedContent->getName()
+ );
+ }
+
+ protected function failureDescription($content): string
+ {
+ return sprintf(
+ 'Content item [%d] "%s" has the same data as [%d] "%s"',
+ $content->id,
+ $content->getName(),
+ $this->expectedContent->id,
+ $this->expectedContent->getName()
+ );
+ }
+
+ private function compareValueObjects(
+ ComparatorFactory $comparatorFactory,
+ ?ValueObject $expected,
+ ?ValueObject $actual
+ ): void {
+ $comparator = $comparatorFactory->getComparatorFor(
+ $expected,
+ $actual
+ );
+
+ $comparator->assertEquals(
+ $expected,
+ $actual,
+ );
+ }
+
+ private function compareArrays(
+ ComparatorFactory $comparatorFactory,
+ array $expected,
+ array $actual
+ ): void {
+ $comparator = $comparatorFactory->getComparatorFor(
+ $expected,
+ $actual
+ );
+
+ $comparator->assertEquals(
+ $expected,
+ $actual,
+ );
+ }
+}
diff --git a/eZ/Publish/API/Repository/Tests/SetupFactory/Legacy.php b/eZ/Publish/API/Repository/Tests/SetupFactory/Legacy.php
index 74f91b3297..1476857d94 100644
--- a/eZ/Publish/API/Repository/Tests/SetupFactory/Legacy.php
+++ b/eZ/Publish/API/Repository/Tests/SetupFactory/Legacy.php
@@ -7,8 +7,11 @@
namespace eZ\Publish\API\Repository\Tests\SetupFactory;
use Doctrine\DBAL\Connection;
+use eZ\Bundle\EzPublishCoreBundle\DependencyInjection\ServiceTags;
use eZ\Publish\API\Repository\Tests\LegacySchemaImporter;
use eZ\Publish\Core\Base\ServiceContainer;
+use eZ\Publish\SPI\Repository\Values\Filter\CriterionQueryBuilder as FilteringCriterionQueryBuilder;
+use eZ\Publish\SPI\Repository\Values\Filter\SortClauseQueryBuilder as FilteringSortClauseQueryBuilder;
use eZ\Publish\SPI\Tests\Persistence\Fixture;
use eZ\Publish\SPI\Tests\Persistence\FixtureImporter;
use eZ\Publish\SPI\Tests\Persistence\YamlFixture;
@@ -319,6 +322,8 @@ public function getServiceContainer()
$containerBuilder->addCompilerPass(new Compiler\Search\FieldRegistryPass());
+ $this->registerForAutoConfiguration($containerBuilder);
+
// load overrides just before creating test Container
$loader->load('tests/override.yml');
@@ -354,4 +359,22 @@ public function getDB()
{
return self::$db;
}
+
+ /**
+ * Apply automatic configuration to needed Symfony Services.
+ *
+ * Note: Based on
+ * {@see \eZ\Bundle\EzPublishCoreBundle\DependencyInjection\EzPublishCoreExtension::registerForAutoConfiguration},
+ * but only for services needed by integration test setup.
+ *
+ * @see
+ */
+ private function registerForAutoConfiguration(ContainerBuilder $containerBuilder): void
+ {
+ $containerBuilder->registerForAutoconfiguration(FilteringCriterionQueryBuilder::class)
+ ->addTag(ServiceTags::FILTERING_CRITERION_QUERY_BUILDER);
+
+ $containerBuilder->registerForAutoconfiguration(FilteringSortClauseQueryBuilder::class)
+ ->addTag(ServiceTags::FILTERING_SORT_CLAUSE_QUERY_BUILDER);
+ }
}
diff --git a/eZ/Publish/API/Repository/Tests/Values/Filter/FilterTest.php b/eZ/Publish/API/Repository/Tests/Values/Filter/FilterTest.php
new file mode 100644
index 0000000000..6edbdef113
--- /dev/null
+++ b/eZ/Publish/API/Repository/Tests/Values/Filter/FilterTest.php
@@ -0,0 +1,433 @@
+getCriterion());
+ self::assertEquals($sortClauses, $filter->getSortClauses());
+ }
+
+ /**
+ * @dataProvider getInvalidSortClausesData
+ *
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::__construct
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function testConstructorThrowsBadStateException(
+ array $sortClauses,
+ string $expectedExceptionMessage
+ ): Filter {
+ $this->expectException(BadStateException::class);
+ $this->expectExceptionMessage($expectedExceptionMessage);
+
+ return new Filter(new Criterion\ParentLocationId(3), $sortClauses);
+ }
+
+ public function getInvalidSortClausesData(): iterable
+ {
+ yield [
+ [
+ new SortClause\Location\Priority(),
+ 1,
+ ],
+ 'Expected an instance of "eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause", ' .
+ 'got "integer" at position 1',
+ ];
+
+ yield [
+ [
+ new SortClause\Location\Depth(),
+ new URLQuerySortClause\URL(Query::SORT_DESC),
+ Query::SORT_ASC,
+ ],
+ 'Expected an instance of "eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause", ' .
+ 'got "eZ\Publish\API\Repository\Values\URL\Query\SortClause\URL" at position 1',
+ ];
+
+ yield [
+ [
+ new SortClause\DatePublished(),
+ new SortClause\SectionIdentifier(Query::SORT_DESC),
+ Query::SORT_ASC,
+ new class('', Query::SORT_DESC) extends URLQuerySortClause {
+ },
+ ],
+ 'Expected an instance of "eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause", ' .
+ 'got "string" at position 2',
+ ];
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::withCriterion
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getCriterion
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function testWithCriterion(): Filter
+ {
+ $filter = new Filter();
+ self::assertNull($filter->getCriterion());
+ $criterion = new Criterion\ContentId(1);
+ $filter->withCriterion($criterion);
+ self::assertEquals($criterion, $filter->getCriterion());
+
+ return $filter;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function testWithCriterionThrowsBadStateException(): void
+ {
+ $filter = new Filter();
+ $filter->withCriterion(new Criterion\ParentLocationId(2));
+
+ $this->expectException(BadStateException::class);
+ $filter->withCriterion(new Criterion\ContentId(2));
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::andWithCriterion
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getCriterion
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function testAndWithCriterion(): Filter
+ {
+ $criterion1 = new Criterion\ContentId(1);
+ $criterion2 = new Criterion\RemoteId(md5('/1/2/3/'));
+ $criterion3 = new Criterion\Ancestor('/1/2/');
+
+ $filter = new Filter();
+ $filter->withCriterion($criterion1);
+
+ $filter->andWithCriterion($criterion2);
+ $expectedCriterion = new Criterion\LogicalAnd([$criterion1, $criterion2]);
+ self::assertEquals($expectedCriterion, $filter->getCriterion());
+
+ $filter->andWithCriterion($criterion3);
+ $expectedCriterion = new Criterion\LogicalAnd([$criterion1, $criterion2, $criterion3]);
+ self::assertEquals($expectedCriterion, $filter->getCriterion());
+
+ return $filter;
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::orWithCriterion
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getCriterion
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function testOrWithCriterion(): Filter
+ {
+ // sanity check
+ $criterion1 = new Criterion\ContentId(1);
+ $criterion2 = new Criterion\RemoteId(1);
+ $criterion3 = new Criterion\Ancestor('/1/2/');
+
+ $filter = new Filter();
+ $filter->withCriterion($criterion1);
+
+ $filter->orWithCriterion($criterion2);
+ $expectedCriterion = new Criterion\LogicalOr([$criterion1, $criterion2]);
+ self::assertEquals($expectedCriterion, $filter->getCriterion());
+
+ $filter->orWithCriterion($criterion3);
+ $expectedCriterion = new Criterion\LogicalOr([$criterion1, $criterion2, $criterion3]);
+ self::assertEquals($expectedCriterion, $filter->getCriterion());
+
+ return $filter;
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::withSortClause
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getSortClauses
+ */
+ public function testWithSortClause(): Filter
+ {
+ $filter = new Filter();
+ // sanity check
+ self::assertSame([], $filter->getSortClauses());
+
+ $sortClause1 = new SortClause\Location\Priority(Query::SORT_DESC);
+ $filter->withSortClause($sortClause1);
+ self::assertContainsEquals($sortClause1, $filter->getSortClauses());
+
+ $sortClause2 = new SortClause\Location\Priority(Query::SORT_DESC);
+ $filter->withSortClause($sortClause2);
+ self::assertContainsEquals($sortClause2, $filter->getSortClauses());
+ self::assertSame([$sortClause1, $sortClause2], $filter->getSortClauses());
+
+ return $filter;
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getCriterion
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getSortClauses
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getOffset
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getLimit
+ *
+ * @dataProvider getComplexFilterTestData
+ *
+ * @param \eZ\Publish\API\Repository\Values\Content\Query\SortClause[] $expectedSortClauses
+ */
+ public function testBuildingComplexFilter(
+ Filter $filter,
+ ?Criterion $expectedCriterion,
+ array $expectedSortClauses,
+ int $expectedLimit = 0,
+ int $expectedOffset = 0
+ ): void {
+ self::assertEquals($expectedCriterion, $filter->getCriterion());
+ self::assertEquals($expectedSortClauses, $filter->getSortClauses());
+ self::assertEquals($expectedOffset, $filter->getOffset());
+ self::assertEquals($expectedLimit, $filter->getLimit());
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ */
+ public function getComplexFilterTestData(): iterable
+ {
+ $parent1Criterion = new Criterion\ParentLocationId(1);
+ $engGBCriterion = new Criterion\LanguageCode('eng-GB');
+ $parent2Criterion = new Criterion\ParentLocationId(2);
+ $gerDECriterion = new Criterion\LanguageCode('ger-DE');
+
+ $criterion = new Criterion\LogicalOr(
+ [
+ new Criterion\LogicalAnd(
+ [
+ $parent1Criterion,
+ $engGBCriterion,
+ ]
+ ),
+ new Criterion\LogicalAnd(
+ [
+ $parent2Criterion,
+ $gerDECriterion,
+ ]
+ ),
+ ]
+ );
+ $sortClauses = [
+ new SortClause\Location\Priority(),
+ new SortClause\ContentName(Query::SORT_DESC),
+ ];
+ $filter = new Filter();
+ $filter
+ ->withCriterion($criterion->criteria[0])
+ ->orWithCriterion($criterion->criteria[1])
+ ->withSortClause($sortClauses[0])
+ ->withSortClause($sortClauses[1]);
+
+ yield '(parent=1 AND language=eng-GB) OR (parent=2 AND language=ger-DE)' => [
+ $filter,
+ $criterion,
+ $sortClauses,
+ ];
+
+ $criterion = new Criterion\LogicalAnd(
+ [
+ new Criterion\LogicalOr(
+ [
+ new Criterion\LogicalAnd(
+ [
+ $parent1Criterion,
+ $engGBCriterion,
+ ]
+ ),
+ $parent2Criterion,
+ ]
+ ),
+ $gerDECriterion,
+ ]
+ );
+
+ $filter = new Filter();
+ $filter
+ ->withCriterion($parent1Criterion)
+ ->andWithCriterion($engGBCriterion)
+ ->orWithCriterion($parent2Criterion)
+ ->andWithCriterion($gerDECriterion)
+ ->withSortClause($sortClauses[1]);
+
+ yield '(parent=1 AND language=eng-GB OR parent=2) AND language=ger-DE' => [
+ $filter,
+ $criterion,
+ [$sortClauses[1]],
+ ];
+
+ // pagination / slices support:
+
+ $filter = new Filter();
+ $filter->sliceBy(10, 0);
+
+ yield 'sliceBy(limit=10, offset=0)' => [
+ $filter,
+ null,
+ [],
+ 10,
+ 0,
+ ];
+
+ $filter = new Filter();
+ $filter->sliceBy(25, 10);
+
+ yield 'sliceBy(limit=25, offset=10)' => [
+ $filter,
+ null,
+ [],
+ 25,
+ 10,
+ ];
+
+ // use case for offset with no limit: skip the latest item
+ $dateTimeSortClause = new SortClause\DatePublished(Query::SORT_DESC);
+ $filter = new Filter();
+ $filter
+ ->sliceBy(0, 1)
+ ->withSortClause($dateTimeSortClause);
+
+ yield 'sliceBy(limit=0, offset=1)' => [
+ $filter,
+ null,
+ [$dateTimeSortClause],
+ 0,
+ 1,
+ ];
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::sliceBy
+ *
+ * @dataProvider getFiltersWithInvalidSliceData
+ */
+ public function testSliceByThrowsInvalidArgumentException(
+ int $limit,
+ int $offset,
+ string $expectedExceptionMessage
+ ): void {
+ $filter = new Filter();
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($expectedExceptionMessage);
+
+ $filter->sliceBy($limit, $offset);
+ }
+
+ public function getFiltersWithInvalidSliceData(): iterable
+ {
+ yield [-1, 0, 'Argument \'$limit\' is invalid: Filtering slice limit needs to be >=0, got -1'];
+ yield [0, -1, 'Argument \'$offset\' is invalid: Filtering slice offset needs to be >=0, got -1'];
+ yield [
+ PHP_INT_MIN,
+ PHP_INT_MIN,
+ sprintf(
+ 'Argument \'$limit\' is invalid: Filtering slice limit needs to be >=0, got %d',
+ PHP_INT_MIN
+ ),
+ ];
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::reset
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getCriterion
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getSortClauses
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getOffset
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::getLimit
+ *
+ * @dataProvider getFilters
+ */
+ public function testReset(Filter $filter): void
+ {
+ $filter->reset();
+ self::assertEmpty($filter->getCriterion());
+ self::assertEmpty($filter->getSortClauses());
+ self::assertSame(0, $filter->getOffset());
+ self::assertSame(0, $filter->getLimit());
+ }
+
+ /**
+ * @covers \eZ\Publish\API\Repository\Values\Filter\Filter::__clone
+ *
+ * @dataProvider getFilters
+ */
+ public function testClone(Filter $filter): void
+ {
+ $clonedFilter = clone $filter;
+
+ self::assertEquals($filter->getCriterion(), $clonedFilter->getCriterion());
+ self::assertEquals($filter->getSortClauses(), $clonedFilter->getSortClauses());
+
+ if (null !== ($expectedCriterion = $filter->getCriterion())) {
+ self::assertNotSame($expectedCriterion, $clonedFilter->getCriterion());
+ }
+ if ([] !== ($expectedSortClauses = $filter->getSortClauses())) {
+ self::assertNotSame($expectedSortClauses, $clonedFilter->getSortClauses());
+ }
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function getFilters(): iterable
+ {
+ $criterion = new Criterion\LogicalAnd(
+ [
+ new Criterion\ParentLocationId(1),
+ ]
+ );
+
+ yield 'Filter with Criterion and Sort Clauses' => [
+ new Filter(
+ $criterion,
+ [
+ new SortClause\Location\Priority(),
+ new SortClause\ContentName(Query::SORT_DESC),
+ ]
+ ),
+ ];
+
+ yield 'Filter with Criterion only' => [new Filter($criterion)];
+
+ yield 'Filter with Sort Clause only' => [new Filter(null, [new SortClause\ContentName()])];
+
+ yield 'Empty Filter' => [new Filter()];
+ }
+}
diff --git a/eZ/Publish/API/Repository/Values/Content/ContentList.php b/eZ/Publish/API/Repository/Values/Content/ContentList.php
new file mode 100644
index 0000000000..60c70ef1a6
--- /dev/null
+++ b/eZ/Publish/API/Repository/Values/Content/ContentList.php
@@ -0,0 +1,47 @@
+totalCount = $totalCount;
+ $this->contentItems = $contentItems;
+ }
+
+ public function getTotalCount(): int
+ {
+ return $this->totalCount;
+ }
+
+ /**
+ * @return \eZ\Publish\API\Repository\Values\Content\Content[]|\Traversable
+ */
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->contentItems);
+ }
+}
diff --git a/eZ/Publish/API/Repository/Values/Content/LocationList.php b/eZ/Publish/API/Repository/Values/Content/LocationList.php
index 4d84c155ef..e483d0ed3e 100644
--- a/eZ/Publish/API/Repository/Values/Content/LocationList.php
+++ b/eZ/Publish/API/Repository/Values/Content/LocationList.php
@@ -27,15 +27,18 @@ class LocationList extends ValueObject implements IteratorAggregate
*
* @var int
*/
- protected $totalCount;
+ protected $totalCount = 0;
/**
* the partial list of locations controlled by offset/limit.
*
* @var \eZ\Publish\API\Repository\Values\Content\Location[]
*/
- protected $locations;
+ protected $locations = [];
+ /**
+ * @return \eZ\Publish\API\Repository\Values\Content\Location[]|\Traversable
+ */
public function getIterator(): Traversable
{
return new ArrayIterator($this->locations);
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Ancestor.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Ancestor.php
index 1a9fbe58e1..d016e19c52 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Ancestor.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Ancestor.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use InvalidArgumentException;
/**
@@ -17,7 +18,7 @@
*
* Content will be matched if it is part of at least one of the given subtree path strings.
*/
-class Ancestor extends Criterion
+class Ancestor extends Criterion implements FilteringCriterion
{
/**
* Creates a new Ancestor criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentId.php
index 9a5977c11c..f3b229e8c2 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on its id.
@@ -18,7 +19,7 @@
* - IN: will match from a list of ContentId
* - EQ: will match against one ContentId
*/
-class ContentId extends Criterion
+class ContentId extends Criterion implements FilteringCriterion
{
/**
* Creates a new ContentId criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeGroupId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeGroupId.php
index 8eaf1f07f5..46ffdf6bba 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeGroupId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeGroupId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that will match content based on its ContentTypeGroup id.
@@ -19,7 +20,7 @@
* - IN: will match from a list of ContentTypeGroup id
* - EQ: will match against one ContentTypeGroup id
*/
-class ContentTypeGroupId extends Criterion
+class ContentTypeGroupId extends Criterion implements FilteringCriterion
{
/**
* Creates a new ContentTypeGroup criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeId.php
index ef146ea434..f6ef15f97c 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeId.php
@@ -9,8 +9,9 @@
namespace eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
-use eZ\Publish\SPI\Repository\Values\Trash\Query\Criterion as TrashCriterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
+use eZ\Publish\SPI\Repository\Values\Trash\Query\Criterion as TrashCriterion;
/**
* A criterion that matches content based on its ContentType id.
@@ -19,7 +20,7 @@
* - IN: will match from a list of ContentTypeId
* - EQ: will match against one ContentTypeId
*/
-class ContentTypeId extends Criterion implements TrashCriterion
+class ContentTypeId extends Criterion implements TrashCriterion, FilteringCriterion
{
/**
* Creates a new ContentType criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeIdentifier.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeIdentifier.php
index ea6212c848..8cb485ce25 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeIdentifier.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ContentTypeIdentifier.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on its ContentType Identifier.
@@ -18,7 +19,7 @@
* - IN: will match from a list of ContentTypeIdentifier
* - EQ: will match against one ContentTypeIdentifier
*/
-class ContentTypeIdentifier extends Criterion
+class ContentTypeIdentifier extends Criterion implements FilteringCriterion
{
/**
* Creates a new ContentType criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/DateMetadata.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/DateMetadata.php
index d2ca0789f6..df5d1b1a4d 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/DateMetadata.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/DateMetadata.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use eZ\Publish\SPI\Repository\Values\Trash\Query\Criterion as TrashCriterion;
use InvalidArgumentException;
@@ -31,7 +32,7 @@
* );
*
*/
-class DateMetadata extends Criterion implements TrashCriterion
+class DateMetadata extends Criterion implements TrashCriterion, FilteringCriterion
{
public const MODIFIED = 'modified';
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserBased.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserBased.php
index 085ba75d95..34da491d7f 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserBased.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserBased.php
@@ -10,8 +10,9 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
-class IsUserBased extends Criterion
+class IsUserBased extends Criterion implements FilteringCriterion
{
public function __construct(bool $value = true)
{
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserEnabled.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserEnabled.php
index 0383a654e2..15db5a031e 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserEnabled.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/IsUserEnabled.php
@@ -10,8 +10,9 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
-class IsUserEnabled extends Criterion
+class IsUserEnabled extends Criterion implements FilteringCriterion
{
public function __construct(bool $value = true)
{
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LanguageCode.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LanguageCode.php
index 50b3ec5b4f..d0c4ff1361 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LanguageCode.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LanguageCode.php
@@ -11,6 +11,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentType;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on its language code and always-available state.
@@ -19,7 +20,7 @@
* - IN: matches against a list of language codes
* - EQ: matches against one language code
*/
-class LanguageCode extends Criterion
+class LanguageCode extends Criterion implements FilteringCriterion
{
/**
* Switch for matching Content that is always-available.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Depth.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Depth.php
index dc08265c9e..6198b0cbec 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Depth.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Depth.php
@@ -11,13 +11,14 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Location;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* The Depth Criterion class.
*
* Provides Location filtering based on depth
*/
-class Depth extends Location
+class Depth extends Location implements FilteringCriterion
{
/**
* Creates a new Depth criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/IsMainLocation.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/IsMainLocation.php
index 1f5c54d929..3b382d1d77 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/IsMainLocation.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/IsMainLocation.php
@@ -11,12 +11,13 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Location;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use InvalidArgumentException;
/**
* A criterion that matches Location based on if it is main Location or not.
*/
-class IsMainLocation extends Location
+class IsMainLocation extends Location implements FilteringCriterion
{
/**
* Main constant: is main.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Priority.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Priority.php
index 5cbb4ea6a5..2be706040f 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Priority.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Location/Priority.php
@@ -11,6 +11,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Location;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches Location based on its priority.
@@ -20,7 +21,7 @@
* - GT, GTE: matches location whose priority is greater than/greater than or equals the given priority
* - LT, LTE: matches location whose priority is lower than/lower than or equals the given priority
*/
-class Priority extends Location
+class Priority extends Location implements FilteringCriterion
{
/**
* Creates a new LocationPriority criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationId.php
index e6ccb6a6c6..a41796e181 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on its own location id.
@@ -20,7 +21,7 @@
* - IN: matches against a list of location ids
* - EQ: matches against a unique location id
*/
-class LocationId extends Criterion
+class LocationId extends Criterion implements FilteringCriterion
{
/**
* Creates a new LocationId criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationRemoteId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationRemoteId.php
index 8c35b9fe4d..166167192e 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationRemoteId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LocationRemoteId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on remote ID of its locations.
@@ -18,7 +19,7 @@
* - IN: will match from a list of location remote IDs
* - EQ: will match against one location remote ID
*/
-class LocationRemoteId extends Criterion
+class LocationRemoteId extends Criterion implements FilteringCriterion
{
/**
* Creates a new locationRemoteId criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalAnd.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalAnd.php
index e9fd823862..fd8f7e33ce 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalAnd.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalAnd.php
@@ -8,12 +8,13 @@
namespace eZ\Publish\API\Repository\Values\Content\Query\Criterion;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use eZ\Publish\SPI\Repository\Values\Trash\Query\Criterion as TrashCriterion;
/**
* This criterion implements a logical AND criterion and will only match
* if ALL of the given criteria match.
*/
-class LogicalAnd extends LogicalOperator implements TrashCriterion
+class LogicalAnd extends LogicalOperator implements TrashCriterion, FilteringCriterion
{
}
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalNot.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalNot.php
index 704025b27f..930d2b45fd 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalNot.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalNot.php
@@ -9,11 +9,12 @@
namespace eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A NOT logical criterion.
*/
-class LogicalNot extends LogicalOperator
+class LogicalNot extends LogicalOperator implements FilteringCriterion
{
/**
* Creates a new NOT logic criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalOr.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalOr.php
index fcf1a6afc1..d5480c6dfe 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalOr.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/LogicalOr.php
@@ -8,10 +8,12 @@
namespace eZ\Publish\API\Repository\Values\Content\Query\Criterion;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
+
/**
* This criterion implements a logical OR criterion and will only match
* if AT LEAST ONE of the given criteria match.
*/
-class LogicalOr extends LogicalOperator
+class LogicalOr extends LogicalOperator implements FilteringCriterion
{
}
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchAll.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchAll.php
index 06708dbd24..f43a8295bd 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchAll.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchAll.php
@@ -9,11 +9,12 @@
namespace eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that just matches everything.
*/
-class MatchAll extends Criterion
+class MatchAll extends Criterion implements FilteringCriterion
{
/**
* Creates a new MatchAll criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchNone.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchNone.php
index 3a6f56ab18..ec474256c0 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchNone.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/MatchNone.php
@@ -9,6 +9,7 @@
namespace eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that just matches nothing.
@@ -16,7 +17,7 @@
* Useful for BlockingLimitation type, where a limitation is typically missing and needs to
* tell the system should block everything within the OR conditions it might be part of.
*/
-class MatchNone extends Criterion
+class MatchNone extends Criterion implements FilteringCriterion
{
public function __construct()
{
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateId.php
index ea6599d9de..4244013c24 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on its state.
@@ -18,7 +19,7 @@
* - IN: matches against a list of object state IDs
* - EQ: matches against one object state ID
*/
-class ObjectStateId extends Criterion
+class ObjectStateId extends Criterion implements FilteringCriterion
{
/**
* Creates a new ObjectStateId criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateIdentifier.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateIdentifier.php
index 261ed44831..c31c96fa89 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateIdentifier.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ObjectStateIdentifier.php
@@ -10,8 +10,9 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
-class ObjectStateIdentifier extends Criterion
+class ObjectStateIdentifier extends Criterion implements FilteringCriterion
{
/**
* @param string|string[] $value
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ParentLocationId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ParentLocationId.php
index eff316d5fe..10ee5f9a6f 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ParentLocationId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/ParentLocationId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on its parent location id.
@@ -20,7 +21,7 @@
* - IN: matches against a list of location ids
* - EQ: matches against a unique location id
*/
-class ParentLocationId extends Criterion
+class ParentLocationId extends Criterion implements FilteringCriterion
{
/**
* Creates a new ParentLocationId criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/RemoteId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/RemoteId.php
index f556fb3883..a33faa9159 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/RemoteId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/RemoteId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content based on its RemoteId.
@@ -18,7 +19,7 @@
* - IN: will match from a list of RemoteId
* - EQ: will match against one RemoteId
*/
-class RemoteId extends Criterion
+class RemoteId extends Criterion implements FilteringCriterion
{
/**
* Creates a new remoteId criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionId.php
index e486af8ea0..a93a453df5 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionId.php
@@ -11,13 +11,14 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
use eZ\Publish\SPI\Repository\Values\Trash\Query\Criterion as TrashCriterion;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* SectionId Criterion.
*
* Will match content that belongs to one of the given sections
*/
-class SectionId extends Criterion implements TrashCriterion
+class SectionId extends Criterion implements TrashCriterion, FilteringCriterion
{
/**
* Creates a new Section criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionIdentifier.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionIdentifier.php
index c50196969b..32943a7141 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionIdentifier.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/SectionIdentifier.php
@@ -10,8 +10,9 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
-class SectionIdentifier extends Criterion
+class SectionIdentifier extends Criterion implements FilteringCriterion
{
/**
* @param string|string[] $value
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Sibling.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Sibling.php
index c8a489c0a5..f16dad4914 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Sibling.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Sibling.php
@@ -9,11 +9,12 @@
namespace eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Location;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
/**
* A criterion that matches content that is sibling to the given Location.
*/
-class Sibling extends CompositeCriterion
+class Sibling extends CompositeCriterion implements FilteringCriterion
{
public function __construct(int $locationId, int $parentLocationId)
{
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Subtree.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Subtree.php
index ec910540e0..ca36bc9719 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Subtree.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Subtree.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use InvalidArgumentException;
/**
@@ -17,7 +18,7 @@
*
* Content will be matched if it is part of at least one of the given subtree path strings
*/
-class Subtree extends Criterion
+class Subtree extends Criterion implements FilteringCriterion
{
/**
* Creates a new SubTree criterion.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserEmail.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserEmail.php
index 9ed55cb17f..0b93657718 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserEmail.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserEmail.php
@@ -10,8 +10,9 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
-class UserEmail extends Criterion
+class UserEmail extends Criterion implements FilteringCriterion
{
/**
* @param string|string[] $value
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserId.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserId.php
index 51c37425fd..f9fda02fb8 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserId.php
@@ -10,8 +10,9 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
-class UserId extends Criterion
+class UserId extends Criterion implements FilteringCriterion
{
/**
* @param int|int[] $value
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserLogin.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserLogin.php
index ffa8d70c9e..acee4ed559 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserLogin.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserLogin.php
@@ -10,8 +10,9 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
-class UserLogin extends Criterion
+class UserLogin extends Criterion implements FilteringCriterion
{
/**
* @param string|string[] $value
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserMetadata.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserMetadata.php
index 264144c6a9..00bc82db86 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserMetadata.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/UserMetadata.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use eZ\Publish\SPI\Repository\Values\Trash\Query\Criterion as TrashCriterion;
use InvalidArgumentException;
@@ -29,7 +30,7 @@
* );
*
*/
-class UserMetadata extends Criterion implements TrashCriterion
+class UserMetadata extends Criterion implements TrashCriterion, FilteringCriterion
{
/**
* UserMetadata target: Owner user.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Visibility.php b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Visibility.php
index 793cd19517..9666d23cf9 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Visibility.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/Criterion/Visibility.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\Operator\Specifications;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use InvalidArgumentException;
/**
@@ -19,7 +20,7 @@
* content within the tree you are searching for if content has visible location elsewhere.
* This is intentional and you should rather use LocationSearch if this is not the behaviour you want.
*/
-class Visibility extends Criterion
+class Visibility extends Criterion implements FilteringCriterion
{
/**
* Visibility constant: visible.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentId.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentId.php
index 758e86f556..5cd8944e74 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentId.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentId.php
@@ -10,6 +10,7 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on Content ID for a content query.
@@ -23,7 +24,7 @@
*
* This reflects API definition of IDs as mixed type (integer or string).
*/
-class ContentId extends SortClause
+class ContentId extends SortClause implements FilteringSortClause
{
/**
* Constructs a new ContentId SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentName.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentName.php
index 72d163c783..ddd02626e1 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentName.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/ContentName.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on Content name for a content query.
*/
-class ContentName extends SortClause
+class ContentName extends SortClause implements FilteringSortClause
{
/**
* Constructs a new ContentName SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DateModified.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DateModified.php
index a1ee7f54a7..6e4cb2acda 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DateModified.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DateModified.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on the content modification date for a content query.
*/
-class DateModified extends SortClause
+class DateModified extends SortClause implements FilteringSortClause
{
/**
* Constructs a new DateModified SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DatePublished.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DatePublished.php
index 5258b33c19..2466703342 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DatePublished.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/DatePublished.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on the content creation date for a content query.
*/
-class DatePublished extends SortClause
+class DatePublished extends SortClause implements FilteringSortClause
{
/**
* Constructs a new DatePublished SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Depth.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Depth.php
index 0637d05e1d..e559c3c784 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Depth.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Depth.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on the Location depth for a Location query.
*/
-class Depth extends Location
+class Depth extends Location implements FilteringSortClause
{
/**
* Constructs a new LocationDepth SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Id.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Id.php
index b968ac4fdb..5d85a9d82b 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Id.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Id.php
@@ -10,13 +10,14 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on Location id for a Location query.
*
* Especially useful to get reproducible search results in tests.
*/
-class Id extends Location
+class Id extends Location implements FilteringSortClause
{
/**
* Constructs a new LocationId SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Path.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Path.php
index 4237a9d97e..27e9defcc3 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Path.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Path.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on the Location path for a Location query.
*/
-class Path extends Location
+class Path extends Location implements FilteringSortClause
{
/**
* Constructs a new LocationPath SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Priority.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Priority.php
index ecd24b8cdb..0ae5c9611e 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Priority.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Priority.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on the Location priority for a Location query.
*/
-class Priority extends Location
+class Priority extends Location implements FilteringSortClause
{
/**
* Constructs a new LocationPriority SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Visibility.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Visibility.php
index 9d45041a64..b867bfd465 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Visibility.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/Location/Visibility.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on the Location visibility for a Location query.
*/
-class Visibility extends Location
+class Visibility extends Location implements FilteringSortClause
{
/**
* Constructs a new Location Visibility SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionIdentifier.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionIdentifier.php
index b8c29962ce..f669d1d2de 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionIdentifier.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionIdentifier.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on Section identifier for a content query.
*/
-class SectionIdentifier extends SortClause
+class SectionIdentifier extends SortClause implements FilteringSortClause
{
/**
* Constructs a new SectionIdentifier SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionName.php b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionName.php
index 87ab47a2b5..a12063e640 100644
--- a/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionName.php
+++ b/eZ/Publish/API/Repository/Values/Content/Query/SortClause/SectionName.php
@@ -10,11 +10,12 @@
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause;
/**
* Sets sort direction on Section name for a content query.
*/
-class SectionName extends SortClause
+class SectionName extends SortClause implements FilteringSortClause
{
/**
* Constructs a new SectionName SortClause.
diff --git a/eZ/Publish/API/Repository/Values/Filter/Filter.php b/eZ/Publish/API/Repository/Values/Filter/Filter.php
new file mode 100644
index 0000000000..4c27671d65
--- /dev/null
+++ b/eZ/Publish/API/Repository/Values/Filter/Filter.php
@@ -0,0 +1,219 @@
+criterion = $criterion;
+ foreach ($sortClauses as $idx => $sortClause) {
+ if (!$sortClause instanceof FilteringSortClause) {
+ throw new BadStateException(
+ '$sortClauses',
+ sprintf(
+ 'Expected an instance of "%s", got "%s" at position %d',
+ FilteringSortClause::class,
+ is_object($sortClause) ? get_class($sortClause) : gettype($sortClause),
+ $idx
+ )
+ );
+ }
+
+ $this->sortClauses[] = $sortClause;
+ }
+ }
+
+ /**
+ * Reset Filter so it can be built from scratch.
+ */
+ public function reset(): Filter
+ {
+ $this->criterion = null;
+ $this->sortClauses = [];
+
+ return $this;
+ }
+
+ /**
+ * Set filtering Criterion.
+ *
+ * If multiple Criteria are required, either use `andWithCriterion`/`orWithCriterion` or wrap
+ * them with Logical operator Criterion.
+ *
+ * To re-build Criterion from scratch `reset` it first.
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException if Criterion is already set
+ *
+ * @see reset
+ * @see andWithCriterion
+ * @see orWithCriterion
+ * @see \eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalOr
+ * @see \eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalAnd
+ */
+ public function withCriterion(FilteringCriterion $criterion): Filter
+ {
+ if (null !== $this->criterion) {
+ throw new BadStateException(
+ '$criterion',
+ 'Criterion is already set. ' .
+ 'To append Criterion invoke either "andWithCriterion" or "orWithCriterion". ' .
+ 'To start building Criterion from scratch "reset" it first.'
+ );
+ }
+
+ $this->criterion = $criterion;
+
+ return $this;
+ }
+
+ /**
+ * @see withCriterion
+ */
+ public function andWithCriterion(FilteringCriterion $criterion): Filter
+ {
+ if (null === $this->criterion) {
+ // for better DX allow operation on uninitialized Criterion by setting it as-is
+ $this->criterion = $criterion;
+ } elseif ($this->criterion instanceof Criterion\LogicalAnd) {
+ $this->criterion->criteria[] = $criterion;
+ } else {
+ $this->criterion = new Criterion\LogicalAnd([$this->criterion, $criterion]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @see withCriterion
+ */
+ public function orWithCriterion(FilteringCriterion $criterion): Filter
+ {
+ if (null === $this->criterion) {
+ // for better DX allow operation on uninitialized Criterion by setting it as-is
+ $this->criterion = $criterion;
+ } elseif ($this->criterion instanceof Criterion\LogicalOr) {
+ $this->criterion->criteria[] = $criterion;
+ } else {
+ $this->criterion = new Criterion\LogicalOr([$this->criterion, $criterion]);
+ }
+
+ return $this;
+ }
+
+ public function withSortClause(FilteringSortClause $sortClause): Filter
+ {
+ $this->sortClauses[] = $sortClause;
+
+ return $this;
+ }
+
+ /**
+ * Request result dataset slice by setting page limit and offset.
+ * Both values MUST be `>=0`.
+ *
+ * @param int $limit >=0, use 0 for no limit.
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ */
+ public function sliceBy(int $limit, int $offset): Filter
+ {
+ if ($limit < 0) {
+ throw new InvalidArgumentException(
+ '$limit',
+ sprintf('Filtering slice limit needs to be >=0, got %d', $limit)
+ );
+ }
+
+ if ($offset < 0) {
+ throw new InvalidArgumentException(
+ '$offset',
+ sprintf('Filtering slice offset needs to be >=0, got %d', $offset)
+ );
+ }
+
+ $this->limit = $limit;
+ $this->offset = $offset;
+
+ return $this;
+ }
+
+ public function getCriterion(): ?FilteringCriterion
+ {
+ return $this->criterion;
+ }
+
+ /**
+ * @return \eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause[]
+ */
+ public function getSortClauses(): array
+ {
+ return $this->sortClauses;
+ }
+
+ /**
+ * Get offset set by sliceBy.
+ *
+ * @see sliceBy
+ */
+ public function getOffset(): int
+ {
+ return $this->offset;
+ }
+
+ /**
+ * Get limit set by sliceBy.
+ *
+ * @see sliceBy
+ */
+ public function getLimit(): int
+ {
+ return $this->limit;
+ }
+
+ public function __clone()
+ {
+ $this->criterion = $this->criterion !== null ? clone $this->criterion : null;
+ $this->sortClauses = array_map(
+ static function (FilteringSortClause $sortClause): FilteringSortClause {
+ return clone $sortClause;
+ },
+ $this->sortClauses
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Base/Container/ApiLoader/RepositoryFactory.php b/eZ/Publish/Core/Base/Container/ApiLoader/RepositoryFactory.php
index ef7df4e2aa..0a51b7538e 100644
--- a/eZ/Publish/Core/Base/Container/ApiLoader/RepositoryFactory.php
+++ b/eZ/Publish/Core/Base/Container/ApiLoader/RepositoryFactory.php
@@ -15,6 +15,8 @@
use eZ\Publish\Core\Repository\Helper\RelationProcessor;
use eZ\Publish\Core\Repository\Mapper;
use eZ\Publish\Core\Search\Common\BackgroundIndexer;
+use eZ\Publish\SPI\Persistence\Filter\Content\Handler as ContentFilteringHandler;
+use eZ\Publish\SPI\Persistence\Filter\Location\Handler as LocationFilteringHandler;
use eZ\Publish\SPI\Persistence\Handler as PersistenceHandler;
use eZ\Publish\SPI\Repository\Strategy\ContentThumbnail\ThumbnailStrategy;
use eZ\Publish\SPI\Repository\Validator\ContentValidator;
@@ -75,6 +77,8 @@ public function buildRepository(
ContentValidator $contentValidator,
LimitationService $limitationService,
PermissionService $permissionService,
+ ContentFilteringHandler $contentFilteringHandler,
+ LocationFilteringHandler $locationFilteringHandler,
array $languages
): Repository {
return new $this->repositoryClass(
@@ -94,6 +98,8 @@ public function buildRepository(
$limitationService,
$this->languageResolver,
$permissionService,
+ $contentFilteringHandler,
+ $locationFilteringHandler,
[
'role' => [
'policyMap' => $this->policyMap,
diff --git a/eZ/Publish/Core/Persistence/Legacy/Content/Language/CachingHandler.php b/eZ/Publish/Core/Persistence/Legacy/Content/Language/CachingHandler.php
index e53851fe08..35e5e1d2b0 100644
--- a/eZ/Publish/Core/Persistence/Legacy/Content/Language/CachingHandler.php
+++ b/eZ/Publish/Core/Persistence/Legacy/Content/Language/CachingHandler.php
@@ -110,7 +110,19 @@ public function loadList(array $ids): iterable
$languages += $loaded;
}
- return $languages;
+ // order languages by ID again so the result is deterministic regardless of cache
+ // note: can't yield due to array access of this result
+ $orderedLanguages = [];
+ foreach ($ids as $id) {
+ // BC: missing IDs are skipped
+ if (!isset($languages[$id])) {
+ continue;
+ }
+
+ $orderedLanguages[$id] = $languages[$id];
+ }
+
+ return $orderedLanguages;
}
/**
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ContentIdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ContentIdQueryBuilder.php
new file mode 100644
index 0000000000..8d69642dde
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ContentIdQueryBuilder.php
@@ -0,0 +1,40 @@
+expr()->in(
+ 'content.id',
+ $queryBuilder->createNamedParameter(
+ array_map('intval', $criterion->value),
+ Connection::PARAM_INT_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/DateMetadataQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/DateMetadataQueryBuilder.php
new file mode 100644
index 0000000000..e268e02778
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/DateMetadataQueryBuilder.php
@@ -0,0 +1,45 @@
+target === DateMetadata::MODIFIED ? 'modified' : 'published';
+ $column = "content.{$column}";
+
+ $value = (array)$criterion->value;
+
+ return $queryBuilder->buildOperatorBasedCriterionConstraint(
+ $column,
+ $value,
+ $criterion->operator
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilder.php
new file mode 100644
index 0000000000..60f2ce0d4a
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilder.php
@@ -0,0 +1,61 @@
+joinOnce(
+ 'version',
+ Gateway::CONTENT_LANGUAGE_TABLE,
+ 'language',
+ // bitwise and for exact language ID match
+ 'language.id & version.language_mask = language.id'
+ );
+
+ // at this point $criterion->value is guaranteed to be an array
+ $expr = $queryBuilder->expr()->in(
+ 'language.locale',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_STR_ARRAY
+ )
+ );
+
+ if ($criterion->matchAlwaysAvailable) {
+ $expr = (string)$queryBuilder->expr()->orX($expr, 'version.language_mask & 1 = 1');
+ }
+
+ return $expr;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ObjectStateIdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ObjectStateIdQueryBuilder.php
new file mode 100644
index 0000000000..133441fbd7
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ObjectStateIdQueryBuilder.php
@@ -0,0 +1,48 @@
+joinOnce(
+ 'content',
+ Gateway::OBJECT_STATE_LINK_TABLE,
+ 'object_state_link',
+ 'content.id = object_state_link.contentobject_id',
+ );
+
+ $value = (array)$criterion->value;
+
+ return $queryBuilder->expr()->in(
+ 'object_state_link.contentobject_state_id',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_INT_ARRAY)
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ObjectStateIdentifierQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ObjectStateIdentifierQueryBuilder.php
new file mode 100644
index 0000000000..817e5f2500
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/ObjectStateIdentifierQueryBuilder.php
@@ -0,0 +1,60 @@
+joinOnce(
+ 'content',
+ ObjectStateGateway::OBJECT_STATE_LINK_TABLE,
+ 'object_state_link',
+ 'content.id = object_state_link.contentobject_id',
+ )
+ ->joinOnce(
+ 'content',
+ ObjectStateGateway::OBJECT_STATE_TABLE,
+ 'object_state',
+ 'object_state_link.contentobject_state_id = object_state.id'
+ )
+ ->joinOnce(
+ 'object_state',
+ ObjectStateGateway::OBJECT_STATE_GROUP_TABLE,
+ 'object_state_group',
+ 'object_state.group_id = object_state_group.id'
+ );
+
+ $value = (array)$criterion->value;
+
+ return $queryBuilder->expr()->in(
+ 'object_state.identifier',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_STR_ARRAY)
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/RemoteIdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/RemoteIdQueryBuilder.php
new file mode 100644
index 0000000000..ae376121d4
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/RemoteIdQueryBuilder.php
@@ -0,0 +1,40 @@
+expr()->in(
+ 'content.remote_id',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_STR_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Section/IdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Section/IdQueryBuilder.php
new file mode 100644
index 0000000000..213f68d5c5
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Section/IdQueryBuilder.php
@@ -0,0 +1,42 @@
+expr()->in(
+ 'content.section_id',
+ $queryBuilder->createNamedParameter(
+ array_map('intval', $criterion->value),
+ Connection::PARAM_INT_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Section/IdentifierQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Section/IdentifierQueryBuilder.php
new file mode 100644
index 0000000000..e9faa888de
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Section/IdentifierQueryBuilder.php
@@ -0,0 +1,51 @@
+joinOnce(
+ 'content',
+ Gateway::CONTENT_SECTION_TABLE,
+ 'section',
+ 'content.section_id = section.id'
+ );
+
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\SectionIdentifier $criterion */
+ return $queryBuilder->expr()->in(
+ 'section.identifier',
+ $queryBuilder->createNamedParameter(
+ (array)$criterion->value,
+ Connection::PARAM_STR_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/SiblingQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/SiblingQueryBuilder.php
new file mode 100644
index 0000000000..3d1ca6d626
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/SiblingQueryBuilder.php
@@ -0,0 +1,48 @@
+logicalAndQueryBuilder = $logicalAndQueryBuilder;
+ }
+
+ public function accepts(FilteringCriterion $criterion): bool
+ {
+ return $criterion instanceof Sibling;
+ }
+
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\Sibling $criterion */
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalAnd $_criterion */
+ $_criterion = $criterion->criteria;
+
+ return $this->logicalAndQueryBuilder->buildQueryConstraint($queryBuilder, $_criterion);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/BaseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/BaseQueryBuilder.php
new file mode 100644
index 0000000000..f1e62cd31f
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/BaseQueryBuilder.php
@@ -0,0 +1,38 @@
+joinOnce(
+ 'content',
+ ContentTypeGateway::CONTENT_TYPE_TABLE,
+ 'content_type',
+ 'content.contentclass_id = content_type.id AND content_type.version = 0'
+ );
+
+ // the returned query constraint depends on concrete implementations
+ return null;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/GroupIdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/GroupIdQueryBuilder.php
new file mode 100644
index 0000000000..a33a57bf5b
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/GroupIdQueryBuilder.php
@@ -0,0 +1,60 @@
+joinOnce(
+ 'content',
+ ContentTypeGateway::CONTENT_TYPE_TO_GROUP_ASSIGNMENT_TABLE,
+ 'content_type_group_assignment',
+ 'content.contentclass_id = content_type_group_assignment.contentclass_id'
+ );
+
+ $queryBuilder
+ ->joinOnce(
+ 'content_type_group_assignment',
+ ContentTypeGateway::CONTENT_TYPE_GROUP_TABLE,
+ 'content_type_group',
+ 'content_type_group_assignment.group_id = content_type_group.id'
+ );
+
+ return $queryBuilder->expr()->in(
+ 'content_type_group.id',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_INT_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/IdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/IdQueryBuilder.php
new file mode 100644
index 0000000000..c93ead89bc
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/IdQueryBuilder.php
@@ -0,0 +1,45 @@
+expr()->in(
+ 'content_type.id',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_INT_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/IdentifierQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/IdentifierQueryBuilder.php
new file mode 100644
index 0000000000..6b470675bd
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/Type/IdentifierQueryBuilder.php
@@ -0,0 +1,45 @@
+expr()->in(
+ 'content_type.identifier',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_STR_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/AncestorQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/AncestorQueryBuilder.php
new file mode 100644
index 0000000000..afe5ce0dee
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/AncestorQueryBuilder.php
@@ -0,0 +1,60 @@
+value e.g. = ['/1/2/', '/1/4/10/']
+ $locationIDs = array_merge(
+ ...array_map(
+ static function (string $pathString) {
+ return array_map(
+ 'intval',
+ array_filter(explode('/', trim($pathString, '/')))
+ );
+ },
+ $criterion->value
+ )
+ );
+
+ return $queryBuilder->expr()->in(
+ 'location.node_id',
+ $queryBuilder->createNamedParameter(
+ array_values(array_unique($locationIDs)),
+ Connection::PARAM_INT_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/BaseLocationCriterionQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/BaseLocationCriterionQueryBuilder.php
new file mode 100644
index 0000000000..4d16d2a4cb
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/BaseLocationCriterionQueryBuilder.php
@@ -0,0 +1,28 @@
+joinAllLocations();
+
+ return null;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/DepthQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/DepthQueryBuilder.php
new file mode 100644
index 0000000000..7fd159026a
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/DepthQueryBuilder.php
@@ -0,0 +1,41 @@
+buildOperatorBasedCriterionConstraint(
+ 'location.depth',
+ $criterion->value,
+ $criterion->operator
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/IdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/IdQueryBuilder.php
new file mode 100644
index 0000000000..46c57f76d6
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/IdQueryBuilder.php
@@ -0,0 +1,41 @@
+expr()->in(
+ 'location.node_id',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_INT_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/IsMainLocationQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/IsMainLocationQueryBuilder.php
new file mode 100644
index 0000000000..61de093e41
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/IsMainLocationQueryBuilder.php
@@ -0,0 +1,36 @@
+value[0] === Location\IsMainLocation::MAIN
+ ? 'location.node_id = location.main_node_id'
+ : 'location.node_id <> location.main_node_id';
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/ParentLocationIdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/ParentLocationIdQueryBuilder.php
new file mode 100644
index 0000000000..e8a7cccc7c
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/ParentLocationIdQueryBuilder.php
@@ -0,0 +1,41 @@
+expr()->in(
+ 'location.parent_node_id',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_INT_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/PriorityQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/PriorityQueryBuilder.php
new file mode 100644
index 0000000000..b262bfa2a8
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/PriorityQueryBuilder.php
@@ -0,0 +1,41 @@
+buildOperatorBasedCriterionConstraint(
+ 'location.priority',
+ $criterion->value,
+ $criterion->operator
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/RemoteIdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/RemoteIdQueryBuilder.php
new file mode 100644
index 0000000000..c745a2d6ea
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/RemoteIdQueryBuilder.php
@@ -0,0 +1,41 @@
+expr()->in(
+ 'location.remote_id',
+ $queryBuilder->createNamedParameter(
+ $criterion->value,
+ Connection::PARAM_STR_ARRAY
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/SubtreeQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/SubtreeQueryBuilder.php
new file mode 100644
index 0000000000..d03c9b17a5
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/SubtreeQueryBuilder.php
@@ -0,0 +1,47 @@
+expr();
+ $statements = array_map(
+ static function (string $pathString) use ($queryBuilder, $expressionBuilder): string {
+ return $expressionBuilder->like(
+ 'location.path_string',
+ $queryBuilder->createNamedParameter($pathString . '%', ParameterType::STRING)
+ );
+ },
+ $criterion->value
+ );
+
+ return (string)$expressionBuilder->orX(...$statements);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/VisibilityQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/VisibilityQueryBuilder.php
new file mode 100644
index 0000000000..583457b562
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/Location/VisibilityQueryBuilder.php
@@ -0,0 +1,62 @@
+expr();
+ $columnsExpressions = $this->getVisibilityColumnsExpressions(
+ $queryBuilder,
+ $criterion->value[0]
+ );
+
+ return $criterion->value[0] === Visibility::VISIBLE
+ ? (string)$expressionBuilder->andX(...$columnsExpressions)
+ : (string)$expressionBuilder->orX(...$columnsExpressions);
+ }
+
+ private function getVisibilityColumnsExpressions(
+ QueryBuilder $queryBuilder,
+ int $visibleFlag
+ ): array {
+ $expressionBuilder = $queryBuilder->expr();
+
+ return [
+ $expressionBuilder->eq(
+ 'location.is_hidden',
+ $queryBuilder->createNamedParameter($visibleFlag, ParameterType::INTEGER)
+ ),
+ $expressionBuilder->eq(
+ 'location.is_invisible',
+ $queryBuilder->createNamedParameter($visibleFlag, ParameterType::INTEGER)
+ ),
+ ];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalAndQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalAndQueryBuilder.php
new file mode 100644
index 0000000000..6150245ace
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalAndQueryBuilder.php
@@ -0,0 +1,55 @@
+criterionVisitor = $criterionVisitor;
+ }
+
+ public function accepts(FilteringCriterion $criterion): bool
+ {
+ return $criterion instanceof LogicalAnd;
+ }
+
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ $constraints = [];
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalAnd $criterion */
+ foreach ($criterion->criteria as $_criterion) {
+ /** @var \eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion $_criterion */
+ $constraint = $this->criterionVisitor->visitCriteria($queryBuilder, $_criterion);
+ if (null !== $constraint) {
+ $constraints[] = $constraint;
+ }
+ }
+
+ if (empty($constraints)) {
+ return null;
+ }
+
+ return (string)$queryBuilder->expr()->andX(...$constraints);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalNotQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalNotQueryBuilder.php
new file mode 100644
index 0000000000..cf9a422ec2
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalNotQueryBuilder.php
@@ -0,0 +1,61 @@
+criterionVisitor = $criterionVisitor;
+ }
+
+ public function accepts(FilteringCriterion $criterion): bool
+ {
+ return $criterion instanceof LogicalNot;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ */
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalNot $criterion */
+ if (!$criterion->criteria[0] instanceof FilteringCriterion) {
+ throw new InvalidArgumentException(
+ '$criterion',
+ sprintf(
+ 'Criterion needs to be a Filtering Criterion, got "%s"',
+ get_class($criterion->criteria[0])
+ )
+ );
+ }
+
+ $constraint = $this->criterionVisitor->visitCriteria(
+ $queryBuilder,
+ $criterion->criteria[0]
+ );
+
+ return null !== $constraint ? sprintf('NOT (%s)', $constraint) : null;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOrQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOrQueryBuilder.php
new file mode 100644
index 0000000000..39f3f2a2df
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOrQueryBuilder.php
@@ -0,0 +1,55 @@
+criterionVisitor = $criterionVisitor;
+ }
+
+ public function accepts(FilteringCriterion $criterion): bool
+ {
+ return $criterion instanceof LogicalOr;
+ }
+
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ $constraints = [];
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalOr $criterion */
+ foreach ($criterion->criteria as $_criterion) {
+ /** @var \eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion $_criterion */
+ $constraint = $this->criterionVisitor->visitCriteria($queryBuilder, $_criterion);
+ if (null !== $constraint) {
+ $constraints[] = $constraint;
+ }
+ }
+
+ if (empty($constraints)) {
+ return null;
+ }
+
+ return (string)$queryBuilder->expr()->orX(...$constraints);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/MatchAllQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/MatchAllQueryBuilder.php
new file mode 100644
index 0000000000..b55f186609
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/MatchAllQueryBuilder.php
@@ -0,0 +1,32 @@
+transformationProcessor = $transformationProcessor;
+ }
+
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ $queryBuilder
+ ->joinOnce(
+ 'content',
+ DoctrineStorage::USER_TABLE,
+ 'user_storage',
+ 'content.id = user_storage.contentobject_id'
+ );
+
+ return null;
+ }
+
+ protected function transformCriterionValueForLikeExpression(string $value): string
+ {
+ return str_replace(
+ '*',
+ '%',
+ addcslashes(
+ $this->transformationProcessor->transformByGroup(
+ $value,
+ 'lowercase'
+ ),
+ '%_'
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/IsUserBasedQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/IsUserBasedQueryBuilder.php
new file mode 100644
index 0000000000..3cf5f02079
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/IsUserBasedQueryBuilder.php
@@ -0,0 +1,49 @@
+leftJoinOnce(
+ 'content',
+ 'ezuser',
+ 'user_storage',
+ 'content.id = user_storage.contentobject_id'
+ );
+
+ $isUserBased = (bool)reset($criterion->value);
+ $databasePlatform = $queryBuilder->getConnection()->getDatabasePlatform();
+
+ return $isUserBased
+ ? $databasePlatform->getIsNotNullExpression('user_storage.contentobject_id')
+ : $databasePlatform->getIsNullExpression('user_storage.contentobject_id');
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/IsUserEnabledQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/IsUserEnabledQueryBuilder.php
new file mode 100644
index 0000000000..b965c6a440
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/IsUserEnabledQueryBuilder.php
@@ -0,0 +1,49 @@
+joinOnce(
+ 'user_storage',
+ DoctrineStorage::USER_SETTING_TABLE,
+ 'user_settings',
+ 'user_storage.contentobject_id = user_settings.user_id'
+ );
+
+ return $queryBuilder->expr()->eq(
+ 'user_settings.is_enabled',
+ $queryBuilder->createNamedParameter(
+ (int)reset($criterion->value),
+ ParameterType::INTEGER
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/GroupQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/GroupQueryBuilder.php
new file mode 100644
index 0000000000..1f1d472f97
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/GroupQueryBuilder.php
@@ -0,0 +1,54 @@
+target === UserMetadata::GROUP;
+ }
+
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\UserMetadata $criterion */
+ $value = (array)$criterion->value;
+
+ $queryBuilder
+ ->joinOnce(
+ 'content',
+ LocationGateway::CONTENT_TREE_TABLE,
+ 'user_location',
+ 'content.owner_id = user_location.contentobject_id'
+ )
+ ->joinOnce(
+ 'user_location',
+ LocationGateway::CONTENT_TREE_TABLE,
+ 'user_group_location',
+ 'user_location.parent_node_id = user_group_location.node_id'
+ );
+
+ return $queryBuilder->expr()->in(
+ 'user_group_location.contentobject_id',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_INT_ARRAY)
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/ModifierQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/ModifierQueryBuilder.php
new file mode 100644
index 0000000000..92b66df636
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/ModifierQueryBuilder.php
@@ -0,0 +1,39 @@
+target === UserMetadata::MODIFIER;
+ }
+
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\UserMetadata $criterion */
+ $value = (array)$criterion->value;
+
+ return $queryBuilder->expr()->in(
+ 'version.creator_id',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_INT_ARRAY)
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/OwnerQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/OwnerQueryBuilder.php
new file mode 100644
index 0000000000..d7bc668cc2
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/Metadata/OwnerQueryBuilder.php
@@ -0,0 +1,39 @@
+target === UserMetadata::OWNER;
+ }
+
+ public function buildQueryConstraint(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): ?string {
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\Criterion\UserMetadata $criterion */
+ $value = (array)$criterion->value;
+
+ return $queryBuilder->expr()->in(
+ 'content.owner_id',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_INT_ARRAY)
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserEmailQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserEmailQueryBuilder.php
new file mode 100644
index 0000000000..e3c84a51ef
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserEmailQueryBuilder.php
@@ -0,0 +1,51 @@
+operator) {
+ $expression = $queryBuilder->expr()->like(
+ 'user_storage.email',
+ $queryBuilder->createNamedParameter(
+ $this->transformCriterionValueForLikeExpression($criterion->value)
+ )
+ );
+ } else {
+ $value = (array)$criterion->value;
+ $expression = $queryBuilder->expr()->in(
+ 'user_storage.email',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_STR_ARRAY)
+ );
+ }
+
+ return $expression;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserIdQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserIdQueryBuilder.php
new file mode 100644
index 0000000000..ad2f9821f7
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserIdQueryBuilder.php
@@ -0,0 +1,40 @@
+value;
+
+ return $queryBuilder->expr()->in(
+ 'user_storage.contentobject_id',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_INT_ARRAY)
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserLoginQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserLoginQueryBuilder.php
new file mode 100644
index 0000000000..e653833f24
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionQueryBuilder/User/UserLoginQueryBuilder.php
@@ -0,0 +1,51 @@
+expr();
+ if (Operator::LIKE === $criterion->operator) {
+ return $expr->like(
+ 'user_storage.login',
+ $queryBuilder->createNamedParameter(
+ $this->transformCriterionValueForLikeExpression($criterion->value)
+ )
+ );
+ }
+
+ $value = (array)$criterion->value;
+
+ return $expr->in(
+ 'user_storage.login',
+ $queryBuilder->createNamedParameter($value, Connection::PARAM_STR_ARRAY)
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionVisitor.php b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionVisitor.php
new file mode 100644
index 0000000000..ba88c073a2
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/CriterionVisitor.php
@@ -0,0 +1,58 @@
+setCriterionQueryBuilders($criterionQueryBuilders);
+ }
+
+ public function setCriterionQueryBuilders(iterable $criterionQueryBuilders): void
+ {
+ $this->criterionQueryBuilders = $criterionQueryBuilders;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotImplementedException if there's no builder for a criterion
+ */
+ public function visitCriteria(
+ FilteringQueryBuilder $queryBuilder,
+ FilteringCriterion $criterion
+ ): string {
+ foreach ($this->criterionQueryBuilders as $criterionQueryBuilder) {
+ if ($criterionQueryBuilder->accepts($criterion)) {
+ return $criterionQueryBuilder->buildQueryConstraint(
+ $queryBuilder,
+ $criterion
+ );
+ }
+ }
+
+ throw new NotImplementedException(
+ sprintf(
+ 'There is no Filtering Criterion Query Builder for %s Criterion',
+ get_class($criterion)
+ )
+ );
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php b/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php
new file mode 100644
index 0000000000..3424272c72
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php
@@ -0,0 +1,287 @@
+ 'content.id',
+ 'content_type_id' => 'content.contentclass_id',
+ 'content_current_version' => 'content.current_version',
+ 'content_initial_language_id' => 'content.initial_language_id',
+ 'content_language_mask' => 'content.language_mask',
+ 'content_modified' => 'content.modified',
+ 'content_name' => 'content.name',
+ 'content_owner_id' => 'content.owner_id',
+ 'content_published' => 'content.published',
+ 'content_remote_id' => 'content.remote_id',
+ 'content_section_id' => 'content.section_id',
+ 'content_status' => 'content.status',
+ 'content_is_hidden' => 'content.is_hidden',
+ // Version Info
+ 'content_version_id' => 'version.id',
+ 'content_version_no' => 'version.version',
+ 'content_version_creator_id' => 'version.creator_id',
+ 'content_version_created' => 'version.created',
+ 'content_version_modified' => 'version.modified',
+ 'content_version_status' => 'version.status',
+ 'content_version_language_mask' => 'version.language_mask',
+ 'content_version_initial_language_id' => 'version.initial_language_id',
+ // Main Location (nullable)
+ 'content_main_location_id' => 'main_location.main_node_id',
+ ];
+
+ /** @var \Doctrine\DBAL\Connection */
+ private $connection;
+
+ /** @var \Doctrine\DBAL\Platforms\AbstractPlatform */
+ private $databasePlatform;
+
+ /** @var \eZ\Publish\SPI\Persistence\Filter\CriterionVisitor */
+ private $criterionVisitor;
+
+ /** @var \eZ\Publish\SPI\Persistence\Filter\SortClauseVisitor */
+ private $sortClauseVisitor;
+
+ /**
+ * @throws \Doctrine\DBAL\DBALException
+ */
+ public function __construct(
+ Connection $connection,
+ CriterionVisitor $criterionVisitor,
+ SortClauseVisitor $sortClauseVisitor
+ ) {
+ $this->connection = $connection;
+ $this->databasePlatform = $connection->getDatabasePlatform();
+ $this->criterionVisitor = $criterionVisitor;
+ $this->sortClauseVisitor = $sortClauseVisitor;
+ }
+
+ public function count(FilteringCriterion $criterion): int
+ {
+ $query = $this->buildQuery(
+ [$this->databasePlatform->getCountExpression('DISTINCT content.id')],
+ $criterion
+ );
+
+ return (int)$query->execute()->fetch(FetchMode::COLUMN);
+ }
+
+ public function find(
+ FilteringCriterion $criterion,
+ array $sortClauses,
+ int $limit,
+ int $offset
+ ): iterable {
+ $query = $this->buildQuery(iterator_to_array($this->getColumns()), $criterion);
+ $this->sortClauseVisitor->visitSortClauses($query, $sortClauses);
+
+ // get additional data for the same query constraints
+ $names = $this->bulkFetchVersionNames(clone $query);
+ $fieldValues = $this->bulkFetchFieldValues(clone $query);
+
+ // wrap query to avoid duplicate entries for multiple Locations
+ $wrappedQuery = $this->wrapMainQuery($query);
+ $wrappedQuery->setFirstResult($offset);
+ if ($limit > 0) {
+ $wrappedQuery->setMaxResults($limit);
+ }
+
+ $resultStatement = $wrappedQuery->execute();
+ while (false !== ($row = $resultStatement->fetch(FetchMode::ASSOCIATIVE))) {
+ $contentId = (int)$row['content_id'];
+ $versionNo = (int)$row['content_version_no'];
+ $row['content_version_names'] = $this->extractVersionNames(
+ $names,
+ $contentId,
+ $versionNo
+ );
+ $row['content_version_fields'] = $this->extractFieldValues(
+ $fieldValues,
+ $contentId,
+ $versionNo
+ );
+
+ yield $row;
+ }
+ }
+
+ private function buildQuery(
+ array $columns,
+ FilteringCriterion $criterion
+ ): FilteringQueryBuilder {
+ $queryBuilder = new FilteringQueryBuilder($this->connection);
+
+ $expressionBuilder = $queryBuilder->expr();
+ $queryBuilder
+ ->select($columns)
+ ->distinct()
+ ->from(ContentGateway::CONTENT_ITEM_TABLE, 'content')
+ ->joinPublishedVersion()
+ ->leftJoin(
+ 'content',
+ LocationGateway::CONTENT_TREE_TABLE,
+ 'main_location',
+ $expressionBuilder->andX(
+ 'content.id = main_location.contentobject_id',
+ 'main_location.main_node_id = main_location.node_id'
+ )
+ );
+
+ $constraint = $this->criterionVisitor->visitCriteria($queryBuilder, $criterion);
+ if (null !== $constraint) {
+ $queryBuilder->where($constraint);
+ }
+
+ return $queryBuilder;
+ }
+
+ /**
+ * Return names as a map of '' => ''
.
+ *
+ * Process data fetched by {@see bulkFetchVersionNames}
+ */
+ private function extractVersionNames(array $names, int $contentId, int $versionNo): array
+ {
+ $rawVersionNames = $this->extractVersionData($names, $contentId, $versionNo);
+
+ $names = [];
+ foreach ($rawVersionNames as $nameRow) {
+ $names[$nameRow['real_translation']] = $nameRow['name'];
+ }
+
+ return $names;
+ }
+
+ private function extractFieldValues(array $fieldValues, int $contentId, int $versionNo): array
+ {
+ return $this->extractVersionData($fieldValues, $contentId, $versionNo);
+ }
+
+ /**
+ * Extract Version-specific data from bulk-loaded rows.
+ */
+ private function extractVersionData(array $rows, int $contentId, int $versionNo): array
+ {
+ return array_filter(
+ $rows,
+ static function (array $row) use ($contentId, $versionNo) {
+ return (int)$row['content_id'] === $contentId
+ && (int)$row['version_no'] === $versionNo;
+ }
+ );
+ }
+
+ private function bulkFetchVersionNames(FilteringQueryBuilder $query): array
+ {
+ $query
+ // completely reset SELECT part to get only needed data
+ ->select(
+ 'content.id AS content_id',
+ 'version.version AS version_no',
+ 'content_name.name',
+ 'content_name.real_translation'
+ )
+ ->distinct()
+ // join names table to pre-existing query
+ ->joinOnce(
+ 'content',
+ ContentGateway::CONTENT_NAME_TABLE,
+ 'content_name',
+ (string)$query->expr()->andX(
+ 'content.id = content_name.contentobject_id',
+ 'version.version = content_name.content_version',
+ 'version.language_mask & content_name.language_id > 0'
+ )
+ )
+ // reset not needed parts, keeping FROM, other JOINs, and WHERE constraints
+ ->setMaxResults(null)
+ ->setFirstResult(null)
+ ->resetQueryPart('orderBy');
+
+ return $query->execute()->fetchAll(FetchMode::ASSOCIATIVE);
+ }
+
+ private function bulkFetchFieldValues(FilteringQueryBuilder $query): array
+ {
+ $query
+ // completely reset SELECT part to get only needed data
+ ->select(
+ 'content_field.contentobject_id AS content_id',
+ 'content_field.version AS version_no',
+ 'content_field.id AS field_id',
+ 'content_field.contentclassattribute_id AS field_definition_id',
+ 'content_field.data_type_string AS field_type',
+ 'content_field.language_code AS field_language_code',
+ 'content_field.data_float AS field_data_float',
+ 'content_field.data_int AS field_data_int',
+ 'content_field.data_text AS field_data_text',
+ 'content_field.sort_key_int AS field_sort_key_int',
+ 'content_field.sort_key_string AS field_sort_key_string'
+ )
+ ->distinct()
+ ->joinOnce(
+ 'content',
+ ContentGateway::CONTENT_FIELD_TABLE,
+ 'content_field',
+ (string)$query->expr()->andX(
+ 'content.id = content_field.contentobject_id',
+ 'version.version = content_field.version',
+ 'version.language_mask & content_field.language_id = content_field.language_id'
+ )
+ )
+ // reset not needed parts, keeping FROM, other JOINs, and WHERE constraints
+ ->setMaxResults(null)
+ ->setFirstResult(null)
+ ->resetQueryPart('orderBy');
+
+ return $query->execute()->fetchAll(FetchMode::ASSOCIATIVE);
+ }
+
+ private function getColumns(): Traversable
+ {
+ foreach (self::COLUMN_MAP as $columnAlias => $columnName) {
+ yield "{$columnName} AS {$columnAlias}";
+ }
+ }
+
+ /**
+ * Wrap query to avoid duplicate entries for multiple Locations.
+ */
+ private function wrapMainQuery(FilteringQueryBuilder $query): QueryBuilder
+ {
+ $wrappedQuery = $this->connection->createQueryBuilder();
+ $wrappedQuery
+ ->select(array_keys(self::COLUMN_MAP))
+ ->distinct()
+ ->from(sprintf('(%s)', $query->getSQL()), 'wrapped')
+ ->setParameters($query->getParameters(), $query->getParameterTypes());
+
+ return $wrappedQuery;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Content/GatewayDataMapper.php b/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Content/GatewayDataMapper.php
new file mode 100644
index 0000000000..b1829121db
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Content/GatewayDataMapper.php
@@ -0,0 +1,25 @@
+languageMaskGenerator = $languageMaskGenerator;
+ $this->languageHandler = $languageHandler;
+ $this->contentTypeHandler = $contentTypeHandler;
+ $this->converterRegistry = $converterRegistry;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Column names come from query built by
+ * {@see \eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Content\Doctrine\DoctrineGateway::buildQuery}
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ */
+ public function mapRawDataToPersistenceContentItem(array $row): Content\ContentItem
+ {
+ $contentInfo = $this->mapContentMetadataToPersistenceContentInfo($row);
+
+ $content = $this->mapContentDataToPersistenceContent($row);
+ $content->versionInfo->contentInfo = $contentInfo;
+
+ // aiming to utilize in-memory caching
+ $contentType = $this->contentTypeHandler->load($contentInfo->contentTypeId);
+
+ return new Content\ContentItem(
+ $content,
+ $contentInfo,
+ $contentType
+ );
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ */
+ private function mapContentDataToPersistenceContent(array $row): Content
+ {
+ $content = new Content();
+ $content->versionInfo = $this->mapVersionDataToPersistenceVersionInfo($row);
+ $content->fields = $this->mapFieldDataToPersistenceFieldList(
+ $row['content_version_fields'],
+ $content->versionInfo->versionNo
+ );
+
+ return $content;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ */
+ private function mapVersionDataToPersistenceVersionInfo(array $row): Content\VersionInfo
+ {
+ $versionInfo = new VersionInfo();
+ $versionInfo->id = (int)$row['content_version_id'];
+ $versionInfo->versionNo = (int)$row['content_version_no'];
+ $versionInfo->creatorId = (int)$row['content_version_creator_id'];
+ $versionInfo->creationDate = (int)$row['content_version_created'];
+ $versionInfo->modificationDate = (int)$row['content_version_modified'];
+ $versionInfo->status = (int)$row['content_version_status'];
+ $versionInfo->names = $row['content_version_names'];
+
+ // Map language codes
+ $versionInfo->languageCodes = $this->languageMaskGenerator->extractLanguageCodesFromMask(
+ (int)$row['content_version_language_mask']
+ );
+ $versionInfo->initialLanguageCode = $this->languageHandler->load(
+ (int)$row['content_version_initial_language_id']
+ )->languageCode;
+
+ return $versionInfo;
+ }
+
+ /**
+ * @return \eZ\Publish\SPI\Persistence\Content\Field[]
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ */
+ private function mapFieldDataToPersistenceFieldList(
+ array $rawVersionFields,
+ int $versionNo
+ ): array {
+ return array_map(
+ function (array $row) use ($versionNo) {
+ $field = new Field();
+ $field->id = (int)$row['field_id'];
+ $field->fieldDefinitionId = (int)$row['field_definition_id'];
+ $field->type = $row['field_type'];
+ $storageValue = $this->mapFieldValueDataToStorageFieldValue($row);
+ $field->value = $this->buildFieldValue($storageValue, $field->type);
+ $field->languageCode = $row['field_language_code'];
+ $field->versionNo = $versionNo;
+
+ return $field;
+ },
+ $rawVersionFields
+ );
+ }
+
+ private function mapFieldValueDataToStorageFieldValue(array $row): StorageFieldValue
+ {
+ $storageValue = new StorageFieldValue();
+
+ // nullable data
+ $storageValue->dataFloat = isset($row['field_data_float']) ? (float)$row['field_data_float'] : null;
+ $storageValue->dataInt = isset($row['field_data_int']) ? (int)$row['field_data_int'] : null;
+ $storageValue->dataText = $row['field_data_text'] ?? null;
+
+ // non-nullable data
+ $storageValue->sortKeyInt = (int)$row['field_sort_key_int'];
+ $storageValue->sortKeyString = $row['field_sort_key_string'];
+
+ return $storageValue;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ */
+ private function buildFieldValue(
+ StorageFieldValue $storageFieldValue,
+ string $fieldType
+ ): FieldValue {
+ $fieldValue = new FieldValue();
+
+ $converter = $this->converterRegistry->getConverter($fieldType);
+ $converter->toFieldValue($storageFieldValue, $fieldValue);
+
+ return $fieldValue;
+ }
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
+ */
+ public function mapContentMetadataToPersistenceContentInfo(array $row): ContentInfo
+ {
+ $contentInfo = new ContentInfo();
+
+ $mainLanguage = $this->languageHandler->load((int)$row['content_initial_language_id']);
+
+ $contentInfo->id = (int)$row['content_id'];
+ $contentInfo->name = $row['content_name'];
+ $contentInfo->contentTypeId = (int)$row['content_type_id'];
+ $contentInfo->sectionId = (int)$row['content_section_id'];
+ $contentInfo->currentVersionNo = (int)$row['content_current_version'];
+ $contentInfo->ownerId = (int)$row['content_owner_id'];
+ $contentInfo->publicationDate = (int)$row['content_published'];
+ $contentInfo->modificationDate = (int)$row['content_modified'];
+ $contentInfo->alwaysAvailable = 1 === ($row['content_language_mask'] & 1);
+ $contentInfo->mainLanguageCode = $mainLanguage->languageCode;
+ $contentInfo->remoteId = $row['content_remote_id'];
+ $contentInfo->mainLocationId = $row['content_main_location_id'] !== null
+ ? (int)$row['content_main_location_id']
+ : null;
+ $contentInfo->status = (int)$row['content_status'];
+ $contentInfo->isHidden = (bool)$row['content_is_hidden'];
+
+ // setting deprecated property for BC reasons
+ $contentInfo->isPublished = $contentInfo->status === ContentInfo::STATUS_PUBLISHED;
+
+ return $contentInfo;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Gateway.php b/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Gateway.php
new file mode 100644
index 0000000000..4bd2429fa3
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/Gateway/Gateway.php
@@ -0,0 +1,37 @@
+connection = $connection;
+ $this->databasePlatform = $connection->getDatabasePlatform();
+ $this->criterionVisitor = $criterionVisitor;
+ $this->sortClauseVisitor = $sortClauseVisitor;
+ }
+
+ public function count(FilteringCriterion $criterion): int
+ {
+ $query = $this->buildQuery($criterion);
+
+ $query->select($this->databasePlatform->getCountExpression('DISTINCT location.node_id'));
+
+ return (int)$query->execute()->fetch(FetchMode::COLUMN);
+ }
+
+ public function find(
+ FilteringCriterion $criterion,
+ array $sortClauses,
+ int $limit,
+ int $offset
+ ): iterable {
+ $query = $this->buildQuery($criterion);
+ $this->sortClauseVisitor->visitSortClauses($query, $sortClauses);
+
+ $query->setFirstResult($offset);
+ if ($limit > 0) {
+ $query->setMaxResults($limit);
+ }
+
+ $resultStatement = $query->execute();
+
+ while (false !== ($row = $resultStatement->fetch(FetchMode::ASSOCIATIVE))) {
+ yield $row;
+ }
+ }
+
+ private function buildQuery(FilteringCriterion $criterion): FilteringQueryBuilder
+ {
+ $queryBuilder = new FilteringQueryBuilder($this->connection);
+ $queryBuilder
+ ->select(
+ [
+ // Location
+ 'location.node_id AS location_node_id',
+ 'location.priority AS location_priority',
+ 'location.is_hidden AS location_is_hidden',
+ 'location.is_invisible AS location_is_invisible',
+ 'location.remote_id AS location_remote_id',
+ 'location.contentobject_id AS location_contentobject_id',
+ 'location.parent_node_id AS location_parent_node_id',
+ 'location.path_identification_string AS location_path_identification_string',
+ 'location.path_string AS location_path_string',
+ 'location.depth AS location_depth',
+ 'location.sort_field AS location_sort_field',
+ 'location.sort_order AS location_sort_order',
+ // Main Location (nullable)
+ 'location.main_node_id AS content_main_location_id',
+ // Content Info
+ 'content.id AS content_id',
+ 'content.contentclass_id AS content_type_id',
+ 'content.current_version AS content_current_version',
+ 'content.initial_language_id AS content_initial_language_id',
+ 'content.language_mask AS content_language_mask',
+ 'content.modified AS content_modified',
+ 'content.name AS content_name',
+ 'content.owner_id AS content_owner_id',
+ 'content.published AS content_published',
+ 'content.remote_id AS content_remote_id',
+ 'content.section_id AS content_section_id',
+ 'content.status AS content_status',
+ 'content.is_hidden AS content_is_hidden',
+ ]
+ )
+ ->distinct()
+ ->from(LocationGateway::CONTENT_TREE_TABLE, 'location')
+ ->join(
+ 'location',
+ ContentGateway::CONTENT_ITEM_TABLE,
+ 'content',
+ 'content.id = location.contentobject_id'
+ )
+ ->joinPublishedVersion()
+ ;
+
+ $constraint = $this->criterionVisitor->visitCriteria($queryBuilder, $criterion);
+ if (null !== $constraint) {
+ $queryBuilder->where($constraint);
+ }
+
+ return $queryBuilder;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/Handler/ContentFilteringHandler.php b/eZ/Publish/Core/Persistence/Legacy/Filter/Handler/ContentFilteringHandler.php
new file mode 100644
index 0000000000..248b91d2ef
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/Handler/ContentFilteringHandler.php
@@ -0,0 +1,75 @@
+gateway = $gateway;
+ $this->mapper = $mapper;
+ $this->fieldHandler = $fieldHandler;
+ }
+
+ /**
+ * @return \eZ\Publish\SPI\Persistence\Filter\Content\LazyContentItemListIterator
+ */
+ public function find(Filter $filter): iterable
+ {
+ $count = $this->gateway->count($filter->getCriterion());
+
+ // wrapped list before creating the actual API ContentList to pass totalCount
+ // for paginated result a total count is not going to be a number of items in a current page
+ $list = new LazyContentItemListIterator($count);
+ if ($count === 0) {
+ return $list;
+ }
+
+ $list->prepareIterator(
+ $this->gateway->find(
+ $filter->getCriterion(),
+ $filter->getSortClauses(),
+ $filter->getLimit(),
+ $filter->getOffset()
+ ),
+ // called on each iteration of the iterator returned by find
+ function (array $row): ContentItem {
+ $contentItem = $this->mapper->mapRawDataToPersistenceContentItem($row);
+ $this->fieldHandler->loadExternalFieldData($contentItem->getContent());
+
+ return $contentItem;
+ }
+ );
+
+ return $list;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/Handler/LocationFilteringHandler.php b/eZ/Publish/Core/Persistence/Legacy/Filter/Handler/LocationFilteringHandler.php
new file mode 100644
index 0000000000..716218cdfa
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/Handler/LocationFilteringHandler.php
@@ -0,0 +1,71 @@
+gateway = $gateway;
+ $this->locationMapper = $locationMapper;
+ $this->contentGatewayDataMapper = $contentGatewayDataMapper;
+ }
+
+ public function find(Filter $filter): iterable
+ {
+ $count = $this->gateway->count($filter->getCriterion());
+
+ // wrapped list before creating the actual API LocationList to pass totalCount
+ // for paginated result a total count is not going to be a number of items in a current page
+ $list = new LazyLocationListIterator($count);
+ if ($count === 0) {
+ return $list;
+ }
+
+ $list->prepareIterator(
+ $this->gateway->find(
+ $filter->getCriterion(),
+ $filter->getSortClauses(),
+ $filter->getLimit(),
+ $filter->getOffset()
+ ),
+ // called on each iteration of the iterator returned by find
+ function (array $row): LocationWithContentInfo {
+ return new LocationWithContentInfo(
+ $this->locationMapper->createLocationFromRow($row, 'location_'),
+ $this->contentGatewayDataMapper->mapContentMetadataToPersistenceContentInfo(
+ $row
+ )
+ );
+ }
+ );
+
+ return $list;
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/DateModifiedSortClauseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/DateModifiedSortClauseQueryBuilder.php
new file mode 100644
index 0000000000..0d254d6e20
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/DateModifiedSortClauseQueryBuilder.php
@@ -0,0 +1,30 @@
+addOrderBy('content.modified', $sortClause->direction);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/DatePublishedSortClauseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/DatePublishedSortClauseQueryBuilder.php
new file mode 100644
index 0000000000..06f2accb13
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/DatePublishedSortClauseQueryBuilder.php
@@ -0,0 +1,30 @@
+addOrderBy('content.published', $sortClause->direction);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/IdSortClauseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/IdSortClauseQueryBuilder.php
new file mode 100644
index 0000000000..91976ba4e9
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/IdSortClauseQueryBuilder.php
@@ -0,0 +1,30 @@
+addOrderBy('content.id', $sortClause->direction);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/NameSortClauseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/NameSortClauseQueryBuilder.php
new file mode 100644
index 0000000000..a176876cf9
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/NameSortClauseQueryBuilder.php
@@ -0,0 +1,30 @@
+addOrderBy('content.name', $sortClause->direction);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/SectionIdentifierSortClauseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/SectionIdentifierSortClauseQueryBuilder.php
new file mode 100644
index 0000000000..770690cb69
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/SectionIdentifierSortClauseQueryBuilder.php
@@ -0,0 +1,40 @@
+addSelect('section.identifier')
+ ->joinOnce(
+ 'content',
+ SectionGateway::CONTENT_SECTION_TABLE,
+ 'section',
+ 'content.section_id = section.id'
+ );
+
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\SortClause $sortClause */
+ $queryBuilder->addOrderBy('section.identifier', $sortClause->direction);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/SectionNameSortClauseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/SectionNameSortClauseQueryBuilder.php
new file mode 100644
index 0000000000..60fc8982ae
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Content/SectionNameSortClauseQueryBuilder.php
@@ -0,0 +1,40 @@
+addSelect('section.name')
+ ->joinOnce(
+ 'content',
+ SectionGateway::CONTENT_SECTION_TABLE,
+ 'section',
+ 'content.section_id = section.id'
+ );
+
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\SortClause $sortClause */
+ $queryBuilder->addOrderBy('section.name', $sortClause->direction);
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Location/BaseLocationSortClauseQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Location/BaseLocationSortClauseQueryBuilder.php
new file mode 100644
index 0000000000..91435e9a83
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Location/BaseLocationSortClauseQueryBuilder.php
@@ -0,0 +1,34 @@
+getSortingExpression();
+ $queryBuilder
+ ->addSelect($this->getSortingExpression())
+ ->joinAllLocations();
+
+ /** @var \eZ\Publish\API\Repository\Values\Content\Query\SortClause $sortClause */
+ $queryBuilder->addOrderBy($sort, $sortClause->direction);
+ }
+
+ abstract protected function getSortingExpression(): string;
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Location/DepthQueryBuilder.php b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Location/DepthQueryBuilder.php
new file mode 100644
index 0000000000..97492d1bb5
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Filter/SortClauseQueryBuilder/Location/DepthQueryBuilder.php
@@ -0,0 +1,28 @@
+sortClauseQueryBuilders = $sortClauseQueryBuilders;
+ }
+
+ /**
+ * @param \eZ\Publish\SPI\Repository\Values\Filter\FilteringSortClause[] $sortClauses
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotImplementedException if there's no builder for a Sort Clause
+ */
+ public function visitSortClauses(FilteringQueryBuilder $queryBuilder, array $sortClauses): void
+ {
+ foreach ($sortClauses as $sortClause) {
+ $this
+ ->getQueryBuilderForSortClause($sortClause)
+ ->buildQuery($queryBuilder, $sortClause);
+ }
+ }
+
+ /**
+ * Cache Query Builders in-memory and get the one for the given Sort Clause.
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotImplementedException
+ */
+ private function getQueryBuilderForSortClause(
+ FilteringSortClause $sortClause
+ ): SortClauseQueryBuilder {
+ $sortClauseFQCN = get_class($sortClause);
+ if (!isset(self::$queryBuildersForSortClauses[$sortClauseFQCN])) {
+ foreach ($this->sortClauseQueryBuilders as $sortClauseQueryBuilder) {
+ if ($sortClauseQueryBuilder->accepts($sortClause)) {
+ self::$queryBuildersForSortClauses[$sortClauseFQCN] = $sortClauseQueryBuilder;
+ break;
+ }
+ }
+ }
+
+ if (!isset(self::$queryBuildersForSortClauses[$sortClauseFQCN])) {
+ throw new NotImplementedException(
+ "There are no Query Builders for {$sortClauseFQCN} Sort Clause"
+ );
+ }
+
+ return self::$queryBuildersForSortClauses[$sortClauseFQCN];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/BaseCriterionVisitorQueryBuilderTestCase.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/BaseCriterionVisitorQueryBuilderTestCase.php
new file mode 100644
index 0000000000..1c07075407
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/BaseCriterionVisitorQueryBuilderTestCase.php
@@ -0,0 +1,105 @@
+criterionVisitor = new CriterionVisitor([]);
+ $this->criterionVisitor->setCriterionQueryBuilders(
+ array_merge(
+ $this->getBaseCriterionQueryBuilders($this->criterionVisitor),
+ $this->getCriterionQueryBuilders()
+ )
+ );
+ }
+
+ /**
+ * @dataProvider getFilteringCriteriaQueryData
+ *
+ * @covers \eZ\Publish\SPI\Repository\Values\Filter\CriterionQueryBuilder::buildQueryConstraint
+ * @covers \eZ\Publish\SPI\Repository\Values\Filter\CriterionQueryBuilder::accepts
+ * @covers \eZ\Publish\Core\Persistence\Legacy\Filter\CriterionVisitor::visitCriteria
+ *
+ * @throws \eZ\Publish\API\Repository\Exceptions\NotImplementedException
+ */
+ public function testVisitCriteriaProducesQuery(
+ FilteringCriterion $criterion,
+ string $expectedQuery,
+ array $expectedParameterValues
+ ): void {
+ $queryBuilder = $this->getQueryBuilder();
+ $actualQuery = $this->criterionVisitor->visitCriteria($queryBuilder, $criterion);
+ $criterionFQCN = get_class($criterion);
+ self::assertSame(
+ $expectedQuery,
+ $actualQuery,
+ sprintf(
+ 'Query Builder for %s Criterion does not produce expected query',
+ $criterionFQCN
+ )
+ );
+ self::assertSame(
+ $expectedParameterValues,
+ $queryBuilder->getParameters(),
+ sprintf(
+ 'Query Builder for %s Criterion does not bind expected query parameter values',
+ $criterionFQCN
+ )
+ );
+ }
+
+ private function getQueryBuilder(): FilteringQueryBuilder
+ {
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock
+ ->method('getExpressionBuilder')
+ ->willReturn(
+ new ExpressionBuilder($connectionMock)
+ );
+
+ return new FilteringQueryBuilder($connectionMock);
+ }
+
+ /**
+ * Create Query Builders needed for every test case.
+ *
+ * @see getCriterionQueryBuilders
+ */
+ private function getBaseCriterionQueryBuilders(CriterionVisitor $criterionVisitor): iterable
+ {
+ return [
+ new CriterionQueryBuilder\LogicalAndQueryBuilder($criterionVisitor),
+ new CriterionQueryBuilder\LogicalOrQueryBuilder($criterionVisitor),
+ new CriterionQueryBuilder\LogicalNotQueryBuilder($criterionVisitor),
+ ];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilderQueryBuilderTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilderQueryBuilderTest.php
new file mode 100644
index 0000000000..5314706f7c
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilderQueryBuilderTest.php
@@ -0,0 +1,40 @@
+ [
+ new Criterion\LanguageCode(['eng-GB', 'eng-US']),
+ '(language.locale IN (:dcValue1)) OR (version.language_mask & 1 = 1)',
+ ['dcValue1' => ['eng-GB', 'eng-US']],
+ ];
+
+ yield 'Language Code=pol-PL, don\'t match always available' => [
+ new Criterion\LanguageCode('pol-PL', false),
+ 'language.locale IN (:dcValue1)',
+ ['dcValue1' => ['pol-PL']],
+ ];
+ }
+
+ protected function getCriterionQueryBuilders(): iterable
+ {
+ return [new LanguageCodeQueryBuilder()];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/Type/ContentTypeGroupIdQueryBuilderTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/Type/ContentTypeGroupIdQueryBuilderTest.php
new file mode 100644
index 0000000000..6c779c402f
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/Type/ContentTypeGroupIdQueryBuilderTest.php
@@ -0,0 +1,40 @@
+ [
+ new ContentTypeGroupId(1),
+ 'content_type_group.id IN (:dcValue1)',
+ ['dcValue1' => [1]],
+ ];
+
+ yield 'Content Type Group ID IN (1, 2)' => [
+ new ContentTypeGroupId([1, 2]),
+ 'content_type_group.id IN (:dcValue1)',
+ ['dcValue1' => [1, 2]],
+ ];
+ }
+
+ protected function getCriterionQueryBuilders(): iterable
+ {
+ return [new GroupIdQueryBuilder()];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/Type/ContentTypeQueryBuildersQueryBuilderTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/Type/ContentTypeQueryBuildersQueryBuilderTest.php
new file mode 100644
index 0000000000..1549af8689
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Content/Type/ContentTypeQueryBuildersQueryBuilderTest.php
@@ -0,0 +1,57 @@
+ [
+ new Criterion\ContentTypeIdentifier('article'),
+ 'content_type.identifier IN (:dcValue1)',
+ ['dcValue1' => ['article']],
+ ];
+
+ yield 'Content Type ID=1' => [
+ new Criterion\ContentTypeId(3),
+ 'content_type.id IN (:dcValue1)',
+ ['dcValue1' => [3]],
+ ];
+
+ yield 'Content Type Identifier=folder OR Content Type ID IN (1, 2)' => [
+ new Criterion\LogicalOr(
+ [
+ new Criterion\ContentTypeIdentifier('folder'),
+ new Criterion\ContentTypeId([1, 2]),
+ ]
+ ),
+ '(content_type.identifier IN (:dcValue1)) OR (content_type.id IN (:dcValue2))',
+ ['dcValue1' => ['folder'], 'dcValue2' => [1, 2]],
+ ];
+ }
+
+ protected function getCriterionQueryBuilders(): iterable
+ {
+ return [
+ new IdentifierQueryBuilder(),
+ new IdQueryBuilder(),
+ ];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/AncestorQueryBuilderTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/AncestorQueryBuilderTest.php
new file mode 100644
index 0000000000..23e65979a9
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/AncestorQueryBuilderTest.php
@@ -0,0 +1,36 @@
+ [
+ new Ancestor('/1/2/'),
+ 'location.node_id IN (:dcValue1)',
+ ['dcValue1' => [1, 2]],
+ ];
+
+ yield 'Ancestor IN (/1/2/, /1/4/10/' => [
+ new Ancestor(['/1/2/', '/1/4/10/']),
+ 'location.node_id IN (:dcValue1)',
+ ['dcValue1' => [1, 2, 4, 10]],
+ ];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/LocationIdQueryBuilderTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/LocationIdQueryBuilderTest.php
new file mode 100644
index 0000000000..1d2748a594
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/LocationIdQueryBuilderTest.php
@@ -0,0 +1,45 @@
+ [
+ new Criterion\LocationId(1),
+ 'location.node_id IN (:dcValue1)',
+ ['dcValue1' => [1]],
+ ];
+
+ yield 'Location ID=1 OR Location ID=2' => [
+ new Criterion\LogicalOr(
+ [
+ new Criterion\LocationId(1),
+ new Criterion\LocationId(2),
+ ]
+ ),
+ '(location.node_id IN (:dcValue1)) OR (location.node_id IN (:dcValue2))',
+ ['dcValue1' => [1], 'dcValue2' => [2]],
+ ];
+ }
+
+ protected function getCriterionQueryBuilders(): iterable
+ {
+ return [new IdQueryBuilder()];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/ParentLocationQueryBuilderTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/ParentLocationQueryBuilderTest.php
new file mode 100644
index 0000000000..89bbccd915
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/Location/ParentLocationQueryBuilderTest.php
@@ -0,0 +1,45 @@
+ [
+ new Criterion\ParentLocationId(1),
+ 'location.parent_node_id IN (:dcValue1)',
+ ['dcValue1' => [1]],
+ ];
+
+ yield 'Parent Location ID=1 OR Parent Location ID=2' => [
+ new Criterion\LogicalOr(
+ [
+ new Criterion\ParentLocationId(1),
+ new Criterion\ParentLocationId(2),
+ ]
+ ),
+ '(location.parent_node_id IN (:dcValue1)) OR (location.parent_node_id IN (:dcValue2))',
+ ['dcValue1' => [1], 'dcValue2' => [2]],
+ ];
+ }
+
+ protected function getCriterionQueryBuilders(): iterable
+ {
+ return [new ParentLocationIdQueryBuilder()];
+ }
+}
diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php
new file mode 100644
index 0000000000..7c798f9a04
--- /dev/null
+++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php
@@ -0,0 +1,78 @@
+ [
+ new Criterion\LogicalAnd(
+ [
+ new Criterion\ParentLocationId(1),
+ new Criterion\LanguageCode('eng-GB'),
+ ]
+ ),
+ '(location.parent_node_id IN (:dcValue1)) AND ((language.locale IN (:dcValue2)) OR (version.language_mask & 1 = 1))',
+ ['dcValue1' => [1], 'dcValue2' => ['eng-GB']],
+ ];
+
+ yield 'Language Code=eng-US OR Parent Location ID=2' => [
+ new Criterion\LogicalOr(
+ [
+ new Criterion\LanguageCode('eng-GB'),
+ new Criterion\ParentLocationId(2),
+ ]
+ ),
+ '((language.locale IN (:dcValue1)) OR (version.language_mask & 1 = 1)) OR (location.parent_node_id IN (:dcValue2))',
+ ['dcValue1' => ['eng-GB'], 'dcValue2' => [2]],
+ ];
+
+ yield 'NOT(Content ID=1 OR (Parent Location ID=2 AND Content ID = 1)' => [
+ new Criterion\LogicalNot(
+ new Criterion\LogicalOr(
+ [
+ new Criterion\ContentId(1),
+ new Criterion\LogicalAnd(
+ [
+ new Criterion\ParentLocationId(2),
+ new Criterion\ContentId(1),
+ ]
+ ),
+ ]
+ )
+ ),
+ 'NOT ((content.id IN (:dcValue1)) OR ((location.parent_node_id IN (:dcValue2)) AND (content.id IN (:dcValue3))))',
+ ['dcValue1' => [1], 'dcValue2' => [2], 'dcValue3' => [1]],
+ ];
+ }
+
+ protected function getCriterionQueryBuilders(): iterable
+ {
+ return [
+ new ParentLocationIdQueryBuilder(),
+ new LanguageCodeQueryBuilder(),
+ new ContentIdQueryBuilder(),
+ ];
+ }
+}
diff --git a/eZ/Publish/Core/Repository/ContentService.php b/eZ/Publish/Core/Repository/ContentService.php
index dfcb5c1231..d4138f5166 100644
--- a/eZ/Publish/Core/Repository/ContentService.php
+++ b/eZ/Publish/Core/Repository/ContentService.php
@@ -9,8 +9,10 @@
namespace eZ\Publish\Core\Repository;
use eZ\Publish\API\Repository\ContentService as ContentServiceInterface;
-use eZ\Publish\API\Repository\PermissionResolver;
+use eZ\Publish\API\Repository\PermissionService;
use eZ\Publish\API\Repository\Repository as RepositoryInterface;
+use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
+use eZ\Publish\API\Repository\Values\Content\Query\Criterion\LanguageCode;
use eZ\Publish\API\Repository\Values\ValueObject;
use eZ\Publish\Core\FieldType\FieldTypeRegistry;
use eZ\Publish\API\Repository\Values\Content\ContentDraftList;
@@ -25,6 +27,7 @@
use eZ\Publish\Core\Repository\Values\Content\Content;
use eZ\Publish\Core\Repository\Values\Content\Location;
use eZ\Publish\API\Repository\Values\Content\Language;
+use eZ\Publish\SPI\Persistence\Filter\Content\Handler as ContentFilteringHandler;
use eZ\Publish\SPI\Persistence\Handler;
use eZ\Publish\API\Repository\Values\Content\ContentUpdateStruct as APIContentUpdateStruct;
use eZ\Publish\API\Repository\Values\ContentType\ContentType;
@@ -54,6 +57,11 @@
use eZ\Publish\SPI\Persistence\Content\ContentInfo as SPIContentInfo;
use Exception;
use eZ\Publish\SPI\Repository\Validator\ContentValidator;
+use eZ\Publish\API\Repository\Values\Content\ContentList;
+use eZ\Publish\API\Repository\Values\Filter\Filter;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
+use function count;
+use function sprintf;
/**
* This class provides service methods for managing content.
@@ -90,6 +98,9 @@ class ContentService implements ContentServiceInterface
/** @var \eZ\Publish\SPI\Repository\Validator\ContentValidator */
private $contentValidator;
+ /** @var \eZ\Publish\SPI\Persistence\Filter\Content\Handler */
+ private $contentFilteringHandler;
+
public function __construct(
RepositoryInterface $repository,
Handler $handler,
@@ -97,9 +108,10 @@ public function __construct(
Helper\RelationProcessor $relationProcessor,
Helper\NameSchemaService $nameSchemaService,
FieldTypeRegistry $fieldTypeRegistry,
- PermissionResolver $permissionResolver,
+ PermissionService $permissionService,
ContentMapper $contentMapper,
ContentValidator $contentValidator,
+ ContentFilteringHandler $contentFilteringHandler,
array $settings = []
) {
$this->repository = $repository;
@@ -113,7 +125,8 @@ public function __construct(
// Version archive limit (0-50), only enforced on publish, not on un-publish.
'default_version_archive_limit' => 5,
];
- $this->permissionResolver = $permissionResolver;
+ $this->contentFilteringHandler = $contentFilteringHandler;
+ $this->permissionResolver = $permissionService;
$this->contentMapper = $contentMapper;
$this->contentValidator = $contentValidator;
}
@@ -2370,4 +2383,36 @@ public function validate(
$fieldIdentifiersToValidate
);
}
+
+ public function find(Filter $filter, ?array $languages = null): ContentList
+ {
+ $filter = clone $filter;
+ if (!empty($languages)) {
+ $filter->andWithCriterion(new LanguageCode($languages));
+ }
+
+ $permissionCriterion = $this->permissionResolver->getQueryPermissionsCriterion();
+ if ($permissionCriterion instanceof Criterion\MatchNone) {
+ return new ContentList(0, []);
+ }
+
+ if (!$permissionCriterion instanceof Criterion\MatchAll) {
+ if (!$permissionCriterion instanceof FilteringCriterion) {
+ return new ContentList(0, []);
+ }
+ $filter->andWithCriterion($permissionCriterion);
+ }
+
+ $contentItems = [];
+ $contentItemsIterator = $this->contentFilteringHandler->find($filter);
+ foreach ($contentItemsIterator as $contentItem) {
+ $contentItems[] = $this->contentDomainMapper->buildContentDomainObjectFromPersistence(
+ $contentItem->content,
+ $contentItem->type,
+ $languages,
+ );
+ }
+
+ return new ContentList($contentItemsIterator->getTotalCount(), $contentItems);
+ }
}
diff --git a/eZ/Publish/Core/Repository/LocationService.php b/eZ/Publish/Core/Repository/LocationService.php
index 0d5c6b84e5..021163d4f0 100644
--- a/eZ/Publish/Core/Repository/LocationService.php
+++ b/eZ/Publish/Core/Repository/LocationService.php
@@ -17,12 +17,14 @@
use eZ\Publish\API\Repository\Values\Content\ContentInfo;
use eZ\Publish\API\Repository\Values\Content\Location as APILocation;
use eZ\Publish\API\Repository\Values\Content\LocationList;
+use eZ\Publish\API\Repository\Values\Content\Query\Criterion\LanguageCode;
use eZ\Publish\API\Repository\Values\Content\VersionInfo;
use eZ\Publish\Core\Repository\Mapper\ContentDomainMapper;
use eZ\Publish\SPI\Persistence\Content\Location as SPILocation;
use eZ\Publish\SPI\Persistence\Content\Location\UpdateStruct;
use eZ\Publish\API\Repository\LocationService as LocationServiceInterface;
use eZ\Publish\API\Repository\Repository as RepositoryInterface;
+use eZ\Publish\SPI\Persistence\Filter\Location\Handler as LocationFilteringHandler;
use eZ\Publish\SPI\Persistence\Handler;
use eZ\Publish\API\Repository\Values\Content\Query;
use eZ\Publish\API\Repository\Values\Content\LocationQuery;
@@ -36,9 +38,12 @@
use eZ\Publish\Core\Base\Exceptions\BadStateException;
use eZ\Publish\Core\Base\Exceptions\UnauthorizedException;
use Exception;
+use eZ\Publish\API\Repository\Values\Filter\Filter;
+use eZ\Publish\SPI\Repository\Values\Filter\FilteringCriterion;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use eZ\Publish\API\Repository\Values\ContentType\ContentType;
+use function count;
/**
* Location service, used for complex subtree operations.
@@ -71,6 +76,9 @@ class LocationService implements LocationServiceInterface
/** @var \eZ\Publish\API\Repository\PermissionResolver */
private $permissionResolver;
+ /** @var \eZ\Publish\SPI\Persistence\Filter\Location\Handler */
+ private $locationFilteringHandler;
+
/**
* Setups service with reference to repository object that created it & corresponding handler.
*
@@ -89,6 +97,7 @@ public function __construct(
Helper\NameSchemaService $nameSchemaService,
PermissionCriterionResolver $permissionCriterionResolver,
PermissionResolver $permissionResolver,
+ LocationFilteringHandler $locationFilteringHandler,
array $settings = [],
LoggerInterface $logger = null
) {
@@ -97,6 +106,7 @@ public function __construct(
$this->contentDomainMapper = $contentDomainMapper;
$this->nameSchemaService = $nameSchemaService;
$this->permissionResolver = $permissionResolver;
+ $this->locationFilteringHandler = $locationFilteringHandler;
// Union makes sure default settings are ignored if provided in argument
$this->settings = $settings + [
//'defaultSetting' => array(),
@@ -898,4 +908,44 @@ function (SPILocation $spiLocation) {
return $locations;
}
+
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
+ */
+ public function find(Filter $filter, ?array $languages = null): LocationList
+ {
+ $filter = clone $filter;
+ if (!empty($languages)) {
+ $filter->andWithCriterion(new LanguageCode($languages));
+ }
+
+ $permissionCriterion = $this->permissionCriterionResolver->getQueryPermissionsCriterion();
+ if ($permissionCriterion instanceof Criterion\MatchNone) {
+ return new LocationList();
+ }
+
+ if (!$permissionCriterion instanceof Criterion\MatchAll) {
+ if (!$permissionCriterion instanceof FilteringCriterion) {
+ return new LocationList();
+ }
+ $filter->andWithCriterion($permissionCriterion);
+ }
+
+ $locations = [];
+ foreach ($this->locationFilteringHandler->find($filter) as $locationWithContentInfo) {
+ $spiContentInfo = $locationWithContentInfo->getContentInfo();
+ $locations[] = $this->contentDomainMapper->buildLocationWithContent(
+ $locationWithContentInfo->getLocation(),
+ $this->contentDomainMapper->buildContentProxy($spiContentInfo),
+ $spiContentInfo,
+ );
+ }
+
+ return new LocationList(
+ [
+ 'totalCount' => count($locations),
+ 'locations' => $locations,
+ ]
+ );
+ }
}
diff --git a/eZ/Publish/Core/Repository/Permission/CachedPermissionService.php b/eZ/Publish/Core/Repository/Permission/CachedPermissionService.php
index a239623d23..b218f18a8d 100644
--- a/eZ/Publish/Core/Repository/Permission/CachedPermissionService.php
+++ b/eZ/Publish/Core/Repository/Permission/CachedPermissionService.php
@@ -12,6 +12,7 @@
use eZ\Publish\API\Repository\PermissionCriterionResolver as APIPermissionCriterionResolver;
use eZ\Publish\API\Repository\PermissionService;
use eZ\Publish\API\Repository\Repository as RepositoryInterface;
+use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\User\LookupLimitationResult;
use eZ\Publish\API\Repository\Values\User\UserReference;
use eZ\Publish\API\Repository\Values\ValueObject;
@@ -150,4 +151,9 @@ public function sudo(callable $callback, RepositoryInterface $outerRepository)
return $returnValue;
}
+
+ public function getQueryPermissionsCriterion(): Criterion
+ {
+ return $this->permissionCriterionResolver->getQueryPermissionsCriterion();
+ }
}
diff --git a/eZ/Publish/Core/Repository/Permission/PermissionCriterionResolver.php b/eZ/Publish/Core/Repository/Permission/PermissionCriterionResolver.php
index edcc2cef45..78081191ca 100644
--- a/eZ/Publish/Core/Repository/Permission/PermissionCriterionResolver.php
+++ b/eZ/Publish/Core/Repository/Permission/PermissionCriterionResolver.php
@@ -9,6 +9,7 @@
namespace eZ\Publish\Core\Repository\Permission;
use eZ\Publish\API\Repository\PermissionCriterionResolver as APIPermissionCriterionResolver;
+use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalAnd;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalOr;
use eZ\Publish\API\Repository\Values\Content\Query\CriterionInterface;
@@ -151,4 +152,21 @@ private function getCriterionForLimitation(Limitation $limitation, UserReference
return $type->getCriterion($limitation, $currentUserRef);
}
+
+ public function getQueryPermissionsCriterion(): Criterion
+ {
+ // Permission Criterion handling work-around to avoid rewriting old architecture of perm. sys.
+ $permissionCriterion = $this->getPermissionsCriterion(
+ 'content',
+ 'read'
+ );
+ if (true === $permissionCriterion) {
+ return new Criterion\MatchAll();
+ }
+ if (false === $permissionCriterion) {
+ return new Criterion\MatchNone();
+ }
+
+ return $permissionCriterion;
+ }
}
diff --git a/eZ/Publish/Core/Repository/Repository.php b/eZ/Publish/Core/Repository/Repository.php
index ed42b54af2..93801b6009 100644
--- a/eZ/Publish/Core/Repository/Repository.php
+++ b/eZ/Publish/Core/Repository/Repository.php
@@ -38,6 +38,8 @@
use eZ\Publish\Core\Repository\User\PasswordHashServiceInterface;
use eZ\Publish\Core\Repository\Helper\RelationProcessor;
use eZ\Publish\Core\Search\Common\BackgroundIndexer;
+use eZ\Publish\SPI\Persistence\Filter\Content\Handler as ContentFilteringHandler;
+use eZ\Publish\SPI\Persistence\Filter\Location\Handler as LocationFilteringHandler;
use eZ\Publish\SPI\Persistence\Handler as PersistenceHandler;
use eZ\Publish\SPI\Repository\Strategy\ContentThumbnail\ThumbnailStrategy;
use eZ\Publish\SPI\Repository\Validator\ContentValidator;
@@ -251,6 +253,12 @@ class Repository implements RepositoryInterface
/** @var \eZ\Publish\SPI\Repository\Validator\ContentValidator */
private $contentValidator;
+ /** @var \eZ\Publish\SPI\Persistence\Filter\Content\Handler */
+ private $contentFilteringHandler;
+
+ /** @var \eZ\Publish\SPI\Persistence\Filter\Location\Handler */
+ private $locationFilteringHandler;
+
public function __construct(
PersistenceHandler $persistenceHandler,
SearchHandler $searchHandler,
@@ -268,6 +276,8 @@ public function __construct(
LimitationService $limitationService,
LanguageResolver $languageResolver,
PermissionService $permissionService,
+ ContentFilteringHandler $contentFilteringHandler,
+ LocationFilteringHandler $locationFilteringHandler,
array $serviceSettings = [],
?LoggerInterface $logger = null
) {
@@ -284,7 +294,9 @@ public function __construct(
$this->roleDomainMapper = $roleDomainMapper;
$this->limitationService = $limitationService;
$this->languageResolver = $languageResolver;
+ $this->contentFilteringHandler = $contentFilteringHandler;
$this->permissionService = $permissionService;
+ $this->locationFilteringHandler = $locationFilteringHandler;
$this->serviceSettings = $serviceSettings + [
'content' => [],
@@ -344,9 +356,10 @@ public function getContentService(): ContentServiceInterface
$this->getRelationProcessor(),
$this->getNameSchemaService(),
$this->fieldTypeRegistry,
- $this->getPermissionResolver(),
+ $this->getPermissionService(),
$this->contentMapper,
$this->contentValidator,
+ $this->contentFilteringHandler,
$this->serviceSettings['content'],
);
@@ -424,6 +437,7 @@ public function getLocationService(): LocationServiceInterface
$this->getNameSchemaService(),
$this->getPermissionCriterionResolver(),
$this->getPermissionResolver(),
+ $this->locationFilteringHandler,
$this->serviceSettings['location'],
$this->logger
);
@@ -691,11 +705,16 @@ public function getFieldTypeService(): FieldTypeServiceInterface
return $this->fieldTypeService;
}
- public function getPermissionResolver(): PermissionResolverInterface
+ public function getPermissionService(): PermissionService
{
return $this->permissionService;
}
+ public function getPermissionResolver(): PermissionResolverInterface
+ {
+ return $this->getPermissionService();
+ }
+
/**
* Get NameSchemaResolverService.
*
@@ -766,7 +785,7 @@ protected function getProxyDomainMapper(): ProxyDomainMapperInterface
protected function getPermissionCriterionResolver(): PermissionCriterionResolverInterface
{
- return $this->permissionService;
+ return $this->getPermissionService();
}
/**
diff --git a/eZ/Publish/Core/Repository/SiteAccessAware/ContentService.php b/eZ/Publish/Core/Repository/SiteAccessAware/ContentService.php
index 0560a18d84..af1f8a7cbc 100644
--- a/eZ/Publish/Core/Repository/SiteAccessAware/ContentService.php
+++ b/eZ/Publish/Core/Repository/SiteAccessAware/ContentService.php
@@ -23,6 +23,8 @@
use eZ\Publish\API\Repository\Values\Content\VersionInfo;
use eZ\Publish\API\Repository\Values\Content\ContentInfo;
use eZ\Publish\API\Repository\LanguageResolver;
+use eZ\Publish\API\Repository\Values\Content\ContentList;
+use eZ\Publish\API\Repository\Values\Filter\Filter;
use eZ\Publish\API\Repository\Values\ValueObject;
/**
@@ -268,4 +270,12 @@ public function validate(
): array {
return $this->service->validate($object, $context, $fieldIdentifiersToValidate);
}
+
+ public function find(Filter $filter, ?array $languages = null): ContentList
+ {
+ return $this->service->find(
+ $filter,
+ $this->languageResolver->getPrioritizedLanguages($languages)
+ );
+ }
}
diff --git a/eZ/Publish/Core/Repository/SiteAccessAware/LocationService.php b/eZ/Publish/Core/Repository/SiteAccessAware/LocationService.php
index 96728b5185..d9eb9cfc14 100644
--- a/eZ/Publish/Core/Repository/SiteAccessAware/LocationService.php
+++ b/eZ/Publish/Core/Repository/SiteAccessAware/LocationService.php
@@ -16,6 +16,7 @@
use eZ\Publish\API\Repository\Values\Content\LocationCreateStruct;
use eZ\Publish\API\Repository\Values\Content\LocationUpdateStruct;
use eZ\Publish\API\Repository\LanguageResolver;
+use eZ\Publish\API\Repository\Values\Filter\Filter;
/**
* LocationService for SiteAccessAware layer.
@@ -177,4 +178,12 @@ public function loadAllLocations(int $offset = 0, int $limit = 25): array
{
return $this->service->loadAllLocations($offset, $limit);
}
+
+ public function find(Filter $filter, ?array $languages = null): LocationList
+ {
+ return $this->service->find(
+ $filter,
+ $this->languageResolver->getPrioritizedLanguages($languages)
+ );
+ }
}
diff --git a/eZ/Publish/Core/Repository/SiteAccessAware/Tests/AbstractServiceTest.php b/eZ/Publish/Core/Repository/SiteAccessAware/Tests/AbstractServiceTest.php
index 0e498864f5..22c6751551 100644
--- a/eZ/Publish/Core/Repository/SiteAccessAware/Tests/AbstractServiceTest.php
+++ b/eZ/Publish/Core/Repository/SiteAccessAware/Tests/AbstractServiceTest.php
@@ -155,7 +155,7 @@ final public function testForLanguagesLookup($method, array $arguments, $return,
{
$languages = ['eng-GB', 'eng-US'];
- $arguments = $this->setLanguagesLookupArguments($arguments, $languageArgumentIndex, $languages);
+ $arguments = $this->setLanguagesLookupArguments($arguments, $languageArgumentIndex);
$expectedArguments = $this->setLanguagesLookupExpectedArguments($arguments, $languageArgumentIndex, $languages);
diff --git a/eZ/Publish/Core/Repository/SiteAccessAware/Tests/ContentServiceTest.php b/eZ/Publish/Core/Repository/SiteAccessAware/Tests/ContentServiceTest.php
index a89f1002e6..30ebb05b0f 100644
--- a/eZ/Publish/Core/Repository/SiteAccessAware/Tests/ContentServiceTest.php
+++ b/eZ/Publish/Core/Repository/SiteAccessAware/Tests/ContentServiceTest.php
@@ -13,6 +13,7 @@
use eZ\Publish\API\Repository\Values\Content\ContentMetadataUpdateStruct;
use eZ\Publish\API\Repository\Values\Content\Language;
use eZ\Publish\API\Repository\Values\Content\LocationCreateStruct;
+use eZ\Publish\API\Repository\Values\Content\Query\Criterion\ContentId;
use eZ\Publish\API\Repository\Values\Content\Relation;
use eZ\Publish\API\Repository\Values\Content\RelationList;
use eZ\Publish\Core\Repository\Values\ContentType\ContentType;
@@ -21,7 +22,12 @@
use eZ\Publish\Core\Repository\Values\Content\ContentCreateStruct;
use eZ\Publish\Core\Repository\Values\Content\ContentUpdateStruct;
use eZ\Publish\Core\Repository\Values\Content\VersionInfo;
+use eZ\Publish\API\Repository\Values\Content\ContentList;
+use eZ\Publish\API\Repository\Values\Filter\Filter;
+/**
+ * @property \eZ\Publish\API\Repository\ContentService $service
+ */
class ContentServiceTest extends AbstractServiceTest
{
public function getAPIServiceClassName()
@@ -34,7 +40,7 @@ public function getSiteAccessAwareServiceClassName()
return ContentService::class;
}
- public function providerForPassTroughMethods()
+ public function providerForPassTroughMethods(): array
{
$contentInfo = new ContentInfo();
$versionInfo = new VersionInfo();
@@ -121,12 +127,17 @@ public function providerForPassTroughMethods()
];
}
- public function providerForLanguagesLookupMethods()
+ /**
+ * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
+ */
+ public function providerForLanguagesLookupMethods(): array
{
$content = $this->createMock(Content::class);
$contentInfo = new ContentInfo();
$versionInfo = new VersionInfo();
+ $filter = new Filter(new ContentId(1));
+
// string $method, array $arguments, bool $return, int $languageArgumentIndex
return [
['loadContentByContentInfo', [$contentInfo], $content, 1],
@@ -143,6 +154,9 @@ public function providerForLanguagesLookupMethods()
['loadContentListByContentInfo', [[$contentInfo]], [], 1],
['loadContentListByContentInfo', [[$contentInfo], self::LANG_ARG, false], [], 1],
+
+ ['find', [$filter], new ContentList(1, [$content]), 1],
+ ['find', [$filter, self::LANG_ARG], new ContentList(1, [$content]), 1],
];
}
}
diff --git a/eZ/Publish/Core/Repository/SiteAccessAware/Tests/LocationServiceTest.php b/eZ/Publish/Core/Repository/SiteAccessAware/Tests/LocationServiceTest.php
index 99f8c249ce..6db96f700c 100644
--- a/eZ/Publish/Core/Repository/SiteAccessAware/Tests/LocationServiceTest.php
+++ b/eZ/Publish/Core/Repository/SiteAccessAware/Tests/LocationServiceTest.php
@@ -11,9 +11,11 @@
use eZ\Publish\API\Repository\Values\Content\LocationCreateStruct;
use eZ\Publish\API\Repository\Values\Content\LocationList;
use eZ\Publish\API\Repository\Values\Content\LocationUpdateStruct;
+use eZ\Publish\API\Repository\Values\Content\Query\Criterion\LocationId;
use eZ\Publish\Core\Repository\SiteAccessAware\LocationService;
use eZ\Publish\Core\Repository\Values\Content\Location;
use eZ\Publish\Core\Repository\Values\Content\VersionInfo;
+use eZ\Publish\API\Repository\Values\Filter\Filter;
class LocationServiceTest extends AbstractServiceTest
{
@@ -27,7 +29,7 @@ public function getSiteAccessAwareServiceClassName()
return LocationService::class;
}
- public function providerForPassTroughMethods()
+ public function providerForPassTroughMethods(): array
{
$location = new Location();
$contentInfo = new ContentInfo();
@@ -63,13 +65,15 @@ public function providerForPassTroughMethods()
];
}
- public function providerForLanguagesLookupMethods()
+ public function providerForLanguagesLookupMethods(): array
{
$location = new Location();
$locationList = new LocationList();
$contentInfo = new ContentInfo();
$versionInfo = new VersionInfo();
+ $filter = new Filter(new LocationId(1));
+
// string $method, array $arguments, mixed|null $return, int $languageArgumentIndex, ?callable $callback, ?int $alwaysAvailableArgumentIndex
return [
['loadLocation', [55], $location, 1],
@@ -92,6 +96,9 @@ public function providerForLanguagesLookupMethods()
['loadParentLocationsForDraftContent', [$versionInfo], [$location], 1],
['loadParentLocationsForDraftContent', [$versionInfo, self::LANG_ARG], [$location], 1],
+
+ ['find', [$filter], $locationList, 1],
+ ['find', [$filter, self::LANG_ARG], $locationList, 1],
];
}
}
diff --git a/eZ/Publish/Core/Repository/Tests/Service/Mock/Base.php b/eZ/Publish/Core/Repository/Tests/Service/Mock/Base.php
index ed38d767d0..eba3e7f613 100644
--- a/eZ/Publish/Core/Repository/Tests/Service/Mock/Base.php
+++ b/eZ/Publish/Core/Repository/Tests/Service/Mock/Base.php
@@ -7,7 +7,6 @@
namespace eZ\Publish\Core\Repository\Tests\Service\Mock;
use eZ\Publish\API\Repository\LanguageResolver;
-use eZ\Publish\API\Repository\PermissionResolver;
use eZ\Publish\API\Repository\PermissionService;
use eZ\Publish\Core\Repository\Mapper\ContentDomainMapper;
use eZ\Publish\Core\Repository\Mapper\ContentMapper;
@@ -22,6 +21,8 @@
use eZ\Publish\Core\Repository\Validator\ContentUpdateStructValidator;
use eZ\Publish\Core\Repository\Validator\VersionValidator;
use eZ\Publish\Core\Search\Common\BackgroundIndexer\NullIndexer;
+use eZ\Publish\SPI\Persistence\Filter\Content\Handler as ContentFilteringHandler;
+use eZ\Publish\SPI\Persistence\Filter\Location\Handler as LocationFilteringHandler;
use eZ\Publish\SPI\Repository\Strategy\ContentThumbnail\ThumbnailStrategy;
use eZ\Publish\SPI\Repository\Validator\ContentValidator;
use PHPUnit\Framework\MockObject\MockObject;
@@ -47,9 +48,6 @@ abstract class Base extends TestCase
/** @var \eZ\Publish\API\Repository\Repository|\PHPUnit\Framework\MockObject\MockObject */
private $repositoryMock;
- /** @var \eZ\Publish\API\Repository\PermissionResolver|\PHPUnit\Framework\MockObject\MockObject */
- private $permissionResolverMock;
-
/** @var \eZ\Publish\API\Repository\PermissionService|\PHPUnit\Framework\MockObject\MockObject */
private $permissionServiceMock;
@@ -89,6 +87,12 @@ abstract class Base extends TestCase
/** @var \eZ\Publish\SPI\Repository\Validator\ContentValidator|\PHPUnit\Framework\MockObject\MockObject */
protected $contentValidatorStrategyMock;
+ /** @var \eZ\Publish\SPI\Persistence\Filter\Content\Handler|\PHPUnit\Framework\MockObject\MockObject */
+ private $contentFilteringHandlerMock;
+
+ /** @var \eZ\Publish\SPI\Persistence\Filter\Location\Handler|\PHPUnit\Framework\MockObject\MockObject */
+ private $locationFilteringHandlerMock;
+
/**
* Get Real repository with mocked dependencies.
*
@@ -116,6 +120,8 @@ protected function getRepository(array $serviceSettings = [])
$this->getLimitationServiceMock(),
$this->getLanguageResolverMock(),
$this->getPermissionServiceMock(),
+ $this->getContentFilteringHandlerMock(),
+ $this->getLocationFilteringHandlerMock(),
$serviceSettings,
);
@@ -175,7 +181,7 @@ protected function getThumbnailStrategy()
protected function getRepositoryMock()
{
if (!isset($this->repositoryMock)) {
- $this->repositoryMock = self::createMock(APIRepository::class);
+ $this->repositoryMock = $this->createMock(APIRepository::class);
}
return $this->repositoryMock;
@@ -186,11 +192,7 @@ protected function getRepositoryMock()
*/
protected function getPermissionResolverMock()
{
- if (!isset($this->permissionResolverMock)) {
- $this->permissionResolverMock = $this->createMock(PermissionResolver::class);
- }
-
- return $this->permissionResolverMock;
+ return $this->getPermissionServiceMock();
}
/**
@@ -416,4 +418,22 @@ protected function getContentValidatorStrategy(): ContentValidator
return new ContentValidatorStrategy($validators);
}
+
+ protected function getContentFilteringHandlerMock(): ContentFilteringHandler
+ {
+ if (null === $this->contentFilteringHandlerMock) {
+ $this->contentFilteringHandlerMock = $this->createMock(ContentFilteringHandler::class);
+ }
+
+ return $this->contentFilteringHandlerMock;
+ }
+
+ private function getLocationFilteringHandlerMock(): LocationFilteringHandler
+ {
+ if (null === $this->locationFilteringHandlerMock) {
+ $this->locationFilteringHandlerMock = $this->createMock(LocationFilteringHandler::class);
+ }
+
+ return $this->locationFilteringHandlerMock;
+ }
}
diff --git a/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php b/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php
index d0550d1329..c2cfb242a4 100644
--- a/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php
+++ b/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php
@@ -77,21 +77,23 @@ public function testConstructor(): void
$relationProcessorMock = $this->getRelationProcessorMock();
$nameSchemaServiceMock = $this->getNameSchemaServiceMock();
$fieldTypeRegistryMock = $this->getFieldTypeRegistryMock();
- $permissionResolverMock = $this->getPermissionResolverMock();
+ $permissionServiceMock = $this->getPermissionServiceMock();
$contentMapper = $this->getContentMapper();
$contentValidatorStrategy = $this->getContentValidatorStrategy();
+ $contentFilteringHandlerMock = $this->getContentFilteringHandlerMock();
$settings = ['default_version_archive_limit' => 10];
- $service = new ContentService(
+ new ContentService(
$repositoryMock,
$persistenceHandlerMock,
$contentDomainMapperMock,
$relationProcessorMock,
$nameSchemaServiceMock,
$fieldTypeRegistryMock,
- $permissionResolverMock,
+ $permissionServiceMock,
$contentMapper,
$contentValidatorStrategy,
+ $contentFilteringHandlerMock,
$settings
);
}
@@ -6237,9 +6239,10 @@ protected function getPartlyMockedContentService(array $methods = null)
$this->getRelationProcessorMock(),
$this->getNameSchemaServiceMock(),
$this->getFieldTypeRegistryMock(),
- $this->getPermissionResolverMock(),
+ $this->getPermissionServiceMock(),
$this->getContentMapper(),
$this->getContentValidatorStrategy(),
+ $this->getContentFilteringHandlerMock(),
[],
]
)
diff --git a/eZ/Publish/Core/settings/repository/inner.yml b/eZ/Publish/Core/settings/repository/inner.yml
index c3b3c53a05..382b65a26f 100644
--- a/eZ/Publish/Core/settings/repository/inner.yml
+++ b/eZ/Publish/Core/settings/repository/inner.yml
@@ -32,6 +32,8 @@ services:
- '@eZ\Publish\SPI\Repository\Validator\ContentValidator'
- '@eZ\Publish\Core\Repository\Permission\LimitationService'
- '@eZ\Publish\API\Repository\PermissionService'
+ - '@eZ\Publish\SPI\Persistence\Filter\Content\Handler'
+ - '@eZ\Publish\SPI\Persistence\Filter\Location\Handler'
- '%languages%'
ezpublish.api.service.inner_content:
diff --git a/eZ/Publish/Core/settings/storage_engines/legacy.yml b/eZ/Publish/Core/settings/storage_engines/legacy.yml
index 1966163035..e29448b967 100644
--- a/eZ/Publish/Core/settings/storage_engines/legacy.yml
+++ b/eZ/Publish/Core/settings/storage_engines/legacy.yml
@@ -7,6 +7,7 @@ imports:
- {resource: storage_engines/legacy/language.yml}
- {resource: storage_engines/legacy/location.yml}
- {resource: storage_engines/legacy/object_state.yml}
+ - {resource: storage_engines/legacy/filter.yaml}
- {resource: storage_engines/legacy/section.yml}
- {resource: storage_engines/legacy/shared_gateway.yaml}
- {resource: storage_engines/legacy/trash.yml}
diff --git a/eZ/Publish/Core/settings/storage_engines/legacy/filter.yaml b/eZ/Publish/Core/settings/storage_engines/legacy/filter.yaml
new file mode 100644
index 0000000000..0de2ff758d
--- /dev/null
+++ b/eZ/Publish/Core/settings/storage_engines/legacy/filter.yaml
@@ -0,0 +1,58 @@
+imports:
+ - { resource: filter/query_builders.yaml }
+
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
+ # injectables:
+ eZ\Publish\SPI\Persistence\Filter\Content\Handler:
+ alias: eZ\Publish\Core\Persistence\Legacy\Filter\Handler\ContentFilteringHandler
+
+ eZ\Publish\SPI\Persistence\Filter\Location\Handler:
+ alias: eZ\Publish\Core\Persistence\Legacy\Filter\Handler\LocationFilteringHandler
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Content\GatewayDataMapper:
+ alias: eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Content\Mapper\DoctrineGatewayDataMapper
+
+ eZ\Publish\SPI\Persistence\Filter\CriterionVisitor:
+ alias: eZ\Publish\Core\Persistence\Legacy\Filter\CriterionVisitor
+
+ eZ\Publish\SPI\Persistence\Filter\SortClauseVisitor:
+ alias: eZ\Publish\Core\Persistence\Legacy\Filter\SortClauseVisitor
+
+ # implementations:
+ eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Content\Mapper\DoctrineGatewayDataMapper:
+ arguments:
+ $languageHandler: '@ezpublish.spi.persistence.language_handler'
+ $languageMaskGenerator: '@ezpublish.persistence.legacy.language.mask_generator'
+ $contentTypeHandler: '@ezpublish.spi.persistence.content_type_handler'
+ $converterRegistry: '@ezpublish.persistence.legacy.field_value_converter.registry'
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\CriterionVisitor:
+ arguments:
+ $criterionQueryBuilders: !tagged_iterator ezplatform.filter.criterion.query_builder
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\SortClauseVisitor:
+ arguments:
+ $sortClauseQueryBuilders: !tagged_iterator ezplatform.filter.sort_clause.query_builder
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Content\Doctrine\DoctrineGateway:
+ arguments:
+ $connection: '@ezpublish.persistence.connection'
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\Handler\ContentFilteringHandler:
+ arguments:
+ $gateway: '@eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Content\Doctrine\DoctrineGateway'
+ $fieldHandler: '@ezpublish.persistence.legacy.field_handler'
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Location\Doctrine\DoctrineGateway:
+ arguments:
+ $connection: '@ezpublish.persistence.connection'
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\Handler\LocationFilteringHandler:
+ arguments:
+ $gateway: '@eZ\Publish\Core\Persistence\Legacy\Filter\Gateway\Location\Doctrine\DoctrineGateway'
+ $locationMapper: '@ezpublish.persistence.legacy.location.mapper'
diff --git a/eZ/Publish/Core/settings/storage_engines/legacy/filter/query_builders.yaml b/eZ/Publish/Core/settings/storage_engines/legacy/filter/query_builders.yaml
new file mode 100644
index 0000000000..663da66fbf
--- /dev/null
+++ b/eZ/Publish/Core/settings/storage_engines/legacy/filter/query_builders.yaml
@@ -0,0 +1,14 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+ bind:
+ $transformationProcessor: '@ezpublish.api.storage_engine.transformation_processor'
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\CriterionQueryBuilder\:
+ resource: '../../../../Persistence/Legacy/Filter/CriterionQueryBuilder/*'
+
+ eZ\Publish\Core\Persistence\Legacy\Filter\SortClauseQueryBuilder\:
+ resource: '../../../../Persistence/Legacy/Filter/SortClauseQueryBuilder/*'
+
diff --git a/eZ/Publish/SPI/Persistence/Content/ContentItem.php b/eZ/Publish/SPI/Persistence/Content/ContentItem.php
new file mode 100644
index 0000000000..373bd4b79b
--- /dev/null
+++ b/eZ/Publish/SPI/Persistence/Content/ContentItem.php
@@ -0,0 +1,57 @@
+content = $content;
+ $this->contentInfo = $contentInfo;
+ $this->type = $type;
+ }
+
+ public function getContent(): Content
+ {
+ return $this->content;
+ }
+
+ public function getContentInfo(): ContentInfo
+ {
+ return $this->contentInfo;
+ }
+
+ public function getType(): Type
+ {
+ return $this->type;
+ }
+}
diff --git a/eZ/Publish/SPI/Persistence/Content/LocationWithContentInfo.php b/eZ/Publish/SPI/Persistence/Content/LocationWithContentInfo.php
new file mode 100644
index 0000000000..4fba4b2701
--- /dev/null
+++ b/eZ/Publish/SPI/Persistence/Content/LocationWithContentInfo.php
@@ -0,0 +1,47 @@
+location = $location;
+ $this->contentInfo = $contentInfo;
+ }
+
+ public function getLocation(): Location
+ {
+ return $this->location;
+ }
+
+ public function getContentInfo(): ContentInfo
+ {
+ return $this->contentInfo;
+ }
+}
diff --git a/eZ/Publish/SPI/Persistence/Filter/Content/Handler.php b/eZ/Publish/SPI/Persistence/Filter/Content/Handler.php
new file mode 100644
index 0000000000..731a9c992c
--- /dev/null
+++ b/eZ/Publish/SPI/Persistence/Filter/Content/Handler.php
@@ -0,0 +1,24 @@
+ 'ASC', Query::SORT_DESC => 'DESC'];
+
+ /**
+ * Create table JOIN, but only if it hasn't been already joined (determined based on $tableAlias).
+ *
+ * @throws \eZ\Publish\Core\Base\Exceptions\DatabaseException if conditions of pre-existing same alias joins are different
+ */
+ public function joinOnce(
+ string $fromAlias,
+ string $tableName,
+ string $tableAlias,
+ string $conditions
+ ): FilteringQueryBuilder {
+ $existingJoinConditions = $this->getExistingTableAliasJoinCondition($tableAlias);
+ if (null !== $existingJoinConditions) {
+ $this->validateJoinOnceConditions(
+ $existingJoinConditions,
+ $conditions,
+ $tableName,
+ $tableAlias
+ );
+
+ return $this;
+ }
+
+ // at this point, if table exists as fromAlias, it means it's a "FROM" table
+ if ($this->isJoinedAsFromTableAlias($tableAlias)) {
+ return $this;
+ }
+
+ $this->join($fromAlias, $tableName, $tableAlias, $conditions);
+
+ return $this;
+ }
+
+ /**
+ * Create table LEFT JOIN, but only if it hasn't been already joined (determined based on $tableAlias).
+ *
+ * @throws \eZ\Publish\Core\Base\Exceptions\DatabaseException if conditions of pre-existing same alias joins are different
+ */
+ public function leftJoinOnce(
+ string $fromAlias,
+ string $tableName,
+ string $tableAlias,
+ string $conditions
+ ): FilteringQueryBuilder {
+ $existingJoinConditions = $this->getExistingTableAliasJoinCondition($tableAlias);
+ if (null !== $existingJoinConditions) {
+ $this->validateJoinOnceConditions(
+ $existingJoinConditions,
+ $conditions,
+ $tableName,
+ $tableAlias
+ );
+
+ return $this;
+ }
+
+ // at this point, if table exists as fromAlias, it means it's a "FROM" table
+ if ($this->isJoinedAsFromTableAlias($tableAlias)) {
+ return $this;
+ }
+
+ $this->leftJoin($fromAlias, $tableName, $tableAlias, $conditions);
+
+ return $this;
+ }
+
+ /**
+ * @return string conditions, null if table is not joined yet.
+ */
+ public function getExistingTableAliasJoinCondition(string $tableAlias): ?string
+ {
+ $joinPart = $this->getQueryPart('join');
+ // joins are stored per each $fromAlias (as an assoc. key), but a flat list of joins is needed here
+ $joins = !empty($joinPart) ? array_merge(...array_values($joinPart)) : [];
+ $existingTableAliasJoins = array_values(
+ array_filter(
+ $joins,
+ static function (array $joinData) use ($tableAlias) {
+ return $joinData['joinAlias'] === $tableAlias;
+ }
+ )
+ );
+
+ $joinCondition = $existingTableAliasJoins[0]['joinCondition'] ?? null;
+
+ return null !== $joinCondition ? (string)$joinCondition : null;
+ }
+
+ /**
+ * Inherited from \Doctrine\DBAL\Query\QueryBuilder::addOrderBy.
+ *
+ * @param string $sort
+ * @param string|null $order
+ */
+ public function addOrderBy($sort, $order = null): FilteringQueryBuilder
+ {
+ return parent::addOrderBy($sort, $this->mapLegacyOrderToDoctrine($order));
+ }
+
+ private function mapLegacyOrderToDoctrine(?string $order): ?string
+ {
+ if (null !== $order && isset(self::SORT_ORDER_MAP[$order])) {
+ return self::SORT_ORDER_MAP[$order];
+ }
+
+ // intentionally pass through
+ return $order;
+ }
+
+ private function validateJoinOnceConditions(
+ string $existingJoinConditions,
+ string $conditions,
+ string $tableName,
+ string $tableAlias
+ ): void {
+ if ($existingJoinConditions !== $conditions) {
+ throw new DatabaseException(
+ sprintf(
+ 'FilteringQueryBuilder: "%s" table cannot be joined as "%s" ' .
+ 'with conditions "%s" because there is a pre-existing join with the same ' .
+ 'alias but different conditions: "%s"',
+ $tableName,
+ $tableAlias,
+ $conditions,
+ $existingJoinConditions
+ )
+ );
+ }
+ }
+
+ private function isJoinedAsFromTableAlias(string $tableAlias): bool
+ {
+ return array_key_exists($tableAlias, $this->getQueryPart('join'));
+ }
+
+ /**
+ * @throws \Doctrine\DBAL\DBALException
+ */
+ public function buildOperatorBasedCriterionConstraint(
+ string $columnName,
+ array $criterionValue,
+ string $operator
+ ): string {
+ switch ($operator) {
+ case Operator::IN:
+ return $this->expr()->in(
+ $columnName,
+ $this->createNamedParameter($criterionValue, Connection::PARAM_INT_ARRAY)
+ );
+
+ case Query\Criterion\Operator::BETWEEN:
+ $databasePlatform = $this->getConnection()->getDatabasePlatform();
+
+ return $databasePlatform->getBetweenExpression(
+ $columnName,
+ $this->createNamedParameter($criterionValue[0], ParameterType::INTEGER),
+ $this->createNamedParameter($criterionValue[1], ParameterType::INTEGER)
+ );
+
+ case Query\Criterion\Operator::EQ:
+ case Query\Criterion\Operator::GT:
+ case Query\Criterion\Operator::GTE:
+ case Query\Criterion\Operator::LT:
+ case Query\Criterion\Operator::LTE:
+ return $this->expr()->comparison(
+ $columnName,
+ $operator,
+ $this->createNamedParameter(reset($criterionValue), ParameterType::INTEGER)
+ );
+
+ default:
+ throw new DatabaseException(
+ "Unsupported operator {$operator} for column {$columnName}"
+ );
+ }
+ }
+
+ public function joinPublishedVersion(): FilteringQueryBuilder
+ {
+ $expressionBuilder = $this->expr();
+
+ $this->joinOnce(
+ 'content',
+ ContentGateway::CONTENT_VERSION_TABLE,
+ 'version',
+ (string)$expressionBuilder->andX(
+ 'content.id = version.contentobject_id',
+ 'content.current_version = version.version',
+ $expressionBuilder->eq(
+ 'version.status',
+ $this->createNamedParameter(
+ VersionInfo::STATUS_PUBLISHED,
+ ParameterType::INTEGER
+ )
+ )
+ )
+ );
+
+ return $this;
+ }
+
+ public function joinAllLocations(): FilteringQueryBuilder
+ {
+ $this->joinOnce(
+ 'content',
+ LocationGateway::CONTENT_TREE_TABLE,
+ 'location',
+ 'content.id = location.contentobject_id'
+ );
+
+ return $this;
+ }
+}
diff --git a/eZ/Publish/SPI/Persistence/Filter/LazyListIterator.php b/eZ/Publish/SPI/Persistence/Filter/LazyListIterator.php
new file mode 100644
index 0000000000..0f5b4d7d04
--- /dev/null
+++ b/eZ/Publish/SPI/Persistence/Filter/LazyListIterator.php
@@ -0,0 +1,70 @@
+totalCount = $totalCount;
+ $this->iterator = $iterator;
+ $this->iterationCallback = $iterationCallback;
+ }
+
+ public function prepareIterator(iterable $iterator, callable $iterationCallback): void
+ {
+ $this->iterator = $iterator;
+ $this->iterationCallback = $iterationCallback;
+ }
+
+ public function getTotalCount(): int
+ {
+ return $this->totalCount;
+ }
+
+ public function getIterator(): iterable
+ {
+ if (0 === $this->totalCount) {
+ yield from [];
+
+ return;
+ }
+
+ if (null === $this->iterator || null === $this->iterationCallback) {
+ throw new RuntimeException(
+ "Iterator is supposed to have {$this->totalCount} elements, but there's no configured " .
+ 'callback to fetch them'
+ );
+ }
+
+ foreach ($this->iterator as $item) {
+ yield ($this->iterationCallback)($item);
+ }
+ }
+}
diff --git a/eZ/Publish/SPI/Persistence/Filter/Location/Handler.php b/eZ/Publish/SPI/Persistence/Filter/Location/Handler.php
new file mode 100644
index 0000000000..0c70951523
--- /dev/null
+++ b/eZ/Publish/SPI/Persistence/Filter/Location/Handler.php
@@ -0,0 +1,24 @@
+innerService->validate($object, $context, $fieldIdentifiersToValidate);
}
+
+ public function find(Filter $filter, ?array $languages = null): ContentList
+ {
+ return $this->innerService->find($filter, $languages);
+ }
}
diff --git a/eZ/Publish/SPI/Repository/Decorator/LocationServiceDecorator.php b/eZ/Publish/SPI/Repository/Decorator/LocationServiceDecorator.php
index 20a02ce402..11e31025ad 100644
--- a/eZ/Publish/SPI/Repository/Decorator/LocationServiceDecorator.php
+++ b/eZ/Publish/SPI/Repository/Decorator/LocationServiceDecorator.php
@@ -15,6 +15,7 @@
use eZ\Publish\API\Repository\Values\Content\LocationList;
use eZ\Publish\API\Repository\Values\Content\LocationUpdateStruct;
use eZ\Publish\API\Repository\Values\Content\VersionInfo;
+use eZ\Publish\API\Repository\Values\Filter\Filter;
abstract class LocationServiceDecorator implements LocationService
{
@@ -148,4 +149,9 @@ public function loadAllLocations(
): array {
return $this->innerService->loadAllLocations($offset, $limit);
}
+
+ public function find(Filter $filter, ?array $languages = null): LocationList
+ {
+ return $this->innerService->find($filter, $languages);
+ }
}
diff --git a/eZ/Publish/SPI/Repository/Values/Filter/CriterionQueryBuilder.php b/eZ/Publish/SPI/Repository/Values/Filter/CriterionQueryBuilder.php
new file mode 100644
index 0000000000..1137cf3abc
--- /dev/null
+++ b/eZ/Publish/SPI/Repository/Values/Filter/CriterionQueryBuilder.php
@@ -0,0 +1,28 @@
+createMock(Connection::class);
+ $connectionMock->method('getExpressionBuilder')->willReturn(
+ new ExpressionBuilder($connectionMock)
+ );
+ $this->queryBuilder = new FilteringQueryBuilder($connectionMock);
+ }
+
+ /**
+ * @covers \eZ\Publish\SPI\Persistence\Filter\Doctrine\FilteringQueryBuilder::joinOnce
+ */
+ public function testJoinOnce(): void
+ {
+ $this->queryBuilder
+ ->select('f.id')->from('foo', 'f')
+ ->joinOnce('f', 'bar', 'b', 'f.id = b.foo_id');
+
+ $expr = $this->queryBuilder->expr();
+ // should not be joined again
+ $this->queryBuilder->joinOnce('f', 'bar', 'b', $expr->eq('f.id', 'b.foo_id'));
+ // can be joined
+ $this->queryBuilder->joinOnce('f', 'bar', 'b2', $expr->eq('f.id', 'b2.foo_id'));
+
+ self::assertSame(
+ 'SELECT f.id FROM foo f ' .
+ 'INNER JOIN bar b ON f.id = b.foo_id ' .
+ 'INNER JOIN bar b2 ON f.id = b2.foo_id',
+ $this->queryBuilder->getSQL()
+ );
+ }
+
+ public function testJoinOnceThrowsDatabaseError(): void
+ {
+ $this
+ ->queryBuilder
+ ->select('f.id')->from('foo', 'f')
+ ->joinOnce('f', 'bar', 'b', 'f.id = b.foo_id');
+
+ $this->expectException(DatabaseException::class);
+ $this->expectExceptionMessageMatches('/^FilteringQueryBuilder: .*f.id = b.foo_id/');
+
+ // different condition should cause Runtime DatabaseException as automatic error recovery is not possible
+ $this->queryBuilder->joinOnce('f', 'bar', 'b', 'f.bar_id = b.id');
+ }
+}
diff --git a/phpunit-integration-legacy.xml b/phpunit-integration-legacy.xml
index 52d89e8bb8..175bd25735 100644
--- a/phpunit-integration-legacy.xml
+++ b/phpunit-integration-legacy.xml
@@ -23,6 +23,7 @@
eZ/Publish/API/Repository/Tests/FieldType/
eZ/Publish/API/Repository/Tests/Limitation
eZ/Publish/API/Repository/Tests/SearchService
+ eZ/Publish/API/Repository/Tests/Filtering
eZ/Publish/API/Repository/Tests/RepositoryTest.php
eZ/Publish/API/Repository/Tests/PermissionResolverTest.php
eZ/Publish/API/Repository/Tests/SectionServiceTest.php
diff --git a/phpunit.xml b/phpunit.xml
index 9aad37b38b..28d2a3edf2 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -70,7 +70,7 @@
eZ/Publish/Core/Helper
- eZ/Publish/API/Repository/Tests/Values/Content
+ eZ/Publish/API/Repository/Tests/Values