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