Skip to content

Commit

Permalink
Add the ground works for filters
Browse files Browse the repository at this point in the history
  • Loading branch information
loevgaard committed Jul 8, 2024
1 parent 74d0441 commit dc7fb7c
Show file tree
Hide file tree
Showing 20 changed files with 339 additions and 47 deletions.
21 changes: 21 additions & 0 deletions src/Builder/FilterBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusMeilisearchPlugin\Builder;

use Symfony\Component\HttpFoundation\Request;

final class FilterBuilder implements FilterBuilderInterface
{
public function build(Request $request): array
{
$filters = [];

if ($request->query->has('onSale')) {
$filters[] = 'onSale = true';
}

return $filters;
}
}
15 changes: 15 additions & 0 deletions src/Builder/FilterBuilderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusMeilisearchPlugin\Builder;

use Symfony\Component\HttpFoundation\Request;

interface FilterBuilderInterface
{
/**
* Takes a Symfony request and returns a filter ready for the Meilisearch client
*/
public function build(Request $request): array;
}
66 changes: 52 additions & 14 deletions src/Controller/SearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
use Doctrine\Persistence\ManagerRegistry;
use Meilisearch\Client;
use Setono\Doctrine\ORMTrait;
use Setono\SyliusMeilisearchPlugin\Builder\FilterBuilderInterface;
use Setono\SyliusMeilisearchPlugin\Config\IndexRegistryInterface;
use Setono\SyliusMeilisearchPlugin\Document\Attribute\Facet;
use Setono\SyliusMeilisearchPlugin\Document\Document;
use Setono\SyliusMeilisearchPlugin\Form\Builder\SearchFormBuilderInterface;
use Setono\SyliusMeilisearchPlugin\Form\Type\SearchWidgetType;
use Setono\SyliusMeilisearchPlugin\Model\IndexableInterface;
use Setono\SyliusMeilisearchPlugin\Resolver\IndexName\IndexNameResolverInterface;
Expand All @@ -30,30 +32,34 @@ public function __construct(
private readonly IndexRegistryInterface $indexRegistry,
private readonly Client $client,
private readonly ProductOptionRepositoryInterface $productOptionRepository,
/** @var list<string> $searchIndexes */
private readonly array $searchIndexes,
private readonly string $searchIndex,
) {
$this->managerRegistry = $managerRegistry;
}

