diff --git a/composer.json b/composer.json index 39ff8a1..990afad 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,10 @@ }, "autoload-dev": { "psr-4": { - "Ibexa\\Tests\\Search\\": "tests/lib/", "Ibexa\\Tests\\Bundle\\Search\\": "tests/bundle/", + "Ibexa\\Tests\\Contracts\\Search\\": "tests/contracts/", + "Ibexa\\Tests\\Search\\": "tests/lib/", + "Ibexa\\Platform\\Tests\\Contracts\\Search\\": "tests/contracts/", "Ibexa\\Platform\\Tests\\Bundle\\Search\\": "tests/bundle/", "Ibexa\\Platform\\Tests\\Search\\": "tests/lib/" } @@ -32,16 +34,18 @@ "symfony/config": "^5.0", "symfony/form": "^5.0", "symfony/event-dispatcher": "^5.0", - "pagerfanta/pagerfanta": "^2.1" + "pagerfanta/pagerfanta": "^2.1", + "symfony/serializer": "^5.4" }, "require-dev": { - "phpunit/phpunit": "^8.5", + "phpunit/phpunit": "^9.6", "friendsofphp/php-cs-fixer": "^3.0", "ibexa/code-style": "^1.0", "ibexa/doctrine-schema": "~4.6.x-dev", "phpstan/phpstan": "^1.10", "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-symfony": "^1.3" + "phpstan/phpstan-symfony": "^1.3", + "matthiasnoback/symfony-dependency-injection-test": "^4.3" }, "scripts": { "fix-cs": "php-cs-fixer fix --config=.php-cs-fixer.php --show-progress=dots", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0083772..ba9018e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -250,6 +250,11 @@ parameters: count: 1 path: src/lib/Mapper/PagerSearchContentToDataMapper.php + - + message: "#^Property Ibexa\\\\Contracts\\\\Core\\\\Repository\\\\Values\\\\Content\\\\Search\\\\SearchHit\\:\\:\\$score \\(float\\) on left side of \\?\\? is not nullable\\.$#" + count: 1 + path: src/lib/Mapper/SearchHitToContentSuggestionMapper.php + - message: "#^Method Ibexa\\\\Search\\\\QueryType\\\\SearchQueryType\\:\\:doGetQuery\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#" count: 1 diff --git a/phpstan.neon b/phpstan.neon index 129755d..d080f65 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,3 +8,6 @@ parameters: paths: - src - tests + + ignoreErrors: + - message: "#^Cannot call method (log|debug|info|notice|warning|error|critical|alert|emergency)\\(\\) on Psr\\\\Log\\\\LoggerInterface\\|null\\.$#" diff --git a/src/bundle/ArgumentResolver/SuggestionQueryArgumentResolver.php b/src/bundle/ArgumentResolver/SuggestionQueryArgumentResolver.php new file mode 100644 index 0000000..002d09a --- /dev/null +++ b/src/bundle/ArgumentResolver/SuggestionQueryArgumentResolver.php @@ -0,0 +1,50 @@ +configResolver = $configResolver; + } + + public function supports(Request $request, ArgumentMetadata $argument): bool + { + return SuggestionQuery::class === $argument->getType(); + } + + /** + * @return iterable<\Ibexa\Search\Model\SuggestionQuery> + * + * @throw \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + */ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + $defaultLimit = $this->configResolver->getParameter('search.suggestion.result_limit'); + $query = $request->query->get('query'); + $limit = $request->query->getInt('limit', $defaultLimit); + $language = $request->query->get('language'); + + if ($query === null) { + throw new BadRequestHttpException('Missing query parameter'); + } + + yield new SuggestionQuery($query, $limit, $language); + } +} diff --git a/src/bundle/Controller/SuggestionController.php b/src/bundle/Controller/SuggestionController.php new file mode 100644 index 0000000..92ab56b --- /dev/null +++ b/src/bundle/Controller/SuggestionController.php @@ -0,0 +1,32 @@ +suggestionService = $suggestionService; + } + + public function suggestAction(SuggestionQuery $suggestionQuery): JsonResponse + { + $result = $this->suggestionService->suggest($suggestionQuery); + + return $this->json($result); + } +} diff --git a/src/bundle/DependencyInjection/Configuration/Parser/SiteAccessAware/SuggestionParser.php b/src/bundle/DependencyInjection/Configuration/Parser/SiteAccessAware/SuggestionParser.php new file mode 100644 index 0000000..bdab0ad --- /dev/null +++ b/src/bundle/DependencyInjection/Configuration/Parser/SiteAccessAware/SuggestionParser.php @@ -0,0 +1,104 @@ + $scopeSettings + */ + public function mapConfig( + array &$scopeSettings, + $currentScope, + ContextualizerInterface $contextualizer + ): void { + if (empty($scopeSettings['search'])) { + return; + } + + $settings = $scopeSettings['search']; + + $this->addSuggestionParameters($settings, $currentScope, $contextualizer); + } + + public function addSemanticConfig(NodeBuilder $nodeBuilder): void + { + $rootProductCatalogNode = $nodeBuilder->arrayNode('search'); + $rootProductCatalogNode->append($this->addSuggestionConfiguration()); + } + + /** + * @param array $settings + */ + private function addSuggestionParameters( + array $settings, + string $currentScope, + ContextualizerInterface $contextualizer + ): void { + $names = [ + 'min_query_length', + 'result_limit', + ]; + + foreach ($names as $name) { + if (isset($settings['suggestion'][$name])) { + $contextualizer->setContextualParameter( + 'search.suggestion.' . $name, + $currentScope, + $settings['suggestion'][$name] + ); + } + } + } + + private function addSuggestionConfiguration(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('suggestion'); + $node = $treeBuilder->getRootNode(); + + $node + ->children() + ->integerNode('min_query_length') + ->info('The minimum length of the query string needed to trigger suggestions. Minimum value is 3.') + ->isRequired() + ->defaultValue(3) + ->min(3) + ->end() + ->integerNode('result_limit') + ->info('The maximum number of suggestion results to return. Minimum value is 5.') + ->isRequired() + ->defaultValue(5) + ->min(5) + ->end() + ->end(); + + return $node; + } +} diff --git a/src/bundle/IbexaSearchBundle.php b/src/bundle/IbexaSearchBundle.php index 45ea758..1dbdb31 100644 --- a/src/bundle/IbexaSearchBundle.php +++ b/src/bundle/IbexaSearchBundle.php @@ -4,10 +4,12 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ + namespace Ibexa\Bundle\Search; use Ibexa\Bundle\Search\DependencyInjection\Configuration\Parser\Search; use Ibexa\Bundle\Search\DependencyInjection\Configuration\Parser\SearchView; +use Ibexa\Bundle\Search\DependencyInjection\Configuration\Parser\SiteAccessAware\SuggestionParser; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -21,6 +23,7 @@ public function build(ContainerBuilder $container) $core->addDefaultSettings(__DIR__ . '/Resources/config', ['default_settings.yaml']); $core->addConfigParser(new Search()); $core->addConfigParser(new SearchView()); + $core->addConfigParser(new SuggestionParser()); } } diff --git a/src/bundle/Resources/config/default_settings.yaml b/src/bundle/Resources/config/default_settings.yaml index 03e3e14..8b8a293 100644 --- a/src/bundle/Resources/config/default_settings.yaml +++ b/src/bundle/Resources/config/default_settings.yaml @@ -1,5 +1,7 @@ parameters: ibexa.site_access.config.default.search.pagination.limit: 10 + ibexa.site_access.config.default.search.suggestion.min_query_length: 3 + ibexa.site_access.config.default.search.suggestion.result_limit: 5 ibexa.site_access.config.site_group.search_view: full: diff --git a/src/bundle/Resources/config/routing.yaml b/src/bundle/Resources/config/routing.yaml index 28e9439..4cf7bfa 100644 --- a/src/bundle/Resources/config/routing.yaml +++ b/src/bundle/Resources/config/routing.yaml @@ -1,5 +1,11 @@ ibexa.search: path: /search - methods: ['GET'] - defaults: - _controller: 'Ibexa\Bundle\Search\Controller\SearchController::searchAction' + methods: [GET] + controller: 'Ibexa\Bundle\Search\Controller\SearchController::searchAction' + +ibexa.search.suggestion: + path: /suggestion + methods: [GET] + controller: 'Ibexa\Bundle\Search\Controller\SuggestionController::suggestAction' + options: + expose: true diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index 86e94a3..5becd8c 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -1,25 +1,32 @@ imports: - - { resource: forms.yaml } - - { resource: twig.yaml } - - { resource: sorting_definitions.yaml } - - { resource: views.yaml } + - { resource: forms.yaml } + - { resource: twig.yaml } + - { resource: sorting_definitions.yaml } + - { resource: views.yaml } + - { resource: services/suggestions.yaml } + - { resource: services/normalizers.yaml } services: - _defaults: - autoconfigure: true - autowire: true - public: false + _defaults: + autoconfigure: true + autowire: true + public: false - Ibexa\Bundle\Search\Controller\SearchController: - tags: - - controller.service_arguments + Ibexa\Bundle\Search\Controller\: + resource: './../../Controller' + tags: + - controller.service_arguments - Ibexa\Search\Mapper\PagerSearchContentToDataMapper: - arguments: - $contentTypeService: '@ibexa.api.service.content_type' - $userService: '@ibexa.api.service.user' - $userLanguagePreferenceProvider: '@Ibexa\Core\MVC\Symfony\Locale\UserLanguagePreferenceProvider' - $translationHelper: '@Ibexa\Core\Helper\TranslationHelper' - $languageService: '@ibexa.api.service.language' + Ibexa\Bundle\Search\Controller\SuggestionController: + tags: + - { name: 'container.service_subscriber', key: 'serializer', id: 'ibexa.search.suggestion.serializer' } - Ibexa\Search\QueryType\SearchQueryType: ~ + Ibexa\Search\Mapper\PagerSearchContentToDataMapper: + arguments: + $contentTypeService: '@ibexa.api.service.content_type' + $userService: '@ibexa.api.service.user' + $userLanguagePreferenceProvider: '@Ibexa\Core\MVC\Symfony\Locale\UserLanguagePreferenceProvider' + $translationHelper: '@Ibexa\Core\Helper\TranslationHelper' + $languageService: '@ibexa.api.service.language' + + Ibexa\Search\QueryType\SearchQueryType: ~ diff --git a/src/bundle/Resources/config/services/normalizers.yaml b/src/bundle/Resources/config/services/normalizers.yaml new file mode 100644 index 0000000..02c86f5 --- /dev/null +++ b/src/bundle/Resources/config/services/normalizers.yaml @@ -0,0 +1,25 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + ibexa.search.suggestion.serializer: + class: Symfony\Component\Serializer\Serializer + autoconfigure: false + arguments: + $normalizers: + - '@Ibexa\Search\Serializer\Normalizer\Suggestion\ContentSuggestionNormalizer' + - '@Ibexa\Search\Serializer\Normalizer\Suggestion\LocationNormalizer' + - '@Ibexa\Search\Serializer\Normalizer\Suggestion\ParentLocationCollectionNormalizer' + $encoders: + - '@serializer.encoder.json' + + Ibexa\Search\Serializer\Normalizer\Suggestion\ContentSuggestionNormalizer: + autoconfigure: false + + Ibexa\Search\Serializer\Normalizer\Suggestion\ParentLocationCollectionNormalizer: + autoconfigure: false + + Ibexa\Search\Serializer\Normalizer\Suggestion\LocationNormalizer: + autoconfigure: false diff --git a/src/bundle/Resources/config/services/suggestions.yaml b/src/bundle/Resources/config/services/suggestions.yaml new file mode 100644 index 0000000..8e05637 --- /dev/null +++ b/src/bundle/Resources/config/services/suggestions.yaml @@ -0,0 +1,26 @@ +services: + _defaults: + autoconfigure: true + autowire: true + public: false + + Ibexa\Bundle\Search\ArgumentResolver\SuggestionQueryArgumentResolver: + tags: + - { name: 'controller.argument_value_resolver' } + + Ibexa\Search\EventDispatcher\EventListener\ContentSuggestionSubscriber: ~ + + Ibexa\Search\Mapper\SearchHitToContentSuggestionMapper: ~ + + Ibexa\Contracts\Search\Mapper\SearchHitToContentSuggestionMapperInterface: '@Ibexa\Search\Mapper\SearchHitToContentSuggestionMapper' + + Ibexa\Search\Service\SuggestionService: ~ + + Ibexa\Contracts\Search\Service\SuggestionServiceInterface: '@Ibexa\Search\Service\SuggestionService' + + Ibexa\Search\Service\Event\SuggestionService: + decorates: Ibexa\Contracts\Search\Service\SuggestionServiceInterface + + Ibexa\Search\Provider\ParentLocationProvider: ~ + + Ibexa\Contracts\Search\Provider\ParentLocationProviderInterface: '@Ibexa\Search\Provider\ParentLocationProvider' diff --git a/src/contracts/Event/BuildSuggestionCollectionEvent.php b/src/contracts/Event/BuildSuggestionCollectionEvent.php new file mode 100644 index 0000000..0ed3533 --- /dev/null +++ b/src/contracts/Event/BuildSuggestionCollectionEvent.php @@ -0,0 +1,35 @@ +suggestionCollection = new SuggestionCollection(); + $this->query = $query; + } + + public function getSuggestionCollection(): SuggestionCollection + { + return $this->suggestionCollection; + } + + public function getQuery(): SuggestionQuery + { + return $this->query; + } +} diff --git a/src/contracts/Event/Service/BeforeSuggestEvent.php b/src/contracts/Event/Service/BeforeSuggestEvent.php new file mode 100644 index 0000000..0ab575b --- /dev/null +++ b/src/contracts/Event/Service/BeforeSuggestEvent.php @@ -0,0 +1,45 @@ +query = $query; + } + + public function setQuery(SuggestionQuery $query): void + { + $this->query = $query; + } + + public function getQuery(): SuggestionQuery + { + return $this->query; + } + + public function getSuggestionCollection(): ?SuggestionCollection + { + return $this->suggestionCollection; + } + + public function setSuggestionCollection(SuggestionCollection $suggestionCollection): void + { + $this->suggestionCollection = $suggestionCollection; + } +} diff --git a/src/contracts/Event/Service/SuggestEvent.php b/src/contracts/Event/Service/SuggestEvent.php new file mode 100644 index 0000000..4094661 --- /dev/null +++ b/src/contracts/Event/Service/SuggestEvent.php @@ -0,0 +1,36 @@ +query = $query; + $this->suggestionCollection = $suggestionCollection; + } + + public function getQuery(): SuggestionQuery + { + return $this->query; + } + + public function getSuggestionCollection(): SuggestionCollection + { + return $this->suggestionCollection; + } +} diff --git a/src/contracts/Mapper/SearchHitToContentSuggestionMapperInterface.php b/src/contracts/Mapper/SearchHitToContentSuggestionMapperInterface.php new file mode 100644 index 0000000..28e8332 --- /dev/null +++ b/src/contracts/Mapper/SearchHitToContentSuggestionMapperInterface.php @@ -0,0 +1,17 @@ + $parentLocations + */ + public function __construct( + float $score, + Content $content, + ContentType $contentType, + string $pathString = '', + array $parentLocations = [] + ) { + parent::__construct($score, $content->getName()); + $this->content = $content; + $this->contentType = $contentType; + $this->pathString = $pathString; + $this->parentsLocation = new ParentLocationCollection($parentLocations); + } + + public function getContent(): Content + { + return $this->content; + } + + public function getContentType(): ContentType + { + return $this->contentType; + } + + public function getPathString(): string + { + return $this->pathString; + } + + public function getParentsLocation(): ParentLocationCollection + { + return $this->parentsLocation; + } +} diff --git a/src/contracts/Model/Suggestion/ParentLocationCollection.php b/src/contracts/Model/Suggestion/ParentLocationCollection.php new file mode 100644 index 0000000..06aaeda --- /dev/null +++ b/src/contracts/Model/Suggestion/ParentLocationCollection.php @@ -0,0 +1,41 @@ + + */ +final class ParentLocationCollection extends MutableArrayList +{ + /** + * @param mixed $item + */ + public function append($item): void + { + if (!$item instanceof Location) { + throw new InvalidArgumentException( + '$item', + sprintf( + 'Argument 1 passed to %s() must be an instance of %s, %s given', + __METHOD__, + Location::class, + get_debug_type($item), + ) + ); + } + + parent::append($item); + } +} diff --git a/src/contracts/Model/Suggestion/Suggestion.php b/src/contracts/Model/Suggestion/Suggestion.php new file mode 100644 index 0000000..cd88f3d --- /dev/null +++ b/src/contracts/Model/Suggestion/Suggestion.php @@ -0,0 +1,38 @@ +score = $score; + $this->name = $name; + + parent::__construct(); + } + + public function getScore(): float + { + return $this->score; + } + + public function getName(): ?string + { + return $this->name; + } +} diff --git a/src/contracts/Model/Suggestion/SuggestionCollection.php b/src/contracts/Model/Suggestion/SuggestionCollection.php new file mode 100644 index 0000000..5fad4e5 --- /dev/null +++ b/src/contracts/Model/Suggestion/SuggestionCollection.php @@ -0,0 +1,50 @@ + + */ +final class SuggestionCollection extends MutableArrayList +{ + /** + * @param mixed $item + */ + public function append($item): void + { + if (!$item instanceof Suggestion) { + throw new InvalidArgumentException( + '$item', + sprintf( + 'Argument 1 passed to %s() must be an instance of %s, %s given', + __METHOD__, + Suggestion::class, + get_debug_type($item), + ) + ); + } + + parent::append($item); + } + + public function sortByScore(): void + { + usort($this->items, static fn ($a, $b): int => $b->getScore() <=> $a->getScore()); + } + + public function truncate(int $count): void + { + $this->items = array_slice($this->items, 0, $count); + } +} diff --git a/src/contracts/Provider/ParentLocationProviderInterface.php b/src/contracts/Provider/ParentLocationProviderInterface.php new file mode 100644 index 0000000..bb81245 --- /dev/null +++ b/src/contracts/Provider/ParentLocationProviderInterface.php @@ -0,0 +1,19 @@ + $parentLocationIds + * + * @return array<\Ibexa\Contracts\Core\Repository\Values\Content\Location> + */ + public function provide(array $parentLocationIds): array; +} diff --git a/src/contracts/Service/Decorator/SuggestionServiceDecorator.php b/src/contracts/Service/Decorator/SuggestionServiceDecorator.php new file mode 100644 index 0000000..9c2e416 --- /dev/null +++ b/src/contracts/Service/Decorator/SuggestionServiceDecorator.php @@ -0,0 +1,28 @@ +innerService = $innerService; + } + + public function suggest(SuggestionQuery $query): SuggestionCollection + { + return $this->innerService->suggest($query); + } +} diff --git a/src/contracts/Service/SuggestionServiceInterface.php b/src/contracts/Service/SuggestionServiceInterface.php new file mode 100644 index 0000000..ec3ebf9 --- /dev/null +++ b/src/contracts/Service/SuggestionServiceInterface.php @@ -0,0 +1,17 @@ +searchService = $searchService; + $this->contentSuggestionMapper = $contentSuggestionMapper; + } + + public static function getSubscribedEvents(): array + { + return [ + BuildSuggestionCollectionEvent::class => 'onBuildSuggestionCollectionEvent', + ]; + } + + public function onBuildSuggestionCollectionEvent( + BuildSuggestionCollectionEvent $event + ): BuildSuggestionCollectionEvent { + $query = $event->getQuery(); + + $value = $query->getQuery(); + $limit = $query->getLimit(); + $language = $query->getLanguageCode(); + + $query = new Query( + [ + 'query' => new Query\Criterion\FullText($value), + 'limit' => $limit, + ] + ); + + try { + $languageFilter = $language ? ['languages' => [$language]] : []; + $searchResult = $this->searchService->findContent($query, $languageFilter); + $suggestionCollection = $event->getSuggestionCollection(); + foreach ($searchResult as $result) { + $contentSuggestion = $this->contentSuggestionMapper->map($result); + if ($contentSuggestion === null) { + continue; + } + $suggestionCollection->append($contentSuggestion); + } + } catch (InvalidArgumentException $e) { + $this->logger->error($e); + } + + return $event; + } +} diff --git a/src/lib/Mapper/SearchHitToContentSuggestionMapper.php b/src/lib/Mapper/SearchHitToContentSuggestionMapper.php new file mode 100644 index 0000000..1ad70fb --- /dev/null +++ b/src/lib/Mapper/SearchHitToContentSuggestionMapper.php @@ -0,0 +1,64 @@ +configResolver = $configResolver; + $this->parentLocationProvider = $parentLocationProvider; + } + + public function map(SearchHit $searchHit): ?ContentSuggestion + { + $content = $searchHit->valueObject; + + if (!$content instanceof Content) { + return null; + } + + $rootLocationId = $this->configResolver->getParameter('content.tree_root.location_id'); + + $mainLocation = $content->getVersionInfo()->getContentInfo()->getMainLocation(); + + if ($mainLocation === null) { + return null; + } + + $parentsLocation = array_map('intval', $mainLocation->path); + $position = array_search($rootLocationId, $parentsLocation, true); + if ($position !== false) { + $parentsLocation = array_slice($parentsLocation, (int)$position); + } + + $parentLocations = $this->parentLocationProvider->provide($parentsLocation); + + return new ContentSuggestion( + $searchHit->score ?? 50.0, + $content, + $content->getContentType(), + implode('/', $parentsLocation), + $parentLocations + ); + } +} diff --git a/src/lib/Model/SuggestionQuery.php b/src/lib/Model/SuggestionQuery.php new file mode 100644 index 0000000..6c6593b --- /dev/null +++ b/src/lib/Model/SuggestionQuery.php @@ -0,0 +1,40 @@ +query = $query; + $this->limit = $limit; + $this->languageCode = $languageCode; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getLanguageCode(): ?string + { + return $this->languageCode; + } +} diff --git a/src/lib/Provider/ParentLocationProvider.php b/src/lib/Provider/ParentLocationProvider.php new file mode 100644 index 0000000..bd8e0c1 --- /dev/null +++ b/src/lib/Provider/ParentLocationProvider.php @@ -0,0 +1,38 @@ +locationService = $locationService; + } + + /** + * @param array $parentLocationIds + * + * @return array<\Ibexa\Contracts\Core\Repository\Values\Content\Location> + */ + public function provide(array $parentLocationIds): array + { + $parentLocations = $this->locationService->loadLocationList($parentLocationIds); + $parentLocationMap = []; + foreach ($parentLocations as $parentLocation) { + $parentLocationMap[] = $parentLocation; + } + + return $parentLocationMap; + } +} diff --git a/src/lib/Serializer/Normalizer/Suggestion/ContentSuggestionNormalizer.php b/src/lib/Serializer/Normalizer/Suggestion/ContentSuggestionNormalizer.php new file mode 100644 index 0000000..cba0d07 --- /dev/null +++ b/src/lib/Serializer/Normalizer/Suggestion/ContentSuggestionNormalizer.php @@ -0,0 +1,53 @@ + + */ + public function normalize($object, string $format = null, array $context = []): array + { + $content = $object->getContent(); + + return [ + 'contentId' => $content->id, + 'locationId' => $content->getVersionInfo()->getContentInfo()->getMainLocation()->id ?? null, + 'contentTypeIdentifier' => $object->getContentType()->identifier, + 'name' => $object->getName(), + 'pathString' => $object->getPathString(), + 'type' => 'content', + 'parentLocations' => $this->normalizer->normalize($object->getParentsLocation()), + ]; + } + + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof ContentSuggestion; + } + + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} diff --git a/src/lib/Serializer/Normalizer/Suggestion/LocationNormalizer.php b/src/lib/Serializer/Normalizer/Suggestion/LocationNormalizer.php new file mode 100644 index 0000000..f30960f --- /dev/null +++ b/src/lib/Serializer/Normalizer/Suggestion/LocationNormalizer.php @@ -0,0 +1,39 @@ + + */ + public function normalize($object, string $format = null, array $context = []): array + { + return [ + 'id' => $object->getContentInfo()->getId(), + 'locationId' => $object->id, + 'name' => $object->getContent()->getName(), + ]; + } + + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Location; + } + + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} diff --git a/src/lib/Serializer/Normalizer/Suggestion/ParentLocationCollectionNormalizer.php b/src/lib/Serializer/Normalizer/Suggestion/ParentLocationCollectionNormalizer.php new file mode 100644 index 0000000..8e2a975 --- /dev/null +++ b/src/lib/Serializer/Normalizer/Suggestion/ParentLocationCollectionNormalizer.php @@ -0,0 +1,50 @@ + $context + * + * @return array. + */ + public function normalize($object, string $format = null, array $context = []): array + { + $normalizedData = []; + + foreach ($object as $parentLocation) { + $normalizedData[] = $this->normalizer->normalize($parentLocation, $format, $context); + } + + return $normalizedData; + } + + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof ParentLocationCollection; + } + + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} diff --git a/src/lib/Service/Event/SuggestionService.php b/src/lib/Service/Event/SuggestionService.php new file mode 100644 index 0000000..83e8a9c --- /dev/null +++ b/src/lib/Service/Event/SuggestionService.php @@ -0,0 +1,60 @@ +eventDispatcher = $eventDispatcher; + } + + public function suggest(SuggestionQuery $query): SuggestionCollection + { + $beforeEvent = $this->eventDispatcher->dispatch( + new BeforeSuggestEvent($query) + ); + + if ($beforeEvent->isPropagationStopped()) { + $suggestionCollection = $beforeEvent->getSuggestionCollection(); + if ($suggestionCollection === null) { + throw new LogicException( + 'The suggestion collection must be set when the propagation is stopped.' + ); + } + + return $suggestionCollection; + } + + $result = $this->innerService->suggest($beforeEvent->getQuery()); + $afterEvent = $this->eventDispatcher->dispatch( + new SuggestEvent( + $query, + $result + ) + ); + + return $afterEvent->getSuggestionCollection(); + } +} diff --git a/src/lib/Service/SuggestionService.php b/src/lib/Service/SuggestionService.php new file mode 100644 index 0000000..f674f7c --- /dev/null +++ b/src/lib/Service/SuggestionService.php @@ -0,0 +1,41 @@ +eventDispatcher = $eventDispatcher; + } + + public function suggest(SuggestionQuery $query): SuggestionCollection + { + /** @var \Ibexa\Contracts\Search\Event\BuildSuggestionCollectionEvent $event */ + $event = $this->eventDispatcher->dispatch( + new BuildSuggestionCollectionEvent( + $query + ) + ); + + $collection = $event->getSuggestionCollection(); + $collection->sortByScore(); + $collection->truncate($query->getLimit()); + + return $collection; + } +} diff --git a/tests/bundle/DependencyInjection/Configuration/Parser/SiteAccessAware/SuggestionParserTest.php b/tests/bundle/DependencyInjection/Configuration/Parser/SiteAccessAware/SuggestionParserTest.php new file mode 100644 index 0000000..27c335f --- /dev/null +++ b/tests/bundle/DependencyInjection/Configuration/Parser/SiteAccessAware/SuggestionParserTest.php @@ -0,0 +1,102 @@ + $config + * @param array $expected + * @param array $expectedNotSet + */ + public function testSettings(array $config, array $expected, array $expectedNotSet = []): void + { + $this->load([ + 'system' => [ + 'ibexa_demo_site' => $config, + ], + ]); + + foreach ($expected as $key => $val) { + $this->assertConfigResolverParameterValue($key, $val, 'ibexa_demo_site'); + } + + foreach ($expectedNotSet as $key) { + $this->assertConfigResolverParameterIsNotSet($key, 'ibexa_demo_site'); + } + } + + /** + * @phpstan-return iterable< + * string, + * array{ + * array, + * array, + * 2?: array, + * }, + * > + */ + public function dataProviderForTestSettings(): iterable + { + yield 'empty configuration' => [ + [], + [], + [ + 'search.suggestion.min_query_length', + 'search.suggestion.result_limit', + ], + ]; + + yield 'suggestion' => [ + [ + 'search' => [ + 'suggestion' => [ + 'min_query_length' => 10, + 'result_limit' => 10, + ], + ], + ], + [ + 'search.suggestion.min_query_length' => 10, + 'search.suggestion.result_limit' => 10, + ], + ]; + } + + private function assertConfigResolverParameterIsNotSet(string $parameterName, ?string $scope = null): void + { + $chainConfigResolver = $this->getConfigResolver(); + try { + $chainConfigResolver->getParameter($parameterName, 'ibexa.site_access.config', $scope); + self::fail(sprintf('Parameter "%s" should not exist in scope "%s"', $parameterName, $scope)); + } catch (Exception $e) { + self::assertInstanceOf(ParameterNotFoundException::class, $e); + } + } +} diff --git a/tests/contracts/EventDispatcher/Event/BuildSuggestionCollectionEventTest.php b/tests/contracts/EventDispatcher/Event/BuildSuggestionCollectionEventTest.php new file mode 100644 index 0000000..833d9b7 --- /dev/null +++ b/tests/contracts/EventDispatcher/Event/BuildSuggestionCollectionEventTest.php @@ -0,0 +1,25 @@ +getSuggestionCollection()); + self::assertSame($suggestionQuery, $suggestionEvent->getQuery()); + } +} diff --git a/tests/contracts/Model/Suggestion/ContentSuggestionTest.php b/tests/contracts/Model/Suggestion/ContentSuggestionTest.php new file mode 100644 index 0000000..08f8508 --- /dev/null +++ b/tests/contracts/Model/Suggestion/ContentSuggestionTest.php @@ -0,0 +1,49 @@ + new VersionInfo([ + 'names' => ['eng-GB' => 'Test'], + 'initialLanguageCode' => 'eng-GB', + 'contentInfo' => [ + 'id' => 1, + ], + ]), + ]); + + $implementation = new ContentSuggestion( + 1, + $content, + $contentType, + '2/4/5', + [new Location([ + 'id' => 1, + 'path' => [2, 4, 5], + ])] + ); + + self::assertSame($content, $implementation->getContent()); + self::assertCount(1, $implementation->getParentsLocation()); + self::assertSame('2/4/5', $implementation->getPathString()); + self::assertSame($contentType, $implementation->getContentType()); + } +} diff --git a/tests/contracts/Model/Suggestion/ParentCollectionTest.php b/tests/contracts/Model/Suggestion/ParentCollectionTest.php new file mode 100644 index 0000000..cb49909 --- /dev/null +++ b/tests/contracts/Model/Suggestion/ParentCollectionTest.php @@ -0,0 +1,61 @@ +append($this->getLocation(10)); + $collection->append($this->getLocation(10)); + $collection->append($this->getLocation(10)); + $collection->append($this->getLocation(40)); + $collection->append($this->getLocation(50)); + $collection->append($this->getLocation(60)); + $collection->append($this->getLocation(70)); + + self::assertCount(7, $collection); + + foreach ($collection as $item) { + self::assertInstanceOf(Location::class, $item); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + <<<'EOD' +Argument 1 passed to Ibexa\Contracts\Search\Model\Suggestion\ParentLocationCollection::append() +must be an instance of Ibexa\Contracts\Core\Repository\Values\Content\Location, stdClass given +EOD + ); + $collection->append(new stdClass()); + } + + private function getLocation(int $locationId): Location + { + return new Location([ + 'id' => $locationId, + 'contentInfo' => new ContentInfo([ + 'id' => $locationId, + 'name' => 'name_' . $locationId, + ]), + ]); + } +} diff --git a/tests/contracts/Model/Suggestion/SuggestionCollectionTest.php b/tests/contracts/Model/Suggestion/SuggestionCollectionTest.php new file mode 100644 index 0000000..cded242 --- /dev/null +++ b/tests/contracts/Model/Suggestion/SuggestionCollectionTest.php @@ -0,0 +1,58 @@ +createMock(Content::class); + $contentTypeMock = $this->createMock(ContentType::class); + + $collection->append(new ContentSuggestion(10, $contentMock, $contentTypeMock, '1/2/3', [new Location()])); + $collection->append(new ContentSuggestion(20, $contentMock, $contentTypeMock, '1/3/5', [new Location()])); + $collection->append(new ContentSuggestion(30, $contentMock, $contentTypeMock, '1/2/6', [new Location()])); + $collection->append(new ContentSuggestion(10, $contentMock, $contentTypeMock, '1/3/4', [new Location()])); + $collection->append(new ContentSuggestion(50, $contentMock, $contentTypeMock, '5/7/10', [new Location()])); + $collection->append(new ContentSuggestion(60, $contentMock, $contentTypeMock, '5/2/1', [new Location()])); + $collection->append(new ContentSuggestion(70, $contentMock, $contentTypeMock, '8/2/10', [new Location()])); + + self::assertCount(7, $collection); + self::assertContainsOnlyInstancesOf(ContentSuggestion::class, $collection); + + $collection->sortByScore(); + self::assertSame(70.0, $collection->first()->getScore()); + + $collection->truncate(5); + self::assertCount(5, $collection); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + <<<'EOD' +Argument 1 passed to Ibexa\Contracts\Search\Model\Suggestion\SuggestionCollection::append() +must be an instance of Ibexa\Contracts\Search\Model\Suggestion\Suggestion, stdClass given +EOD + ); + $collection->append(new stdClass()); + } +} diff --git a/tests/contracts/Model/Suggestion/SuggestionTest.php b/tests/contracts/Model/Suggestion/SuggestionTest.php new file mode 100644 index 0000000..2da2619 --- /dev/null +++ b/tests/contracts/Model/Suggestion/SuggestionTest.php @@ -0,0 +1,38 @@ +createSuggestion( + 0, + 'name' + ); + + self::assertInstanceOf(Suggestion::class, $implementation); + self::assertSame('name', $implementation->getName()); + } + + private function createSuggestion( + int $score, + string $name + ): Suggestion { + return new class($score, $name) extends Suggestion { + public function getType(): string + { + return 'test_implementation'; + } + }; + } +} diff --git a/tests/lib/EventDispatcher/EventListener/ContentSuggestionSubscriberTest.php b/tests/lib/EventDispatcher/EventListener/ContentSuggestionSubscriberTest.php new file mode 100644 index 0000000..de1da1b --- /dev/null +++ b/tests/lib/EventDispatcher/EventListener/ContentSuggestionSubscriberTest.php @@ -0,0 +1,91 @@ +assertSame( + [BuildSuggestionCollectionEvent::class => 'onBuildSuggestionCollectionEvent'], + ContentSuggestionSubscriber::getSubscribedEvents() + ); + } + + public function testOnContentSuggestion(): void + { + $query = new SuggestionQuery('test', 10, 'eng-GB'); + $searchService = $this->getSearchServiceMock(); + $mapper = $this->getSearchHitToContentSuggestionMapperMock(); + + $subscriber = new ContentSuggestionSubscriber($searchService, $mapper); + + $event = new BuildSuggestionCollectionEvent($query); + $subscriber->onBuildSuggestionCollectionEvent($event); + + $collection = $event->getSuggestionCollection(); + + self::assertCount(1, $collection); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|\Ibexa\Core\Repository\SiteAccessAware\SearchService + */ + private function getSearchServiceMock(): SearchService + { + $searchServiceMock = $this->createMock(SearchService::class); + $searchServiceMock->method('findContent')->willReturn( + new SearchResult( + [ + 'searchHits' => [ + $this->createMock(SearchHit::class), + ], + ] + ) + ); + + return $searchServiceMock; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|\Ibexa\Contracts\Search\Mapper\SearchHitToContentSuggestionMapperInterface + */ + private function getSearchHitToContentSuggestionMapperMock(): SearchHitToContentSuggestionMapperInterface + { + $searchHitToContentSuggestionMapperMock = $this->createMock( + SearchHitToContentSuggestionMapperInterface::class + ); + $searchHitToContentSuggestionMapperMock->method('map')->willReturn( + new ContentSuggestionModel( + 10.0, + $this->createMock(Content::class), + $this->createMock(ContentType::class), + '1/2/3', + [ + new Location(['id' => 1, 'path' => [1, 2, 3]]), + ] + ) + ); + + return $searchHitToContentSuggestionMapperMock; + } +} diff --git a/tests/lib/Mapper/SearchHitToContentSuggestionMapperTest.php b/tests/lib/Mapper/SearchHitToContentSuggestionMapperTest.php new file mode 100644 index 0000000..76fc9d2 --- /dev/null +++ b/tests/lib/Mapper/SearchHitToContentSuggestionMapperTest.php @@ -0,0 +1,101 @@ +getParentLocationProviderMock(), + $this->getConfigResolverMock() + ); + + $content = new Content([ + 'id' => 1, + 'contentInfo' => new ContentInfo([ + 'name' => 'name', + 'mainLanguageCode' => 'eng-GB', + 'mainLocationId' => 1, + 'contentTypeId' => 1, + ]), + 'versionInfo' => new VersionInfo([ + 'initialLanguageCode' => 'eng-GB', + 'names' => ['eng-GB' => 'name_eng'], + 'contentInfo' => new ContentInfo([ + 'id' => 1, + 'mainLanguageCode' => 'eng-GB', + 'contentTypeId' => 1, + 'mainLocation' => new Location([ + 'id' => 8, + 'path' => ['1', '2', '3', '4', '5', '6', '7'], + ]), + ]), + ]), + 'contentType' => new ContentType([ + 'identifier' => 'content_type_identifier', + ]), + ]); + + $result = $mapper->map( + new SearchHit([ + 'valueObject' => $content, + ]) + ); + + self::assertInstanceOf(ContentSuggestion::class, $result); + self::assertSame($content, $result->getContent()); + self::assertSame('5/6/7', $result->getPathString()); + self::assertCount(3, $result->getParentsLocation()); + self::assertSame('name_eng', $result->getName()); + self::assertSame(50.0, $result->getScore()); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|\Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface + */ + private function getConfigResolverMock(): ConfigResolverInterface + { + $configResolverMock = $this->createMock(ConfigResolverInterface::class); + $configResolverMock->method('getParameter')->willReturn(5); + + return $configResolverMock; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|\Ibexa\Contracts\Search\Provider\ParentLocationProviderInterface + */ + private function getParentLocationProviderMock(): ParentLocationProviderInterface + { + $configResolverMock = $this->createMock(ParentLocationProviderInterface::class); + $configResolverMock->method('provide')->willReturnCallback(static function (array $locationIds): array { + $locations = []; + + foreach ($locationIds as $locationId) { + $locations[] = new Location(['id' => $locationId]); + } + + return $locations; + }); + + return $configResolverMock; + } +} diff --git a/tests/lib/Provider/ParentLocationProviderTest.php b/tests/lib/Provider/ParentLocationProviderTest.php new file mode 100644 index 0000000..aed9bba --- /dev/null +++ b/tests/lib/Provider/ParentLocationProviderTest.php @@ -0,0 +1,50 @@ +getLocationServiceMock()); + + $parentLocations = $provider->provide([1, 2, 3]); + + self::assertCount(3, $parentLocations); + } + + private function getLocationServiceMock(): LocationService + { + $locationServiceMock = $this->createMock(LocationService::class); + $locationServiceMock->method('loadLocationList')->willReturnCallback(function (array $locationIds): iterable { + foreach ($locationIds as $locationId) { + yield $this->getLocation($locationId); + } + }); + + return $locationServiceMock; + } + + private function getLocation(int $locationId): Location + { + return new Location([ + 'id' => $locationId, + 'contentInfo' => new ContentInfo([ + 'id' => $locationId, + 'name' => 'name_' . $locationId, + ]), + ]); + } +} diff --git a/tests/lib/Service/Event/SuggestionServiceTest.php b/tests/lib/Service/Event/SuggestionServiceTest.php new file mode 100644 index 0000000..78ceb7d --- /dev/null +++ b/tests/lib/Service/Event/SuggestionServiceTest.php @@ -0,0 +1,92 @@ +innerServiceMock = $this->createMock(SuggestionServiceInterface::class); + $this->eventDispatcherMock = $this->createMock(EventDispatcherInterface::class); + } + + public function testSuggestWithoutPropagationStop(): void + { + $query = new SuggestionQuery('test', 10, 'eng-GB'); + $suggestionCollection = new SuggestionCollection(); + $callCount = 0; + $this->eventDispatcherMock + ->expects(self::exactly(2)) + ->method('dispatch') + ->willReturnCallback( + static function (Event $event) use (&$callCount, $query, $suggestionCollection): Event { + ++$callCount; + if ($callCount === 1) { + self::assertInstanceOf(BeforeSuggestEvent::class, $event); + + return new BeforeSuggestEvent($query); + } + + self::assertInstanceOf(SuggestEvent::class, $event); + + return new SuggestEvent($query, $suggestionCollection); + } + ); + + $this->innerServiceMock + ->expects($this->once()) + ->method('suggest') + ->with($query) + ->willReturn($suggestionCollection); + + $service = new SuggestionService($this->innerServiceMock, $this->eventDispatcherMock); + $result = $service->suggest($query); + + self::assertEquals($suggestionCollection, $result); + } + + public function testSuggestWithPropagationStop(): void + { + $query = new SuggestionQuery('test', 10, 'eng-GB'); + $beforeEvent = new BeforeSuggestEvent($query); + $beforeEvent->stopPropagation(); + + $this->eventDispatcherMock + ->expects($this->once()) + ->method('dispatch') + ->willReturn($beforeEvent); + + $this->innerServiceMock + ->expects($this->never()) + ->method('suggest'); + + $service = new SuggestionService($this->innerServiceMock, $this->eventDispatcherMock); + + self::expectException(LogicException::class); + self::expectExceptionMessage('The suggestion collection must be set when the propagation is stopped.'); + $service->suggest($query); + } +} diff --git a/tests/lib/Service/SuggestionServiceTest.php b/tests/lib/Service/SuggestionServiceTest.php new file mode 100644 index 0000000..a978051 --- /dev/null +++ b/tests/lib/Service/SuggestionServiceTest.php @@ -0,0 +1,40 @@ +getEventDispatcherMock(); + $eventDispatcherMock + ->method('dispatch') + ->willReturnArgument(0); + + $service = new SuggestionService($eventDispatcherMock); + $result = $service->suggest(new SuggestionQuery('query', 10, 'eng-GB')); + + self::assertCount(0, $result); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject|\Symfony\Contracts\EventDispatcher\EventDispatcherInterface + */ + private function getEventDispatcherMock(): EventDispatcherInterface + { + return $this->getMockBuilder(EventDispatcherInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } +}