diff --git a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php index 5f221fdc6b..413ed0bd22 100644 --- a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php @@ -18,7 +18,7 @@ use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; -use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; @@ -33,7 +33,6 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\Type; -use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; /** @@ -44,7 +43,7 @@ class AbstractUuidFilter implements FilterInterface, ManagerRegistryAwareInterfa use BackwardCompatibleFilterDescriptionTrait; use LoggerAwareTrait; use ManagerRegistryAwareTrait; - use OrmPropertyHelperTrait; + use NestedPropertyHelperTrait; use PropertyHelperTrait; private const UUID_SCHEMA = [ @@ -52,6 +51,22 @@ class AbstractUuidFilter implements FilterInterface, ManagerRegistryAwareInterfa 'format' => 'uuid', ]; + /** + * Gets class metadata for the given resource. + */ + protected function getClassMetadata(string $resourceClass): \Doctrine\Persistence\Mapping\ClassMetadata + { + $manager = $this + ->getManagerRegistry() + ->getManagerForClass($resourceClass); + + if ($manager) { + return $manager->getClassMetadata($resourceClass); + } + + return new \Doctrine\ORM\Mapping\ClassMetadata($resourceClass); + } + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter'] ?? null; @@ -60,26 +75,28 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q } if (null === $parameter->getProperty()) { - throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Nested properties are not automatically resolved. Please provide the property explicitly.', $parameter->getKey())); + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); } - $this->filterProperty($parameter->getProperty(), $parameter->getValue(), $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + $this->filterProperty($parameter, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } - private function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + $property = $parameter->getProperty(); + $value = $parameter->getValue(); $alias = $queryBuilder->getRootAliases()[0]; - $field = $property; - $associations = []; - if ($this->isPropertyNested($property, $resourceClass)) { - [$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); - } + [$alias, $field] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + + // Get the target resource class for nested properties + $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + $targetResourceClass = $nestedInfo['leaf_class'] ?? $resourceClass; - $metadata = $this->getNestedMetadata($resourceClass, $associations); + $metadata = $this->getClassMetadata($targetResourceClass); if ($metadata->hasField($field)) { - $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($property, $resourceClass), $value); + $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($field, $targetResourceClass), $value); $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value); return; @@ -89,8 +106,8 @@ private function filterProperty(string $property, mixed $value, QueryBuilder $qu if (!$metadata->hasAssociation($field)) { $this->logger->notice('Tried to filter on a non-existent field or association', [ 'field' => $field, - 'resource_class' => $resourceClass, - 'exception' => new InvalidArgumentException(\sprintf('Property "%s" does not exist in resource "%s".', $field, $resourceClass)), + 'resource_class' => $targetResourceClass, + 'exception' => new InvalidArgumentException(\sprintf('Property "%s" does not exist in resource "%s".', $field, $targetResourceClass)), ]); return; diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 194c985eb3..1420fcbb40 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -44,7 +44,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); - [$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); if (\is_array($value)) { $queryBuilder diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 43ec3ba94a..7bd0963114 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -29,6 +30,7 @@ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void @@ -42,19 +44,66 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; + + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $parameterName = $queryNameGenerator->generateParameterName($property); - $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); + // Resolve the metadata for the entity that owns the leaf property. + // For nested properties like "department.company", we need to walk the association chain + // to get the metadata of the entity that owns "company" (i.e. FilterDepartment). + $em = $queryBuilder->getEntityManager(); + $metadata = $em->getClassMetadata($resourceClass); + $originalProperty = $parameter->getProperty(); + $segments = explode('.', $originalProperty); + // Walk all segments except the last (which is the leaf property) + for ($i = 0, $count = \count($segments) - 1; $i < $count; ++$i) { + $associationMapping = $metadata->getAssociationMapping($segments[$i]); + $metadata = $em->getClassMetadata($associationMapping['targetEntity']); + } + + // Determine if the association is a collection (OneToMany/ManyToMany) or single-valued (ManyToOne/OneToOne). + // Collection associations require a JOIN to compare individual elements. + // Single-valued associations can be compared directly, which avoids issues with custom ID types (e.g. UUID). + $isCollectionAssociation = $metadata->isCollectionValuedAssociation($property); + + if ($isCollectionAssociation) { + $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); + + if (is_iterable($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + } - if (is_iterable($value)) { - $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); $queryBuilder->setParameter($parameterName, $value); - } else { + + return; + } + + $propertyExpr = \sprintf('%s.%s', $alias, $property); + + if (is_iterable($value)) { $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $propertyExpr, $parameterName)); $queryBuilder->setParameter($parameterName, $value); + + return; } + + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $propertyExpr, $parameterName)); + + // Extract the identifier value and its type from the target entity metadata + // to properly handle custom ID types (e.g. UUID). + $associationMapping = $metadata->getAssociationMapping($property); + $targetMetadata = $em->getClassMetadata($associationMapping['targetEntity']); + $idFieldNames = $targetMetadata->getIdentifierFieldNames(); + $idType = $targetMetadata->getTypeOfField($idFieldNames[0]); + $identifierValues = $targetMetadata->getIdentifierValues($value); + $queryBuilder->setParameter($parameterName, reset($identifierValues), $idType); } public static function getParameterProvider(): string diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index 30ad8385f1..2b78b5350f 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -45,7 +45,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; - [$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); $field = $alias.'.'.$property; $values = $parameter->getValue(); diff --git a/src/Doctrine/Orm/Filter/SortFilter.php b/src/Doctrine/Orm/Filter/SortFilter.php new file mode 100644 index 0000000000..4b85a80389 --- /dev/null +++ b/src/Doctrine/Orm/Filter/SortFilter.php @@ -0,0 +1,88 @@ + + * + * 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\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +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 Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +/** + * Parameter-based order filter for sorting a collection by a property. + * + * Unlike {@see OrderFilter}, this filter does not extend AbstractFilter and is designed + * exclusively for use with Parameters (QueryParameter). + * + * Usage: `new QueryParameter(filter: new SortFilter(), property: 'department.name')`. + * + * @author Kévin Dunglas + */ +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct( + private readonly ?string $nullsComparison = null, + ) { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter'] ?? null; + if (null === $parameter) { + return; + } + + $value = $context['filters'][$parameter->getProperty() ?? ''] ?? null; + if (null === $value) { + return; + } + + $direction = strtoupper($value); + if (!\in_array($direction, ['ASC', 'DESC'], true)) { + return; + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + + [$alias, $field] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter, Join::LEFT_JOIN); + + if (null !== $nullsComparison = $this->nullsComparison) { + $nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction]; + $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); + $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); + $queryBuilder->addOrderBy($nullRankHiddenField, $nullsDirection); + } + + $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); + } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']]; + } +} diff --git a/src/Doctrine/Orm/NestedPropertyHelperTrait.php b/src/Doctrine/Orm/NestedPropertyHelperTrait.php index c76c6cc6f8..1abb8be8d8 100644 --- a/src/Doctrine/Orm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Orm/NestedPropertyHelperTrait.php @@ -31,12 +31,13 @@ trait NestedPropertyHelperTrait * @return array An array where the first element is the join $alias of the leaf entity, * the second element is the leaf property */ - protected function addJoinsForNestedProperty( + protected function addNestedParameterJoins( string $property, string $alias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, Parameter $parameter, + ?string $joinType = null, ): array { $extraProperties = $parameter->getExtraProperties(); $nestedInfo = $extraProperties['nested_property_info'] ?? null; @@ -50,7 +51,8 @@ protected function addJoinsForNestedProperty( $queryBuilder, $queryNameGenerator, $alias, - $association + $association, + $joinType ); } diff --git a/src/Laravel/Eloquent/Filter/EqualsFilter.php b/src/Laravel/Eloquent/Filter/EqualsFilter.php index c640e935f2..bc7622a680 100644 --- a/src/Laravel/Eloquent/Filter/EqualsFilter.php +++ b/src/Laravel/Eloquent/Filter/EqualsFilter.php @@ -28,7 +28,7 @@ final class EqualsFilter implements FilterInterface */ public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder { - return $this->applyWithNestedProperty( + return $this->addNestedParameterJoins( $builder, $parameter, static fn (Builder $query, string $property, string $whereClause) => $query->{$whereClause}($property, $values), diff --git a/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php b/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php index a066b785b2..99f674440d 100644 --- a/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php +++ b/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php @@ -29,7 +29,7 @@ trait NestedPropertyTrait * * @return Builder<\Illuminate\Database\Eloquent\Model> */ - private function applyWithNestedProperty( + private function addNestedParameterJoins( Builder $builder, Parameter $parameter, callable $condition, diff --git a/src/Laravel/Eloquent/Filter/OrderFilter.php b/src/Laravel/Eloquent/Filter/OrderFilter.php index 3cf3b23987..0856299149 100644 --- a/src/Laravel/Eloquent/Filter/OrderFilter.php +++ b/src/Laravel/Eloquent/Filter/OrderFilter.php @@ -19,6 +19,8 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOneOrMany; final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { @@ -43,7 +45,56 @@ public function apply(Builder $builder, mixed $values, Parameter $parameter, arr return $builder; } - return $builder->orderBy($this->getQueryProperty($parameter), $values); + $direction = strtoupper($values); + if (!\in_array($direction, ['ASC', 'DESC'], true)) { + return $builder; + } + + $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + + if (!$nestedInfo || 0 === \count($nestedInfo['relation_segments'])) { + return $builder->orderBy($this->getQueryProperty($parameter), $direction); + } + + $relationSegments = $nestedInfo['relation_segments']; + $relationClasses = $nestedInfo['relation_classes']; + $leafProperty = $nestedInfo['leaf_property']; + + $currentModel = $builder->getModel(); + foreach ($relationSegments as $i => $segment) { + if (!method_exists($currentModel, $segment)) { + return $builder; + } + + $relation = $currentModel->{$segment}(); + $relatedTable = $relation->getRelated()->getTable(); + + if ($relation instanceof BelongsTo) { + $builder->leftJoin( + $relatedTable, + $currentModel->getTable().'.'.$relation->getForeignKeyName(), + '=', + $relatedTable.'.'.$relation->getOwnerKeyName() + ); + } elseif ($relation instanceof HasOneOrMany) { + $builder->leftJoin( + $relatedTable, + $currentModel->getTable().'.'.$relation->getLocalKeyName(), + '=', + $relatedTable.'.'.$relation->getForeignKeyName() + ); + } else { + return $builder; + } + + $nextClass = $relationClasses[$i + 1] ?? null; + /** @var Model $currentModel */ + $currentModel = $nextClass ? new $nextClass() : $relation->getRelated(); + } + + $builder->select($builder->getModel()->getTable().'.*'); + + return $builder->orderBy($currentModel->getTable().'.'.$leafProperty, $direction); } /** diff --git a/src/Laravel/Eloquent/Filter/PartialSearchFilter.php b/src/Laravel/Eloquent/Filter/PartialSearchFilter.php index 419855608f..136deae559 100644 --- a/src/Laravel/Eloquent/Filter/PartialSearchFilter.php +++ b/src/Laravel/Eloquent/Filter/PartialSearchFilter.php @@ -28,7 +28,7 @@ final class PartialSearchFilter implements FilterInterface */ public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder { - return $this->applyWithNestedProperty( + return $this->addNestedParameterJoins( $builder, $parameter, static fn (Builder $query, string $property, string $whereClause) => $query->{$whereClause}($property, 'like', '%'.$values.'%'), diff --git a/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterCompany.php b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterCompany.php new file mode 100644 index 0000000000..23592d7ff6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterCompany.php @@ -0,0 +1,64 @@ + + * + * 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\FilterNestedTest; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +/** + * Company entity for testing nested filter support. + */ +#[ORM\Entity] +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + ] +)] +class FilterCompany +{ + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid')] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + private Uuid $id; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + public function __construct() + { + $this->id = Uuid::v4(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterDepartment.php b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterDepartment.php new file mode 100644 index 0000000000..3607b7c574 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterDepartment.php @@ -0,0 +1,80 @@ + + * + * 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\FilterNestedTest; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +/** + * Department entity for testing nested filter support. + */ +#[ORM\Entity] +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + ] +)] +class FilterDepartment +{ + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid')] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + private Uuid $id; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\ManyToOne(targetEntity: FilterCompany::class)] + #[ORM\JoinColumn(nullable: false)] + private FilterCompany $company; + + public function __construct() + { + $this->id = Uuid::v4(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getCompany(): FilterCompany + { + return $this->company; + } + + public function setCompany(FilterCompany $company): self + { + $this->company = $company; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterEmployee.php b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterEmployee.php new file mode 100644 index 0000000000..63eafed7db --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterEmployee.php @@ -0,0 +1,113 @@ + + * + * 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\FilterNestedTest; + +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Doctrine\Orm\Filter\UuidFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\Uid\Uuid; + +/** + * Employee entity for testing nested filter support with IriFilter, UuidFilter and OrderFilter. + */ +#[ORM\Entity] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'department' => new QueryParameter(filter: new IriFilter()), + 'departmentId' => new QueryParameter(filter: new UuidFilter(), property: 'department'), + + 'departmentCompany' => new QueryParameter(filter: new IriFilter(), property: 'department.company'), + 'departmentCompanyId' => new QueryParameter(filter: new UuidFilter(), property: 'department.company'), + + 'orderDepartmentName' => new QueryParameter(filter: new SortFilter(), property: 'department.name', nativeType: new BuiltinType(TypeIdentifier::STRING)), + 'orderName' => new QueryParameter(filter: new SortFilter(), property: 'name', nativeType: new BuiltinType(TypeIdentifier::STRING)), + 'orderHireDate' => new QueryParameter(filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), property: 'hireDate', nativeType: new BuiltinType(TypeIdentifier::STRING)), + 'orderHireDateNullsLast' => new QueryParameter(filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_LAST), property: 'hireDate', nativeType: new BuiltinType(TypeIdentifier::STRING)), + ] + ), + ] +)] +class FilterEmployee +{ + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid')] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + private Uuid $id; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $hireDate = null; + + #[ORM\ManyToOne(targetEntity: FilterDepartment::class)] + #[ORM\JoinColumn(nullable: false)] + private FilterDepartment $department; + + public function __construct() + { + $this->id = Uuid::v4(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getHireDate(): ?\DateTimeImmutable + { + return $this->hireDate; + } + + public function setHireDate(?\DateTimeImmutable $hireDate): self + { + $this->hireDate = $hireDate; + + return $this; + } + + public function getDepartment(): FilterDepartment + { + return $this->department; + } + + public function setDepartment(FilterDepartment $department): self + { + $this->department = $department; + + return $this; + } +} diff --git a/tests/Functional/NestedFilterTest.php b/tests/Functional/NestedFilterTest.php new file mode 100644 index 0000000000..c34bb770e7 --- /dev/null +++ b/tests/Functional/NestedFilterTest.php @@ -0,0 +1,272 @@ + + * + * 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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterNestedTest\FilterCompany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterNestedTest\FilterDepartment; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterNestedTest\FilterEmployee; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests for nested property filtering with IriFilter and UuidFilter. + */ +final class NestedFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilterCompany::class, FilterDepartment::class, FilterEmployee::class]; + } + + public function testIriFilterWithDirectRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + [$company1, $company2, $dept1, $dept2, $emp1, $emp2] = $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/filter_employees?department=/filter_departments/'.$dept1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find employees in department 1'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testIriFilterWithNestedRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + [$company1, $company2, $dept1, $dept2, $emp1, $emp2] = $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/filter_employees?departmentCompany=/filter_companies/'.$company1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find employees whose department belongs to company 1'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testUuidFilterWithDirectRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + [$company1, $company2, $dept1, $dept2, $emp1, $emp2] = $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/filter_employees?departmentId='.$dept1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find employees in department 1 by UUID'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testUuidFilterWithNestedRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + [$company1, $company2, $dept1, $dept2, $emp1, $emp2] = $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/filter_employees?departmentCompanyId='.$company1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find employees whose department belongs to company 1 by UUID'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testOrderFilterWithNestedRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + + // Order by department.name ASC — Engineering < Sales + $response = self::createClient()->request('GET', '/filter_employees?orderDepartmentName=asc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + // Engineering employees first (Alice, Charlie), then Sales (Bob) + $this->assertEquals('Bob', $data['hydra:member'][2]['name']); + + // Order by department.name DESC — Sales > Engineering + $response = self::createClient()->request('GET', '/filter_employees?orderDepartmentName=desc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + $this->assertEquals('Bob', $data['hydra:member'][0]['name']); + } + + public function testOrderFilterWithDirectProperty(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + + // Order by name ASC — Alice < Bob < Charlie + $response = self::createClient()->request('GET', '/filter_employees?orderName=asc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + $this->assertEquals('Alice', $data['hydra:member'][0]['name']); + $this->assertEquals('Bob', $data['hydra:member'][1]['name']); + $this->assertEquals('Charlie', $data['hydra:member'][2]['name']); + + // Order by name DESC — Charlie > Bob > Alice + $response = self::createClient()->request('GET', '/filter_employees?orderName=desc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + $this->assertEquals('Charlie', $data['hydra:member'][0]['name']); + $this->assertEquals('Bob', $data['hydra:member'][1]['name']); + $this->assertEquals('Alice', $data['hydra:member'][2]['name']); + } + + public function testSortFilterNullsAlwaysFirst(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + + // ASC with nulls_always_first — Charlie (null) first, then Alice (2024-01), then Bob (2024-06) + $response = self::createClient()->request('GET', '/filter_employees?orderHireDate=asc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + $this->assertEquals('Charlie', $data['hydra:member'][0]['name']); + $this->assertEquals('Alice', $data['hydra:member'][1]['name']); + $this->assertEquals('Bob', $data['hydra:member'][2]['name']); + + // DESC with nulls_always_first — Charlie (null) first, then Bob (2024-06), then Alice (2024-01) + $response = self::createClient()->request('GET', '/filter_employees?orderHireDate=desc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + $this->assertEquals('Charlie', $data['hydra:member'][0]['name']); + $this->assertEquals('Bob', $data['hydra:member'][1]['name']); + $this->assertEquals('Alice', $data['hydra:member'][2]['name']); + } + + public function testSortFilterNullsAlwaysLast(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not supported for this test'); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + + // ASC with nulls_always_last — Alice (2024-01), Bob (2024-06), then Charlie (null) + $response = self::createClient()->request('GET', '/filter_employees?orderHireDateNullsLast=asc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + $this->assertEquals('Alice', $data['hydra:member'][0]['name']); + $this->assertEquals('Bob', $data['hydra:member'][1]['name']); + $this->assertEquals('Charlie', $data['hydra:member'][2]['name']); + + // DESC with nulls_always_last — Bob (2024-06), Alice (2024-01), then Charlie (null) + $response = self::createClient()->request('GET', '/filter_employees?orderHireDateNullsLast=desc'); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member']); + $this->assertEquals('Bob', $data['hydra:member'][0]['name']); + $this->assertEquals('Alice', $data['hydra:member'][1]['name']); + $this->assertEquals('Charlie', $data['hydra:member'][2]['name']); + } + + private function loadFixtures(): array + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + + $company1 = new FilterCompany(); + $company1->setName('Acme Corp'); + $manager->persist($company1); + + $company2 = new FilterCompany(); + $company2->setName('TechStart Inc'); + $manager->persist($company2); + + $manager->flush(); + + $dept1 = new FilterDepartment(); + $dept1->setName('Engineering'); + $dept1->setCompany($company1); + $manager->persist($dept1); + + $dept2 = new FilterDepartment(); + $dept2->setName('Sales'); + $dept2->setCompany($company2); + $manager->persist($dept2); + + $manager->flush(); + + $emp1 = new FilterEmployee(); + $emp1->setName('Alice'); + $emp1->setDepartment($dept1); + $emp1->setHireDate(new \DateTimeImmutable('2024-01-15')); + $manager->persist($emp1); + + $emp2 = new FilterEmployee(); + $emp2->setName('Bob'); + $emp2->setDepartment($dept2); + $emp2->setHireDate(new \DateTimeImmutable('2024-06-01')); + $manager->persist($emp2); + + $emp3 = new FilterEmployee(); + $emp3->setName('Charlie'); + $emp3->setDepartment($dept1); + // hireDate left null + $manager->persist($emp3); + + $manager->flush(); + + return [$company1, $company2, $dept1, $dept2, $emp1, $emp2, $emp3]; + } +} diff --git a/tests/Functional/Parameters/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php index 53a6683596..cc0893e3dc 100644 --- a/tests/Functional/Parameters/OrFilterTest.php +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -76,7 +76,7 @@ public function testOrFilterWithAnd(): void /** @var DoctrineDataCollector */ $db = $profile->getCollector('db'); - $this->assertStringContainsString('WHERE c1_.id = ? AND (c2_.name = ? OR c2_.ean = ?))', end($db->getQueries()['default'])['sql']); + $this->assertStringContainsString('WHERE c0_.chickenCoop_id = ? AND (c0_.name = ? OR c0_.ean = ?)', end($db->getQueries()['default'])['sql']); } public function testOrFilterWithOneToManyRelation(): void