From 9b3e8259847c6e1114eeb25cf4cc0bfef4117e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Fri, 5 Jul 2024 14:01:47 +0200 Subject: [PATCH] Add attributes to make it easy to configure the index --- psalm-baseline.xml | 41 +++--------- src/Controller/SearchController.php | 34 +++++++++- src/DataMapper/Product/OptionsDataMapper.php | 47 ++++++++++++-- src/Document/Attribute/Facet.php | 12 ++++ src/Document/Attribute/Filterable.php | 2 +- src/Document/Attribute/MapProductOption.php | 24 +++++++ src/Document/Attribute/Searchable.php | 2 +- src/Document/Attribute/Sortable.php | 2 +- src/Document/Product.php | 13 ++-- src/Normalizer/ProductNormalizer.php | 59 ----------------- src/Provider/Settings/SettingsProvider.php | 37 +++++++++++ src/Resources/config/services/controller.xml | 1 + src/Resources/config/services/normalizer.xml | 6 -- tests/Application/Document/Product.php | 14 ++-- .../Product/OptionsDataMapperTest.php | 65 +++++++++++++++++++ 15 files changed, 242 insertions(+), 117 deletions(-) create mode 100644 src/Document/Attribute/Facet.php create mode 100644 src/Document/Attribute/MapProductOption.php delete mode 100644 src/Normalizer/ProductNormalizer.php create mode 100644 tests/DataMapper/Product/OptionsDataMapperTest.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4fd3732..e2c899f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,34 +1,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index 79c1c7c..da92310 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -8,9 +8,12 @@ use Meilisearch\Client; use Setono\Doctrine\ORMTrait; use Setono\SyliusMeilisearchPlugin\Config\IndexRegistryInterface; +use Setono\SyliusMeilisearchPlugin\Document\Attribute\Facet; +use Setono\SyliusMeilisearchPlugin\Document\Document; use Setono\SyliusMeilisearchPlugin\Form\Type\SearchWidgetType; use Setono\SyliusMeilisearchPlugin\Model\IndexableInterface; use Setono\SyliusMeilisearchPlugin\Resolver\IndexName\IndexNameResolverInterface; +use Sylius\Component\Product\Repository\ProductOptionRepositoryInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -26,6 +29,7 @@ public function __construct( private readonly IndexNameResolverInterface $indexNameResolver, private readonly IndexRegistryInterface $indexRegistry, private readonly Client $client, + private readonly ProductOptionRepositoryInterface $productOptionRepository, /** @var list $searchIndexes */ private readonly array $searchIndexes, ) { @@ -34,12 +38,14 @@ public function __construct( public function search(Request $request): Response { - $indexNames = array_map(fn (string $searchIndex) => $this->indexNameResolver->resolve($this->indexRegistry->get($searchIndex)), $this->searchIndexes); + $indexes = array_map(fn (string $searchIndex) => $this->indexRegistry->get($searchIndex), $this->searchIndexes); $items = []; - foreach ($indexNames as $indexName) { - $searchResult = $this->client->index($indexName)->search($request->query->getString('q')); + foreach ($indexes as $index) { + $searchResult = $this->client->index($this->indexNameResolver->resolve($index))->search($request->query->getString('q'), [ + 'facets' => $this->getFacets($index->document), + ]); /** @var array{entityClass: class-string, entityId: mixed} $hit */ foreach ($searchResult->getHits() as $hit) { @@ -60,4 +66,26 @@ public function widget(FormFactoryInterface $formFactory): Response 'form' => $form->createView(), ])); } + + /** + * @param class-string $document + * + * @return list + */ + private function getFacets(string $document): array + { + $facets = []; + + $reflectionClass = new \ReflectionClass($document); + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + foreach ($reflectionProperty->getAttributes() as $reflectionAttribute) { + $attribute = $reflectionAttribute->newInstance(); + if ($attribute instanceof Facet) { + $facets[] = $reflectionProperty->getName(); + } + } + } + + return $facets; + } } diff --git a/src/DataMapper/Product/OptionsDataMapper.php b/src/DataMapper/Product/OptionsDataMapper.php index 4667cc7..8d1aee9 100644 --- a/src/DataMapper/Product/OptionsDataMapper.php +++ b/src/DataMapper/Product/OptionsDataMapper.php @@ -5,6 +5,7 @@ namespace Setono\SyliusMeilisearchPlugin\DataMapper\Product; use Setono\SyliusMeilisearchPlugin\DataMapper\DataMapperInterface; +use Setono\SyliusMeilisearchPlugin\Document\Attribute\MapProductOption; use Setono\SyliusMeilisearchPlugin\Document\Document; use Setono\SyliusMeilisearchPlugin\Document\Product as ProductDocument; use Setono\SyliusMeilisearchPlugin\Model\IndexableInterface; @@ -12,25 +13,63 @@ use Sylius\Component\Core\Model\ProductInterface; use Webmozart\Assert\Assert; +/** + * todo make this prettier + */ final class OptionsDataMapper implements DataMapperInterface { public function map(IndexableInterface $source, Document $target, IndexScope $indexScope, array $context = []): void { Assert::true($this->supports($source, $target, $indexScope, $context)); + /** @var array> $options */ + $options = []; + foreach ($source->getEnabledVariants() as $variant) { foreach ($variant->getOptionValues() as $optionValue) { - $option = $optionValue->getOption()?->getCode(); + $option = $optionValue->getOptionCode(); if ($option === null) { continue; } - $target->options[$option][] = (string) $optionValue->getValue(); + $options[$option][] = (string) $optionValue->getValue(); } } - foreach ($target->options as $option => $values) { - $target->options[$option] = array_values(array_unique($values)); + foreach ($options as $option => $values) { + $options[$option] = array_values(array_unique($values)); + } + + $documentReflection = new \ReflectionClass($target); + foreach ($documentReflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $reflectionProperty) { + $propertyName = $reflectionProperty->getName(); + + foreach ($reflectionProperty->getAttributes() as $reflectionAttribute) { + $attribute = $reflectionAttribute->newInstance(); + + if (!$attribute instanceof MapProductOption) { + continue; + } + + if (!isset($target->{$propertyName}) || !is_array($target->{$propertyName})) { + continue; + } + + $values = []; + + foreach ($attribute->codes as $code) { + if (!isset($options[$code])) { + continue; + } + + $values[] = $options[$code]; + } + + $values = array_values(array_unique(array_merge(...$values))); + + /** @psalm-suppress MixedArgument */ + $target->{$propertyName} = array_merge($target->{$propertyName}, $values); + } } } diff --git a/src/Document/Attribute/Facet.php b/src/Document/Attribute/Facet.php new file mode 100644 index 0000000..c0a5833 --- /dev/null +++ b/src/Document/Attribute/Facet.php @@ -0,0 +1,12 @@ + */ + public array $codes = []; + + /** + * todo should be nullable to just use the property name as the code + * + * @param list|string $codes + */ + public function __construct(array|string $codes) + { + $this->codes = is_string($codes) ? [$codes] : $codes; + } +} diff --git a/src/Document/Attribute/Searchable.php b/src/Document/Attribute/Searchable.php index 5ae23e2..3d6b30d 100644 --- a/src/Document/Attribute/Searchable.php +++ b/src/Document/Attribute/Searchable.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_PROPERTY)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] final class Searchable { } diff --git a/src/Document/Attribute/Sortable.php b/src/Document/Attribute/Sortable.php index ebbff52..1c3d99b 100644 --- a/src/Document/Attribute/Sortable.php +++ b/src/Document/Attribute/Sortable.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_PROPERTY)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] final class Sortable { } diff --git a/src/Document/Product.php b/src/Document/Product.php index 77b2f31..79e868a 100644 --- a/src/Document/Product.php +++ b/src/Document/Product.php @@ -4,6 +4,7 @@ namespace Setono\SyliusMeilisearchPlugin\Document; +use Setono\SyliusMeilisearchPlugin\Document\Attribute\Facet; use Setono\SyliusMeilisearchPlugin\Document\Attribute\Filterable; use Setono\SyliusMeilisearchPlugin\Document\Attribute\Searchable; use Setono\SyliusMeilisearchPlugin\Document\Attribute\Sortable; @@ -37,18 +38,19 @@ class Product extends Document implements UrlAwareInterface, ImageUrlsAwareInter public ?string $currency = null; + #[Facet] #[Filterable] + #[Sortable] public ?float $price = null; public ?float $originalPrice = null; - /** @var array> */ - public array $options = []; - /** * This attribute will allow you to create a filter like 'Only show products on sale' */ - public function onSale(): bool + #[Filterable] + #[Facet] + public function isOnSale(): bool { return null !== $this->originalPrice && null !== $this->price && $this->price < $this->originalPrice; } @@ -63,7 +65,8 @@ public function onSale(): bool * ]; * } */ - public function discount(): float + #[Sortable] + public function getDiscount(): float { if (null === $this->originalPrice || null === $this->price) { return 0; diff --git a/src/Normalizer/ProductNormalizer.php b/src/Normalizer/ProductNormalizer.php deleted file mode 100644 index 6ddb836..0000000 --- a/src/Normalizer/ProductNormalizer.php +++ /dev/null @@ -1,59 +0,0 @@ -supportsNormalization($object)) { - throw new LogicException(sprintf('The object must be an instance of %s', Product::class)); - } - - $data = $this->normalizer->normalize($object, $format, $context); - if ($data instanceof \ArrayObject) { - $data = $data->getArrayCopy(); - } - - if (!is_array($data)) { - throw new LogicException('The normalized product data must be an array or an ArrayObject'); - } - - /** - * @var string $option - * @var list $values - */ - foreach ($data['options'] as $option => $values) { - $data[$option . '_option'] = $values; - } - - unset($data['options']); - - return $data; - } - - /** - * @psalm-assert-if-true Product $data - */ - public function supportsNormalization(mixed $data, ?string $format = null): bool - { - return $data instanceof Product; - } - - public function getSupportedTypes(?string $format): array - { - return [ - Product::class => true, - ]; - } -} diff --git a/src/Provider/Settings/SettingsProvider.php b/src/Provider/Settings/SettingsProvider.php index 8a22f4d..d4f1700 100644 --- a/src/Provider/Settings/SettingsProvider.php +++ b/src/Provider/Settings/SettingsProvider.php @@ -20,14 +20,51 @@ public function getSettings(IndexScope $indexScope): Settings foreach ($documentReflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $reflectionProperty) { foreach ($reflectionProperty->getAttributes() as $reflectionAttribute) { $attribute = $reflectionAttribute->newInstance(); + match ($attribute::class) { Filterable::class => $settings->filterableAttributes[] = $reflectionProperty->getName(), Searchable::class => $settings->searchableAttributes[] = $reflectionProperty->getName(), Sortable::class => $settings->sortableAttributes[] = $reflectionProperty->getName(), + default => null, + }; + } + } + + foreach ($documentReflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) { + $property = self::getterProperty($reflectionMethod); + if (null === $property) { + continue; + } + + foreach ($reflectionMethod->getAttributes() as $reflectionAttribute) { + $attribute = $reflectionAttribute->newInstance(); + + match ($attribute::class) { + Filterable::class => $settings->filterableAttributes[] = $property, + Searchable::class => $settings->searchableAttributes[] = $property, + Sortable::class => $settings->sortableAttributes[] = $property, + default => null, }; } } return $settings; } + + 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; + } } diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml index bf2c471..6a6615a 100644 --- a/src/Resources/config/services/controller.xml +++ b/src/Resources/config/services/controller.xml @@ -8,6 +8,7 @@ + %setono_sylius_meilisearch.search.indexes% diff --git a/src/Resources/config/services/normalizer.xml b/src/Resources/config/services/normalizer.xml index 243acb4..1e3cb7f 100644 --- a/src/Resources/config/services/normalizer.xml +++ b/src/Resources/config/services/normalizer.xml @@ -2,12 +2,6 @@ - - - - - - diff --git a/tests/Application/Document/Product.php b/tests/Application/Document/Product.php index 0819997..dcbf466 100644 --- a/tests/Application/Document/Product.php +++ b/tests/Application/Document/Product.php @@ -4,14 +4,16 @@ namespace Setono\SyliusMeilisearchPlugin\Tests\Application\Document; +use Setono\SyliusMeilisearchPlugin\Document\Attribute\Facet; +use Setono\SyliusMeilisearchPlugin\Document\Attribute\Filterable; +use Setono\SyliusMeilisearchPlugin\Document\Attribute\MapProductOption; use Setono\SyliusMeilisearchPlugin\Document\Product as BaseProduct; final class Product extends BaseProduct { - public static function getSortableAttributes(): array - { - return [ - 'price' => 'asc', - ]; - } + /** @var list */ + #[Filterable] + #[Facet] + #[MapProductOption(['t_shirt_size', 'dress_size', 'jeans_size'])] + public array $size = []; } diff --git a/tests/DataMapper/Product/OptionsDataMapperTest.php b/tests/DataMapper/Product/OptionsDataMapperTest.php new file mode 100644 index 0000000..2571907 --- /dev/null +++ b/tests/DataMapper/Product/OptionsDataMapperTest.php @@ -0,0 +1,65 @@ +prophesize(ProductOptionValueInterface::class); + $optionValue1->getOptionCode()->willReturn('t_shirt_size'); + $optionValue1->getValue()->willReturn('L'); + + $optionValue2 = $this->prophesize(ProductOptionValueInterface::class); + $optionValue2->getOptionCode()->willReturn('dress_size'); + $optionValue2->getValue()->willReturn('Big'); + + $optionValue3 = $this->prophesize(ProductOptionValueInterface::class); + $optionValue3->getOptionCode()->willReturn('jeans_size'); + $optionValue3->getValue()->willReturn('31/34'); + + $productVariant = $this->prophesize(ProductVariantInterface::class); + $productVariant->getOptionValues()->willReturn(new ArrayCollection([ + $optionValue1->reveal(), + $optionValue2->reveal(), + $optionValue3->reveal(), + ])); + + $product = $this->prophesize(Product::class); + $product->getEnabledVariants()->willReturn(new ArrayCollection([$productVariant->reveal()])); + + $productDocument = new ProductDocument(); + + $dataMapper = new OptionsDataMapper(); + $dataMapper->map($product->reveal(), $productDocument, new IndexScope(new Index('products', ProductDocument::class, [], new Container()))); + + $this->assertSame(['L', 'Big', '31/34'], $productDocument->size); + } +} + +final class ProductDocument extends BaseProduct +{ + /** @var list */ + #[MapProductOption(['t_shirt_size', 'dress_size', 'jeans_size'])] + public array $size = []; +}