Skip to content

Commit

Permalink
IBX-6413: Search Suggestion (Autocomplete for search) (#33)
Browse files Browse the repository at this point in the history
Co-authored-by: Krzysztof Słomka <[email protected]>
Co-authored-by: Paweł Niedzielski <[email protected]>
Co-authored-by: Mikolaj Adamczyk <[email protected]>
  • Loading branch information
4 people authored Nov 17, 2023
1 parent 9b1fb5e commit 32becdd
Show file tree
Hide file tree
Showing 43 changed files with 1,847 additions and 26 deletions.
12 changes: 8 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
}
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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\\.$#"
50 changes: 50 additions & 0 deletions src/bundle/ArgumentResolver/SuggestionQueryArgumentResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\Search\ArgumentResolver;

use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface;
use Ibexa\Search\Model\SuggestionQuery;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

final class SuggestionQueryArgumentResolver implements ArgumentValueResolverInterface
{
private ConfigResolverInterface $configResolver;

public function __construct(ConfigResolverInterface $configResolver)
{
$this->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);
}
}
32 changes: 32 additions & 0 deletions src/bundle/Controller/SuggestionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\Search\Controller;

use Ibexa\Search\Model\SuggestionQuery;
use Ibexa\Search\Service\SuggestionService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;

final class SuggestionController extends AbstractController
{
private SuggestionService $suggestionService;

public function __construct(
SuggestionService $suggestionService
) {
$this->suggestionService = $suggestionService;
}

public function suggestAction(SuggestionQuery $suggestionQuery): JsonResponse
{
$result = $this->suggestionService->suggest($suggestionQuery);

return $this->json($result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\Search\DependencyInjection\Configuration\Parser\SiteAccessAware;

use Ibexa\Bundle\Core\DependencyInjection\Configuration\AbstractParser;
use Ibexa\Bundle\Core\DependencyInjection\Configuration\SiteAccessAware\ContextualizerInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;

/**
* Configuration parser for search.
*
* @Example configuration:
*
* ```yaml
* ibexa:
* system:
* default: # configuration per siteaccess or siteaccess group
* search:
* suggestion:
* min_query_length: 3
* result_limit: 5
* ```
*/
final class SuggestionParser extends AbstractParser
{
/**
* @param array<string, mixed> $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<string, mixed> $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;
}
}
3 changes: 3 additions & 0 deletions src/bundle/IbexaSearchBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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());
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/bundle/Resources/config/default_settings.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
12 changes: 9 additions & 3 deletions src/bundle/Resources/config/routing.yaml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 26 additions & 19 deletions src/bundle/Resources/config/services.yaml
Original file line number Diff line number Diff line change
@@ -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: ~
25 changes: 25 additions & 0 deletions src/bundle/Resources/config/services/normalizers.yaml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions src/bundle/Resources/config/services/suggestions.yaml
Original file line number Diff line number Diff line change
@@ -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'
Loading

0 comments on commit 32becdd

Please sign in to comment.