From 74919ffee5aa7ca92d3a0e02c9150995bae8205b Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Mon, 4 Nov 2024 17:12:05 +0100 Subject: [PATCH] feat(doctrine): doctrine filters like laravel eloquent filters --- .../Filter/ManagerRegistryAwareInterface.php | 25 +++ .../Odm/Extension/ParameterExtension.php | 47 ++++-- src/Doctrine/Odm/Filter/AbstractFilter.php | 35 ++++- src/Doctrine/Odm/Filter/BooleanFilter.php | 12 +- src/Doctrine/Odm/Filter/DateFilter.php | 50 ++++-- src/Doctrine/Odm/Filter/ExistsFilter.php | 38 ++++- src/Doctrine/Odm/Filter/NumericFilter.php | 9 +- src/Doctrine/Odm/Filter/OrderFilter.php | 39 ++++- src/Doctrine/Odm/Filter/RangeFilter.php | 19 ++- src/Doctrine/Odm/PropertyHelperTrait.php | 8 +- .../Orm/Extension/ParameterExtension.php | 32 +++- src/Doctrine/Orm/Filter/AbstractFilter.php | 38 +++-- src/Doctrine/Orm/Filter/BooleanFilter.php | 12 +- src/Doctrine/Orm/Filter/DateFilter.php | 50 ++++-- src/Doctrine/Orm/Filter/ExistsFilter.php | 41 ++++- src/Doctrine/Orm/Filter/NumericFilter.php | 9 +- src/Doctrine/Orm/Filter/OrderFilter.php | 39 ++++- src/Doctrine/Orm/Filter/RangeFilter.php | 19 ++- src/Doctrine/Orm/PropertyHelperTrait.php | 2 +- src/Laravel/ApiPlatformProvider.php | 3 +- src/Metadata/Parameter.php | 12 +- ...meterResourceMetadataCollectionFactory.php | 42 ++++- .../Resources/config/doctrine_mongodb_odm.xml | 2 +- .../Bundle/Resources/config/doctrine_orm.xml | 1 + .../Resources/config/metadata/resource.xml | 2 + .../Document/FilteredBooleanParameter.php | 60 ++++++++ .../Document/FilteredDateParameter.php | 71 +++++++++ .../Document/FilteredExistsParameter.php | 61 ++++++++ .../Document/FilteredNumericParameter.php | 77 ++++++++++ .../Document/FilteredOrderParameter.php | 71 +++++++++ .../Document/FilteredRangeParameter.php | 61 ++++++++ .../Entity/FilteredBooleanParameter.php | 62 ++++++++ .../Entity/FilteredDateParameter.php | 73 +++++++++ .../Entity/FilteredExistsParameter.php | 63 ++++++++ .../Entity/FilteredNumericParameter.php | 79 ++++++++++ .../Entity/FilteredOrderParameter.php | 73 +++++++++ .../Entity/FilteredRangeParameter.php | 63 ++++++++ .../Parameters/BooleanFilterTest.php | 133 ++++++++++++++++ .../Functional/Parameters/DateFilterTest.php | 144 ++++++++++++++++++ .../Parameters/ExistsFilterTest.php | 95 ++++++++++++ .../Parameters/NumericFilterTest.php | 123 +++++++++++++++ .../Functional/Parameters/OrderFilterTest.php | 132 ++++++++++++++++ .../Functional/Parameters/RangeFilterTest.php | 136 +++++++++++++++++ 43 files changed, 2066 insertions(+), 97 deletions(-) create mode 100644 src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredDateParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredExistsParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredExistsParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php create mode 100644 tests/Functional/Parameters/BooleanFilterTest.php create mode 100644 tests/Functional/Parameters/DateFilterTest.php create mode 100644 tests/Functional/Parameters/ExistsFilterTest.php create mode 100644 tests/Functional/Parameters/NumericFilterTest.php create mode 100644 tests/Functional/Parameters/OrderFilterTest.php create mode 100644 tests/Functional/Parameters/RangeFilterTest.php diff --git a/src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php b/src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php new file mode 100644 index 00000000000..c4a6da3affb --- /dev/null +++ b/src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php @@ -0,0 +1,25 @@ + + * + * 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; + +use Doctrine\Persistence\ManagerRegistry; + +interface ManagerRegistryAwareInterface +{ + public function hasManagerRegistry(): bool; + + public function getManagerRegistry(): ManagerRegistry; + + public function setManagerRegistry(ManagerRegistry $managerRegistry): void; +} diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 0191871c07f..00e82cc5675 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -13,10 +13,13 @@ namespace ApiPlatform\Doctrine\Odm\Extension; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; +use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Psr\Container\ContainerInterface; @@ -29,14 +32,20 @@ final class ParameterExtension implements AggregationCollectionExtensionInterfac { use ParameterValueExtractorTrait; - public function __construct(private readonly ContainerInterface $filterLocator) - { + public function __construct( + private readonly ContainerInterface $filterLocator, + private readonly ?ManagerRegistry $managerRegistry = null, + ) { } + /** + * @param array $context + */ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void { foreach ($operation->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + // TODO: remove the null equality as a parameter can have a null value + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } @@ -45,14 +54,30 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; - if ($filter instanceof FilterInterface) { - $filterContext = ['filters' => $values, 'parameter' => $parameter]; - $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']; - } + $filter = match (true) { + $filterId instanceof FilterInterface => $filterId, + \is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId), + default => null, + }; + + if (!$filter instanceof FilterInterface) { + continue; + } + + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $filter->setManagerRegistry($this->managerRegistry); + } + + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { + $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); + $filter->setProperties([$propertyKey => $parameter->getFilterContext()]); + } + + $filterContext = ['filters' => $values, 'parameter' => $parameter]; + $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']; } } } diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index 87c30390c32..824a85c6917 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -13,9 +13,11 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\Persistence\ManagerRegistry; @@ -30,14 +32,18 @@ * * @author Alan Poulain */ -abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface { use MongoDbOdmPropertyHelperTrait; use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) - { + public function __construct( + protected ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, + protected ?array $properties = null, + protected ?NameConverterInterface $nameConverter = null, + ) { $this->logger = $logger ?? new NullLogger(); } @@ -56,18 +62,35 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera */ abstract protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; - protected function getManagerRegistry(): ManagerRegistry + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry { + if (!$this->hasManagerRegistry()) { + throw new RuntimeException('ManagerRegistry must be initialized before accessing it.'); + } + return $this->managerRegistry; } - protected function getProperties(): ?array + public function setManagerRegistry(ManagerRegistry $managerRegistry): void + { + $this->managerRegistry = $managerRegistry; + } + + /** + * @return array|null + */ + public function getProperties(): ?array { return $this->properties; } /** - * @param string[] $properties + * @param array $properties */ public function setProperties(array $properties): void { diff --git a/src/Doctrine/Odm/Filter/BooleanFilter.php b/src/Doctrine/Odm/Filter/BooleanFilter.php index babe3309ed0..02de91ae589 100644 --- a/src/Doctrine/Odm/Filter/BooleanFilter.php +++ b/src/Doctrine/Odm/Filter/BooleanFilter.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -104,7 +106,7 @@ * @author Teoh Han Hui * @author Alan Poulain */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -139,4 +141,12 @@ protected function filterProperty(string $property, $value, Builder $aggregation $aggregationBuilder->match()->field($matchField)->equals($value); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'boolean']; + } } diff --git a/src/Doctrine/Odm/Filter/DateFilter.php b/src/Doctrine/Odm/Filter/DateFilter.php index 8be5534fbca..86a0958aad1 100644 --- a/src/Doctrine/Odm/Filter/DateFilter.php +++ b/src/Doctrine/Odm/Filter/DateFilter.php @@ -16,7 +16,12 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -117,7 +122,7 @@ * @author Théo FIDRY * @author Alan Poulain */ -final class DateFilter extends AbstractFilter implements DateFilterInterface +final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use DateFilterTrait; @@ -129,11 +134,11 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - // Expect $values to be an array having the period as keys and the date value as values + // Expect $value to be an array having the period as keys and the date value as values if ( - !\is_array($values) + !\is_array($value) || !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) || !$this->isDateField($property, $resourceClass) @@ -153,42 +158,42 @@ protected function filterProperty(string $property, $values, Builder $aggregatio $aggregationBuilder->match()->field($matchField)->notEqual(null); } - if (isset($values[self::PARAMETER_BEFORE])) { + if (isset($value[self::PARAMETER_BEFORE])) { $this->addMatch( $aggregationBuilder, $matchField, self::PARAMETER_BEFORE, - $values[self::PARAMETER_BEFORE], + $value[self::PARAMETER_BEFORE], $nullManagement ); } - if (isset($values[self::PARAMETER_STRICTLY_BEFORE])) { + if (isset($value[self::PARAMETER_STRICTLY_BEFORE])) { $this->addMatch( $aggregationBuilder, $matchField, self::PARAMETER_STRICTLY_BEFORE, - $values[self::PARAMETER_STRICTLY_BEFORE], + $value[self::PARAMETER_STRICTLY_BEFORE], $nullManagement ); } - if (isset($values[self::PARAMETER_AFTER])) { + if (isset($value[self::PARAMETER_AFTER])) { $this->addMatch( $aggregationBuilder, $matchField, self::PARAMETER_AFTER, - $values[self::PARAMETER_AFTER], + $value[self::PARAMETER_AFTER], $nullManagement ); } - if (isset($values[self::PARAMETER_STRICTLY_AFTER])) { + if (isset($value[self::PARAMETER_STRICTLY_AFTER])) { $this->addMatch( $aggregationBuilder, $matchField, self::PARAMETER_STRICTLY_AFTER, - $values[self::PARAMETER_STRICTLY_AFTER], + $value[self::PARAMETER_STRICTLY_AFTER], $nullManagement ); } @@ -237,4 +242,25 @@ private function addMatch(Builder $aggregationBuilder, string $field, string $op $aggregationBuilder->match()->addAnd($aggregationBuilder->matchExpr()->field($field)->operator($operatorValue[$operator], $value)); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'date']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[after]', in: $in), + new OpenApiParameter(name: $key.'[before]', in: $in), + new OpenApiParameter(name: $key.'[strictly_after]', in: $in), + new OpenApiParameter(name: $key.'[strictly_before]', in: $in), + ]; + } } diff --git a/src/Doctrine/Odm/Filter/ExistsFilter.php b/src/Doctrine/Odm/Filter/ExistsFilter.php index db57f81fdad..2bab9998888 100644 --- a/src/Doctrine/Odm/Filter/ExistsFilter.php +++ b/src/Doctrine/Odm/Filter/ExistsFilter.php @@ -15,7 +15,11 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface; use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; @@ -107,12 +111,16 @@ * @author Teoh Han Hui * @author Alan Poulain */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface +final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use ExistsFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { + if (\is_array($properties) && \is_int(key($properties))) { + $properties = array_flip($properties); + } + parent::__construct($managerRegistry, $logger, $properties, $nameConverter); $this->existsParameterName = $existsParameterName; @@ -123,6 +131,12 @@ public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $ */ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { + $parameter = $context['parameter'] ?? null; + + if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $aggregationBuilder, $resourceClass, $operation, $context); + } + foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { $this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context); } @@ -167,4 +181,24 @@ protected function isNullableField(string $property, string $resourceClass): boo return $metadata instanceof ClassMetadata && $metadata->hasField($field) ? $metadata->isNullable($field) : false; } + + public function getSchema(Parameter $parameter): array + { + return ['type' => 'boolean']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + if (str_contains($parameter->getKey(), ':property')) { + $parameters = []; + $key = str_replace('[:property]', '', $parameter->getKey()); + foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { + $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); + } + + return $parameters; + } + + return null; + } } diff --git a/src/Doctrine/Odm/Filter/NumericFilter.php b/src/Doctrine/Odm/Filter/NumericFilter.php index 34cec17bb05..6b5cefb9a1f 100644 --- a/src/Doctrine/Odm/Filter/NumericFilter.php +++ b/src/Doctrine/Odm/Filter/NumericFilter.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\NumericFilterTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -104,7 +106,7 @@ * @author Teoh Han Hui * @author Alan Poulain */ -final class NumericFilter extends AbstractFilter +final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { use NumericFilterTrait; @@ -163,4 +165,9 @@ protected function getType(?string $doctrineType = null): string return 'int'; } + + public function getSchema(Parameter $parameter): array + { + return ['type' => 'numeric']; + } } diff --git a/src/Doctrine/Odm/Filter/OrderFilter.php b/src/Doctrine/Odm/Filter/OrderFilter.php index a8cf49b1212..f8e8af2420f 100644 --- a/src/Doctrine/Odm/Filter/OrderFilter.php +++ b/src/Doctrine/Odm/Filter/OrderFilter.php @@ -15,7 +15,11 @@ use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; use ApiPlatform\Doctrine\Common\Filter\OrderFilterTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; @@ -196,11 +200,11 @@ * @author Théo FIDRY * @author Alan Poulain */ -final class OrderFilter extends AbstractFilter implements OrderFilterInterface +final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use OrderFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null) + public function __construct(?ManagerRegistry $managerRegistry = null, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null) { if (null !== $properties) { $properties = array_map(static function ($propertyOptions) { @@ -225,10 +229,6 @@ public function __construct(ManagerRegistry $managerRegistry, string $orderParam */ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - if (isset($context['filters']) && !isset($context['filters'][$this->orderParameterName])) { - return; - } - if (!isset($context['filters'][$this->orderParameterName]) || !\is_array($context['filters'][$this->orderParameterName])) { parent::apply($aggregationBuilder, $resourceClass, $operation, $context); @@ -243,13 +243,13 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera /** * {@inheritdoc} */ - protected function filterProperty(string $property, $direction, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) { return; } - $direction = $this->normalizeValue($direction, $property); + $direction = $this->normalizeValue($value, $property); if (null === $direction) { return; } @@ -264,4 +264,27 @@ protected function filterProperty(string $property, $direction, Builder $aggrega $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$matchField => $direction] ); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc']]; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + if (str_contains($parameter->getKey(), ':property')) { + $parameters = []; + $key = str_replace('[:property]', '', $parameter->getKey()); + foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { + $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); + } + + return $parameters; + } + + return null; + } } diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php index e34733df07d..6274ccb4499 100644 --- a/src/Doctrine/Odm/Filter/RangeFilter.php +++ b/src/Doctrine/Odm/Filter/RangeFilter.php @@ -15,7 +15,11 @@ use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface; use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; /** @@ -104,7 +108,7 @@ * @author Lee Siong Chan * @author Alan Poulain */ -final class RangeFilter extends AbstractFilter implements RangeFilterInterface +final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { use RangeFilterTrait; @@ -204,4 +208,17 @@ protected function addMatch(Builder $aggregationBuilder, string $field, string $ break; } } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[gt]', in: $in), + new OpenApiParameter(name: $key.'[lt]', in: $in), + new OpenApiParameter(name: $key.'[gte]', in: $in), + new OpenApiParameter(name: $key.'[lte]', in: $in), + ]; + } } diff --git a/src/Doctrine/Odm/PropertyHelperTrait.php b/src/Doctrine/Odm/PropertyHelperTrait.php index e1c7693f2b0..6e73db7893e 100644 --- a/src/Doctrine/Odm/PropertyHelperTrait.php +++ b/src/Doctrine/Odm/PropertyHelperTrait.php @@ -27,7 +27,7 @@ */ trait PropertyHelperTrait { - abstract protected function getManagerRegistry(): ManagerRegistry; + abstract protected function getManagerRegistry(): ?ManagerRegistry; /** * Splits the given property into parts. @@ -39,9 +39,9 @@ abstract protected function splitPropertyParts(string $property, string $resourc */ protected function getClassMetadata(string $resourceClass): ClassMetadata { - $manager = $this - ->getManagerRegistry() - ->getManagerForClass($resourceClass); + /** @var ?ManagerRegistry $managerRegistry */ + $managerRegistry = $this->getManagerRegistry(); + $manager = $managerRegistry?->getManagerForClass($resourceClass); if ($manager) { return $manager->getClassMetadata($resourceClass); diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 6ae3a00100b..b1a55cc7014 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -21,6 +23,7 @@ use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; use Psr\Container\ContainerInterface; +use Symfony\Bridge\Doctrine\ManagerRegistry; /** * Reads operation parameters and execute its filter. @@ -31,8 +34,10 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que { use ParameterValueExtractorTrait; - public function __construct(private readonly ContainerInterface $filterLocator) - { + public function __construct( + private readonly ContainerInterface $filterLocator, + private readonly ?ManagerRegistry $managerRegistry = null, + ) { } /** @@ -41,7 +46,8 @@ public function __construct(private readonly ContainerInterface $filterLocator) private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($operation?->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + // TODO: remove the null equality as a parameter can have a null value + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } @@ -50,12 +56,28 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + $filter = match (true) { + $filterId instanceof FilterInterface => $filterId, + \is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId), + default => null, + }; + if (!$filter instanceof FilterInterface) { throw new InvalidArgumentException(\sprintf('Could not find filter "%s" for parameter "%s" in operation "%s" for resource "%s".', $filterId, $parameter->getKey(), $operation?->getShortName(), $resourceClass)); } - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $filter->setManagerRegistry($this->managerRegistry); + } + + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { + $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); + $filter->setProperties([$propertyKey => $parameter->getFilterContext()]); + } + + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, + ['filters' => $values, 'parameter' => $parameter] + $context + ); } } diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 4ec704638a7..18b29282227 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -13,10 +13,12 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -24,14 +26,18 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) - { + public function __construct( + protected ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, + protected ?array $properties = null, + protected ?NameConverterInterface $nameConverter = null, + ) { $this->logger = $logger ?? new NullLogger(); } @@ -53,29 +59,43 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q */ abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; - protected function getManagerRegistry(): ManagerRegistry + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry { + if (!$this->hasManagerRegistry()) { + throw new RuntimeException('ManagerRegistry must be initialized before accessing it.'); + } + return $this->managerRegistry; } - protected function getProperties(): ?array + public function setManagerRegistry(ManagerRegistry $managerRegistry): void { - return $this->properties; + $this->managerRegistry = $managerRegistry; } - protected function getLogger(): LoggerInterface + public function getProperties(): ?array { - return $this->logger; + return $this->properties; } /** - * @param string[] $properties + * @param array $properties */ public function setProperties(array $properties): void { $this->properties = $properties; } + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + /** * Determines whether the given property is enabled. */ diff --git a/src/Doctrine/Orm/Filter/BooleanFilter.php b/src/Doctrine/Orm/Filter/BooleanFilter.php index e9f0a8373e0..6cc7dfd4e06 100644 --- a/src/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Doctrine/Orm/Filter/BooleanFilter.php @@ -15,7 +15,9 @@ use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -106,7 +108,7 @@ * @author Amrouche Hamza * @author Teoh Han Hui */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -145,4 +147,12 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'boolean']; + } } diff --git a/src/Doctrine/Orm/Filter/DateFilter.php b/src/Doctrine/Orm/Filter/DateFilter.php index 8533ee34406..d92375e107b 100644 --- a/src/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Doctrine/Orm/Filter/DateFilter.php @@ -17,7 +17,12 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\DBAL\Types\Type as DBALType; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; @@ -120,7 +125,7 @@ * @author Kévin Dunglas * @author Théo FIDRY */ -final class DateFilter extends AbstractFilter implements DateFilterInterface +final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use DateFilterTrait; @@ -138,11 +143,11 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - // Expect $values to be an array having the period as keys and the date value as values + // Expect $value to be an array having the period as keys and the date value as values if ( - !\is_array($values) + !\is_array($value) || !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) || !$this->isDateField($property, $resourceClass) @@ -164,53 +169,53 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $queryBuilder->andWhere($queryBuilder->expr()->isNotNull(\sprintf('%s.%s', $alias, $field))); } - if (isset($values[self::PARAMETER_BEFORE])) { + if (isset($value[self::PARAMETER_BEFORE])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_BEFORE, - $values[self::PARAMETER_BEFORE], + $value[self::PARAMETER_BEFORE], $nullManagement, $type ); } - if (isset($values[self::PARAMETER_STRICTLY_BEFORE])) { + if (isset($value[self::PARAMETER_STRICTLY_BEFORE])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_STRICTLY_BEFORE, - $values[self::PARAMETER_STRICTLY_BEFORE], + $value[self::PARAMETER_STRICTLY_BEFORE], $nullManagement, $type ); } - if (isset($values[self::PARAMETER_AFTER])) { + if (isset($value[self::PARAMETER_AFTER])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_AFTER, - $values[self::PARAMETER_AFTER], + $value[self::PARAMETER_AFTER], $nullManagement, $type ); } - if (isset($values[self::PARAMETER_STRICTLY_AFTER])) { + if (isset($value[self::PARAMETER_STRICTLY_AFTER])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_STRICTLY_AFTER, - $values[self::PARAMETER_STRICTLY_AFTER], + $value[self::PARAMETER_STRICTLY_AFTER], $nullManagement, $type ); @@ -269,4 +274,25 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf $queryBuilder->setParameter($valueParameter, $value, $type); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'date']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[after]', in: $in), + new OpenApiParameter(name: $key.'[before]', in: $in), + new OpenApiParameter(name: $key.'[strictly_after]', in: $in), + new OpenApiParameter(name: $key.'[strictly_before]', in: $in), + ]; + } } diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index 0dde78e9e97..e84fb0b499f 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -17,7 +17,11 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; @@ -113,12 +117,16 @@ * * @author Teoh Han Hui */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface +final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use ExistsFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { + if (\is_array($properties) && \is_int(key($properties))) { + $properties = array_flip($properties); + } + parent::__construct($managerRegistry, $logger, $properties, $nameConverter); $this->existsParameterName = $existsParameterName; @@ -129,6 +137,12 @@ public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $ */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + $parameter = $context['parameter'] ?? null; + + if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } @@ -263,4 +277,27 @@ private function isAssociationNullable(AssociationMapping|array $associationMapp return true; } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'boolean']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + if (str_contains($parameter->getKey(), ':property')) { + $parameters = []; + $key = str_replace('[:property]', '', $parameter->getKey()); + foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { + $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); + } + + return $parameters; + } + + return null; + } } diff --git a/src/Doctrine/Orm/Filter/NumericFilter.php b/src/Doctrine/Orm/Filter/NumericFilter.php index 545b552d040..c81a118990c 100644 --- a/src/Doctrine/Orm/Filter/NumericFilter.php +++ b/src/Doctrine/Orm/Filter/NumericFilter.php @@ -15,7 +15,9 @@ use ApiPlatform\Doctrine\Common\Filter\NumericFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -106,7 +108,7 @@ * @author Amrouche Hamza * @author Teoh Han Hui */ -final class NumericFilter extends AbstractFilter +final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { use NumericFilterTrait; @@ -176,4 +178,9 @@ protected function getType(?string $doctrineType = null): string return 'int'; } + + public function getSchema(Parameter $parameter): array + { + return ['type' => 'numeric']; + } } diff --git a/src/Doctrine/Orm/Filter/OrderFilter.php b/src/Doctrine/Orm/Filter/OrderFilter.php index b2abdabb94c..50b9a116d4f 100644 --- a/src/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Doctrine/Orm/Filter/OrderFilter.php @@ -16,7 +16,11 @@ use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; use ApiPlatform\Doctrine\Common\Filter\OrderFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -195,11 +199,11 @@ * @author Kévin Dunglas * @author Théo FIDRY */ -final class OrderFilter extends AbstractFilter implements OrderFilterInterface +final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use OrderFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) + public function __construct(?ManagerRegistry $managerRegistry = null, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) { if (null !== $properties) { $properties = array_map(static function ($propertyOptions) { @@ -224,10 +228,6 @@ public function __construct(ManagerRegistry $managerRegistry, string $orderParam */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if (isset($context['filters']) && !isset($context['filters'][$this->orderParameterName])) { - return; - } - if (!isset($context['filters'][$this->orderParameterName]) || !\is_array($context['filters'][$this->orderParameterName])) { parent::apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); @@ -242,13 +242,13 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $direction, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) { return; } - $direction = $this->normalizeValue($direction, $property); + $direction = $this->normalizeValue($value, $property); if (null === $direction) { return; } @@ -271,4 +271,27 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc']]; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + if (str_contains($parameter->getKey(), ':property')) { + $parameters = []; + $key = str_replace('[:property]', '', $parameter->getKey()); + foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { + $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); + } + + return $parameters; + } + + return null; + } } diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php index 85de7117171..ae052fa5769 100644 --- a/src/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Doctrine/Orm/Filter/RangeFilter.php @@ -16,7 +16,11 @@ use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface; use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -105,7 +109,7 @@ * * @author Lee Siong Chan */ -final class RangeFilter extends AbstractFilter implements RangeFilterInterface +final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { use RangeFilterTrait; @@ -222,4 +226,17 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf break; } } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[gt]', in: $in), + new OpenApiParameter(name: $key.'[lt]', in: $in), + new OpenApiParameter(name: $key.'[gte]', in: $in), + new OpenApiParameter(name: $key.'[lte]', in: $in), + ]; + } } diff --git a/src/Doctrine/Orm/PropertyHelperTrait.php b/src/Doctrine/Orm/PropertyHelperTrait.php index 8431e3e1680..d9376bc7ff6 100644 --- a/src/Doctrine/Orm/PropertyHelperTrait.php +++ b/src/Doctrine/Orm/PropertyHelperTrait.php @@ -29,7 +29,7 @@ */ trait PropertyHelperTrait { - abstract protected function getManagerRegistry(): ManagerRegistry; + abstract protected function getManagerRegistry(): ?ManagerRegistry; /** * Splits the given property into parts. diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index a71a43ebb92..979d7056dfb 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -397,7 +397,8 @@ public function register(): void ) ), $app->make('filters'), - $app->make(CamelCaseToSnakeCaseNameConverter::class) + $app->make(CamelCaseToSnakeCaseNameConverter::class), + $this->app->make(LoggerInterface::class) ), $app->make('filters') ) diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index aa91e5d762f..a1faa5b12c5 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -45,7 +45,7 @@ public function __construct( protected string|\Stringable|null $security = null, protected ?string $securityMessage = null, protected ?array $extraProperties = [], - protected ?array $filterContext = null, + protected array|string|null $filterContext = null, ) { } @@ -138,7 +138,7 @@ public function getExtraProperties(): array return $this->extraProperties; } - public function getFilterContext(): ?array + public function getFilterContext(): array|string|null { return $this->filterContext; } @@ -203,6 +203,14 @@ public function withFilter(mixed $filter): static return $self; } + public function withFilterContext(array|string $filterContext): static + { + $self = clone $this; + $self->filterContext = $filterContext; + + return $self; + } + public function withProperty(string $property): static { $self = clone $this; diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 066244428d1..85c784d0d42 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Odm\State\Options as DoctrineODMOptions; use ApiPlatform\Doctrine\Orm\State\Options as DoctrineORMOptions; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -28,6 +29,7 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -45,6 +47,7 @@ public function __construct( private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null, private readonly ?NameConverterInterface $nameConverter = null, + private readonly ?LoggerInterface $logger = null, ) { } @@ -184,12 +187,6 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); } - // Read filter description to populate the Parameter - $description = $filter instanceof FilterInterface ? $filter->getDescription($this->getFilterClass($operation)) : []; - if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { - $parameter = $parameter->withSchema($schema); - } - $currentKey = $key; if (null === $parameter->getProperty() && isset($properties[$key])) { $parameter = $parameter->withProperty($key); @@ -204,11 +201,42 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withExtraProperties(['_query_property' => $eloquentRelation['foreign_key']] + $parameter->getExtraProperties()); } + $parameter = $this->addFilterMetadata($parameter); + + if ($filter instanceof FilterInterface) { + try { + return $this->getLegacyFilterMetadata($parameter, $operation, $filter); + } catch (RuntimeException $exception) { + $this->logger?->alert($exception->getMessage(), ['exception' => $exception]); + + return $parameter; + } + } + + return $parameter; + } + + private function getLegacyFilterMetadata(Parameter $parameter, Operation $operation, FilterInterface $filter): Parameter + { + $description = $filter->getDescription($this->getFilterClass($operation)); + $key = $parameter->getKey(); + if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { + $parameter = $parameter->withSchema($schema); + } + + if (null === $parameter->getProperty() && ($property = $description[$key]['property'] ?? null)) { + $parameter = $parameter->withProperty($property); + } + if (null === $parameter->getRequired() && ($required = $description[$key]['required'] ?? null)) { $parameter = $parameter->withRequired($required); } - return $this->addFilterMetadata($parameter); + if (null === $parameter->getOpenApi() && ($openApi = $description[$key]['openapi'] ?? null) && $openApi instanceof OpenApiParameter) { + $parameter = $parameter->withOpenApi($openApi); + } + + return $parameter; } private function getFilterClass(Operation $operation): ?string diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index e4206ea097d..d6485bf1d1a 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -137,7 +137,7 @@ - + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index d6b3b1ffee8..6e00d888829 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -150,6 +150,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 59b9422a9df..48a193173e1 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -84,6 +84,8 @@ + + diff --git a/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php new file mode 100644 index 00000000000..efa2428f864 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php @@ -0,0 +1,60 @@ + + * + * 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\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + ), + ], +)] +#[ODM\Document] +class FilteredBooleanParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'bool', nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $active): void + { + $this->active = $active; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php b/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php new file mode 100644 index 00000000000..d3008fd2f11 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredDateParameter.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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\DateFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new DateFilter(), + ), + 'date' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + ), + 'date_include_null_always' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + filterContext: DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER, + ), + 'date_old_way' => new QueryParameter( + filter: new DateFilter(properties: ['createdAt' => DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER]), + property: 'createdAt', + ), + ], +)] +#[ODM\Document] +class FilteredDateParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FilteredExistsParameter.php b/tests/Fixtures/TestBundle/Document/FilteredExistsParameter.php new file mode 100644 index 00000000000..b6d97ae5e87 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredExistsParameter.php @@ -0,0 +1,61 @@ + + * + * 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\Doctrine\Odm\Filter\ExistsFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new ExistsFilter(), + ), + 'hasCreationDate' => new QueryParameter( + filter: new ExistsFilter(), + property: 'createdAt', + ), + ], +)] +#[ODM\Document] +class FilteredExistsParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php new file mode 100644 index 00000000000..30ae305d677 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php @@ -0,0 +1,77 @@ + + * + * 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\Doctrine\Odm\Filter\NumericFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new NumericFilter(), + ), + 'amount' => new QueryParameter( + filter: new NumericFilter(), + property: 'quantity', + ), + 'ratio' => new QueryParameter( + filter: new NumericFilter(), + ), + ], +)] +#[ODM\Document] +class FilteredNumericParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'int', nullable: true)] + public ?int $quantity = null, + + #[ODM\Field(type: 'float', nullable: true)] + public ?float $ratio = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } + + public function getRatio(): ?float + { + return $this->ratio; + } + + public function setRatio(?float $ratio): void + { + $this->ratio = $ratio; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php new file mode 100644 index 00000000000..a111ec9cfc7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new OrderFilter(), + ), + 'date' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + ), + 'date_null_always_first' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + filterContext: OrderFilterInterface::NULLS_ALWAYS_FIRST, + ), + 'date_null_always_first_old_way' => new QueryParameter( + filter: new OrderFilter(properties: ['createdAt' => OrderFilterInterface::NULLS_ALWAYS_FIRST]), + property: 'createdAt', + ), + ], +)] +#[ODM\Document] +class FilteredOrderParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php b/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php new file mode 100644 index 00000000000..9a77be00c7a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php @@ -0,0 +1,61 @@ + + * + * 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\Doctrine\Odm\Filter\RangeFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new RangeFilter(), + ), + 'amount' => new QueryParameter( + filter: new RangeFilter(), + property: 'quantity', + ), + ], +)] +#[ODM\Document] +class FilteredRangeParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'int', nullable: true)] + public ?int $quantity = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php new file mode 100644 index 00000000000..6ba24650a79 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php @@ -0,0 +1,62 @@ + + * + * 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\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + ), + ], +)] +#[ORM\Entity] +class FilteredBooleanParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $isActive): void + { + $this->active = $isActive; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php new file mode 100644 index 00000000000..5d920b09e60 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php @@ -0,0 +1,73 @@ + + * + * 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\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new DateFilter(), + ), + 'date' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + ), + 'date_include_null_always' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + filterContext: DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER, + ), + 'date_old_way' => new QueryParameter( + filter: new DateFilter(properties: ['createdAt' => DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER]), + property: 'createdAt', + ), + ], +)] +#[ORM\Entity] +class FilteredDateParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredExistsParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredExistsParameter.php new file mode 100644 index 00000000000..bce01a7560a --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredExistsParameter.php @@ -0,0 +1,63 @@ + + * + * 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\Doctrine\Orm\Filter\ExistsFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new ExistsFilter(), + ), + 'hasCreationDate' => new QueryParameter( + filter: new ExistsFilter(), + property: 'createdAt', + ), + ], +)] +#[ORM\Entity] +class FilteredExistsParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php new file mode 100644 index 00000000000..20e1e152be5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php @@ -0,0 +1,79 @@ + + * + * 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\Doctrine\Orm\Filter\NumericFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new NumericFilter(), + ), + 'amount' => new QueryParameter( + filter: new NumericFilter(), + property: 'quantity', + ), + 'ratio' => new QueryParameter( + filter: new NumericFilter(), + ), + ], +)] +#[ORM\Entity] +class FilteredNumericParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?int $quantity = null, + + #[ORM\Column(nullable: true)] + public ?float $ratio = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } + + public function getRatio(): ?float + { + return $this->ratio; + } + + public function setRatio(?float $ratio): void + { + $this->ratio = $ratio; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php new file mode 100644 index 00000000000..0c41815db72 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php @@ -0,0 +1,73 @@ + + * + * 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\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new OrderFilter(), + ), + 'date' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + ), + 'date_null_always_first' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + filterContext: OrderFilterInterface::NULLS_ALWAYS_FIRST, + ), + 'date_null_always_first_old_way' => new QueryParameter( + filter: new OrderFilter(properties: ['createdAt' => OrderFilterInterface::NULLS_ALWAYS_FIRST]), + property: 'createdAt', + ), + ], +)] +#[ORM\Entity] +class FilteredOrderParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php new file mode 100644 index 00000000000..e3f53bdc5dc --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php @@ -0,0 +1,63 @@ + + * + * 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\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new RangeFilter(), + ), + 'amount' => new QueryParameter( + filter: new RangeFilter(), + property: 'quantity', + ), + ], +)] +#[ORM\Entity] +class FilteredRangeParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?int $quantity = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } +} diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php new file mode 100644 index 00000000000..a85975e7619 --- /dev/null +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -0,0 +1,133 @@ + + * + * 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\FilteredBooleanParameter as FilteredBooleanParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredBooleanParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class BooleanFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredBooleanParameter::class]; + } + + /** + * @throws MongoDBException + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + */ + #[DataProvider('booleanFilterScenariosProvider')] + public function testBooleanFilterResponses(string $url, int $expectedActiveItemCount, bool $expectedActiveStatus): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedActiveItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedActiveItemCount, $url)); + + foreach ($filteredItems as $item) { + $this->assertSame($expectedActiveStatus, $item['active'], \sprintf("Expected 'active' to be %s", $expectedActiveStatus)); + } + } + + public static function booleanFilterScenariosProvider(): \Generator + { + yield 'active_true' => ['/filtered_boolean_parameters?active=true', 2, true]; + yield 'active_false' => ['/filtered_boolean_parameters?active=false', 1, false]; + yield 'active_numeric_1' => ['/filtered_boolean_parameters?active=1', 2, true]; + yield 'active_numeric_0' => ['/filtered_boolean_parameters?active=0', 1, false]; + yield 'enabled_alias_true' => ['/filtered_boolean_parameters?enabled=true', 2, true]; + yield 'enabled_alias_false' => ['/filtered_boolean_parameters?enabled=false', 1, false]; + yield 'enabled_alias_numeric_1' => ['/filtered_boolean_parameters?enabled=1', 2, true]; + yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1, false]; + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] + public function testBooleanFilterWithNullAndEmptyValues(string $url): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $expectedItemCount = 3; + $this->assertCount($expectedItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedItemCount, $url)); + } + + public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator + { + yield 'active_null_value' => ['/filtered_boolean_parameters?active=null']; + yield 'active_empty_value' => ['/filtered_boolean_parameters?active=', 3]; + yield 'enabled_alias_null_value' => ['/filtered_boolean_parameters?enabled=null']; + yield 'enabled_alias_empty_value' => ['/filtered_boolean_parameters?enabled=', 3]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $booleanStates = [true, true, false, null]; + foreach ($booleanStates as $activeValue) { + $entity = new $entityClass(active: $activeValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/DateFilterTest.php b/tests/Functional/Parameters/DateFilterTest.php new file mode 100644 index 00000000000..3d068a031cb --- /dev/null +++ b/tests/Functional/Parameters/DateFilterTest.php @@ -0,0 +1,144 @@ + + * + * 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\FilteredDateParameter as FilteredDateParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredDateParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class DateFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredDateParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredDateParameterDocument::class : FilteredDateParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('dateFilterScenariosProvider')] + public function testDateFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function dateFilterScenariosProvider(): \Generator + { + yield 'created_at_after' => ['/filtered_date_parameters?createdAt[after]=2024-01-01', 3]; + yield 'created_at_before' => ['/filtered_date_parameters?createdAt[before]=2024-12-31', 3]; + yield 'created_at_before_single_result' => ['/filtered_date_parameters?createdAt[before]=2024-01-02', 1]; + yield 'created_at_strictly_after' => ['/filtered_date_parameters?createdAt[strictly_after]=2024-01-01', 2]; + yield 'created_at_strictly_before' => ['/filtered_date_parameters?createdAt[strictly_before]=2024-12-31T23:59:59Z', 3]; + yield 'date_alias_after' => ['/filtered_date_parameters?date[after]=2024-01-01', 3]; + yield 'date_alias_before' => ['/filtered_date_parameters?date[before]=2024-12-31', 3]; + yield 'date_alias_before_first' => ['/filtered_date_parameters?date[before]=2024-01-02', 1]; + yield 'date_alias_strictly_after' => ['/filtered_date_parameters?date[strictly_after]=2024-01-01', 2]; + yield 'date_alias_strictly_before' => ['/filtered_date_parameters?date[strictly_before]=2024-12-31T23:59:59Z', 3]; + yield 'date_alias_include_null_always_after_date' => ['/filtered_date_parameters?date_include_null_always[after]=2024-06-15', 3]; + yield 'date_alias_include_null_always_before_date' => ['/filtered_date_parameters?date_include_null_always[before]=2024-06-14', 2]; + yield 'date_alias_include_null_always_before_all_date' => ['/filtered_date_parameters?date_include_null_always[before]=2024-12-31', 4]; + yield 'date_alias_old_way' => ['/filtered_date_parameters?date_old_way[before]=2024-06-14', 2]; + yield 'date_alias_old_way_after_last_one' => ['/filtered_date_parameters?date_old_way[after]=2024-12-31', 1]; + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('dateFilterNullAndEmptyScenariosProvider')] + public function testDateFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function dateFilterNullAndEmptyScenariosProvider(): \Generator + { + yield 'created_at_null_value' => ['/filtered_date_parameters?createdAt=null', 4]; + yield 'created_at_empty_value' => ['/filtered_date_parameters?createdAt=', 4]; + yield 'date_null_value_alias' => ['/filtered_date_parameters?date=null', 4]; + yield 'date_empty_value_alias' => ['/filtered_date_parameters?date=', 4]; + yield 'date_alias__include_null_always_with_null_alias' => ['/filtered_date_parameters?date_include_null_always=null', 4]; + yield 'date__alias_include_null_always_with_empty_alias' => ['/filtered_date_parameters?date_include_null_always=', 4]; + yield 'date_alias_old_way_with_null_alias' => ['/filtered_date_parameters?date_old_way=null', 4]; + yield 'date__alias_old_way_with_empty_alias' => ['/filtered_date_parameters?date_old_way=', 4]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $dates = [ + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-06-15'), + new \DateTimeImmutable('2024-12-25'), + null, + ]; + + foreach ($dates as $createdAtValue) { + $entity = new $entityClass(createdAt: $createdAtValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/ExistsFilterTest.php b/tests/Functional/Parameters/ExistsFilterTest.php new file mode 100644 index 00000000000..136913c5e4a --- /dev/null +++ b/tests/Functional/Parameters/ExistsFilterTest.php @@ -0,0 +1,95 @@ + + * + * 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\FilteredExistsParameter as FilteredExistsParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredExistsParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class ExistsFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredExistsParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredExistsParameterDocument::class : FilteredExistsParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('existsFilterScenariosProvider')] + public function testExistsFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function existsFilterScenariosProvider(): \Generator + { + yield 'created_at_exists_entities' => ['/filtered_exists_parameters?createdAt=true', 2]; + yield 'created_at_not_exists' => ['/filtered_exists_parameters?createdAt=false', 1]; + yield 'has_creation_date_alias_exists_entities' => ['/filtered_exists_parameters?hasCreationDate=true', 2]; + yield 'has_creation_date_alias__not_exists_entities' => ['/filtered_exists_parameters?hasCreationDate=false', 1]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach ([new \DateTimeImmutable(), null, new \DateTimeImmutable()] as $createdAt) { + $entity = new $entityClass(createdAt: $createdAt); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/NumericFilterTest.php b/tests/Functional/Parameters/NumericFilterTest.php new file mode 100644 index 00000000000..e48042d32cb --- /dev/null +++ b/tests/Functional/Parameters/NumericFilterTest.php @@ -0,0 +1,123 @@ + + * + * 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\FilteredNumericParameter as FilteredNumericParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredNumericParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class NumericFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredNumericParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredNumericParameterDocument::class : FilteredNumericParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('rangeFilterScenariosProvider')] + public function testRangeFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function rangeFilterScenariosProvider(): \Generator + { + yield 'quantity_int_equal' => ['/filtered_numeric_parameters?quantity=10', 1]; + yield 'ratio_float_equal' => ['/filtered_numeric_parameters?ratio=1.0', 2]; + yield 'amount_alias_int_equal' => ['/filtered_numeric_parameters?amount=20', 2]; + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('nullAndEmptyScenariosProvider')] + public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function nullAndEmptyScenariosProvider(): \Generator + { + yield 'quantity_int_null_value' => ['/filtered_numeric_parameters?quantity=null', 4]; + yield 'quantity_int_empty_value' => ['/filtered_numeric_parameters?quantity=', 4]; + yield 'ratio_float_null_value' => ['/filtered_numeric_parameters?ratio=null', 4]; + yield 'ratio_float_empty_value' => ['/filtered_numeric_parameters?ratio=', 4]; + yield 'amount_alias_int_null_value' => ['/filtered_numeric_parameters?amount=null', 4]; + yield 'amount_alias_int_empty_value' => ['/filtered_numeric_parameters?amount=', 4]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach ([[10, 1.0], [20, 2.0], [30, 3.0], [20, 1.0]] as [$quantity, $ratio]) { + $entity = new $entityClass(quantity: $quantity, ratio: $ratio); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/OrderFilterTest.php b/tests/Functional/Parameters/OrderFilterTest.php new file mode 100644 index 00000000000..7b4851d20ad --- /dev/null +++ b/tests/Functional/Parameters/OrderFilterTest.php @@ -0,0 +1,132 @@ + + * + * 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\FilteredOrderParameter as FilteredOrderParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredOrderParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class OrderFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredOrderParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredOrderParameterDocument::class : FilteredOrderParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('orderFilterScenariosProvider')] + public function testOrderFilterResponses(string $url, array $expectedOrder): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $orderedItems = $responseData['hydra:member']; + + $actualOrder = array_map(fn ($item) => $item['createdAt'] ?? null, $orderedItems); + + $this->assertSame($expectedOrder, $actualOrder, \sprintf('Expected order does not match for URL %s', $url)); + } + + public static function orderFilterScenariosProvider(): \Generator + { + yield 'created_at_ordered_asc' => [ + '/filtered_order_parameters?createdAt=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'created_at_ordered_desc' => [ + '/filtered_order_parameters?createdAt=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + yield 'date_alias_ordered_asc' => [ + '/filtered_order_parameters?date=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_alias_ordered_desc' => [ + '/filtered_order_parameters?date=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + yield 'date_null_always_first_alias_nulls_first' => [ + '/filtered_order_parameters?date_null_always_first=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_null_always_first_alias_nulls_last' => [ + '/filtered_order_parameters?date_null_always_first=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + yield 'date_null_always_first_old_way_alias_nulls_first' => [ + '/filtered_order_parameters?date_null_always_first_old_way=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_null_always_first_old_way_alias_nulls_last' => [ + '/filtered_order_parameters?date_null_always_first_old_way=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $dates = [ + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-12-25'), + null, + new \DateTimeImmutable('2024-06-15'), + ]; + + foreach ($dates as $createdAtValue) { + $entity = new $entityClass(createdAt: $createdAtValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/RangeFilterTest.php b/tests/Functional/Parameters/RangeFilterTest.php new file mode 100644 index 00000000000..c0eaa317bca --- /dev/null +++ b/tests/Functional/Parameters/RangeFilterTest.php @@ -0,0 +1,136 @@ + + * + * 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\FilteredRangeParameter as FilteredRangeParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredRangeParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class RangeFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredRangeParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredRangeParameterDocument::class : FilteredRangeParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('rangeFilterScenariosProvider')] + public function testRangeFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function rangeFilterScenariosProvider(): \Generator + { + yield 'quantity_greater_than' => ['/filtered_range_parameters?quantity[gt]=10', 3]; + yield 'quantity_less_than' => ['/filtered_range_parameters?quantity[lt]=50', 3]; + yield 'quantity_between' => ['/filtered_range_parameters?quantity[between]=15..40', 2]; + yield 'quantity_gte' => ['/filtered_range_parameters?quantity[gte]=20', 3]; + yield 'quantity_lte' => ['/filtered_range_parameters?quantity[lte]=30', 3]; + yield 'quantity_greater_than_and_less_than' => ['/filtered_range_parameters?quantity[gt]=10&quantity[lt]=50', 2]; + yield 'quantity_gte_and_lte' => ['/filtered_range_parameters?quantity[gte]=20&quantity[lte]=30', 2]; + yield 'quantity_gte_and_less_than' => ['/filtered_range_parameters?quantity[gte]=15&quantity[lt]=50', 2]; + yield 'quantity_between_and_lte' => ['/filtered_range_parameters?quantity[between]=15..40&quantity[lte]=30', 2]; + yield 'amount_alias_greater_than' => ['/filtered_range_parameters?amount[gt]=10', 3]; + yield 'amount_alias_less_than' => ['/filtered_range_parameters?amount[lt]=50', 3]; + yield 'amount_alias_between' => ['/filtered_range_parameters?amount[between]=15..40', 2]; + yield 'amount_alias_gte' => ['/filtered_range_parameters?amount[gte]=20', 3]; + yield 'amount_alias_lte' => ['/filtered_range_parameters?amount[lte]=30', 3]; + yield 'amount_alias_gte_and_lte' => ['/filtered_range_parameters?amount[gte]=20&amount[lte]=30', 2]; + yield 'amount_alias_greater_than_and_less_than' => ['/filtered_range_parameters?amount[gt]=10&amount[lt]=50', 2]; + yield 'amount_alias_between_and_gte' => ['/filtered_range_parameters?amount[between]=15..40&amount[gte]=20', 2]; + yield 'amount_alias_lte_and_between' => ['/filtered_range_parameters?amount[lte]=30&amount[between]=15..40', 2]; + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('nullAndEmptyScenariosProvider')] + public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function nullAndEmptyScenariosProvider(): \Generator + { + yield 'quantity_null_value' => ['/filtered_range_parameters?quantity=null', 4]; + yield 'quantity_empty_value' => ['/filtered_range_parameters?quantity=', 4]; + yield 'amont_alias_null_value' => ['/filtered_range_parameters?amount=null', 4]; + yield 'amount_alias_empty_value' => ['/filtered_range_parameters?amount=', 4]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach ([10, 20, 30, 50] as $quantity) { + $entity = new $entityClass(quantity: $quantity); + $manager->persist($entity); + } + + $manager->flush(); + } +}