public function search(Request $request): Response
public function search(Request $request, SearchFormBuilderInterface $searchFormBuilder, FilterBuilderInterface $filterBuilder): Response
{
$indexes = array_map(fn (string $searchIndex) => $this->indexRegistry->get($searchIndex), $this->searchIndexes);
$index = $this->indexRegistry->get($this->searchIndex);

$items = [];

foreach ($indexes as $index) {
$searchResult = $this->client->index($this->indexNameResolver->resolve($index))->search($request->query->getString('q'), [
'facets' => $this->getFacets($index->document),
]);
$searchResult = $this->client->index($this->indexNameResolver->resolve($index))->search($request->query->getString('q'), [

Check failure on line 46 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: highest | SF~5.4.0)

MixedArgument

src/Controller/SearchController.php:46:97: MixedArgument: Argument 1 of Meilisearch\Endpoints\Indexes::search cannot be mixed, expecting null|string (see https://psalm.dev/030)

Check failure on line 46 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: highest | SF~5.4.0)

UndefinedMethod

src/Controller/SearchController.php:46:114: UndefinedMethod: Method Symfony\Component\HttpFoundation\InputBag::getString does not exist (see https://psalm.dev/022)

Check failure on line 46 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: lowest | SF~5.4.0)

MixedArgument

src/Controller/SearchController.php:46:97: MixedArgument: Argument 1 of Meilisearch\Endpoints\Indexes::search cannot be mixed, expecting null|string (see https://psalm.dev/030)

Check failure on line 46 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: lowest | SF~5.4.0)

UndefinedMethod

src/Controller/SearchController.php:46:114: UndefinedMethod: Method Symfony\Component\HttpFoundation\InputBag::getString does not exist (see https://psalm.dev/022)

Check failure on line 46 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: highest | SF~5.4.0)

MixedArgument

src/Controller/SearchController.php:46:97: MixedArgument: Argument 1 of Meilisearch\Endpoints\Indexes::search cannot be mixed, expecting null|string (see https://psalm.dev/030)

Check failure on line 46 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: highest | SF~5.4.0)

UndefinedMethod

src/Controller/SearchController.php:46:114: UndefinedMethod: Method Symfony\Component\HttpFoundation\InputBag::getString does not exist (see https://psalm.dev/022)
'facets' => $this->getFacets($index->document),
'filter' => $filterBuilder->build($request),
]);

/** @var array{entityClass: class-string<IndexableInterface>, entityId: mixed} $hit */
foreach ($searchResult->getHits() as $hit) {
$items[] = $this->getManager($hit['entityClass'])->find($hit['entityClass'], $hit['entityId']);
}
$searchForm = $searchFormBuilder->build($searchResult);
$searchForm->handleRequest($request);

dump($searchResult);

Check failure on line 54 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: highest | SF~5.4.0)

ForbiddenCode

src/Controller/SearchController.php:54:9: ForbiddenCode: You have forbidden the use of dump (see https://psalm.dev/002)

Check failure on line 54 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: highest | SF~6.4.0)

ForbiddenCode

src/Controller/SearchController.php:54:9: ForbiddenCode: You have forbidden the use of dump (see https://psalm.dev/002)

Check failure on line 54 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: lowest | SF~5.4.0)

ForbiddenCode

src/Controller/SearchController.php:54:9: ForbiddenCode: You have forbidden the use of dump (see https://psalm.dev/002)

Check failure on line 54 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: highest | SF~5.4.0)

ForbiddenCode

src/Controller/SearchController.php:54:9: ForbiddenCode: You have forbidden the use of dump (see https://psalm.dev/002)

Check failure on line 54 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: lowest | SF~6.4.0)

ForbiddenCode

src/Controller/SearchController.php:54:9: ForbiddenCode: You have forbidden the use of dump (see https://psalm.dev/002)

Check failure on line 54 in src/Controller/SearchController.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: highest | SF~6.4.0)

ForbiddenCode

src/Controller/SearchController.php:54:9: ForbiddenCode: You have forbidden the use of dump (see https://psalm.dev/002)

/** @var array{entityClass: class-string<IndexableInterface>, entityId: mixed} $hit */
foreach ($searchResult->getHits() as $hit) {
$items[] = $this->getManager($hit['entityClass'])->find($hit['entityClass'], $hit['entityId']);
}

return new Response($this->twig->render('@SetonoSyliusMeilisearchPlugin/search/index.html.twig', [
'searchForm' => $searchForm->createView(),
'items' => $items,
]));
}
Expand All @@ -76,8 +82,8 @@ private function getFacets(string $document): array
{
$facets = [];

$reflectionClass = new \ReflectionClass($document);
foreach ($reflectionClass->getProperties() as $reflectionProperty) {
$documentReflection = new \ReflectionClass($document);
foreach ($documentReflection->getProperties() as $reflectionProperty) {
foreach ($reflectionProperty->getAttributes() as $reflectionAttribute) {
$attribute = $reflectionAttribute->newInstance();
if ($attribute instanceof Facet) {
Expand All @@ -86,6 +92,38 @@ private function getFacets(string $document): array
}
}

foreach ($documentReflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
$property = self::getterProperty($reflectionMethod);
if (null === $property) {
continue;
}

foreach ($reflectionMethod->getAttributes() as $reflectionAttribute) {
$attribute = $reflectionAttribute->newInstance();

if ($attribute instanceof Facet) {
$facets[] = $property;
}
}
}

return $facets;
}

private static function getterProperty(\ReflectionMethod $reflectionMethod): ?string
{
if ($reflectionMethod->getNumberOfParameters() > 0) {
return null;
}

$name = $reflectionMethod->getName();

foreach (['get', 'is', 'has'] as $prefix) {
if (str_starts_with($name, $prefix)) {
return lcfirst(substr($name, strlen($prefix)));
}
}

return null;
}
}
7 changes: 3 additions & 4 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,9 @@ public function getConfigTreeBuilder(): TreeBuilder
->info('This is the path where searches are displayed')
->cannotBeEmpty()
->end()
->arrayNode('indexes')
->requiresAtLeastOneElement()
->info('The indexes to search (must be configured in setono_sylius_meilisearch.indexes). Please notice that if you enable search you MUST provide at least one index to search.')
->scalarPrototype()->end()
->scalarNode('index')
->info('The index to search (must be configured in setono_sylius_meilisearch.indexes)')
->cannotBeEmpty()
;

return $treeBuilder;
Expand Down
17 changes: 7 additions & 10 deletions src/DependencyInjection/SetonoSyliusMeilisearchExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ public function load(array $configs, ContainerBuilder $container): void
* @var array{
* indexes: array<string, array{document: class-string<Document>, indexer: string|null, entities: list<class-string>, prefix: string|null}>,
* server: array{ host: string, master_key: string },
* search: array{ enabled: bool, route: string, indexes: list<string> },
* routes: array{ search: string }
* search: array{ enabled: bool, route: string, index: string }
* } $config
*/
$config = $this->processConfiguration($this->getConfiguration([], $container), $configs);
Expand Down Expand Up @@ -142,23 +141,21 @@ private static function registerDefaultIndexer(ContainerBuilder $container, stri
/**
* todo the search controller should only be available when search is enabled
*
* @param array{ enabled: bool, route: string, indexes: list<string> } $config the search configuration
* @param array{ enabled: bool, route: string, index: string } $config the search configuration
* @param list<string> $indexes a list of index names
*/
private static function registerSearchConfiguration(array $config, array $indexes, ContainerBuilder $container): void
{
if (true === $config['enabled'] && [] === $config['indexes']) {
throw new \RuntimeException('When you enable search you need to provide at least one index to search');
if (true === $config['enabled'] && !isset($config['index'])) {
throw new \RuntimeException('When you enable search you need to provide an index to search');
}

foreach ($config['indexes'] as $index) {
if (!in_array($index, $indexes, true)) {
throw new \RuntimeException(sprintf('For the search configuration you have added the index "%s". That index is not configured in setono_sylius_meilisearch.indexes. Available indexes are [%s]', $index, implode(', ', $indexes)));
}
if (!in_array($config['index'], $indexes, true)) {
throw new \RuntimeException(sprintf('For the search configuration you have added the index "%s". That index is not configured in setono_sylius_meilisearch.indexes. Available indexes are [%s]', $config['index'], implode(', ', $indexes)));
}

$container->setParameter('setono_sylius_meilisearch.search.enabled', $config['enabled']);
$container->setParameter('setono_sylius_meilisearch.search.route', $config['route']);
$container->setParameter('setono_sylius_meilisearch.search.indexes', $config['indexes']);
$container->setParameter('setono_sylius_meilisearch.search.index', $config['index']);
}
}
38 changes: 38 additions & 0 deletions src/Form/Builder/CompositeFacetFormBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusMeilisearchPlugin\Form\Builder;

use Setono\CompositeCompilerPass\CompositeService;
use Symfony\Component\Form\FormBuilderInterface;

/**
* @extends CompositeService<FacetFormBuilderInterface>
*/
final class CompositeFacetFormBuilder extends CompositeService implements FacetFormBuilderInterface
{
public function build(FormBuilderInterface $builder, string $name, array $values, array $stats = null): void
{
foreach ($this->services as $service) {
if ($service->supports($name, $values, $stats)) {
$service->build($builder, $name, $values, $stats);

return;
}
}

throw new \RuntimeException(sprintf('No facet form builder supports the facet with name "%s"', $name));
}

public function supports(string $name, array $values, array $stats = null): bool
{
foreach ($this->services as $service) {
if ($service->supports($name, $values, $stats)) {
return true;
}
}

return false;
}
}
24 changes: 24 additions & 0 deletions src/Form/Builder/FacetFormBuilderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusMeilisearchPlugin\Form\Builder;

use Symfony\Component\Form\FormBuilderInterface;

interface FacetFormBuilderInterface
{
/**
* @param string $name The name of the facet. This could be 'price' or 'color' for instance
* @param array<string, int> $values The values of the facet. This could be ['red' => 10, 'blue' => 5] where the key is the facet value and the value is the number of matching documents
* @param array{min: int|float, max: int|float}|null $stats The stats of the facet. This could be ['min' => 10, 'max' => 100] where min is the minimum value and max is the maximum value
*/
public function build(FormBuilderInterface $builder, string $name, array $values, array $stats = null): void;

/**
* @param string $name The name of the facet. This could be 'price' or 'color' for instance
* @param array<string, int> $values
* @param array{min: int|float, max: int|float}|null $stats
*/
public function supports(string $name, array $values, array $stats = null): bool;
}
67 changes: 67 additions & 0 deletions src/Form/Builder/SearchFormBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusMeilisearchPlugin\Form\Builder;

use Meilisearch\Search\SearchResult;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;

final class SearchFormBuilder implements SearchFormBuilderInterface
{
public function __construct(private readonly FormFactoryInterface $formFactory, private readonly FacetFormBuilderInterface $facetFormBuilder)
{
}

public function build(SearchResult $searchResult): FormInterface
{
$builder = $this->formFactory->createNamedBuilder('', options: [
'csrf_protection' => false,
]);

/**
* Here is an example of the facet stats array
*
* [
* "price" => [
* "min" => 1.24
* "max" => 47.42
* ]
* ]
*
* @var array<string, array{min: int|float, max: int|float}> $facetStats
*/
$facetStats = $searchResult->getFacetStats();

/**
* Here is an example of the facet distribution array
*
* [
* "onSale" => [
* "false" => 16
* "true" => 1
* ]
* "size" => [
* "L" => 17
* "M" => 17
* "S" => 17
* "XL" => 17
* "XXL" => 17
* ]
* ]
*
* @var string $name
* @var array<string, int> $values
*/
foreach ($searchResult->getFacetDistribution() as $name => $values) {
if ($this->facetFormBuilder->supports($name, $values, $facetStats[$name] ?? null)) {
$this->facetFormBuilder->build($builder, $name, $values, $facetStats[$name] ?? null);
}
}

$builder->setMethod('GET');

return $builder->getForm();
}
}
13 changes: 13 additions & 0 deletions src/Form/Builder/SearchFormBuilderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusMeilisearchPlugin\Form\Builder;

use Meilisearch\Search\SearchResult;
use Symfony\Component\Form\FormInterface;

interface SearchFormBuilderInterface
{
public function build(SearchResult $searchResult): FormInterface;
}
31 changes: 31 additions & 0 deletions src/Form/Builder/ToggleFacetFormBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusMeilisearchPlugin\Form\Builder;

use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use function Symfony\Component\String\u;

final class ToggleFacetFormBuilder implements FacetFormBuilderInterface
{
public function build(FormBuilderInterface $builder, string $name, array $values, array $stats = null): void
{
$builder->add($name, CheckboxType::class, [
'label' => sprintf('setono_sylius_meilisearch.form.search.facet.%s', u($name)->snake()),
'required' => false,
]);
}

public function supports(string $name, array $values, array $stats = null): bool
{
$c = count($values);

return match ($c) {
1 => isset($values['true']) || isset($values['false']),
2 => isset($values['true'], $values['false']),
default => false,
};
}
}
Loading

0 comments on commit dc7fb7c

Please sign in to comment.