diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index d841fb9240e..fc312a696a5 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -69,12 +69,18 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass $this->configureFilter($filter, $parameter); + $previousFilters = $context['filters'] ?? null; $context['filters'] = $values; $context['parameter'] = $parameter; $filter->apply($aggregationBuilder, $resourceClass, $operation, $context); - unset($context['filters'], $context['parameter']); + unset($context['parameter']); + if (null !== $previousFilters) { + $context['filters'] = $previousFilters; + } else { + unset($context['filters']); + } } if (isset($context['match'])) { diff --git a/src/Doctrine/Odm/Filter/ComparisonFilter.php b/src/Doctrine/Odm/Filter/ComparisonFilter.php new file mode 100644 index 00000000000..b74e369c266 --- /dev/null +++ b/src/Doctrine/Odm/Filter/ComparisonFilter.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +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; + +/** + * Decorates an equality filter (ExactFilter) to add comparison operators (gt, gte, lt, lte). + * + * @experimental + */ +final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + private const OPERATORS = [ + 'gt' => 'gt', + 'gte' => 'gte', + 'lt' => 'lt', + 'lte' => 'lte', + ]; + + public const ALLOWED_COMPARISON_METHODS = ['equals', 'gt', 'gte', 'lt', 'lte']; + + public function __construct(private readonly FilterInterface $filter) + { + } + + /** + * @param-out array $context + */ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + $values = $parameter->getValue(); + + if (!\is_array($values)) { + return; + } + + foreach ($values as $operator => $value) { + if ('' === $value || null === $value) { + continue; + } + + if (isset(self::OPERATORS[$operator])) { + $this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); + } + } + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + $schema = $this->getInnerSchema($parameter); + + return [ + new OpenApiParameter(name: "{$key}[gt]", in: $in, schema: $schema), + new OpenApiParameter(name: "{$key}[gte]", in: $in, schema: $schema), + new OpenApiParameter(name: "{$key}[lt]", in: $in, schema: $schema), + new OpenApiParameter(name: "{$key}[lte]", in: $in, schema: $schema), + ]; + } + + public function getSchema(Parameter $parameter): array + { + $innerSchema = $this->getInnerSchema($parameter); + + return [ + 'type' => 'object', + 'properties' => [ + 'gt' => $innerSchema, + 'gte' => $innerSchema, + 'lt' => $innerSchema, + 'lte' => $innerSchema, + ], + ]; + } + + /** + * @param array $context + * + * @param-out array $context + */ + private function applyOperator(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation, array &$context, Parameter $parameter, string $comparisonMethod, mixed $value): void + { + if (!\is_string($value) && !is_numeric($value) && !$value instanceof \DateTimeInterface) { + return; + } + + $subParameter = (clone $parameter)->setValue($value); + $newContext = ['comparisonMethod' => $comparisonMethod, 'parameter' => $subParameter] + $context; + $this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext); + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } + } + + private function getInnerSchema(Parameter $parameter): array + { + if ($this->filter instanceof JsonSchemaFilterInterface) { + return $this->filter->getSchema($parameter); + } + + return ['type' => 'string']; + } +} diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 17c664393fb..18c5b51c83d 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -61,8 +61,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $classMetadata = $documentManager->getClassMetadata($resourceClass); if (!$classMetadata->hasReference($property)) { + $comparisonMethod = $context['comparisonMethod'] ?? (is_iterable($value) ? 'in' : 'equals'); + if (!\in_array($comparisonMethod, ComparisonFilter::ALLOWED_COMPARISON_METHODS, true) && 'in' !== $comparisonMethod) { + throw new InvalidArgumentException(\sprintf('Unsupported comparison method "%s".', $comparisonMethod)); + } $match - ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{is_iterable($value) ? 'in' : 'equals'}($value)); + ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{$comparisonMethod}($value)); return; } diff --git a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php index 84b73db2285..40b2a40ff07 100644 --- a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php @@ -96,9 +96,14 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder $metadata = $this->getClassMetadata($targetResourceClass); + $operator = $context['operator'] ?? '='; + if (!\in_array($operator, ComparisonFilter::ALLOWED_DQL_OPERATORS, true)) { + throw new InvalidArgumentException(\sprintf('Unsupported operator "%s".', $operator)); + } + if ($metadata->hasField($field)) { $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($field, $targetResourceClass), $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value, $operator, $context); return; } @@ -129,7 +134,7 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder } $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value, $operator, $context); } /** @@ -162,21 +167,28 @@ private function convertValuesToTheDatabaseRepresentation(QueryBuilder $queryBui /** * Adds where clause. */ - private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value): void + private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value, string $operator = '=', array $context = []): void { $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); $aliasedField = \sprintf('%s.%s', $alias, $field); + $whereClause = $context['whereClause'] ?? 'andWhere'; if (!\is_array($value)) { - $queryBuilder - ->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter)) - ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + if ('=' === $operator) { + $queryBuilder + ->{$whereClause}($queryBuilder->expr()->eq($aliasedField, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + } else { + $queryBuilder + ->{$whereClause}(\sprintf('%s %s %s', $aliasedField, $operator, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + } return; } $queryBuilder - ->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter)) + ->{$whereClause}($queryBuilder->expr()->in($aliasedField, $valueParameter)) ->setParameter($valueParameter, $value, $this->getDoctrineArrayParameterType()); } diff --git a/src/Doctrine/Orm/Filter/ComparisonFilter.php b/src/Doctrine/Orm/Filter/ComparisonFilter.php new file mode 100644 index 00000000000..e3db47b4823 --- /dev/null +++ b/src/Doctrine/Orm/Filter/ComparisonFilter.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +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\ORM\QueryBuilder; + +/** + * Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte). + * + * @experimental + */ +final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + private const OPERATORS = [ + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + ]; + + public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>']; + + public function __construct(private readonly FilterInterface $filter) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + $values = $parameter->getValue(); + + if (!\is_array($values)) { + return; + } + + foreach ($values as $operator => $value) { + if ('' === $value || null === $value) { + continue; + } + + if (isset(self::OPERATORS[$operator])) { + $this->applyOperator($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); + } + } + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + $schema = $this->getInnerSchema($parameter); + + return [ + new OpenApiParameter(name: "{$key}[gt]", in: $in, schema: $schema), + new OpenApiParameter(name: "{$key}[gte]", in: $in, schema: $schema), + new OpenApiParameter(name: "{$key}[lt]", in: $in, schema: $schema), + new OpenApiParameter(name: "{$key}[lte]", in: $in, schema: $schema), + ]; + } + + public function getSchema(Parameter $parameter): array + { + $innerSchema = $this->getInnerSchema($parameter); + + return [ + 'type' => 'object', + 'properties' => [ + 'gt' => $innerSchema, + 'gte' => $innerSchema, + 'lt' => $innerSchema, + 'lte' => $innerSchema, + ], + ]; + } + + private function applyOperator(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation, array $context, Parameter $parameter, string $operator, mixed $value): void + { + if (!\is_string($value) && !is_numeric($value) && !$value instanceof \DateTimeInterface) { + return; + } + + $subParameter = (clone $parameter)->setValue($value); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => $operator, 'parameter' => $subParameter] + $context + ); + } + + private function getInnerSchema(Parameter $parameter): array + { + if ($this->filter instanceof JsonSchemaFilterInterface) { + return $this->filter->getSchema($parameter); + } + + return ['type' => 'string']; + } +} diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 1420fcbb406..bd45dbf5d20 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -50,8 +50,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); } else { + $operator = $context['operator'] ?? '='; + if (!\in_array($operator, ComparisonFilter::ALLOWED_DQL_OPERATORS, true)) { + throw new InvalidArgumentException(\sprintf('Unsupported operator "%s".', $operator)); + } $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)); + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s %s :%s', $alias, $property, $operator, $parameterName)); } $queryBuilder->setParameter($parameterName, $value); diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 388850cb093..613d1a22409 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -311,6 +311,14 @@ private function getDefaultParameters(Operation $operation, string $resourceClas } } + if ($parameter->getCastToNativeType() && null === $parameter->getCastFn()) { + $propertyKey = $parameter->getProperty() ?? $key; + $propNativeType = ($properties[$propertyKey] ?? null)?->getNativeType(); + if ($propNativeType && $propNativeType->isIdentifiedBy(\DateTimeInterface::class)) { + $parameter = $parameter->withCastFn([ValueCaster::class, 'toDateTime']); + } + } + $priority = $parameter->getPriority() ?? $internalPriority--; $parameters->add($key, $parameter->withPriority($priority)); } diff --git a/src/State/Parameter/ValueCaster.php b/src/State/Parameter/ValueCaster.php index d876f44b739..bcfa4f8fc84 100644 --- a/src/State/Parameter/ValueCaster.php +++ b/src/State/Parameter/ValueCaster.php @@ -56,4 +56,21 @@ public static function toFloat(mixed $v): mixed return false === $value ? $v : $value; } + + public static function toDateTime(mixed $v, Parameter $parameter = null): mixed + { + if ($v instanceof \DateTimeInterface) { + return $v; + } + + if (!\is_string($v)) { + return $v; + } + + try { + return new \DateTimeImmutable($v); + } catch (\Exception) { + return $v; + } + } } diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index cf530ad586b..11df7b64297 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Odm\Filter\IriFilter; @@ -62,6 +63,10 @@ filter: new ExactFilter(), properties: ['owner.name'], ), + 'idComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'id', + ), ], ), new Get(), diff --git a/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php b/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php index 90674e4441a..e7c7a9be5ea 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Odm\Filter\DateFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -45,6 +47,11 @@ property: 'createdAt', openApi: new Parameter('date_old_way', 'query', allowEmptyValue: true) ), + 'createdAtComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'createdAt', + castToNativeType: true, + ), ], )] #[ODM\Document] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 8fd7fe1a8ea..83101bc3f64 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Orm\Filter\IriFilter; @@ -62,6 +63,10 @@ filter: new ExactFilter(), properties: ['owner.name'], ), + 'idComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'id', + ), ], ), new Get(), diff --git a/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php index 01bb7b3ad7a..97786fe1330 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -45,6 +47,11 @@ property: 'createdAt', openApi: new Parameter('date_old_way', 'query', allowEmptyValue: true) ), + 'createdAtComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'createdAt', + castToNativeType: true, + ), ], )] #[ORM\Entity] diff --git a/tests/Functional/Parameters/ComparisonFilterTest.php b/tests/Functional/Parameters/ComparisonFilterTest.php new file mode 100644 index 00000000000..8eb6f24264d --- /dev/null +++ b/tests/Functional/Parameters/ComparisonFilterTest.php @@ -0,0 +1,185 @@ + + * + * 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\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ComparisonFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + /** + * @return array name => id mapping + */ + private function getChickenIds(): array + { + $response = self::createClient()->request('GET', '/chickens?itemsPerPage=10'); + $members = $response->toArray()['member']; + $ids = []; + foreach ($members as $member) { + $ids[$member['name']] = $member['id']; + } + + return $ids; + } + + public function testGt(): void + { + $ids = $this->getChickenIds(); + // gt: id > second chicken should return third and fourth + $response = self::createClient()->request('GET', '/chickens?idComparison[gt]='.$ids['Bravo']); + $this->assertResponseIsSuccessful(); + $names = array_map(static fn ($c) => $c['name'], $response->toArray()['member']); + sort($names); + $this->assertSame(['Charlie', 'Delta'], $names); + } + + public function testGte(): void + { + $ids = $this->getChickenIds(); + $response = self::createClient()->request('GET', '/chickens?idComparison[gte]='.$ids['Bravo']); + $this->assertResponseIsSuccessful(); + $names = array_map(static fn ($c) => $c['name'], $response->toArray()['member']); + sort($names); + $this->assertSame(['Bravo', 'Charlie', 'Delta'], $names); + } + + public function testLt(): void + { + $ids = $this->getChickenIds(); + $response = self::createClient()->request('GET', '/chickens?idComparison[lt]='.$ids['Charlie']); + $this->assertResponseIsSuccessful(); + $names = array_map(static fn ($c) => $c['name'], $response->toArray()['member']); + sort($names); + $this->assertSame(['Alpha', 'Bravo'], $names); + } + + public function testLte(): void + { + $ids = $this->getChickenIds(); + $response = self::createClient()->request('GET', '/chickens?idComparison[lte]='.$ids['Charlie']); + $this->assertResponseIsSuccessful(); + $names = array_map(static fn ($c) => $c['name'], $response->toArray()['member']); + sort($names); + $this->assertSame(['Alpha', 'Bravo', 'Charlie'], $names); + } + + public function testCombinedGtAndLt(): void + { + $ids = $this->getChickenIds(); + $response = self::createClient()->request('GET', '/chickens?idComparison[gt]='.$ids['Alpha'].'&idComparison[lt]='.$ids['Delta']); + $this->assertResponseIsSuccessful(); + $names = array_map(static fn ($c) => $c['name'], $response->toArray()['member']); + sort($names); + $this->assertSame(['Bravo', 'Charlie'], $names); + } + + public function testGtNoResults(): void + { + $response = self::createClient()->request('GET', '/chickens?idComparison[gt]=999999'); + $this->assertResponseIsSuccessful(); + $this->assertCount(0, $response->toArray()['member']); + } + + public function testGteAllResults(): void + { + $ids = $this->getChickenIds(); + $minId = min($ids); + $response = self::createClient()->request('GET', '/chickens?idComparison[gte]='.$minId.'&itemsPerPage=10'); + $this->assertResponseIsSuccessful(); + $this->assertCount(4, $response->toArray()['member']); + } + + public function testOpenApiDocumentation(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + $openApiDoc = $response->toArray(); + + $parameters = $openApiDoc['paths']['/chickens']['get']['parameters']; + $parameterNames = array_column($parameters, 'name'); + + foreach (['idComparison[gt]', 'idComparison[gte]', 'idComparison[lt]', 'idComparison[lte]'] as $expectedName) { + $this->assertContains($expectedName, $parameterNames, \sprintf('Expected parameter "%s" in OpenAPI documentation', $expectedName)); + } + + $comparisonParams = array_filter($parameters, static fn ($p) => str_starts_with($p['name'], 'idComparison[')); + foreach ($comparisonParams as $param) { + $this->assertSame('query', $param['in']); + $this->assertArrayHasKey('schema', $param); + + $this->assertSame('string', $param['schema']['type']); + } + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner = new $ownerClass(); + $owner->setName('TestOwner'); + $manager->persist($owner); + $manager->flush(); + + $coop = new $coopClass(); + $manager->persist($coop); + + foreach (['Alpha', 'Bravo', 'Charlie', 'Delta'] as $name) { + $chicken = new $chickenClass(); + $chicken->setName($name); + $chicken->setEan('000000000000'); + $chicken->setChickenCoop($coop); + $chicken->setOwner($owner); + $coop->addChicken($chicken); + $manager->persist($chicken); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/DateFilterTest.php b/tests/Functional/Parameters/DateFilterTest.php index 77174da8c58..72c90d214ef 100644 --- a/tests/Functional/Parameters/DateFilterTest.php +++ b/tests/Functional/Parameters/DateFilterTest.php @@ -76,6 +76,12 @@ public static function dateFilterScenariosProvider(): \Generator 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]; + // ComparisonFilter(ExactFilter) on date column + yield 'comparison_gt' => ['/filtered_date_parameters?createdAtComparison[gt]=2024-01-01', 2]; + yield 'comparison_gte' => ['/filtered_date_parameters?createdAtComparison[gte]=2024-01-01', 3]; + yield 'comparison_lt' => ['/filtered_date_parameters?createdAtComparison[lt]=2024-12-25', 2]; + yield 'comparison_lte' => ['/filtered_date_parameters?createdAtComparison[lte]=2024-12-25', 3]; + yield 'comparison_gt_and_lt' => ['/filtered_date_parameters?createdAtComparison[gt]=2024-01-01&createdAtComparison[lt]=2024-12-25', 1]; } #[DataProvider('dateFilterNullAndEmptyScenariosProvider')]