From 842030d55737e8eb15ce575c89f5449493a157d8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 Mar 2024 17:11:24 +0100 Subject: [PATCH] feat(doctrine): parameter filter extension (#6248) * feat(doctrine): parameter filtering * feat(graphql): parameter graphql arguments --- .../Filter/PropertyAwareFilterInterface.php | 27 ++++ .../Odm/Extension/ParameterExtension.php | 71 +++++++++++ src/Doctrine/Odm/Filter/AbstractFilter.php | 11 +- .../Orm/Extension/ParameterExtension.php | 70 +++++++++++ src/Doctrine/Orm/Filter/AbstractFilter.php | 11 +- src/GraphQl/Type/FieldsBuilder.php | 112 ++++++++++++++++- .../Compiler/AttributeFilterPass.php | 6 +- .../Resources/config/doctrine_mongodb_odm.xml | 49 ++++++-- .../Bundle/Resources/config/doctrine_orm.xml | 29 +++++ .../Bundle/Resources/config/graphql.xml | 5 + .../SearchFilterParameterDocument.php | 76 +++++++++++ .../Entity/SearchFilterParameter.php | 90 +++++++++++++ .../ODMSearchFilterValueTransformer.php | 42 +++++++ .../Filter/ODMSearchTextAndDateFilter.php | 46 +++++++ .../Filter/SearchFilterValueTransformer.php | 47 +++++++ .../Filter/SearchTextAndDateFilter.php | 54 ++++++++ tests/Fixtures/app/config/config_doctrine.yml | 10 ++ tests/Fixtures/app/config/config_mongodb.yml | 10 ++ tests/Functional/Parameters/DoctrineTests.php | 119 ++++++++++++++++++ 19 files changed, 866 insertions(+), 19 deletions(-) create mode 100644 src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php create mode 100644 src/Doctrine/Odm/Extension/ParameterExtension.php create mode 100644 src/Doctrine/Orm/Extension/ParameterExtension.php create mode 100644 tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php create mode 100644 tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php create mode 100644 tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php create mode 100644 tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php create mode 100644 tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php create mode 100644 tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php create mode 100644 tests/Functional/Parameters/DoctrineTests.php diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php new file mode 100644 index 00000000000..aa0857cef20 --- /dev/null +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Filter; + +/** + * @author Antoine Bluchet + * + * @experimental + */ +interface PropertyAwareFilterInterface +{ + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void; +} diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php new file mode 100644 index 00000000000..8fa4ca5db78 --- /dev/null +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Extension; + +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Psr\Container\ContainerInterface; + +/** + * Reads operation parameters and execute its filter. + * + * @author Antoine Bluchet + */ +final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface +{ + public function __construct(private readonly ContainerInterface $filterLocator) + { + } + + private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $values = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!$values) { + continue; + } + + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $filterContext = ['filters' => $values]; + $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + // update by reference + if (isset($filterContext['mongodb_odm_sort_fields'])) { + $context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields']; + } + } + } + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context); + } + + /** + * {@inheritdoc} + */ + public function applyToItem(Builder $aggregationBuilder, string $resourceClass, array $identifiers, ?Operation $operation = null, array &$context = []): void + { + $this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context); + } +} diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index d1b30add62a..87c30390c32 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\Operation; @@ -29,7 +30,7 @@ * * @author Alan Poulain */ -abstract class AbstractFilter implements FilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface { use MongoDbOdmPropertyHelperTrait; use PropertyHelperTrait; @@ -65,6 +66,14 @@ protected function getProperties(): ?array return $this->properties; } + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + protected function getLogger(): LoggerInterface { return $this->logger; diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php new file mode 100644 index 00000000000..792f311dd09 --- /dev/null +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Extension; + +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Psr\Container\ContainerInterface; + +/** + * Reads operation parameters and execute its filter. + * + * @author Antoine Bluchet + */ +final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface +{ + public function __construct(private readonly ContainerInterface $filterLocator) + { + } + + /** + * @param array $context + */ + private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $values = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!$values) { + continue; + } + + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values] + $context); + } + } + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + + /** + * {@inheritdoc} + */ + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void + { + $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } +} diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 0ad569e5145..2e258af34ae 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; @@ -23,7 +24,7 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements FilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; @@ -64,6 +65,14 @@ protected function getLogger(): LoggerInterface return $this->logger; } + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + /** * Determines whether the given property is enabled. */ diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 3eaa9c6fbb7..f708c6b9f84 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -290,9 +290,111 @@ public function resolveResourceArgs(array $args, Operation $operation): array $args[$id]['type'] = $this->typeConverter->resolveType($arg['type']); } + /* + * This is @experimental, read the comment on the parameterToObjectType function as additional information. + */ + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); + + if (str_contains($key, ':property')) { + if (!($filterId = $parameter->getFilter()) || !$this->filterLocator->has($filterId)) { + continue; + } + + $parsedKey = explode('[:property]', $key); + $flattenFields = []; + foreach ($this->filterLocator->get($filterId)->getDescription($operation->getClass()) as $key => $value) { + $values = []; + parse_str($key, $values); + if (isset($values[$parsedKey[0]])) { + $values = $values[$parsedKey[0]]; + } + + $name = key($values); + $flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string']; + } + + $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]); + continue; + } + + $args[$key] = ['type' => GraphQLType::string()]; + + if ($parameter->getRequired()) { + $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); + } + } + return $args; } + /** + * Transform the result of a parse_str to a GraphQL object type. + * We should consider merging getFilterArgs and this, `getFilterArgs` uses `convertType` whereas we assume that parameters have only scalar types. + * Note that this method has a lower complexity then the `getFilterArgs` one. + * TODO: Is there a use case with an argument being a complex type (eg: a Resource, Enum etc.)? + * + * @param array $flattenFields + */ + private function parameterToObjectType(array $flattenFields, string $name): InputObjectType + { + $fields = []; + foreach ($flattenFields as $field) { + $key = $field['name']; + $type = $this->getParameterType(\in_array($field['type'], Type::$builtinTypes, true) ? new Type($field['type'], !$field['required']) : new Type('object', !$field['required'], $field['type'])); + + if (\is_array($l = $field['leafs'])) { + if (0 === key($l)) { + $key = $key; + $type = GraphQLType::listOf($type); + } else { + $n = []; + foreach ($field['leafs'] as $l => $value) { + $n[] = ['required' => null, 'name' => $l, 'leafs' => $value, 'type' => 'string', 'description' => null]; + } + + $type = $this->parameterToObjectType($n, $key); + if (isset($fields[$key]) && ($t = $fields[$key]['type']) instanceof InputObjectType) { + $t = $fields[$key]['type']; + $t->config['fields'] = array_merge($t->config['fields'], $type->config['fields']); + $type = $t; + } + } + } + + if ($field['required']) { + $type = GraphQLType::nonNull($type); + } + + if (isset($fields[$key])) { + if ($type instanceof ListOfType) { + $key .= '_list'; + } + } + + $fields[$key] = ['type' => $type, 'name' => $key]; + } + + return new InputObjectType(['name' => $name, 'fields' => $fields]); + } + + /** + * A simplified version of convert type that does not support resources. + */ + private function getParameterType(Type $type): GraphQLType + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_BOOL => GraphQLType::boolean(), + Type::BUILTIN_TYPE_INT => GraphQLType::int(), + Type::BUILTIN_TYPE_FLOAT => GraphQLType::float(), + Type::BUILTIN_TYPE_STRING => GraphQLType::string(), + Type::BUILTIN_TYPE_ARRAY => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])), + Type::BUILTIN_TYPE_ITERABLE => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])), + Type::BUILTIN_TYPE_OBJECT => GraphQLType::string(), + default => GraphQLType::string(), + }; + } + /** * Get the field configuration of a resource. * @@ -450,9 +552,9 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root } } - foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $value) { - $nullable = isset($value['required']) ? !$value['required'] : true; - $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']); + foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $description) { + $nullable = isset($description['required']) ? !$description['required'] : true; + $filterType = \in_array($description['type'], Type::$builtinTypes, true) ? new Type($description['type'], $nullable) : new Type('object', $nullable, $description['type']); $graphqlFilterType = $this->convertType($filterType, false, $resourceOperation, $rootOperation, $resourceClass, $rootResource, $property, $depth); if (str_ends_with($key, '[]')) { @@ -467,8 +569,8 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root if (\array_key_exists($key, $parsed) && \is_array($parsed[$key])) { $parsed = [$key => '']; } - array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void { - $value = $graphqlFilterType; + array_walk_recursive($parsed, static function (&$v) use ($graphqlFilterType): void { + $v = $graphqlFilterType; }); $args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key); } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php index ed9b98c4c45..866ffafb56d 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php @@ -52,7 +52,7 @@ public function process(ContainerBuilder $container): void */ private function createFilterDefinitions(\ReflectionClass $resourceReflectionClass, ContainerBuilder $container): void { - foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass]) { + foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass, $filterAttribute]) { if ($container->has($id)) { continue; } @@ -69,6 +69,10 @@ private function createFilterDefinitions(\ReflectionClass $resourceReflectionCla } $definition->addTag(self::TAG_FILTER_NAME); + if ($filterAttribute->alias) { + $definition->addTag(self::TAG_FILTER_NAME, ['id' => $filterAttribute->alias]); + } + $definition->setAutowired(true); $parameterNames = []; diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index f9e7ce7156f..e4206ea097d 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -34,6 +34,18 @@ + + + + + + + + + + + + @@ -41,6 +53,9 @@ + + + @@ -48,6 +63,9 @@ + + + @@ -56,6 +74,9 @@ + + + @@ -63,6 +84,9 @@ + + + @@ -71,6 +95,9 @@ + + + @@ -78,6 +105,9 @@ + + + @@ -105,6 +135,14 @@ + + + + + + + + - - - - - - - - - - - diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index ad0d77a5121..14d197c01fc 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -35,6 +35,9 @@ %api_platform.collection.order_nulls_comparison% + + + @@ -42,6 +45,9 @@ + + + @@ -49,6 +55,9 @@ + + + @@ -56,6 +65,9 @@ + + + @@ -63,6 +75,9 @@ + + + @@ -71,6 +86,9 @@ + + + @@ -120,6 +138,13 @@ + + + + + + + @@ -159,6 +184,10 @@ + + + + diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index a089eebb2bc..9edae7441f5 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -153,6 +153,11 @@ %api_platform.graphql.nesting_separator% + + + + + diff --git a/tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php b/tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php new file mode 100644 index 00000000000..d1b7da59538 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchFilterValueTransformer; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchTextAndDateFilter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[GetCollection( + uriTemplate: 'search_filter_parameter_document{._format}', + parameters: [ + 'foo' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter'), + 'order[:property]' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter.order_filter'), + + 'searchPartial[:property]' => new QueryParameter(filter: 'app_odm_search_filter_partial'), + 'searchExact[:property]' => new QueryParameter(filter: 'app_odm_search_filter_with_exact'), + 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_odm_filter_date_and_search'), + 'q' => new QueryParameter(property: 'hydra:freetextQuery'), + ] +)] +#[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])] +#[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])] +#[ApiFilter(ODMSearchTextAndDateFilter::class, alias: 'app_odm_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])] +#[ODM\Document] +class SearchFilterParameterDocument +{ + /** + * @var int The id + */ + #[ODM\Field] + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + #[ODM\Field] + private string $foo = ''; + #[ODM\Field(type: 'date_immutable', nullable: true)] + private ?\DateTimeImmutable $createdAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php new file mode 100644 index 00000000000..133e98adcd1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchFilterValueTransformer; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection( + uriTemplate: 'search_filter_parameter{._format}', + parameters: [ + 'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'), + 'order[:property]' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'), + + 'searchPartial[:property]' => new QueryParameter(filter: 'app_search_filter_partial'), + 'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'), + 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), + 'q' => new QueryParameter(property: 'hydra:freetextQuery'), + ] +)] +#[QueryCollection( + parameters: [ + 'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'), + 'order[:property]' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'), + + 'searchPartial[:property]' => new QueryParameter(filter: 'app_search_filter_partial'), + 'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'), + 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), + 'q' => new QueryParameter(property: 'hydra:freetextQuery'), + ] +)] +#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])] +#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])] +#[ApiFilter(SearchTextAndDateFilter::class, alias: 'app_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])] +#[ORM\Entity] +class SearchFilterParameter +{ + /** + * @var int The id + */ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + #[ORM\Column(type: 'string')] + private string $foo = ''; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $createdAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php new file mode 100644 index 00000000000..38d66a36a8e --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class ODMSearchFilterValueTransformer implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine_mongodb.odm.search_filter.instance')] readonly FilterInterface $searchFilter, ?array $properties = null, private readonly ?string $key = null) + { + if ($searchFilter instanceof PropertyAwareFilterInterface) { + $searchFilter->setProperties($properties); + } + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return $this->searchFilter->getDescription($resourceClass); + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $filterContext = ['filters' => $context['filters'][$this->key]] + $context; + $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + } +} diff --git a/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php new file mode 100644 index 00000000000..283a0cff2d0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class ODMSearchTextAndDateFilter implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine_mongodb.odm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine_mongodb.odm.date_filter.instance')] readonly FilterInterface $dateFilter, protected ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = []) + { + if ($searchFilter instanceof PropertyAwareFilterInterface) { + $searchFilter->setProperties($searchFilterProperties); + } + if ($dateFilter instanceof PropertyAwareFilterInterface) { + $dateFilter->setProperties($dateFilterProperties); + } + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return array_merge($this->searchFilter->getDescription($resourceClass), $this->dateFilter->getDescription($resourceClass)); + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $filterContext = ['filters' => $context['filters']['searchOnTextAndDate']] + $context; + $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + $this->dateFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + } +} diff --git a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php new file mode 100644 index 00000000000..979824463fd --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class SearchFilterValueTransformer implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, private ?array $properties = null, private readonly ?string $key = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->properties); + } + + return $this->searchFilter->getDescription($resourceClass); + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->properties); + } + + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters'][$this->key]] + $context); + } +} diff --git a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php new file mode 100644 index 00000000000..8f01e0570e4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class SearchTextAndDateFilter implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, protected ?array $properties = null, private array $dateFilterProperties = [], private array $searchFilterProperties = []) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->searchFilterProperties); + } + if ($this->dateFilter instanceof PropertyAwareFilterInterface) { + $this->dateFilter->setProperties($this->dateFilterProperties); + } + + return array_merge($this->searchFilter->getDescription($resourceClass), $this->dateFilter->getDescription($resourceClass)); + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->searchFilterProperties); + } + if ($this->dateFilter instanceof PropertyAwareFilterInterface) { + $this->dateFilter->setProperties($this->dateFilterProperties); + } + + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); + $this->dateFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); + } +} diff --git a/tests/Fixtures/app/config/config_doctrine.yml b/tests/Fixtures/app/config/config_doctrine.yml index f6b33631765..929fb6289ec 100644 --- a/tests/Fixtures/app/config/config_doctrine.yml +++ b/tests/Fixtures/app/config/config_doctrine.yml @@ -135,3 +135,13 @@ services: - name: 'api_platform.state_provider' arguments: $itemProvider: '@ApiPlatform\Doctrine\Orm\State\ItemProvider' + + app_search_filter_via_parameter: + parent: 'api_platform.doctrine.orm.search_filter' + arguments: [ { foo: 'exact' } ] + tags: [ { name: 'api_platform.filter', id: 'app_search_filter_via_parameter' } ] + + app_search_filter_via_parameter.order_filter: + parent: 'api_platform.doctrine.orm.order_filter' + arguments: [ { id: 'ASC', foo: 'DESC' } ] + tags: [ 'api_platform.filter' ] diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index 814603f182b..8f42de9e3ab 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -159,3 +159,13 @@ services: $decorated: '@ApiPlatform\Doctrine\Common\State\PersistProcessor' tags: - name: 'api_platform.state_processor' + + app_odm_search_filter_via_parameter: + parent: 'api_platform.doctrine_mongodb.odm.search_filter' + arguments: [ { foo: 'exact' } ] + tags: [ { name: 'api_platform.filter', id: 'app_odm_search_filter_via_parameter' } ] + + app_odm_search_filter_via_parameter.order_filter: + parent: 'api_platform.doctrine_mongodb.odm.order_filter' + arguments: [ { id: 'ASC', foo: 'DESC' } ] + tags: [ 'api_platform.filter' ] diff --git a/tests/Functional/Parameters/DoctrineTests.php b/tests/Functional/Parameters/DoctrineTests.php new file mode 100644 index 00000000000..c9fdba3ae5a --- /dev/null +++ b/tests/Functional/Parameters/DoctrineTests.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; + +final class DoctrineTests extends ApiTestCase +{ + public function testDoctrineEntitySearchFilter(): void + { + $this->recreateSchema(); + $container = static::getContainer(); + $route = 'mongodb' === $container->getParameter('kernel.environment') ? 'search_filter_parameter_document' : 'search_filter_parameter'; + $response = self::createClient()->request('GET', $route.'?foo=bar'); + $a = $response->toArray(); + $this->assertCount(2, $a['hydra:member']); + $this->assertEquals('bar', $a['hydra:member'][0]['foo']); + $this->assertEquals('bar', $a['hydra:member'][1]['foo']); + + $this->assertArraySubset(['hydra:search' => [ + 'hydra:template' => sprintf('/%s{?foo,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q}', $route), + 'hydra:mapping' => [ + ['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo'], + ], + ]], $a); + + $response = self::createClient()->request('GET', $route.'?order[foo]=asc'); + $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); + $response = self::createClient()->request('GET', $route.'?order[foo]=desc'); + $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'foo'); + + $response = self::createClient()->request('GET', $route.'?searchPartial[foo]=az'); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertArraySubset(['foo' => 'baz'], $members[0]); + + $response = self::createClient()->request('GET', $route.'?searchOnTextAndDate[foo]=bar&searchOnTextAndDate[createdAt][before]=2024-01-21'); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $members[0]); + } + + public function testGraphQl(): void + { + $this->recreateSchema(); + $container = static::getContainer(); + $object = 'mongodb' === $container->getParameter('kernel.environment') ? 'searchFilterParameterDocuments' : 'searchFilterParameters'; + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(foo: "bar") { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertEquals('bar', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(searchPartial: {foo: "az"}) { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(searchExact: {foo: "baz"}) { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(searchOnTextAndDate: {foo: "bar", createdAt: {before: "2024-01-21"}}) { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']); + } + + /** + * @param array $options kernel options + */ + private function recreateSchema(array $options = []): void + { + self::bootKernel($options); + + $container = static::getContainer(); + $registry = $this->getContainer()->get('mongodb' === $container->getParameter('kernel.environment') ? 'doctrine_mongodb' : 'doctrine'); + $resource = 'mongodb' === $container->getParameter('kernel.environment') ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $manager = $registry->getManager(); + + if ($manager instanceof EntityManagerInterface) { + $classes = $manager->getClassMetadata($resource); + $schemaTool = new SchemaTool($manager); + @$schemaTool->dropSchema([$classes]); + @$schemaTool->createSchema([$classes]); + } else { + $schemaManager = $manager->getSchemaManager(); + $schemaManager->dropCollections(); + } + + $date = new \DateTimeImmutable('2024-01-21'); + foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { + $s = new $resource(); + $s->setFoo($t); + if ('bar' === $t) { + $s->setCreatedAt($date); + $date = new \DateTimeImmutable('2024-01-22'); + } + + $manager->persist($s); + } + $manager->flush(); + } +}