diff --git a/doc/swagger.yml b/doc/swagger.yml index 3c17b7a76..a6f93535f 100644 --- a/doc/swagger.yml +++ b/doc/swagger.yml @@ -514,6 +514,17 @@ paths: required: false type: "integer" example: 2 + - name: "filters" + in: "query" + required: false + description: "List of filters." + type: "object" + properties: + type: + $ref: "#/definitions/Filter" + example: + boolean: + enabled: "true" responses: 200: description: "Paginated product list." @@ -562,6 +573,17 @@ paths: required: false type: "integer" example: 2 + - name: "filters" + in: "query" + required: false + description: "List of filters." + type: "object" + properties: + type: + $ref: "#/definitions/Filter" + example: + boolean: + enabled: "true" responses: 200: description: "Paginated product list." @@ -1431,3 +1453,12 @@ definitions: items: type: "string" example: "sylius.shop_api.additionalProp.not_null" + Filter: + type: "object" + description: "Resource property name to filter to and the value to apply." + properties: + property: + type: "string" + description: "propertyName" + additionalProperties: + type: "string" diff --git a/spec/FilterExtension/FilterExtensionSpec.php b/spec/FilterExtension/FilterExtensionSpec.php new file mode 100644 index 000000000..1e988820f --- /dev/null +++ b/spec/FilterExtension/FilterExtensionSpec.php @@ -0,0 +1,38 @@ +shouldHaveType(FilterExtension::class); + } + + function it_should_apply_filters(FilterInterface $filter, QueryBuilder $queryBuilder) + { + $ressourceClass = Product::class; + $filterConditions = ['boolean'=>['attribute'=>true]]; + + $filter->applyFilter($filterConditions, $ressourceClass, $queryBuilder)->shouldBeCalled(); + $this->addFilter($filter); + $this->applyFilters($queryBuilder, $ressourceClass, $filterConditions); + } + + function it_should_not_apply_anything(FilterInterface $filter, QueryBuilder $queryBuilder) + { + $ressourceClass = Product::class; + $filterConditions = ['boolean'=>['attribute'=>true]]; + + $filter->applyFilter($filterConditions, $ressourceClass, $queryBuilder)->shouldNotBeCalled(); + $this->applyFilters($queryBuilder, $ressourceClass, $filterConditions); + } +} diff --git a/spec/FilterExtension/Filters/BooleanFilterSpec.php b/spec/FilterExtension/Filters/BooleanFilterSpec.php new file mode 100644 index 000000000..b463a8e10 --- /dev/null +++ b/spec/FilterExtension/Filters/BooleanFilterSpec.php @@ -0,0 +1,83 @@ +beConstructedWith($managerRegistry, $logger); + } + + function it_is_initializable() + { + $this->shouldHaveType(BooleanFilter::class); + } + + function it_should_ignore_a_non_boolean_condition(QueryBuilder $queryBuilder) + { + $conditions = ['search'=>['enabled'=>true]]; + $resourceClass = Product::class; + + $queryBuilder->getRootAliases()->shouldNotBeCalled(); + + $this->applyFilter($conditions, $resourceClass, $queryBuilder); + } + + function it_should_apply_a_boolean_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder) + { + $conditions = ['boolean'=>['enabled'=>true]]; + $resourceClass = Product::class; + + + // is property an association false + $classMetadata->hasAssociation('enabled')->willReturn(false); + // yes, this is a boolean + $classMetadata->getTypeOfField('enabled')->willReturn('boolean'); + // is property mapped true + $classMetadata->hasField('enabled')->willReturn(true); + + $om->getClassMetadata($resourceClass)->willReturn($classMetadata); + $managerRegistry->getManagerForClass($resourceClass)->willReturn($om); + + $this->beConstructedWith($managerRegistry, $logger); + $queryBuilder->getRootAliases()->shouldBeCalled(); + $queryBuilder->andWhere(".enabled = :enabled_p1")->shouldBeCalled()->willReturn($queryBuilder); + $queryBuilder->setParameter("enabled_p1", true)->shouldBeCalled(); + + $this->applyFilter($conditions, $resourceClass, $queryBuilder); + } + + function it_should_ignore_an_invalid_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder) + { + $conditions = ['boolean'=>['enabled'=>'invalid']]; + $resourceClass = Product::class; + + + // is property an association false + $classMetadata->hasAssociation('enabled')->willReturn(false); + // yes, this is a boolean + $classMetadata->getTypeOfField('enabled')->willReturn('boolean'); + // is property mapped true + $classMetadata->hasField('enabled')->willReturn(true); + + $om->getClassMetadata($resourceClass)->willReturn($classMetadata); + $managerRegistry->getManagerForClass($resourceClass)->willReturn($om); + + $this->beConstructedWith($managerRegistry, $logger); + $queryBuilder->getRootAliases()->shouldNotBeCalled(); + + $this->applyFilter($conditions, $resourceClass, $queryBuilder); + } +} diff --git a/spec/FilterExtension/Filters/SearchFilterSpec.php b/spec/FilterExtension/Filters/SearchFilterSpec.php new file mode 100644 index 000000000..22aaf0247 --- /dev/null +++ b/spec/FilterExtension/Filters/SearchFilterSpec.php @@ -0,0 +1,105 @@ +beConstructedWith($managerRegistry, $logger); + } + + function it_is_initializable() + { + $this->shouldHaveType(SearchFilter::class); + } + + function it_should_ignore_a_non_search_condition(QueryBuilder $queryBuilder) + { + $conditions = ['boolean'=>['translations.name'=>['exact'=>'Banane']]]; + $resourceClass = Product::class; + + $queryBuilder->getRootAliases()->shouldNotBeCalled(); + + $this->applyFilter($conditions, $resourceClass, $queryBuilder); + } + + function it_should_apply_a_search_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder) + { + $conditions = ['search'=>['translations.name'=>['exact'=>'Banane']]]; + $resourceClass = Product::class; + + // is property an association true + $classMetadata->hasAssociation('translations')->willReturn(true); + $classMetadata->hasAssociation('name')->willReturn(false); + + $classMetadata->getAssociationTargetClass('translations')->willReturn(TranslationInterface::class); + + // yes, this is a string + $classMetadata->getTypeOfField('translations.name')->willReturn('string'); + + // is property mapped true + $classMetadata->hasField('translations.name')->willReturn(true); + $classMetadata->hasField('name')->willReturn(true); + $classMetadata->getTypeOfField('name')->willReturn('string'); + + $om->getClassMetadata($resourceClass)->willReturn($classMetadata); + $om->getClassMetadata(TranslationInterface::class)->willReturn($classMetadata); + + $managerRegistry->getManagerForClass(TranslationInterface::class)->willReturn($om); + $managerRegistry->getManagerForClass($resourceClass)->willReturn($om); + + $this->beConstructedWith($managerRegistry, $logger); + $queryBuilder->getRootAliases()->shouldBeCalled()->willReturn('o'); + $queryBuilder->getDQLPart('join')->shouldBeCalled()->willReturn([]); + $queryBuilder->innerJoin('o.translations', 'translations_a1', null, null)->shouldBeCalled(); + $queryBuilder->andWhere('translations_a1.name = :name_p1')->shouldBeCalled()->willReturn($queryBuilder); + $queryBuilder->setParameter("name_p1", "Banane")->shouldBeCalled()->willReturn($queryBuilder); + + $this->applyFilter($conditions, $resourceClass, $queryBuilder); + } + + function it_should_ignore_an_invalid_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder) + { + $conditions = ['search'=>['translations.name'=>['invalid'=>[]]]]; + $resourceClass = Product::class; + + // is property an association true + $classMetadata->hasAssociation('translations')->willReturn(true); + $classMetadata->hasAssociation('name')->willReturn(false); + + $classMetadata->getAssociationTargetClass('translations')->willReturn(TranslationInterface::class); + + // yes, this is a string + $classMetadata->getTypeOfField('translations.name')->willReturn('string'); + + // is property mapped true + $classMetadata->hasField('translations.name')->willReturn(true); + $classMetadata->hasField('name')->willReturn(true); + + $om->getClassMetadata($resourceClass)->willReturn($classMetadata); + $om->getClassMetadata(TranslationInterface::class)->willReturn($classMetadata); + + $managerRegistry->getManagerForClass(TranslationInterface::class)->willReturn($om); + $managerRegistry->getManagerForClass($resourceClass)->willReturn($om); + + $this->beConstructedWith($managerRegistry, $logger); + $queryBuilder->getRootAliases()->shouldBeCalled()->willReturn('o'); + $queryBuilder->getDQLPart('join')->shouldBeCalled()->willReturn([]); + $queryBuilder->innerJoin("o.translations", "translations_a1", null, null)->shouldBeCalled(); + + $this->applyFilter($conditions, $resourceClass, $queryBuilder); + } +} diff --git a/src/Controller/Product/ShowLatestProductAction.php b/src/Controller/Product/ShowLatestProductAction.php index 362f4332c..bdae78611 100644 --- a/src/Controller/Product/ShowLatestProductAction.php +++ b/src/Controller/Product/ShowLatestProductAction.php @@ -1,4 +1,5 @@ attributes->get('code'), $request->query->get('channel'), new PaginatorDetails($request->attributes->get('_route'), $request->query->all()), - $request->query->get('locale') + $request->query->get('locale'), + $request->query->get('filters') ), Response::HTTP_OK)); } catch (\InvalidArgumentException $exception) { throw new NotFoundHttpException($exception->getMessage()); diff --git a/src/Controller/Product/ShowProductCatalogByTaxonSlugAction.php b/src/Controller/Product/ShowProductCatalogByTaxonSlugAction.php index 6ce3a135b..2122333f7 100644 --- a/src/Controller/Product/ShowProductCatalogByTaxonSlugAction.php +++ b/src/Controller/Product/ShowProductCatalogByTaxonSlugAction.php @@ -39,7 +39,8 @@ public function __invoke(Request $request): Response $request->attributes->get('taxonSlug'), $request->query->get('channel'), new PaginatorDetails($request->attributes->get('_route'), $request->query->all()), - $request->query->get('locale') + $request->query->get('locale'), + $request->query->get('filters') ), Response::HTTP_OK)); } catch (\InvalidArgumentException $exception) { throw new NotFoundHttpException($exception->getMessage()); diff --git a/src/DependencyInjection/Compiler/FiltersDefinitionPass.php b/src/DependencyInjection/Compiler/FiltersDefinitionPass.php new file mode 100644 index 000000000..7fa332951 --- /dev/null +++ b/src/DependencyInjection/Compiler/FiltersDefinitionPass.php @@ -0,0 +1,31 @@ + + */ +class FiltersDefinitionPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + if (!$container->has('sylius.shop_api_plugin.filters.filter_extension')) { + return; + } + + $definition = $container->findDefinition('sylius.shop_api_plugin.filters.filter_extension'); + $taggedServices = $container->findTaggedServiceIds('sylius.shop_api_plugin.filter'); + + foreach ($taggedServices as $id => $tags) { + $definition->addMethodCall('addFilter', [new Reference($id)]); + } + } +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..3aac9a164 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,14 @@ + + */ +class InvalidArgumentException extends \InvalidArgumentException +{ +} diff --git a/src/FilterExtension/FilterExtension.php b/src/FilterExtension/FilterExtension.php new file mode 100644 index 000000000..897d06ad6 --- /dev/null +++ b/src/FilterExtension/FilterExtension.php @@ -0,0 +1,60 @@ + + */ +class FilterExtension implements FilterExtensionInterface +{ + /** + * @var ArrayCollection + */ + private $filters; + + public function __construct() + { + $this->filters = new ArrayCollection(); + } + + /** + * Add a filter + * + * @param FilterInterface $filter + * + * @internal + */ + public function addFilter(FilterInterface $filter) + { + if ($this->filters->contains($filter)) { + return; + } + + $this->filters->add($filter); + } + + /** + * Applies the filters. + * + * {@inheritdoc} + */ + public function applyFilters(QueryBuilder $queryBuilder, string $resourceClass, ?array $filterConditions): void + { + if (empty($filterConditions)) { + return; + } + + foreach ($this->filters as $filter) { + /** @var FilterInterface $filter */ + $filter->applyFilter($filterConditions, $resourceClass, $queryBuilder); + } + } +} diff --git a/src/FilterExtension/FilterExtensionInterface.php b/src/FilterExtension/FilterExtensionInterface.php new file mode 100644 index 000000000..69fc52649 --- /dev/null +++ b/src/FilterExtension/FilterExtensionInterface.php @@ -0,0 +1,22 @@ + + */ +interface FilterExtensionInterface +{ + /** + * @param QueryBuilder $queryBuilder + * @param string $resourceClass + * @param array|null $filterConditions + */ + public function applyFilters(QueryBuilder $queryBuilder, string $resourceClass, ?array $filterConditions): void; +} diff --git a/src/FilterExtension/Filters/AbstractFilter.php b/src/FilterExtension/Filters/AbstractFilter.php new file mode 100644 index 000000000..38e7336c9 --- /dev/null +++ b/src/FilterExtension/Filters/AbstractFilter.php @@ -0,0 +1,217 @@ + + */ +abstract class AbstractFilter implements FilterInterface +{ + /** + * @var array + */ + protected $conditions; + /** + * @var LoggerInterface + */ + protected $logger; + /** + * @var ManagerRegistry + */ + protected $managerRegistry; + /** + * @var array + */ + protected $properties; + + /** + * @param LoggerInterface $logger + * @param ManagerRegistry $managerRegistry , + */ + public function __construct(ManagerRegistry $managerRegistry, LoggerInterface $logger = null) + { + $this->logger = $logger; + $this->managerRegistry = $managerRegistry; + } + + /** + * Adds the necessary joins for a nested property. + * + * @param string $property + * @param string $rootAlias + * @param QueryBuilder $queryBuilder + * @param QueryNameGeneratorInterface $queryNameGenerator + * @param string $resourceClass + * + * @throws InvalidArgumentException If property is not nested + * + * @return array An array where the first element is the join $alias of the leaf entity, + * the second element is the $field name + * the third element is the $associations array + */ + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass): array + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + + foreach ($propertyParts['associations'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association); + $parentAlias = $alias; + } + + if (!isset($alias)) { + throw new InvalidArgumentException(sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; + } + + /** + * Determines whether the given property is mapped. + * + * @param string $property + * @param string $resourceClass + * @param bool $allowAssociation + * + * @return bool + */ + protected function isPropertyMapped(string $property, string $resourceClass, bool $allowAssociation = false): bool + { + if ($this->isPropertyNested($property, $resourceClass)) { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); + $property = $propertyParts['field']; + } else { + $metadata = $this->getClassMetadata($resourceClass); + } + + return $metadata->hasField($property) || ($allowAssociation && $metadata->hasAssociation($property)); + } + + /** + * Gets class metadata for the given resource. + * + * @param string $resourceClass + * + * @return ClassMetadata + */ + protected function getClassMetadata(string $resourceClass): ClassMetadata + { + return $this + ->managerRegistry + ->getManagerForClass($resourceClass) + ->getClassMetadata($resourceClass); + } + + /** + * Gets nested class metadata for the given resource. + * + * @param string $resourceClass + * @param string[] $associations + * + * @return ClassMetadata + */ + protected function getNestedMetadata(string $resourceClass, array $associations): ClassMetadata + { + $metadata = $this->getClassMetadata($resourceClass); + + foreach ($associations as $association) { + if ($metadata->hasAssociation($association)) { + $associationClass = $metadata->getAssociationTargetClass($association); + + $metadata = $this + ->managerRegistry + ->getManagerForClass($associationClass) + ->getClassMetadata($associationClass); + } + } + + return $metadata; + } + + /** + * Determines whether the given property is nested. + * + * @param string $property + * + * @return bool + */ + protected function isPropertyNested(string $property, string $resourceClass): bool + { + if (false === $pos = strpos($property, '.')) { + return false; + } + + return null !== $resourceClass && $this->getClassMetadata($resourceClass)->hasAssociation(substr($property, 0, $pos)); + } + + /** + * Splits the given property into parts. + * + * Returns an array with the following keys: + * - associations: array of associations according to nesting order + * - field: string holding the actual field (leaf node) + * + * @param string $property + * + * @return array + */ + protected function splitPropertyParts(string $property, string $resourceClass): array + { + $parts = explode('.', $property); + + if (!isset($resourceClass)) { + return [ + 'associations' => \array_slice($parts, 0, -1), + 'field' => end($parts), + ]; + } + + $metadata = $this->getClassMetadata($resourceClass); + $slice = 0; + + foreach ($parts as $part) { + if ($metadata->hasAssociation($part)) { + $metadata = $this->getClassMetadata($metadata->getAssociationTargetClass($part)); + ++$slice; + } + } + + if ($slice === \count($parts)) { + --$slice; + } + + return [ + 'associations' => \array_slice($parts, 0, $slice), + 'field' => implode('.', \array_slice($parts, $slice)), + ]; + } + + /** + * Gets the Doctrine Type of a given property/resourceClass. + * + * @param string $property + * @param string $resourceClass + * + * @return Type|string|null + */ + protected function getDoctrineFieldType(string $property, string $resourceClass) + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); + + return $metadata->getTypeOfField($propertyParts['field']); + } +} diff --git a/src/FilterExtension/Filters/BooleanFilter.php b/src/FilterExtension/Filters/BooleanFilter.php new file mode 100644 index 000000000..702c5dc97 --- /dev/null +++ b/src/FilterExtension/Filters/BooleanFilter.php @@ -0,0 +1,103 @@ + + * @author Amrouche Hamza + * @author Teoh Han Hui + */ +class BooleanFilter extends AbstractFilter +{ + /** + * Determines whether the given property refers to a boolean field. + * + * @param string $property + * @param string $resourceClass + * + * @return bool + */ + protected function isBooleanField(string $property, string $resourceClass): bool + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); + + return DBALType::BOOLEAN === $metadata->getTypeOfField($propertyParts['field']); + } + + /** + * Applies the filter. + * + * @param array $conditions + * @param string $resourceClass + * @param QueryBuilder $queryBuilder + */ + public function applyFilter(array $conditions, string $resourceClass, QueryBuilder $queryBuilder): void + { + if (empty($conditions['boolean'])) { + return; + } + + foreach ($conditions['boolean'] as $property => $value) { + $queryNameGenerator = new QueryNameGenerator(); + + if ( + !$this->isPropertyMapped($property, $resourceClass) || + !$this->isBooleanField($property, $resourceClass) + ) { + continue; + } + + if (\in_array($value, [true, 'true', '1'], true)) { + $value = true; + } elseif (\in_array($value, [false, 'false', '0'], true)) { + $value = false; + } else { + $this->logger->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $property, implode('" | "', [ + 'true', + 'false', + '1', + '0', + ]))), + ]); + + continue; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); + } + + $valueParameter = $queryNameGenerator->generateParameterName($field); + + $queryBuilder + ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + } + } +} diff --git a/src/FilterExtension/Filters/FilterInterface.php b/src/FilterExtension/Filters/FilterInterface.php new file mode 100644 index 000000000..3f81d72d2 --- /dev/null +++ b/src/FilterExtension/Filters/FilterInterface.php @@ -0,0 +1,22 @@ + + */ +interface FilterInterface +{ + /** + * Sets the conditions to the filter. + * + * @param mixed $conditions Tells the Filter what condition to apply. + */ + public function applyFilter(array $conditions, string $resourceClass, QueryBuilder $queryBuilder): void; +} diff --git a/src/FilterExtension/Filters/SearchFilter.php b/src/FilterExtension/Filters/SearchFilter.php new file mode 100644 index 000000000..f6546fcd3 --- /dev/null +++ b/src/FilterExtension/Filters/SearchFilter.php @@ -0,0 +1,285 @@ + + * @author Grégoire Hébert + */ +class SearchFilter extends AbstractFilter +{ + /** + * @var string Exact matching + */ + public const STRATEGY_EXACT = 'exact'; + + /** + * @var string The value must be contained in the field + */ + public const STRATEGY_PARTIAL = 'partial'; + + /** + * @var string Finds fields that are starting with the value + */ + public const STRATEGY_START = 'start'; + + /** + * @var string Finds fields that are ending with the value + */ + public const STRATEGY_END = 'end'; + + /** + * @var string Finds fields that are starting with the word + */ + public const STRATEGY_WORD_START = 'word_start'; + + /** + * {@inheritdoc} + * + * @throws \invalidArgumentException + */ + // protected function applyFilter(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + public function applyFilter(array $conditions, string $resourceClass, QueryBuilder $queryBuilder): void + { + if (empty($conditions['search'])) { + return; + } + + foreach ($conditions['search'] as $property => $strategies) { + foreach ($strategies as $strategy => $value) { + $queryNameGenerator = new QueryNameGenerator(); + + if ( + null === $value || + !$this->isPropertyMapped($property, $resourceClass, true) + ) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); + $metadata = $this->getNestedMetadata($resourceClass, $associations); + } else { + $metadata = $this->getClassMetadata($resourceClass); + } + + $values = $this->normalizeValues((array) $value); + + if (empty($values)) { + $this->logger->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('At least one value is required, multiple values should be in "filter=[search][%1$s][%2$s][]=firstvalue&filter=[search][%1$s][%2$s][]=secondvalue" format', $property, $strategy)), + ]); + + return; + } + + $caseSensitive = true; + + if ($metadata->hasField($field)) { + if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) { + $this->logger->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), + ]); + + return; + } + + $strategy = $strategy ?? self::STRATEGY_EXACT; + + // prefixing the strategy with i makes it case insensitive + if (0 === strpos($strategy, 'i')) { + $strategy = substr($strategy, 1); + $caseSensitive = false; + } + + if (1 === \count($values)) { + $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive); + + return; + } + + if (self::STRATEGY_EXACT !== $strategy) { + $this->logger->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('"%s" strategy selected for "%s" property, but only "%s" strategy supports multiple values', $strategy, $property, self::STRATEGY_EXACT)), + ]); + + return; + } + + $wrapCase = $this->createWrapCase($caseSensitive); + $valueParameter = $queryNameGenerator->generateParameterName($field); + + $queryBuilder + ->andWhere(sprintf($wrapCase('%s.%s') . ' IN (:%s)', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values)); + } + + // metadata doesn't have the field, nor an association on the field + if (!$metadata->hasAssociation($field)) { + return; + } + + if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) { + $this->logger->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), + ]); + + return; + } + + $association = $field; + $valueParameter = $queryNameGenerator->generateParameterName($association); + + if ($metadata->isCollectionValuedAssociation($association)) { + $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $association); + $associationField = 'id'; + } else { + $associationAlias = $alias; + $associationField = $field; + } + + if (1 === \count($values)) { + $queryBuilder + ->andWhere(sprintf('%s.%s = :%s', $associationAlias, $associationField, $valueParameter)) + ->setParameter($valueParameter, $values[0]); + } else { + $queryBuilder + ->andWhere(sprintf('%s.%s IN (:%s)', $associationAlias, $associationField, $valueParameter)) + ->setParameter($valueParameter, $values); + } + } + } + } + + /** + * Adds where clause according to the strategy. + * + * @param string $strategy + * @param QueryBuilder $queryBuilder + * @param QueryNameGeneratorInterface $queryNameGenerator + * @param string $alias + * @param string $field + * @param mixed $value + * @param bool $caseSensitive + * + * @throws InvalidArgumentException If strategy does not exist + */ + protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $value, bool $caseSensitive) + { + $wrapCase = $this->createWrapCase($caseSensitive); + $valueParameter = $queryNameGenerator->generateParameterName($field); + + switch ($strategy) { + case null: + case self::STRATEGY_EXACT: + $queryBuilder + ->andWhere(sprintf($wrapCase('%s.%s') . ' = ' . $wrapCase(':%s'), $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + case self::STRATEGY_PARTIAL: + $queryBuilder + ->andWhere(sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + case self::STRATEGY_START: + $queryBuilder + ->andWhere(sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + case self::STRATEGY_END: + $queryBuilder + ->andWhere(sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + case self::STRATEGY_WORD_START: + $queryBuilder + ->andWhere(sprintf($wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(:%3$s, \'%%\')') . ' OR ' . $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'), $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + default: + throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)); + } + } + + /** + * Creates a function that will wrap a Doctrine expression according to the + * specified case sensitivity. + * + * For example, "o.name" will get wrapped into "LOWER(o.name)" when $caseSensitive + * is false. + * + * @param bool $caseSensitive + * + * @return \Closure + */ + protected function createWrapCase(bool $caseSensitive): \Closure + { + return function (string $expr) use ($caseSensitive): string { + if ($caseSensitive) { + return $expr; + } + + return sprintf('LOWER(%s)', $expr); + }; + } + + /** + * Normalize the values array. + * + * @param array $values + * + * @return array + */ + protected function normalizeValues(array $values): array + { + foreach ($values as $key => $value) { + if (!\is_int($key) || !\is_string($value)) { + unset($values[$key]); + } + } + + return array_values($values); + } + + /** + * When the field should be an integer, check that the given value is a valid one. + * + * @param array $values + * @param Type|string $type + * + * @return bool + */ + protected function hasValidValues(array $values, $type = null): bool + { + foreach ($values as $key => $value) { + if (Type::INTEGER === $type && null !== $value && false === filter_var($value, FILTER_VALIDATE_INT)) { + return false; + } + } + + return true; + } +} diff --git a/src/FilterExtension/Util/QueryBuilderHelper.php b/src/FilterExtension/Util/QueryBuilderHelper.php new file mode 100644 index 000000000..f2ad51027 --- /dev/null +++ b/src/FilterExtension/Util/QueryBuilderHelper.php @@ -0,0 +1,71 @@ + + * + * @internal + */ +final class QueryBuilderHelper +{ + private function __construct() + { + } + + /** + * Adds a join to the queryBuilder if none exists. + */ + public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association, string $joinType = null, string $conditionType = null, string $condition = null): string + { + $join = self::getExistingJoin($queryBuilder, $alias, $association); + + if (null !== $join) { + return $join->getAlias(); + } + + $associationAlias = $queryNameGenerator->generateJoinAlias($association); + $query = "$alias.$association"; + + if (Join::LEFT_JOIN === $joinType || QueryChecker::hasLeftJoin($queryBuilder)) { + $queryBuilder->leftJoin($query, $associationAlias, $conditionType, $condition); + } else { + $queryBuilder->innerJoin($query, $associationAlias, $conditionType, $condition); + } + + return $associationAlias; + } + + /** + * Get the existing join from queryBuilder DQL parts. + * + * @return Join|null + */ + private static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association) + { + $parts = $queryBuilder->getDQLPart('join'); + $rootAlias = $queryBuilder->getRootAliases()[0]; + + if (!isset($parts[$rootAlias])) { + return null; + } + + foreach ($parts[$rootAlias] as $join) { + /** @var Join $join */ + if (sprintf('%s.%s', $alias, $association) === $join->getJoin()) { + return $join; + } + } + + return null; + } +} diff --git a/src/FilterExtension/Util/QueryChecker.php b/src/FilterExtension/Util/QueryChecker.php new file mode 100644 index 000000000..3bfc91d2c --- /dev/null +++ b/src/FilterExtension/Util/QueryChecker.php @@ -0,0 +1,200 @@ + + * @author Vincent Chalamon + */ +final class QueryChecker +{ + private function __construct() + { + } + + /** + * Determines whether the query builder uses a HAVING clause. + * + * @param QueryBuilder $queryBuilder + * + * @return bool + */ + public static function hasHavingClause(QueryBuilder $queryBuilder): bool + { + return !empty($queryBuilder->getDQLPart('having')); + } + + /** + * Determines whether the query builder has any root entity with foreign key identifier. + * + * @param QueryBuilder $queryBuilder + * @param ManagerRegistry $managerRegistry + * + * @return bool + */ + public static function hasRootEntityWithForeignKeyIdentifier(QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): bool + { + return self::hasRootEntityWithIdentifier($queryBuilder, $managerRegistry, true); + } + + /** + * Determines whether the query builder has any composite identifier. + * + * @param QueryBuilder $queryBuilder + * @param ManagerRegistry $managerRegistry + * + * @return bool + */ + public static function hasRootEntityWithCompositeIdentifier(QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): bool + { + return self::hasRootEntityWithIdentifier($queryBuilder, $managerRegistry, false); + } + + /** + * Detects if the root entity has the given identifier. + * + * @param QueryBuilder $queryBuilder + * @param ManagerRegistry $managerRegistry + * @param bool $isForeign + * + * @return bool + */ + private static function hasRootEntityWithIdentifier(QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry, bool $isForeign): bool + { + foreach ($queryBuilder->getRootEntities() as $rootEntity) { + $rootMetadata = $managerRegistry + ->getManagerForClass($rootEntity) + ->getClassMetadata($rootEntity); + + if ($rootMetadata instanceof ClassMetadata && ($isForeign ? $rootMetadata->isIdentifierComposite : $rootMetadata->containsForeignIdentifier)) { + return true; + } + } + + return false; + } + + /** + * Determines whether the query builder has the maximum number of results specified. + * + * @param QueryBuilder $queryBuilder + * + * @return bool + */ + public static function hasMaxResults(QueryBuilder $queryBuilder): bool + { + return null !== $queryBuilder->getMaxResults(); + } + + /** + * Determines whether the query builder has ORDER BY on entity joined through + * to-many association. + * + * @param QueryBuilder $queryBuilder + * @param ManagerRegistry $managerRegistry + * + * @return bool + */ + public static function hasOrderByOnToManyJoin(QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): bool + { + if ( + empty($orderByParts = $queryBuilder->getDQLPart('orderBy')) || + empty($joinParts = $queryBuilder->getDQLPart('join')) + ) { + return false; + } + + $orderByAliases = []; + foreach ($orderByParts as $orderBy) { + $parts = QueryJoinParser::getOrderByParts($orderBy); + + foreach ($parts as $part) { + if (false !== ($pos = strpos($part, '.'))) { + $alias = substr($part, 0, $pos); + + $orderByAliases[$alias] = true; + } + } + } + + if (empty($orderByAliases)) { + return false; + } + + foreach ($joinParts as $joins) { + foreach ($joins as $join) { + $alias = QueryJoinParser::getJoinAlias($join); + + if (!isset($orderByAliases[$alias])) { + continue; + } + $relationship = QueryJoinParser::getJoinRelationship($join); + + if (false !== strpos($relationship, '.')) { + /* + * We select the parent alias because it may differ from the origin alias given above + * @see https://github.com/api-platform/core/issues/1313 + */ + [$relationAlias, $association] = explode('.', $relationship); + $metadata = QueryJoinParser::getClassMetadataFromJoinAlias($relationAlias, $queryBuilder, $managerRegistry); + if ($metadata->isCollectionValuedAssociation($association)) { + return true; + } + } else { + $parentMetadata = $managerRegistry->getManagerForClass($relationship)->getClassMetadata($relationship); + + foreach ($queryBuilder->getRootEntities() as $rootEntity) { + $rootMetadata = $managerRegistry + ->getManagerForClass($rootEntity) + ->getClassMetadata($rootEntity); + + if (!$rootMetadata instanceof ClassMetadata) { + continue; + } + + foreach ($rootMetadata->getAssociationsByTargetClass($relationship) as $association => $mapping) { + if ($parentMetadata->isCollectionValuedAssociation($association)) { + return true; + } + } + } + } + } + } + + return false; + } + + /** + * Determines whether the query builder already has a left join. + * + * @param QueryBuilder $queryBuilder + * + * @return bool + */ + public static function hasLeftJoin(QueryBuilder $queryBuilder): bool + { + foreach ($queryBuilder->getDQLPart('join') as $dqlParts) { + foreach ($dqlParts as $dqlPart) { + if (Join::LEFT_JOIN === $dqlPart->getJoinType()) { + return true; + } + } + } + + return false; + } +} diff --git a/src/FilterExtension/Util/QueryJoinParser.php b/src/FilterExtension/Util/QueryJoinParser.php new file mode 100644 index 000000000..92530e965 --- /dev/null +++ b/src/FilterExtension/Util/QueryJoinParser.php @@ -0,0 +1,164 @@ + + * @author Vincent Chalamon + */ +final class QueryJoinParser +{ + private function __construct() + { + } + + /** + * Gets the class metadata from a given join alias. + * + * @param string $alias + * @param QueryBuilder $queryBuilder + * @param ManagerRegistry $managerRegistry + * + * @return ClassMetadata + */ + public static function getClassMetadataFromJoinAlias(string $alias, QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): ClassMetadata + { + $rootEntities = $queryBuilder->getRootEntities(); + $rootAliases = $queryBuilder->getRootAliases(); + + $joinParts = $queryBuilder->getDQLPart('join'); + + $aliasMap = []; + $targetAlias = $alias; + + foreach ($joinParts as $rootAlias => $joins) { + $aliasMap[$rootAlias] = 'root'; + + foreach ($joins as $join) { + $alias = self::getJoinAlias($join); + $relationship = self::getJoinRelationship($join); + + $pos = strpos($relationship, '.'); + + if (false !== $pos) { + $aliasMap[$alias] = [ + 'parentAlias' => substr($relationship, 0, $pos), + 'association' => substr($relationship, $pos + 1), + ]; + } + } + } + + $associationStack = []; + $rootAlias = null; + + while (null === $rootAlias) { + $mapping = $aliasMap[$targetAlias]; + + if ('root' === $mapping) { + $rootAlias = $targetAlias; + } else { + $associationStack[] = $mapping['association']; + $targetAlias = $mapping['parentAlias']; + } + } + + $rootEntity = $rootEntities[array_search($rootAlias, $rootAliases, true)]; + + $rootMetadata = $managerRegistry + ->getManagerForClass($rootEntity) + ->getClassMetadata($rootEntity); + + $metadata = $rootMetadata; + + while (null !== ($association = array_pop($associationStack))) { + $associationClass = $metadata->getAssociationTargetClass($association); + + $metadata = $managerRegistry + ->getManagerForClass($associationClass) + ->getClassMetadata($associationClass); + } + + return $metadata; + } + + /** + * Gets the relationship from a Join expression. + * + * @param Join $join + * + * @return string + */ + public static function getJoinRelationship(Join $join): string + { + static $relationshipProperty = null; + static $initialized = false; + + if (!$initialized && !method_exists(Join::class, 'getJoin')) { + $relationshipProperty = new \ReflectionProperty(Join::class, '_join'); + $relationshipProperty->setAccessible(true); + + $initialized = true; + } + + return (null === $relationshipProperty) ? $join->getJoin() : $relationshipProperty->getValue($join); + } + + /** + * Gets the alias from a Join expression. + * + * @param Join $join + * + * @return string + */ + public static function getJoinAlias(Join $join): string + { + static $aliasProperty = null; + static $initialized = false; + + if (!$initialized && !method_exists(Join::class, 'getAlias')) { + $aliasProperty = new \ReflectionProperty(Join::class, '_alias'); + $aliasProperty->setAccessible(true); + + $initialized = true; + } + + return (null === $aliasProperty) ? $join->getAlias() : $aliasProperty->getValue($join); + } + + /** + * Gets the parts from an OrderBy expression. + * + * @param OrderBy $orderBy + * + * @return string[] + */ + public static function getOrderByParts(OrderBy $orderBy): array + { + static $partsProperty = null; + static $initialized = false; + + if (!$initialized && !method_exists(OrderBy::class, 'getParts')) { + $partsProperty = new \ReflectionProperty(OrderBy::class, '_parts'); + $partsProperty->setAccessible(true); + + $initialized = true; + } + + return (null === $partsProperty) ? $orderBy->getParts() : $partsProperty->getValue($orderBy); + } +} diff --git a/src/FilterExtension/Util/QueryNameGenerator.php b/src/FilterExtension/Util/QueryNameGenerator.php new file mode 100644 index 000000000..3570b267a --- /dev/null +++ b/src/FilterExtension/Util/QueryNameGenerator.php @@ -0,0 +1,38 @@ + + * @author Vincent Chalamon + * @author Amrouche Hamza + */ +final class QueryNameGenerator implements QueryNameGeneratorInterface +{ + private $incrementedAssociation = 1; + private $incrementedName = 1; + + /** + * {@inheritdoc} + */ + public function generateJoinAlias(string $association): string + { + return sprintf('%s_a%d', $association, $this->incrementedAssociation++); + } + + /** + * {@inheritdoc} + */ + public function generateParameterName(string $name): string + { + return sprintf('%s_p%d', str_replace('.', '_', $name), $this->incrementedName++); + } +} diff --git a/src/FilterExtension/Util/QueryNameGeneratorInterface.php b/src/FilterExtension/Util/QueryNameGeneratorInterface.php new file mode 100644 index 000000000..259674e4c --- /dev/null +++ b/src/FilterExtension/Util/QueryNameGeneratorInterface.php @@ -0,0 +1,33 @@ + + */ +interface QueryNameGeneratorInterface +{ + /** + * Generates a cacheable alias for DQL join. + * + * @param string $association + * + * @return string + */ + public function generateJoinAlias(string $association): string; + + /** + * Generates a cacheable parameter name for DQL query. + * + * @param string $name + * + * @return string + */ + public function generateParameterName(string $name): string; +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 6bfbfd8c1..239e49c62 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -18,6 +18,7 @@ + diff --git a/src/Resources/config/services/filters.xml b/src/Resources/config/services/filters.xml new file mode 100644 index 000000000..907dbc1df --- /dev/null +++ b/src/Resources/config/services/filters.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/queries.xml b/src/Resources/config/services/queries.xml index 85fd9b45b..7f3057a82 100644 --- a/src/Resources/config/services/queries.xml +++ b/src/Resources/config/services/queries.xml @@ -19,6 +19,7 @@ + diff --git a/src/ShopApiPlugin.php b/src/ShopApiPlugin.php index b8be19a75..e6596b2c7 100644 --- a/src/ShopApiPlugin.php +++ b/src/ShopApiPlugin.php @@ -5,9 +5,16 @@ namespace Sylius\ShopApiPlugin; use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; +use Sylius\ShopApiPlugin\DependencyInjection\Compiler\FiltersDefinitionPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; final class ShopApiPlugin extends Bundle { use SyliusPluginTrait; + + public function build(ContainerBuilder $container) + { + $container->addCompilerPass(new FiltersDefinitionPass()); + } } diff --git a/src/ViewRepository/ProductCatalogViewRepository.php b/src/ViewRepository/ProductCatalogViewRepository.php index 13deb2708..a10f4517b 100644 --- a/src/ViewRepository/ProductCatalogViewRepository.php +++ b/src/ViewRepository/ProductCatalogViewRepository.php @@ -14,6 +14,7 @@ use Sylius\Component\Taxonomy\Repository\TaxonRepositoryInterface; use Sylius\ShopApiPlugin\Factory\PageViewFactoryInterface; use Sylius\ShopApiPlugin\Factory\ProductViewFactoryInterface; +use Sylius\ShopApiPlugin\FilterExtension\FilterExtensionInterface; use Sylius\ShopApiPlugin\Model\PaginatorDetails; use Sylius\ShopApiPlugin\View\PageView; use Webmozart\Assert\Assert; @@ -35,21 +36,26 @@ final class ProductCatalogViewRepository implements ProductCatalogViewRepository /** @var PageViewFactoryInterface */ private $pageViewFactory; + /** @var FilterExtensionInterface */ + private $filterExtension; + public function __construct( ChannelRepositoryInterface $channelRepository, ProductRepositoryInterface $productRepository, TaxonRepositoryInterface $taxonRepository, ProductViewFactoryInterface $productViewFactory, - PageViewFactoryInterface $pageViewFactory + PageViewFactoryInterface $pageViewFactory, + FilterExtensionInterface $filterExtension ) { $this->channelRepository = $channelRepository; $this->productRepository = $productRepository; $this->productViewFactory = $productViewFactory; $this->taxonRepository = $taxonRepository; $this->pageViewFactory = $pageViewFactory; + $this->filterExtension = $filterExtension; } - public function findByTaxonSlug(string $taxonSlug, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode): PageView + public function findByTaxonSlug(string $taxonSlug, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode, $filters = []): PageView { $channel = $this->getChannel($channelCode); $localeCode = $this->getLocaleCode($localeCode, $channel); @@ -60,10 +66,10 @@ public function findByTaxonSlug(string $taxonSlug, string $channelCode, Paginato Assert::notNull($taxon, sprintf('Taxon with slug %s in locale %s has not been found', $taxonSlug, $localeCode)); $paginatorDetails->addToParameters('taxonSlug', $taxonSlug); - return $this->findByTaxon($taxon, $channel, $paginatorDetails, $localeCode); + return $this->findByTaxon($taxon, $channel, $paginatorDetails, $localeCode, $filters); } - public function findByTaxonCode(string $taxonCode, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode): PageView + public function findByTaxonCode(string $taxonCode, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode, $filters = []): PageView { $channel = $this->getChannel($channelCode); $localeCode = $this->getLocaleCode($localeCode, $channel); @@ -74,7 +80,7 @@ public function findByTaxonCode(string $taxonCode, string $channelCode, Paginato Assert::notNull($taxon, sprintf('Taxon with code %s has not been found', $taxonCode)); $paginatorDetails->addToParameters('code', $taxonCode); - return $this->findByTaxon($taxon, $channel, $paginatorDetails, $localeCode); + return $this->findByTaxon($taxon, $channel, $paginatorDetails, $localeCode, $filters); } /** @@ -109,9 +115,10 @@ private function getLocaleCode(?string $localeCode, ChannelInterface $channel): return $localeCode; } - private function findByTaxon(TaxonInterface $taxon, ChannelInterface $channel, PaginatorDetails $paginatorDetails, string $localeCode): PageView + private function findByTaxon(TaxonInterface $taxon, ChannelInterface $channel, PaginatorDetails $paginatorDetails, string $localeCode, $filters = []): PageView { $queryBuilder = $this->productRepository->createShopListQueryBuilder($channel, $taxon, $localeCode); + $this->filterExtension->applyFilters($queryBuilder, $this->productRepository->getClassName(), $filters); $pagerfanta = new Pagerfanta(new DoctrineORMAdapter($queryBuilder)); diff --git a/src/ViewRepository/ProductCatalogViewRepositoryInterface.php b/src/ViewRepository/ProductCatalogViewRepositoryInterface.php index 130f608d1..1cee3b3bc 100644 --- a/src/ViewRepository/ProductCatalogViewRepositoryInterface.php +++ b/src/ViewRepository/ProductCatalogViewRepositoryInterface.php @@ -9,7 +9,7 @@ interface ProductCatalogViewRepositoryInterface { - public function findByTaxonSlug(string $taxonSlug, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode): PageView; + public function findByTaxonSlug(string $taxonSlug, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode, $filters = []): PageView; - public function findByTaxonCode(string $taxonCode, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode): PageView; + public function findByTaxonCode(string $taxonCode, string $channelCode, PaginatorDetails $paginatorDetails, ?string $localeCode, $filters = []): PageView; } diff --git a/src/ViewRepository/ProductLatestViewRepository.php b/src/ViewRepository/ProductLatestViewRepository.php index 4dcff82f4..4d1583fbe 100644 --- a/src/ViewRepository/ProductLatestViewRepository.php +++ b/src/ViewRepository/ProductLatestViewRepository.php @@ -17,7 +17,7 @@ final class ProductLatestViewRepository implements ProductLatestViewRepositoryIn /** @var ChannelRepositoryInterface */ private $channelRepository; - /** @var ProductRepositoryInterface */ + /** @var ProductRepositoryInterface */ private $productRepository; /** @var ProductViewFactoryInterface */ @@ -72,7 +72,7 @@ private function getLocaleCode(?string $localeCode, ChannelInterface $channel): * @param string $localeCode * @param iterable|LocaleInterface[] $supportedLocales */ - private function assertLocaleSupport(string $localeCode, iterable $supportedLocales):void + private function assertLocaleSupport(string $localeCode, iterable $supportedLocales): void { $supportedLocaleCodes = []; foreach ($supportedLocales as $locale) { diff --git a/tests/Controller/ProductShowCatalogByCodeApiTest.php b/tests/Controller/ProductShowCatalogByCodeApiTest.php index 9fefbfae6..555e04c3e 100644 --- a/tests/Controller/ProductShowCatalogByCodeApiTest.php +++ b/tests/Controller/ProductShowCatalogByCodeApiTest.php @@ -60,4 +60,72 @@ public function it_shows_second_page_of_paginated_products_from_some_taxon_by_co $this->assertResponse($response, 'product/limited_product_list_page_by_code_response', Response::HTTP_OK); } + + /** + * @test + * @group filtered + */ + public function it_shows_paginated_products_from_some_taxon_by_code_boolean_filtered() + { + $this->loadFixturesFromFile('shop.yml'); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[boolean][variants.shippingRequired]=false', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_boolean_filtered_false_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&&filters[boolean][variants.shippingRequired]=true', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_boolean_filtered_true_response', Response::HTTP_OK); + } + + /** + * @test + * @group filtered + */ + public function it_shows_paginated_products_from_some_taxon_by_code_string_filtered() + { + $this->loadFixturesFromFile('shop.yml'); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][partial][]=bana', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_partial_filtered_true_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][exact][]=Banane', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_exact_filtered_true_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][start][]=Ban', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_start_filtered_true_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][end][]=ane', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_end_filtered_true_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][partial][]=erry', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_partial_filtered_false_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][exact][]=Cherry', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_exact_filtered_false_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][start][]=Che', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_start_filtered_false_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products/FRUITS?channel=WEB_FR&filters[search][translations.name][end][]=rry', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_code_search_end_filtered_false_response', Response::HTTP_OK); + } } diff --git a/tests/Controller/ProductShowCatalogBySlugApiTest.php b/tests/Controller/ProductShowCatalogBySlugApiTest.php index 1f085a56a..35fa47a1a 100644 --- a/tests/Controller/ProductShowCatalogBySlugApiTest.php +++ b/tests/Controller/ProductShowCatalogBySlugApiTest.php @@ -76,4 +76,23 @@ public function it_expose_only_some_of_products_in_the_list() $this->assertResponse($response, 'product/product_list_page_by_slug_response', Response::HTTP_OK); } + + /** + * @test + * @group filtered + */ + public function it_shows_paginated_products_from_some_taxon_by_slug_boolean_filtered() + { + $this->loadFixturesFromFile('shop.yml'); + + $this->client->request('GET', '/shop-api/taxon-products-by-slug/fruits-utile?channel=WEB_FR&filters[boolean][variants.shippingRequired]=false', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_slug_boolean_filtered_false_response', Response::HTTP_OK); + + $this->client->request('GET', '/shop-api/taxon-products-by-slug/fruits-utile?channel=WEB_FR&filters[boolean][variants.shippingRequired]=true', [], [], ['ACCEPT' => 'application/json']); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'product/product_list_by_slug_boolean_filtered_true_response', Response::HTTP_OK); + } } diff --git a/tests/DataFixtures/ORM/channel.yml b/tests/DataFixtures/ORM/channel.yml index e09b15bb4..58b15be6d 100644 --- a/tests/DataFixtures/ORM/channel.yml +++ b/tests/DataFixtures/ORM/channel.yml @@ -23,6 +23,18 @@ Sylius\Component\Core\Model\Channel: enabled: true taxCalculationStrategy: "order_items_based" accountVerificationRequired: false + fr_web_channel: + code: "WEB_FR" + name: "French Channel" + hostname: "localhost" + description: "Welcome to Lille !" + baseCurrency: "@euro" + defaultLocale: "@locale_fr_fr" + locales: ["@locale_fr_fr"] + color: "white" + enabled: true + taxCalculationStrategy: "order_items_based" + accountVerificationRequired: false Sylius\Component\Currency\Model\Currency: pound: @@ -35,3 +47,5 @@ Sylius\Component\Locale\Model\Locale: code: en_GB locale_de_de: code: de_DE + locale_fr_fr: + code: fr_FR \ No newline at end of file diff --git a/tests/DataFixtures/ORM/shop.yml b/tests/DataFixtures/ORM/shop.yml index 40a7083ab..6d5693fac 100644 --- a/tests/DataFixtures/ORM/shop.yml +++ b/tests/DataFixtures/ORM/shop.yml @@ -21,6 +21,18 @@ Sylius\Component\Core\Model\Channel: color: "blue" enabled: true taxCalculationStrategy: "order_items_based" + fr_web_channel: + code: "WEB_FR" + name: "French Channel" + hostname: "localhost" + description: "Welcome to Lille !" + baseCurrency: "@euro" + defaultLocale: "@locale_fr_fr" + locales: ["@locale_fr_fr"] + color: "white" + enabled: true + taxCalculationStrategy: "order_items_based" + accountVerificationRequired: false Sylius\Component\Currency\Model\Currency: pound: @@ -33,6 +45,8 @@ Sylius\Component\Locale\Model\Locale: code: en_GB locale_de_de: code: de_DE + locale_fr_fr: + code: fr_FR Sylius\Component\Core\Model\Product: mug: @@ -73,6 +87,15 @@ Sylius\Component\Core\Model\Product: currentLocale: "en_GB" currentTranslation: "@en_gb_shoes_product_translation" translations: ["@en_gb_shoes_product_translation"] + banana: + code: "BANANA_CODE" + channels: ["@fr_web_channel"] + + currentLocale: "fr_FR" + currentTranslation: "@fr_fr_banana_product_translation" + translations: ["@fr_fr_banana_product_translation"] + mainTaxon: "@fruits_taxon" + productTaxons: ["@fruits_product_taxon"] Sylius\Component\Core\Model\ProductTranslation: en_gb_mug_product_translation: @@ -110,6 +133,12 @@ Sylius\Component\Core\Model\ProductTranslation: name: "Logan Shoes" description: "Some description Lorem ipsum dolor sit amet." translatable: "@shoes" + fr_fr_banana_product_translation: + slug: "banana" + locale: "fr_FR" + name: "Banane" + description: "Outil de mesure." + translatable: "@banana" Sylius\Component\Core\Model\ProductVariant: mug_variant: @@ -177,6 +206,15 @@ Sylius\Component\Core\Model\ProductVariant: channelPricings: WEB_GB: "@gb_large_blue_hat_web_channel_pricing" WEB_DE: "@de_large_blue_hat_web_channel_pricing" + banana_plantain: + code: "BANANE_PLANTAIN_CODE" + product: "@banana" + currentLocale: "fr_FR" + currentTranslation: "@banana_plantain_fr_fr_translation" + translations: ["@banana_plantain_fr_fr_translation"] + shippingRequired: false + channelPricings: + WEB_FR: "@fr_banana_plantain_web_channel_pricing" Sylius\Component\Product\Model\ProductVariantTranslation: en_gb_mug_variant_translation: @@ -223,7 +261,10 @@ Sylius\Component\Product\Model\ProductVariantTranslation: locale: "de_DE" name: "Großes Blau Logan Hut" translatable: "@hat_blue_large" - + banana_plantain_fr_fr_translation: + locale: "fr_FR" + name: "Banane plantain" + translatable: "@banana_plantain" Sylius\Component\Core\Model\ChannelPricing: gb_mug_web_channel_pricing: channelCode: "WEB_GB" @@ -267,6 +308,9 @@ Sylius\Component\Core\Model\ChannelPricing: de_large_blue_hat_web_channel_pricing: channelCode: "WEB_DE" price: 2699 + fr_banana_plantain_web_channel_pricing: + channelCode: "WEB_FR" + price: 99 Sylius\Component\Product\Model\ProductOption: hat_size: @@ -431,6 +475,11 @@ Sylius\Component\Core\Model\Taxon: currentLocale: "en_GB" currentTranslation: "@en_gb_yet_another_taxon_translation" translations: ["@en_gb_yet_another_taxon_translation"] + fruits_taxon: + code: "FRUITS" + currentLocale: "fr_FR" + currentTranslation: "@fr_fr_fruits_translation" + translations: ["@fr_fr_fruits_translation"] Sylius\Component\Taxonomy\Model\TaxonTranslation: en_gb_category_translation: @@ -515,6 +564,12 @@ Sylius\Component\Taxonomy\Model\TaxonTranslation: name: "Marken" description: "Einige Beschreibung Lorem ipsum dolor sit amet." translatable: "@brand_taxon" + fr_fr_fruits_translation: + slug: "fruits-utile" + locale: "fr_FR" + name: "Fruits" + description: "Désirez vous un fruit ?" + translatable: "@fruits_taxon" Sylius\Component\Core\Model\ProductTaxon: mug_product_taxon: @@ -535,6 +590,9 @@ Sylius\Component\Core\Model\ProductTaxon: hat_brand_taxon: product: "@hat" taxon: "@brand_taxon" + fruits_product_taxon: + product: "@banana" + taxon: "@fruits_taxon" Sylius\Component\Core\Model\TaxonImage: t_shirt_taxon_thumbnail: diff --git a/tests/Responses/Expected/product/product_list_by_code_boolean_filtered_false_response.json b/tests/Responses/Expected/product/product_list_by_code_boolean_filtered_false_response.json new file mode 100644 index 000000000..eab8e9c72 --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_boolean_filtered_false_response.json @@ -0,0 +1,48 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 1, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10" + }, + "items": [ + { + "code": "BANANA_CODE", + "name": "Banane", + "slug": "banana", + "description": "Outil de mesure.", + "averageRating": 0, + "taxons": { + "main": "FRUITS", + "others": [ + "FRUITS" + ] + }, + "variants": { + "BANANE_PLANTAIN_CODE": { + "code": "BANANE_PLANTAIN_CODE", + "name": "Banane plantain", + "axis": [], + "nameAxis": {}, + "price": { + "current": 99, + "currency": "EUR" + }, + "images": [] + } + }, + "attributes": [], + "associations": [], + "images": [], + "_links": { + "self": { + "href": "\/shop-api\/products-by-slug\/banana" + } + } + } + ] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_boolean_filtered_true_response.json b/tests/Responses/Expected/product/product_list_by_code_boolean_filtered_true_response.json new file mode 100644 index 000000000..bf764c86d --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_boolean_filtered_true_response.json @@ -0,0 +1,13 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 0, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10" + }, + "items":[] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_end_filtered_false_response.json b/tests/Responses/Expected/product/product_list_by_code_search_end_filtered_false_response.json new file mode 100644 index 000000000..e7098b992 --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_end_filtered_false_response.json @@ -0,0 +1,13 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 0, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=rry&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=rry&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=rry&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=rry&page=1&limit=10" + }, + "items":[] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_end_filtered_true_response.json b/tests/Responses/Expected/product/product_list_by_code_search_end_filtered_true_response.json new file mode 100644 index 000000000..65b418317 --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_end_filtered_true_response.json @@ -0,0 +1,48 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 1, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=ane&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=ane&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=ane&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bend%5D%5B0%5D=ane&page=1&limit=10" + }, + "items": [ + { + "code": "BANANA_CODE", + "name": "Banane", + "slug": "banana", + "description": "Outil de mesure.", + "averageRating": 0, + "taxons": { + "main": "FRUITS", + "others": [ + "FRUITS" + ] + }, + "variants": { + "BANANE_PLANTAIN_CODE": { + "code": "BANANE_PLANTAIN_CODE", + "name": "Banane plantain", + "axis": [], + "nameAxis": {}, + "price": { + "current": 99, + "currency": "EUR" + }, + "images": [] + } + }, + "attributes": [], + "associations": [], + "images": [], + "_links": { + "self": { + "href": "\/shop-api\/products-by-slug\/banana" + } + } + } + ] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_exact_filtered_false_response.json b/tests/Responses/Expected/product/product_list_by_code_search_exact_filtered_false_response.json new file mode 100644 index 000000000..f9f997b5e --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_exact_filtered_false_response.json @@ -0,0 +1,13 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 0, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Cherry&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Cherry&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Cherry&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Cherry&page=1&limit=10" + }, + "items":[] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_exact_filtered_true_response.json b/tests/Responses/Expected/product/product_list_by_code_search_exact_filtered_true_response.json new file mode 100644 index 000000000..e39af6990 --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_exact_filtered_true_response.json @@ -0,0 +1,48 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 1, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Banane&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Banane&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Banane&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bexact%5D%5B0%5D=Banane&page=1&limit=10" + }, + "items": [ + { + "code": "BANANA_CODE", + "name": "Banane", + "slug": "banana", + "description": "Outil de mesure.", + "averageRating": 0, + "taxons": { + "main": "FRUITS", + "others": [ + "FRUITS" + ] + }, + "variants": { + "BANANE_PLANTAIN_CODE": { + "code": "BANANE_PLANTAIN_CODE", + "name": "Banane plantain", + "axis": [], + "nameAxis": {}, + "price": { + "current": 99, + "currency": "EUR" + }, + "images": [] + } + }, + "attributes": [], + "associations": [], + "images": [], + "_links": { + "self": { + "href": "\/shop-api\/products-by-slug\/banana" + } + } + } + ] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_partial_filtered_false_response.json b/tests/Responses/Expected/product/product_list_by_code_search_partial_filtered_false_response.json new file mode 100644 index 000000000..fa2c4c5ef --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_partial_filtered_false_response.json @@ -0,0 +1,13 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 0, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=erry&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=erry&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=erry&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=erry&page=1&limit=10" + }, + "items":[] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_partial_filtered_true_response.json b/tests/Responses/Expected/product/product_list_by_code_search_partial_filtered_true_response.json new file mode 100644 index 000000000..b91dfd817 --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_partial_filtered_true_response.json @@ -0,0 +1,48 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 1, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=bana&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=bana&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=bana&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bpartial%5D%5B0%5D=bana&page=1&limit=10" + }, + "items": [ + { + "code": "BANANA_CODE", + "name": "Banane", + "slug": "banana", + "description": "Outil de mesure.", + "averageRating": 0, + "taxons": { + "main": "FRUITS", + "others": [ + "FRUITS" + ] + }, + "variants": { + "BANANE_PLANTAIN_CODE": { + "code": "BANANE_PLANTAIN_CODE", + "name": "Banane plantain", + "axis": [], + "nameAxis": {}, + "price": { + "current": 99, + "currency": "EUR" + }, + "images": [] + } + }, + "attributes": [], + "associations": [], + "images": [], + "_links": { + "self": { + "href": "\/shop-api\/products-by-slug\/banana" + } + } + } + ] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_start_filtered_false_response.json b/tests/Responses/Expected/product/product_list_by_code_search_start_filtered_false_response.json new file mode 100644 index 000000000..9a3db966e --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_start_filtered_false_response.json @@ -0,0 +1,13 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 0, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Che&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Che&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Che&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Che&page=1&limit=10" + }, + "items":[] +} diff --git a/tests/Responses/Expected/product/product_list_by_code_search_start_filtered_true_response.json b/tests/Responses/Expected/product/product_list_by_code_search_start_filtered_true_response.json new file mode 100644 index 000000000..70d658fa3 --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_code_search_start_filtered_true_response.json @@ -0,0 +1,48 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 1, + "_links": { + "self": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Ban&page=1&limit=10", + "first": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Ban&page=1&limit=10", + "last": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Ban&page=1&limit=10", + "next": "\/shop-api\/taxon-products\/FRUITS?channel=WEB_FR&filters%5Bsearch%5D%5Btranslations.name%5D%5Bstart%5D%5B0%5D=Ban&page=1&limit=10" + }, + "items": [ + { + "code": "BANANA_CODE", + "name": "Banane", + "slug": "banana", + "description": "Outil de mesure.", + "averageRating": 0, + "taxons": { + "main": "FRUITS", + "others": [ + "FRUITS" + ] + }, + "variants": { + "BANANE_PLANTAIN_CODE": { + "code": "BANANE_PLANTAIN_CODE", + "name": "Banane plantain", + "axis": [], + "nameAxis": {}, + "price": { + "current": 99, + "currency": "EUR" + }, + "images": [] + } + }, + "attributes": [], + "associations": [], + "images": [], + "_links": { + "self": { + "href": "\/shop-api\/products-by-slug\/banana" + } + } + } + ] +} diff --git a/tests/Responses/Expected/product/product_list_by_slug_boolean_filtered_false_response.json b/tests/Responses/Expected/product/product_list_by_slug_boolean_filtered_false_response.json new file mode 100644 index 000000000..b3a5e8fda --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_slug_boolean_filtered_false_response.json @@ -0,0 +1,48 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 1, + "_links": { + "self": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10", + "first": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10", + "last": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10", + "next": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=false&page=1&limit=10" + }, + "items": [ + { + "code": "BANANA_CODE", + "name": "Banane", + "slug": "banana", + "description": "Outil de mesure.", + "averageRating": 0, + "taxons": { + "main": "FRUITS", + "others": [ + "FRUITS" + ] + }, + "variants": { + "BANANE_PLANTAIN_CODE": { + "code": "BANANE_PLANTAIN_CODE", + "name": "Banane plantain", + "axis": [], + "nameAxis": {}, + "price": { + "current": 99, + "currency": "EUR" + }, + "images": [] + } + }, + "attributes": [], + "associations": [], + "images": [], + "_links": { + "self": { + "href": "\/shop-api\/products-by-slug\/banana" + } + } + } + ] +} diff --git a/tests/Responses/Expected/product/product_list_by_slug_boolean_filtered_true_response.json b/tests/Responses/Expected/product/product_list_by_slug_boolean_filtered_true_response.json new file mode 100644 index 000000000..6f4883d24 --- /dev/null +++ b/tests/Responses/Expected/product/product_list_by_slug_boolean_filtered_true_response.json @@ -0,0 +1,13 @@ +{ + "page": 1, + "limit": 10, + "pages": 1, + "total": 0, + "_links": { + "self": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10", + "first": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10", + "last": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10", + "next": "\/shop-api\/taxon-products-by-slug\/fruits-utile?channel=WEB_FR&filters%5Bboolean%5D%5Bvariants.shippingRequired%5D=true&page=1&limit=10" + }, + "items": [] +} diff --git a/tests/Responses/Expected/taxon/all_taxons_response.json b/tests/Responses/Expected/taxon/all_taxons_response.json index 824532cfd..447fefe3a 100644 --- a/tests/Responses/Expected/taxon/all_taxons_response.json +++ b/tests/Responses/Expected/taxon/all_taxons_response.json @@ -77,5 +77,11 @@ "position": 2, "children": [], "images": [] + }, + { + "code": "FRUITS", + "position": 3, + "children": [], + "images": [] } ] diff --git a/tests/Responses/Expected/taxon/german_all_taxons_response.json b/tests/Responses/Expected/taxon/german_all_taxons_response.json index aa4b1798d..0ab4ea405 100644 --- a/tests/Responses/Expected/taxon/german_all_taxons_response.json +++ b/tests/Responses/Expected/taxon/german_all_taxons_response.json @@ -43,7 +43,7 @@ "code": "WOMEN_T_SHIRTS", "name": "Frauen T-Shirts", "slug": "frauen-t-shirts", - "description": "@string@", + "description": "Einige Beschreibung Lorem ipsum dolor sit amet.", "position": 1, "children": [], "images": [] @@ -76,5 +76,11 @@ "position": 2, "children": [], "images": [] + }, + { + "code": "FRUITS", + "position": 3, + "children": [], + "images": [] } ]