diff --git a/composer.json b/composer.json index aceabbe..f89ed15 100644 --- a/composer.json +++ b/composer.json @@ -91,7 +91,8 @@ }, "autoload-dev": { "psr-4": { - "Setono\\SyliusMeilisearchPlugin\\Tests\\": "tests/" + "Setono\\SyliusMeilisearchPlugin\\Tests\\": "tests/", + "TestApp\\": "tests/Application/src/" }, "classmap": [ "tests/Application/Kernel.php" diff --git a/src/Engine/SearchEngine.php b/src/Engine/SearchEngine.php index f8c6230..bb9507e 100644 --- a/src/Engine/SearchEngine.php +++ b/src/Engine/SearchEngine.php @@ -5,8 +5,10 @@ namespace Setono\SyliusMeilisearchPlugin\Engine; use Meilisearch\Client; +use Meilisearch\Contracts\SearchQuery; use Meilisearch\Search\SearchResult; use Setono\SyliusMeilisearchPlugin\Config\Index; +use Setono\SyliusMeilisearchPlugin\Document\Metadata\Facet; use Setono\SyliusMeilisearchPlugin\Document\Metadata\MetadataFactoryInterface; use Setono\SyliusMeilisearchPlugin\Meilisearch\Builder\FilterBuilderInterface; use Setono\SyliusMeilisearchPlugin\Resolver\IndexName\IndexNameResolverInterface; @@ -27,19 +29,69 @@ public function execute(?string $query, array $parameters = []): SearchResult { $page = max(1, (int) ($parameters['p'] ?? 1)); $sort = (string) ($parameters['sort'] ?? ''); + $facetsFilter = $parameters['facets'] ?? []; $metadata = $this->metadataFactory->getMetadataFor($this->index->document); + $indexUid = $this->indexNameResolver->resolve($this->index); + $query = $query ?? ''; + $facets = array_map(static fn (Facet $facet) => $facet->name, $metadata->getFacets()); + $filter = $this->filterBuilder->build($facetsFilter); + + $mainQuery = $this->buildSearchQuery($indexUid, $query, $facets, $filter) + ->setHitsPerPage($this->hitsPerPage) + ->setPage($page) + ; - $searchParams = [ - 'facets' => $metadata->getFacetableAttributeNames(), - 'filter' => $this->filterBuilder->build($parameters), - 'hitsPerPage' => $this->hitsPerPage, - 'page' => $page, - ]; if ('' !== $sort) { - $searchParams['sort'] = [$sort]; + $mainQuery->setSort([$sort]); + } + + $results = $this->client->multiSearch([ + $mainQuery, + ...$this->createSearchQueries($indexUid, $facets, $query), + ])['results'] ?? []; + /** @var array{facetDistribution: array} $firstResult */ + $firstResult = current($results); + $firstResult['facetDistribution'] = array_merge(...array_column($results, 'facetDistribution')); + + return new SearchResult($firstResult); + } + + /** + * @param array $facets + * + * @return array + */ + private function createSearchQueries(string $indexUid, array $facets, ?string $query): array + { + $searchQueries = []; + + foreach ($facets as $facet) { + $facets = [$facet]; + $filteredFacets = array_filter( + $parameters['facets'] ?? [], + static fn ($value) => $value !== $facet, + \ARRAY_FILTER_USE_KEY, + ); + $filter = $this->filterBuilder->build($filteredFacets); + + $searchQueries[] = $this->buildSearchQuery($indexUid, $query, $facets, $filter)->setLimit(1); } - return $this->client->index($this->indexNameResolver->resolve($this->index))->search($query, $searchParams); + return $searchQueries; + } + + /** + * @param array $facets + * @param array $filter + */ + private function buildSearchQuery(string $indexUid, ?string $query, array $facets, array $filter): SearchQuery + { + return (new SearchQuery()) + ->setIndexUid($indexUid) + ->setQuery($query ?? '') + ->setFacets($facets) + ->setFilter($filter) + ; } } diff --git a/src/Meilisearch/Builder/CompositeFilterBuilder.php b/src/Meilisearch/Builder/CompositeFilterBuilder.php new file mode 100644 index 0000000..ddc7039 --- /dev/null +++ b/src/Meilisearch/Builder/CompositeFilterBuilder.php @@ -0,0 +1,34 @@ + $filterBuilders + */ + public function __construct( + private readonly iterable $filterBuilders, + ) { + } + + public function build(array $facets): string|array + { + $filters = []; + + foreach ($this->filterBuilders as $filterBuilder) { + if ($filterBuilder->supports($facets)) { + $filters[] = $filterBuilder->build($facets); + } + } + + return $filters; + } + + public function supports(array $facets): bool + { + return true; + } +} diff --git a/src/Meilisearch/Builder/FilterBuilder.php b/src/Meilisearch/Builder/FilterBuilder.php deleted file mode 100644 index cb35039..0000000 --- a/src/Meilisearch/Builder/FilterBuilder.php +++ /dev/null @@ -1,29 +0,0 @@ - + alias="Setono\SyliusMeilisearchPlugin\Meilisearch\Builder\CompositeFilterBuilder"/> - + + + get(SearchEngine::class); + $result = $searchEngine->execute( + 'jeans', + ['facets' => ['brand' => ['Celsius small']]], + ); + + $this->assertSame(1, $result->getHitsCount()); + $this->assertCount(4, $result->getFacetDistribution()['brand']); + } } diff --git a/tests/Unit/Meilisearch/Builder/CompositeFilterBuilderTest.php b/tests/Unit/Meilisearch/Builder/CompositeFilterBuilderTest.php new file mode 100644 index 0000000..9c82ecc --- /dev/null +++ b/tests/Unit/Meilisearch/Builder/CompositeFilterBuilderTest.php @@ -0,0 +1,58 @@ +createMock(FilterBuilderInterface::class); + $brandFilterBuilder->method('build')->willReturn('(brand = "brand1")'); + $brandFilterBuilder->method('supports')->willReturn(true); + + $sizeFilterBuilder = $this->createMock(FilterBuilderInterface::class); + $sizeFilterBuilder->method('build')->willReturn('(size = "size1" OR size = "size2")'); + $sizeFilterBuilder->method('supports')->willReturn(true); + + $compositeFilterBuilder = new CompositeFilterBuilder([$brandFilterBuilder, $sizeFilterBuilder]); + + $filters = $compositeFilterBuilder->build([ + 'onSale' => true, + 'brand' => ['brand1'], + 'size' => ['size1', 'size2'], + ]); + + $this->assertSame([ + '(brand = "brand1")', + '(size = "size1" OR size = "size2")', + ], $filters); + } + + public function test_it_uses_only_supported_filter_builders(): void + { + $brandFilterBuilder = $this->createMock(FilterBuilderInterface::class); + $brandFilterBuilder->expects($this->never())->method('build'); + $brandFilterBuilder->method('supports')->willReturn(false); + + $sizeFilterBuilder = $this->createMock(FilterBuilderInterface::class); + $sizeFilterBuilder->method('build')->willReturn('(size = "size1" OR size = "size2")'); + $sizeFilterBuilder->method('supports')->willReturn(true); + + $compositeFilterBuilder = new CompositeFilterBuilder([$brandFilterBuilder, $sizeFilterBuilder]); + + $filters = $compositeFilterBuilder->build([ + 'onSale' => true, + 'size' => ['size1', 'size2'], + ]); + + $this->assertSame([ + '(size = "size1" OR size = "size2")', + ], $filters); + } +}