diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 07ecb2f8d9..54acab26b1 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,7 +9,7 @@ parameters: treatPhpDocTypesAsCertain: false ignoreErrors: - - message: "#^Cannot call method (fetchOne|fetchColumn|fetchAllAssociative|fetchAssociative|fetchAllKeyValue)\\(\\) on Doctrine\\\\DBAL\\\\ForwardCompatibility\\\\Result\\|int\\|string\\.$#" + message: "#^Cannot call method (fetchOne|fetchColumn|fetchAllAssociative|fetchAssociative|fetchAllKeyValue|fetchFirstColumn)\\(\\) on Doctrine\\\\DBAL\\\\ForwardCompatibility\\\\Result\\|int\\|string\\.$#" paths: - src/* - tests/* diff --git a/src/contracts/Persistence/Bookmark/Handler.php b/src/contracts/Persistence/Bookmark/Handler.php index 3a714bd9da..f9ef1a1cae 100644 --- a/src/contracts/Persistence/Bookmark/Handler.php +++ b/src/contracts/Persistence/Bookmark/Handler.php @@ -8,6 +8,8 @@ namespace Ibexa\Contracts\Core\Persistence\Bookmark; +use Ibexa\Contracts\Core\Persistence\Content\Location; + interface Handler { /** @@ -38,6 +40,13 @@ public function delete(int $bookmarkId): void; */ public function loadByUserIdAndLocationId(int $userId, array $locationIds): array; + /** + * Get user ids who have bookmarked given location. + * + * @return array + */ + public function loadUserIdsByLocation(Location $location): array; + /** * Loads bookmarks owned by user. * diff --git a/src/contracts/Repository/Values/Content/Query/Criterion/Location/IsBookmarked.php b/src/contracts/Repository/Values/Content/Query/Criterion/Location/IsBookmarked.php new file mode 100644 index 0000000000..36d7972270 --- /dev/null +++ b/src/contracts/Repository/Values/Content/Query/Criterion/Location/IsBookmarked.php @@ -0,0 +1,40 @@ +persistenceHandler->bookmarkHandler()->locationSwapped($location1Id, $location2Id); } + + public function loadUserIdsByLocation(Location $location): array + { + $this->logger->logCall(__METHOD__, [ + 'locationId' => $location->id, + ]); + + return $this->persistenceHandler->bookmarkHandler()->loadUserIdsByLocation($location); + } } class_alias(BookmarkHandler::class, 'eZ\Publish\Core\Persistence\Cache\BookmarkHandler'); diff --git a/src/lib/Persistence/Legacy/Bookmark/Gateway.php b/src/lib/Persistence/Legacy/Bookmark/Gateway.php index 5caaeda334..3128e2022e 100644 --- a/src/lib/Persistence/Legacy/Bookmark/Gateway.php +++ b/src/lib/Persistence/Legacy/Bookmark/Gateway.php @@ -9,6 +9,7 @@ namespace Ibexa\Core\Persistence\Legacy\Bookmark; use Ibexa\Contracts\Core\Persistence\Bookmark\Bookmark; +use Ibexa\Contracts\Core\Persistence\Content\Location; /** * Base class for bookmark gateways. @@ -41,6 +42,13 @@ abstract public function deleteBookmark(int $id): void; */ abstract public function loadBookmarkDataByUserIdAndLocationId(int $userId, array $locationIds): array; + /** + * Load user ids by the given $location. + * + * @return array + */ + abstract public function loadUserIdsByLocation(Location $location): array; + /** * Load data for all bookmarks owned by given $userId. * diff --git a/src/lib/Persistence/Legacy/Bookmark/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Bookmark/Gateway/DoctrineDatabase.php index f60daac442..e30578885c 100644 --- a/src/lib/Persistence/Legacy/Bookmark/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Bookmark/Gateway/DoctrineDatabase.php @@ -9,7 +9,9 @@ namespace Ibexa\Core\Persistence\Legacy\Bookmark\Gateway; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\ParameterType; use Ibexa\Contracts\Core\Persistence\Bookmark\Bookmark; +use Ibexa\Contracts\Core\Persistence\Content\Location; use Ibexa\Core\Persistence\Legacy\Bookmark\Gateway; use PDO; @@ -100,6 +102,27 @@ public function loadBookmarkDataByUserIdAndLocationId(int $userId, array $locati return $query->execute()->fetchAll(PDO::FETCH_ASSOC); } + public function loadUserIdsByLocation(Location $location): array + { + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder + ->select(self::COLUMN_USER_ID) + ->from(self::TABLE_BOOKMARKS) + ->andWhere( + $queryBuilder + ->expr() + ->eq( + self::COLUMN_LOCATION_ID, + $queryBuilder->createNamedParameter( + $location->id, + ParameterType::INTEGER + ) + ) + ); + + return $queryBuilder->execute()->fetchFirstColumn(); + } + /** * {@inheritdoc} */ diff --git a/src/lib/Persistence/Legacy/Bookmark/Gateway/ExceptionConversion.php b/src/lib/Persistence/Legacy/Bookmark/Gateway/ExceptionConversion.php index 75cc30c728..72bd954999 100644 --- a/src/lib/Persistence/Legacy/Bookmark/Gateway/ExceptionConversion.php +++ b/src/lib/Persistence/Legacy/Bookmark/Gateway/ExceptionConversion.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\DBALException; use Ibexa\Contracts\Core\Persistence\Bookmark\Bookmark; +use Ibexa\Contracts\Core\Persistence\Content\Location; use Ibexa\Core\Base\Exceptions\DatabaseException; use Ibexa\Core\Persistence\Legacy\Bookmark\Gateway; use PDOException; @@ -82,6 +83,15 @@ public function locationSwapped(int $location1Id, int $location2Id): void throw DatabaseException::wrap($e); } } + + public function loadUserIdsByLocation(Location $location): array + { + try { + return $this->innerGateway->loadUserIdsByLocation($location); + } catch (DBALException | PDOException $e) { + throw DatabaseException::wrap($e); + } + } } class_alias(ExceptionConversion::class, 'eZ\Publish\Core\Persistence\Legacy\Bookmark\Gateway\ExceptionConversion'); diff --git a/src/lib/Persistence/Legacy/Bookmark/Handler.php b/src/lib/Persistence/Legacy/Bookmark/Handler.php index 2772388f00..71d075fbae 100644 --- a/src/lib/Persistence/Legacy/Bookmark/Handler.php +++ b/src/lib/Persistence/Legacy/Bookmark/Handler.php @@ -11,6 +11,7 @@ use Ibexa\Contracts\Core\Persistence\Bookmark\Bookmark; use Ibexa\Contracts\Core\Persistence\Bookmark\CreateStruct; use Ibexa\Contracts\Core\Persistence\Bookmark\Handler as HandlerInterface; +use Ibexa\Contracts\Core\Persistence\Content\Location; /** * Storage Engine handler for bookmarks. @@ -98,6 +99,14 @@ public function locationSwapped(int $location1Id, int $location2Id): void { $this->gateway->locationSwapped($location1Id, $location2Id); } + + public function loadUserIdsByLocation(Location $location): array + { + return array_map( + static fn ($userId): int => (int)$userId, + $this->gateway->loadUserIdsByLocation($location) + ); + } } class_alias(Handler::class, 'eZ\Publish\Core\Persistence\Legacy\Bookmark\Handler'); diff --git a/src/lib/Resources/settings/search_engines/common.yml b/src/lib/Resources/settings/search_engines/common.yml index 3164615f7a..9e31bcb722 100644 --- a/src/lib/Resources/settings/search_engines/common.yml +++ b/src/lib/Resources/settings/search_engines/common.yml @@ -49,7 +49,7 @@ services: Ibexa\Core\Search\Common\EventSubscriber\: resource: '../../../Search/Common/EventSubscriber/*' - exclude: '../../../Search/Common/EventSubscriber/{AbstractSearchEventSubscriber.php}' + exclude: '../../../Search/Common/EventSubscriber/{AbstractSearchEventSubscriber}' autoconfigure: true autowire: true public: false diff --git a/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_location.yml b/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_location.yml index 1d811974b8..22443d221c 100644 --- a/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_location.yml +++ b/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_location.yml @@ -58,3 +58,11 @@ services: class: Ibexa\Core\Search\Legacy\Content\Location\Gateway\CriterionHandler\Visibility tags: - {name: ibexa.search.legacy.gateway.criterion_handler.location} + + Ibexa\Core\Search\Legacy\Content\Location\Gateway\CriterionHandler\Location\IsBookmarked: + parent: Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler + arguments: + $connection: '@ibexa.persistence.connection' + $permissionResolver: '@Ibexa\Contracts\Core\Repository\PermissionResolver' + tags: + - { name: ibexa.search.legacy.gateway.criterion_handler.location } diff --git a/src/lib/Search/Common/EventSubscriber/BookmarkEventSubscriber.php b/src/lib/Search/Common/EventSubscriber/BookmarkEventSubscriber.php new file mode 100644 index 0000000000..a6d474c06a --- /dev/null +++ b/src/lib/Search/Common/EventSubscriber/BookmarkEventSubscriber.php @@ -0,0 +1,55 @@ + ['onCreateBookmark', -100], + DeleteBookmarkEvent::class => ['onDeleteBookmark', -100], + ]; + } + + public function onCreateBookmark(CreateBookmarkEvent $event): void + { + $location = $event->getLocation(); + $this->updateContentIndex($location->getContentId()); + $this->updateLocationIndex($location->getId()); + } + + public function onDeleteBookmark(DeleteBookmarkEvent $event): void + { + $location = $event->getLocation(); + $this->updateContentIndex($location->getContentId()); + $this->updateLocationIndex($location->getId()); + } + + private function updateContentIndex(int $contentId): void + { + $persistenceContent = $this->persistenceHandler->contentHandler()->load($contentId); + + $this->searchHandler->indexContent($persistenceContent); + } + + private function updateLocationIndex(int $locationId): void + { + $persistenceLocation = $this->persistenceHandler->locationHandler()->load($locationId); + + $this->searchHandler->indexLocation($persistenceLocation); + } +} diff --git a/src/lib/Search/Legacy/Content/Location/Gateway/CriterionHandler/Location/IsBookmarked.php b/src/lib/Search/Legacy/Content/Location/Gateway/CriterionHandler/Location/IsBookmarked.php new file mode 100644 index 0000000000..8fa14d3ab2 --- /dev/null +++ b/src/lib/Search/Legacy/Content/Location/Gateway/CriterionHandler/Location/IsBookmarked.php @@ -0,0 +1,87 @@ +permissionResolver = $permissionResolver; + } + + public function accept(Criterion $criterion): bool + { + return $criterion instanceof Criterion\Location\IsBookmarked + && $criterion->operator === Criterion\Operator::EQ; + } + + /** + * @param array{languages: string[]} $languageSettings + */ + public function handle( + CriteriaConverter $converter, + QueryBuilder $queryBuilder, + Criterion $criterion, + array $languageSettings + ) { + if (!is_array($criterion->value)) { + throw new LogicException(sprintf( + 'Expected %s Criterion value to be an array, %s received', + IsBookmarked::class, + get_debug_type($criterion->value), + )); + } + + $userId = $this->permissionResolver + ->getCurrentUserReference() + ->getUserId(); + + $subQueryBuilder = $this->connection->createQueryBuilder(); + $subQueryBuilder + ->select('1') + ->from(DoctrineDatabase::TABLE_BOOKMARKS, 'b') + ->andWhere( + $queryBuilder + ->expr() + ->eq( + 'b.' . DoctrineDatabase::COLUMN_USER_ID, + $queryBuilder->createNamedParameter($userId, ParameterType::INTEGER) + ), + $queryBuilder + ->expr() + ->eq('b.node_id', 't.node_id') + ); + + $query = 'EXISTS (%s)'; + if (!$criterion->value[0]) { + $query = 'NOT ' . $query; + } + + return sprintf( + $query, + $subQueryBuilder->getSQL() + ); + } +} diff --git a/tests/integration/Core/Persistence/Legacy/BookmarkHandlerTest.php b/tests/integration/Core/Persistence/Legacy/BookmarkHandlerTest.php new file mode 100644 index 0000000000..85239fbc36 --- /dev/null +++ b/tests/integration/Core/Persistence/Legacy/BookmarkHandlerTest.php @@ -0,0 +1,92 @@ +handler = self::getServiceByClassName(Handler::class); + $this->bookmarkHandler = $this->handler->bookmarkHandler(); + } + + public function testLoadUserIdsByLocation(): void + { + $content = $this->createTestContent(); + $locationId = $content->getContentInfo()->getMainLocationId(); + + self::assertNotEmpty($locationId); + + $location = $this->handler->locationHandler()->load($locationId); + + // Location is not bookmarked yet + self::assertSameArrayWithUserIds([], $location); + + $bookmark = $this->addToBookmark($location); + + self::assertSameArrayWithUserIds([self::ADMIN_USER_ID], $location); + + $this->deleteBookmark($bookmark->id); + + // Check if location has been removed from bookmarks + self::assertSameArrayWithUserIds([], $location); + } + + /** + * @param array $expected + */ + private function assertSameArrayWithUserIds( + array $expected, + Location $location + ): void { + self::assertSame( + $expected, + $this->bookmarkHandler->loadUserIdsByLocation($location) + ); + } + + private function createTestContent(): Content + { + return $this->createFolder( + ['eng-GB' => 'Foo'] + ); + } + + private function addToBookmark(Location $location): Bookmark + { + $createStruct = new CreateStruct(); + $createStruct->name = ''; + $createStruct->userId = self::ADMIN_USER_ID; + $createStruct->locationId = $location->id; + + return $this->bookmarkHandler->create($createStruct); + } + + private function deleteBookmark(int $bookmarkId): void + { + $this->bookmarkHandler->delete($bookmarkId); + } +} diff --git a/tests/integration/Core/Repository/SearchServiceBookmarkTest.php b/tests/integration/Core/Repository/SearchServiceBookmarkTest.php new file mode 100644 index 0000000000..6e21ee2e3b --- /dev/null +++ b/tests/integration/Core/Repository/SearchServiceBookmarkTest.php @@ -0,0 +1,240 @@ +addTestContentToBookmark(); + + $this->refreshSearch(); + } + + /** + * @dataProvider provideDataForTestCriterion + * + * @param array<\Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion> $criteria + * @param array $remoteIds + */ + public function testCriterion( + int $expectedCount, + array $criteria, + array $remoteIds + ): void { + $query = $this->createQuery($criteria); + + $this->assertExpectedSearchHits($expectedCount, $remoteIds, $query); + } + + /** + * @return iterable + * }> + */ + public function provideDataForTestCriterion(): iterable + { + yield 'All bookmarked locations' => [ + self::ALL_BOOKMARKED_LOCATIONS, + [ + new Query\Criterion\Location\IsBookmarked(), + ], + self::ALL_BOOKMARKED_CONTENT_REMOTE_IDS, + ]; + + yield 'All bookmarked locations limited to folder content type' => [ + 1, + [ + new Query\Criterion\ContentTypeIdentifier(self::FOLDER_CONTENT_TYPE_IDENTIFIER), + new Query\Criterion\Location\IsBookmarked(), + ], + [self::MEDIA_CONTENT_REMOTE_ID], + ]; + + yield 'All bookmarked locations limited to user group content type' => [ + 4, + [ + new Query\Criterion\ContentTypeIdentifier('user_group'), + new Query\Criterion\Location\IsBookmarked(), + ], + [ + '3c160cca19fb135f83bd02d911f04db2', + '5f7f0bdb3381d6a461d8c29ff53d908f', + '9b47a45624b023b1a76c73b74d704acf', + 'f5c88a2209584891056f987fd965b0ba', + ], + ]; + + yield 'All bookmarked locations limited to user content type' => [ + 1, + [ + new Query\Criterion\ContentTypeIdentifier('user'), + new Query\Criterion\Location\IsBookmarked(), + ], + ['1bb4fe25487f05527efa8bfd394cecc7'], + ]; + + yield 'All no bookmarked locations' => [ + 12, + [ + new Query\Criterion\Location\IsBookmarked(false), + ], + self::ALL_NO_BOOKMARKED_CONTENT_REMOTE_IDS, + ]; + } + + public function testCriterionDeleteBookmark(): void + { + $query = $this->createQuery( + [ + new Query\Criterion\Location\IsBookmarked(), + ] + ); + + $this->assertExpectedSearchHits( + self::ALL_BOOKMARKED_LOCATIONS, + self::ALL_BOOKMARKED_CONTENT_REMOTE_IDS, + $query + ); + + $mediaLocation = $this->loadMediaFolderLocation(); + + // Delete bookmark, number of search hits should be changed + $this + ->getBookmarkService() + ->deleteBookmark($mediaLocation); + + $this->refreshSearch(); + + $this->assertExpectedSearchHits( + 5, + [ + '1bb4fe25487f05527efa8bfd394cecc7', + '3c160cca19fb135f83bd02d911f04db2', + '5f7f0bdb3381d6a461d8c29ff53d908f', + '9b47a45624b023b1a76c73b74d704acf', + 'f5c88a2209584891056f987fd965b0ba', + ], + $query + ); + } + + /** + * @param array $expectedRemoteIds + */ + private function assertExpectedSearchHits( + int $expectedCount, + array $expectedRemoteIds, + LocationQuery $query + ): void { + $searchHits = self::getSearchService()->findLocations($query); + + self::assertSame($expectedCount, $searchHits->totalCount); + + $remoteIds = $this->extractRemoteIds($searchHits); + + self::assertSame($expectedRemoteIds, $remoteIds); + } + + /** + * @return array + */ + private function extractRemoteIds(SearchResult $result): array + { + $remoteIds = array_map( + static function (SearchHit $searchHit): string { + /** @var \Ibexa\Contracts\Core\Repository\Values\Content\Location $location */ + $location = $searchHit->valueObject; + + return $location->getContentInfo()->remoteId; + }, + $result->searchHits + ); + + sort($remoteIds); + + return $remoteIds; + } + + /** + * @param array<\Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion> $criteria + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException + */ + private function createQuery(array $criteria): LocationQuery + { + $query = new LocationQuery(); + $query->filter = new Query\Criterion\LogicalAnd( + $criteria + ); + + return $query; + } + + public function addTestContentToBookmark(): void + { + $location = $this->loadMediaFolderLocation(); + $this->addLocationToBookmark($location); + } + + private function addLocationToBookmark(Location $location): void + { + $this->getBookmarkService()->createBookmark($location); + } + + private function loadMediaFolderLocation(): Location + { + return $this + ->getLocationService() + ->loadLocation(self::MEDIA_CONTENT_TYPE_ID); + } + + private function getBookmarkService(): BookmarkService + { + return self::getServiceByClassName(BookmarkService::class); + } +} diff --git a/tests/integration/Core/RepositoryTestCase.php b/tests/integration/Core/RepositoryTestCase.php index 97de4dc0d1..0ce17b2e8b 100644 --- a/tests/integration/Core/RepositoryTestCase.php +++ b/tests/integration/Core/RepositoryTestCase.php @@ -17,6 +17,7 @@ abstract class RepositoryTestCase extends IbexaKernelTestCase { public const CONTENT_TREE_ROOT_ID = 2; + public const ADMIN_USER_ID = 14; private const CONTENT_TYPE_FOLDER_IDENTIFIER = 'folder'; private const MAIN_USER_GROUP_REMOTE_ID = 'f5c88a2209584891056f987fd965b0ba'; diff --git a/tests/integration/Core/Resources/settings/common.yml b/tests/integration/Core/Resources/settings/common.yml index 2bedb7dd96..33597b4f9e 100644 --- a/tests/integration/Core/Resources/settings/common.yml +++ b/tests/integration/Core/Resources/settings/common.yml @@ -20,6 +20,7 @@ services: - [ 'addSubscriber', [ '@Ibexa\Core\Search\Common\EventSubscriber\SectionEventSubscriber' ] ] - [ 'addSubscriber', [ '@Ibexa\Core\Search\Common\EventSubscriber\TrashEventSubscriber' ] ] - [ 'addSubscriber', [ '@Ibexa\Core\Search\Common\EventSubscriber\UserEventSubscriber' ] ] + - [ 'addSubscriber', [ '@Ibexa\Core\Search\Common\EventSubscriber\BookmarkEventSubscriber' ] ] - [ 'addSubscriber', [ '@Ibexa\Core\Repository\EventSubscriber\NameSchemaSubscriber' ] ] - [ 'addSubscriber', [ '@Ibexa\Core\Persistence\Legacy\Content\Mapper\ResolveVirtualFieldSubscriber' ] ]