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..3c822f1b3f3 --- /dev/null +++ b/src/Doctrine/Odm/Filter/ComparisonFilter.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\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 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(); + + return [ + new OpenApiParameter(name: "{$key}[gt]", in: $in), + new OpenApiParameter(name: "{$key}[gte]", in: $in), + new OpenApiParameter(name: "{$key}[lt]", in: $in), + new OpenApiParameter(name: "{$key}[lte]", in: $in), + ]; + } + + public function getSchema(Parameter $parameter): array + { + $innerSchema = ['type' => 'string']; + if ($this->filter instanceof JsonSchemaFilterInterface) { + $innerSchema = $this->filter->getSchema($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']; + } + } +} diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 17c664393fb..03d2b3bb914 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -61,8 +61,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $classMetadata = $documentManager->getClassMetadata($resourceClass); if (!$classMetadata->hasReference($property)) { + $comparisonMethod = $context['comparisonMethod'] ?? (is_iterable($value) ? 'in' : 'equals'); $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..41a1bce6e89 --- /dev/null +++ b/src/Doctrine/Orm/Filter/ComparisonFilter.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\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(); + + return [ + new OpenApiParameter(name: "{$key}[gt]", in: $in), + new OpenApiParameter(name: "{$key}[gte]", in: $in), + new OpenApiParameter(name: "{$key}[lt]", in: $in), + new OpenApiParameter(name: "{$key}[lte]", in: $in), + ]; + } + + public function getSchema(Parameter $parameter): array + { + $innerSchema = ['type' => 'string']; + if ($this->filter instanceof JsonSchemaFilterInterface) { + $innerSchema = $this->filter->getSchema($parameter); + } + + return [ + 'type' => 'object', + 'properties' => [ + 'gt' => $innerSchema, + 'gte' => $innerSchema, + 'lt' => $innerSchema, + 'lte' => $innerSchema, + ], + ]; + } + + /** + * @param array $context + */ + 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 + ); + } +} 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..7b9366e8d07 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): 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..c9385f1f41c 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'], ), + 'nameComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'name', + ), ], ), 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..3f785481b44 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'], ), + 'nameComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'name', + ), ], ), 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..34ae583e7e0 --- /dev/null +++ b/tests/Functional/Parameters/ComparisonFilterTest.php @@ -0,0 +1,166 @@ + + * + * 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(); + } + + public function testGt(): void + { + // gt "Bravo": names > "Bravo" alphabetically → Charlie, Delta + $response = self::createClient()->request('GET', '/chickens?nameComparison[gt]=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 + { + // gte "Bravo": names >= "Bravo" → Bravo, Charlie, Delta + $response = self::createClient()->request('GET', '/chickens?nameComparison[gte]=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 + { + // lt "Charlie": names < "Charlie" → Alpha, Bravo + $response = self::createClient()->request('GET', '/chickens?nameComparison[lt]=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 + { + // lte "Charlie": names <= "Charlie" → Alpha, Bravo, Charlie + $response = self::createClient()->request('GET', '/chickens?nameComparison[lte]=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 + { + // gt "Alpha" AND lt "Delta" → Bravo, Charlie + $response = self::createClient()->request('GET', '/chickens?nameComparison[gt]=Alpha&nameComparison[lt]=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 + { + // gt "ZZZZ": no name is alphabetically after "ZZZZ" + $response = self::createClient()->request('GET', '/chickens?nameComparison[gt]=ZZZZ'); + $this->assertResponseIsSuccessful(); + $this->assertCount(0, $response->toArray()['member']); + } + + public function testGteAllResults(): void + { + // gte "A": all names start with A or later → all 4 + $response = self::createClient()->request('GET', '/chickens?nameComparison[gte]=A&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 (['nameComparison[gt]', 'nameComparison[gte]', 'nameComparison[lt]', 'nameComparison[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'], 'nameComparison[')); + foreach ($comparisonParams as $param) { + $this->assertSame('query', $param['in']); + } + } + + 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')] diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index e440c1c3a7e..534163f081e 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -87,24 +87,30 @@ public static function exactSearchFilterProvider(): \Generator 0, [], ]; + } - yield 'filter by exact coop id' => [ - '/chickens?chickenCoopId=1', - 1, - ['Gertrude'], - ]; + public function testExactSearchFilterByCoopId(): void + { + $client = self::createClient(); + // Fetch the first coop's id dynamically (INCREMENT strategy doesn't reset on collection drop) + $coops = $client->request('GET', '/chicken_coops')->toArray()['member']; + $firstCoopId = $coops[0]['id']; - yield 'filter by coop id and correct name' => [ - '/chickens?chickenCoopId=1&name=Gertrude', - 1, - ['Gertrude'], - ]; + // filter by exact coop id + $response = $client->request('GET', '/chickens?chickenCoopId='.$firstCoopId); + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['member']); + $this->assertSame('Gertrude', $response->toArray()['member'][0]['name']); - yield 'filter by coop id and incorrect name' => [ - '/chickens?chickenCoopId=1&name=Henriette', - 0, - [], - ]; + // filter by coop id and correct name + $response = $client->request('GET', '/chickens?chickenCoopId='.$firstCoopId.'&name=Gertrude'); + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['member']); + + // filter by coop id and incorrect name + $response = $client->request('GET', '/chickens?chickenCoopId='.$firstCoopId.'&name=Henriette'); + $this->assertResponseIsSuccessful(); + $this->assertCount(0, $response->toArray()['member']); } #[DataProvider('exactSearchFilterWithOneToManyRelationProvider')] diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php index 07e5fb75bdf..d91537ce0a8 100644 --- a/tests/Functional/Parameters/IriFilterTest.php +++ b/tests/Functional/Parameters/IriFilterTest.php @@ -37,13 +37,33 @@ public static function getResources(): array return [ChickenCoop::class, Chicken::class]; } + /** + * @return array{chickenIris: string[], coopIris: string[]} + */ + private function getIris(): array + { + $client = self::createClient(); + $chickens = $client->request('GET', '/chickens')->toArray()['member']; + $coops = $client->request('GET', '/chicken_coops')->toArray()['member']; + + $chickenIris = []; + foreach ($chickens as $c) { + $chickenIris[$c['name']] = '/chickens/'.$c['id']; + } + + $coopIris = array_map(static fn ($c) => '/chicken_coops/'.$c['id'], $coops); + + return ['chickenIris' => $chickenIris, 'coopIris' => $coopIris]; + } + public function testIriFilter(): void { + $iris = $this->getIris(); $client = $this->createClient(); - $res = $client->request('GET', '/chickens?chickenCoop=/chicken_coops/2')->toArray(); + $res = $client->request('GET', '/chickens?chickenCoop='.$iris['coopIris'][1])->toArray(); $this->assertCount(1, $res['member']); - $this->assertEquals('/chicken_coops/2', $res['member'][0]['chickenCoop']); + $this->assertEquals($iris['coopIris'][1], $res['member'][0]['chickenCoop']); $res = $client->request('GET', '/chickens?chickenCoop=/chicken_coops/595')->toArray(); $this->assertCount(0, $res['member']); @@ -51,34 +71,37 @@ public function testIriFilter(): void public function testIriFilterMultiple(): void { + $iris = $this->getIris(); $client = $this->createClient(); - $res = $client->request('GET', '/chickens?chickenCoop[]=/chicken_coops/2&chickenCoop[]=/chicken_coops/1')->toArray(); + $res = $client->request('GET', '/chickens?chickenCoop[]='.$iris['coopIris'][1].'&chickenCoop[]='.$iris['coopIris'][0])->toArray(); $this->assertCount(2, $res['member']); } public function testIriFilterWithOneToManyRelation(): void { + $iris = $this->getIris(); + $chickenIri = $iris['chickenIris']['Gertrude']; $client = $this->createClient(); - $response = $client->request('GET', '/chicken_coops?chickenIri=/chickens/1'); + $response = $client->request('GET', '/chicken_coops?chickenIri='.$chickenIri); $this->assertResponseIsSuccessful(); $responseData = $response->toArray(); $filteredCoops = $responseData['member']; - $this->assertCount(1, $filteredCoops, 'Expected 1 coop for URL /chicken_coops?chickenIri=/chickens/1'); + $this->assertCount(1, $filteredCoops); $allChickenNames = []; foreach ($filteredCoops as $coop) { - foreach ($coop['chickens'] as $chickenIri) { - $chickenResponse = $this->createClient()->request('GET', $chickenIri); + foreach ($coop['chickens'] as $ci) { + $chickenResponse = $this->createClient()->request('GET', $ci); $chickenData = $chickenResponse->toArray(); $allChickenNames[] = $chickenData['name']; } } sort($allChickenNames); - $this->assertSame(['Gertrude'], $allChickenNames, 'The chicken names in coops do not match the expected values.'); + $this->assertSame(['Gertrude'], $allChickenNames); $res = $client->request('GET', '/chicken_coops?chickenIri=/chickens/595')->toArray(); $this->assertCount(0, $res['member']); @@ -86,51 +109,57 @@ public function testIriFilterWithOneToManyRelation(): void public function testIriFilterWithOneToManyRelationWithMultiple(): void { - $response = $this->createClient()->request('GET', '/chicken_coops?chickenIri[]=/chickens/1&chickenIri[]=/chickens/2'); + $iris = $this->getIris(); + $chicken1Iri = $iris['chickenIris']['Gertrude']; + $chicken2Iri = $iris['chickenIris']['Henriette']; + + $response = $this->createClient()->request('GET', '/chicken_coops?chickenIri[]='.$chicken1Iri.'&chickenIri[]='.$chicken2Iri); $this->assertResponseIsSuccessful(); $responseData = $response->toArray(); $filteredCoops = $responseData['member']; - $this->assertCount(2, $filteredCoops, 'Expected 2 coops for URL /chicken_coops?chickenIri[]=/chickens/1&chickenIri[]=/chickens/2'); + $this->assertCount(2, $filteredCoops); $allChickenNames = []; foreach ($filteredCoops as $coop) { - foreach ($coop['chickens'] as $chickenIri) { - $chickenResponse = $this->createClient()->request('GET', $chickenIri); + foreach ($coop['chickens'] as $ci) { + $chickenResponse = $this->createClient()->request('GET', $ci); $chickenData = $chickenResponse->toArray(); $allChickenNames[] = $chickenData['name']; } } sort($allChickenNames); - $this->assertSame(['Gertrude', 'Henriette'], $allChickenNames, 'The chicken names in coops do not match the expected values.'); + $this->assertSame(['Gertrude', 'Henriette'], $allChickenNames); } public function testIriFilterWithOneToManyRelationWithPropertyPlaceholder(): void { + $iris = $this->getIris(); + $chickenIri = $iris['chickenIris']['Gertrude']; $client = $this->createClient(); $propertyName = $this->isMongoDB() ? 'chickenReferences' : 'chickens'; - $response = $client->request('GET', '/chicken_coops?searchChickenIri['.$propertyName.']=/chickens/1'); + $response = $client->request('GET', '/chicken_coops?searchChickenIri['.$propertyName.']='.$chickenIri); $this->assertResponseIsSuccessful(); $responseData = $response->toArray(); $filteredCoops = $responseData['member']; - $this->assertCount(1, $filteredCoops, 'Expected 1 coop for URL /chicken_coops?searchChickenIri['.$propertyName.']=/chickens/1'); + $this->assertCount(1, $filteredCoops); $allChickenNames = []; foreach ($filteredCoops as $coop) { - foreach ($coop['chickens'] as $chickenIri) { - $chickenResponse = $this->createClient()->request('GET', $chickenIri); + foreach ($coop['chickens'] as $ci) { + $chickenResponse = $this->createClient()->request('GET', $ci); $chickenData = $chickenResponse->toArray(); $allChickenNames[] = $chickenData['name']; } } sort($allChickenNames); - $this->assertSame(['Gertrude'], $allChickenNames, 'The chicken names in coops do not match the expected values.'); + $this->assertSame(['Gertrude'], $allChickenNames); $res = $client->request('GET', '/chicken_coops?searchChickenIri['.$propertyName.']=/chickens/595')->toArray(); $this->assertCount(0, $res['member']); @@ -138,30 +167,36 @@ public function testIriFilterWithOneToManyRelationWithPropertyPlaceholder(): voi public function testIriFilterWithOneToManyRelationWithMultiplePropertyPlaceholder(): void { - $response = $this->createClient()->request('GET', '/chicken_coops?searchChickenIri[chickens][]=/chickens/1&searchChickenIri[chickens][]=/chickens/2'); + $iris = $this->getIris(); + $chicken1Iri = $iris['chickenIris']['Gertrude']; + $chicken2Iri = $iris['chickenIris']['Henriette']; + $propertyName = $this->isMongoDB() ? 'chickenReferences' : 'chickens'; + + $response = $this->createClient()->request('GET', '/chicken_coops?searchChickenIri['.$propertyName.'][]='.$chicken1Iri.'&searchChickenIri['.$propertyName.'][]='.$chicken2Iri); $this->assertResponseIsSuccessful(); $responseData = $response->toArray(); $filteredCoops = $responseData['member']; - $this->assertCount(2, $filteredCoops, 'Expected 2 coops for URL /chicken_coops?searchChickenIri[chickens][]=/chickens/1&searchChickenIri[chickens][]=/chickens/2'); + $this->assertCount(2, $filteredCoops); $allChickenNames = []; foreach ($filteredCoops as $coop) { - foreach ($coop['chickens'] as $chickenIri) { - $chickenResponse = $this->createClient()->request('GET', $chickenIri); + foreach ($coop['chickens'] as $ci) { + $chickenResponse = $this->createClient()->request('GET', $ci); $chickenData = $chickenResponse->toArray(); $allChickenNames[] = $chickenData['name']; } } sort($allChickenNames); - $this->assertSame(['Gertrude', 'Henriette'], $allChickenNames, 'The chicken names in coops do not match the expected values.'); + $this->assertSame(['Gertrude', 'Henriette'], $allChickenNames); } public function testIriFilterThrowsExceptionWhenPropertyIsMissing(): void { - $response = self::createClient()->request('GET', '/chickens?chickenCoopNoProperty=/chicken_coops/1'); + $iris = $this->getIris(); + $response = self::createClient()->request('GET', '/chickens?chickenCoopNoProperty='.$iris['coopIris'][0]); $this->assertResponseStatusCodeSame(400); $responseData = $response->toArray(false); diff --git a/tests/RecreateSchemaTrait.php b/tests/RecreateSchemaTrait.php index 80ac15a8b96..7ab756e4604 100644 --- a/tests/RecreateSchemaTrait.php +++ b/tests/RecreateSchemaTrait.php @@ -31,7 +31,7 @@ private function recreateSchema(array $classes = []): void $schemaManager = $manager->getSchemaManager(); foreach ($classes as $c) { $class = str_contains($c, 'Entity') ? str_replace('Entity', 'Document', $c) : $c; - $schemaManager->dropDocumentDatabase($class); + $schemaManager->dropDocumentCollection($class); } return;