diff --git a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php index 5f221fdc6b..84b73db228 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,7 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\Type; -use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\QueryBuilder; /** @@ -44,7 +44,7 @@ class AbstractUuidFilter implements FilterInterface, ManagerRegistryAwareInterfa use BackwardCompatibleFilterDescriptionTrait; use LoggerAwareTrait; use ManagerRegistryAwareTrait; - use OrmPropertyHelperTrait; + use NestedPropertyHelperTrait; use PropertyHelperTrait; private const UUID_SCHEMA = [ @@ -52,6 +52,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 ClassMetadata($resourceClass); + } + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter'] ?? null; @@ -60,26 +76,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 +107,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..9ec3b579b9 --- /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 Antoine Bluchet + */ +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/ApiResource/PaginationMappedResource.php b/tests/Fixtures/TestBundle/ApiResource/PaginationMappedResource.php new file mode 100644 index 0000000000..d2a051c003 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/PaginationMappedResource.php @@ -0,0 +1,40 @@ + + * + * 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\ApiResource; + +use ApiPlatform\Doctrine\Odm\State\Options; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\PartialPaginationMappedDocument; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + operations: [ + new GetCollection( + paginationItemsPerPage: 3, + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], + ), + ], + stateOptions: new Options(documentClass: PartialPaginationMappedDocument::class), +)] +#[Map(target: PartialPaginationMappedDocument::class)] +final class PaginationMappedResource +{ + #[Map(if: false)] + public ?int $id = null; + + #[Map(target: 'name')] + public string $title; +} diff --git a/tests/Fixtures/TestBundle/Document/PartialPaginationMappedDocument.php b/tests/Fixtures/TestBundle/Document/PartialPaginationMappedDocument.php new file mode 100644 index 0000000000..e12cb2e0e0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PartialPaginationMappedDocument.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +class PartialPaginationMappedDocument +{ + #[ODM\Id(strategy: 'INCREMENT')] + public ?int $id = null; + + #[ODM\Field(type: 'string')] + public ?string $name = null; +} 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/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Author.php b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Author.php new file mode 100644 index 0000000000..cbc14ce761 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Author.php @@ -0,0 +1,161 @@ + + * + * 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\IriFilterRelationsTest; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Author entity for IriFilter relations testing - central entity with all relation types. + */ +#[ORM\Entity] +#[ORM\Table(name: 'iri_filter_relations_author')] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'profile' => new QueryParameter(filter: new IriFilter()), + 'biography' => new QueryParameter(filter: new IriFilter()), + 'book' => new QueryParameter(filter: new IriFilter(), property: 'books'), + 'publisher' => new QueryParameter(filter: new IriFilter()), + 'publisherCountry' => new QueryParameter(filter: new IriFilter(), property: 'publisher.country'), + 'bookPublisher' => new QueryParameter(filter: new IriFilter(), property: 'books.publisher'), + ] + ), + ] +)] +class Author +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\OneToOne(targetEntity: Profile::class, inversedBy: 'author')] + #[ORM\JoinColumn(nullable: true)] + private ?Profile $profile = null; + + #[ORM\OneToOne(targetEntity: Biography::class, inversedBy: 'author')] + #[ORM\JoinColumn(nullable: true)] + private ?Biography $biography = null; + + #[ORM\ManyToMany(targetEntity: Book::class, mappedBy: 'authors')] + private Collection $books; + + #[ORM\ManyToOne(targetEntity: Publisher::class, inversedBy: 'authors')] + #[ORM\JoinColumn(nullable: true)] + private ?Publisher $publisher = null; + + public function __construct() + { + $this->books = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getProfile(): ?Profile + { + return $this->profile; + } + + public function setProfile(?Profile $profile): self + { + $this->profile = $profile; + + if (null !== $profile && $profile->getAuthor() !== $this) { + $profile->setAuthor($this); + } + + return $this; + } + + public function getBiography(): ?Biography + { + return $this->biography; + } + + public function setBiography(?Biography $biography): self + { + if (null === $biography && null !== $this->biography) { + $this->biography->setAuthor(null); + } + + $this->biography = $biography; + + if (null !== $biography && $biography->getAuthor() !== $this) { + $biography->setAuthor($this); + } + + return $this; + } + + public function getBooks(): Collection + { + return $this->books; + } + + public function addBook(Book $book): self + { + if (!$this->books->contains($book)) { + $this->books[] = $book; + $book->addAuthor($this); + } + + return $this; + } + + public function removeBook(Book $book): self + { + if ($this->books->removeElement($book)) { + $book->removeAuthor($this); + } + + return $this; + } + + public function getPublisher(): ?Publisher + { + return $this->publisher; + } + + public function setPublisher(?Publisher $publisher): self + { + $this->publisher = $publisher; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Biography.php b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Biography.php new file mode 100644 index 0000000000..cbc30026d0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Biography.php @@ -0,0 +1,85 @@ + + * + * 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\IriFilterRelationsTest; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Biography entity for IriFilter relations testing - OneToOne inverse side. + */ +#[ORM\Entity] +#[ORM\Table(name: 'iri_filter_relations_biography')] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'author' => new QueryParameter(filter: new IriFilter()), + ] + ), + ] +)] +class Biography +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'text')] + private string $text; + + #[ORM\OneToOne(targetEntity: Author::class, mappedBy: 'biography')] + private ?Author $author = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getText(): string + { + return $this->text; + } + + public function setText(string $text): self + { + $this->text = $text; + + return $this; + } + + public function getAuthor(): ?Author + { + return $this->author; + } + + public function setAuthor(?Author $author): self + { + if (null === $author && null !== $this->author) { + $this->author->setBiography(null); + } + + $this->author = $author; + + if (null !== $author && $author->getBiography() !== $this) { + $author->setBiography($this); + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Book.php b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Book.php new file mode 100644 index 0000000000..cfe767390d --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Book.php @@ -0,0 +1,145 @@ + + * + * 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\IriFilterRelationsTest; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Book entity for IriFilter relations testing - ManyToMany and nested relations. + */ +#[ORM\Entity] +#[ORM\Table(name: 'iri_filter_relations_book')] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'author' => new QueryParameter(filter: new IriFilter(), property: 'authors'), + 'publisher' => new QueryParameter(filter: new IriFilter()), + 'publisherCountry' => new QueryParameter(filter: new IriFilter(), property: 'publisher.country'), + ] + ), + ] +)] +class Book +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $title; + + #[ORM\ManyToMany(targetEntity: Author::class, inversedBy: 'books')] + #[ORM\JoinTable(name: 'iri_filter_relations_book_author')] + private Collection $authors; + + #[ORM\ManyToOne(targetEntity: Publisher::class, inversedBy: 'books')] + #[ORM\JoinColumn(nullable: true)] + private ?Publisher $publisher = null; + + #[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'book')] + private Collection $reviews; + + public function __construct() + { + $this->authors = new ArrayCollection(); + $this->reviews = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getAuthors(): Collection + { + return $this->authors; + } + + public function addAuthor(Author $author): self + { + if (!$this->authors->contains($author)) { + $this->authors[] = $author; + $author->addBook($this); + } + + return $this; + } + + public function removeAuthor(Author $author): self + { + if ($this->authors->removeElement($author)) { + $author->removeBook($this); + } + + return $this; + } + + public function getPublisher(): ?Publisher + { + return $this->publisher; + } + + public function setPublisher(?Publisher $publisher): self + { + $this->publisher = $publisher; + + return $this; + } + + public function getReviews(): Collection + { + return $this->reviews; + } + + public function addReview(Review $review): self + { + if (!$this->reviews->contains($review)) { + $this->reviews[] = $review; + $review->setBook($this); + } + + return $this; + } + + public function removeReview(Review $review): self + { + if ($this->reviews->removeElement($review)) { + if ($review->getBook() === $this) { + $review->setBook(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Country.php b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Country.php new file mode 100644 index 0000000000..696a9fcbcc --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Country.php @@ -0,0 +1,98 @@ + + * + * 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\IriFilterRelationsTest; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Country entity for IriFilter relations testing - leaf entity for deep nesting. + */ +#[ORM\Entity] +#[ORM\Table(name: 'iri_filter_relations_country')] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'publisher' => new QueryParameter(filter: new IriFilter()), + ] + ), + ] +)] +class Country +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\OneToMany(targetEntity: Publisher::class, mappedBy: 'country')] + private Collection $publishers; + + public function __construct() + { + $this->publishers = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getPublishers(): Collection + { + return $this->publishers; + } + + public function addPublisher(Publisher $publisher): self + { + if (!$this->publishers->contains($publisher)) { + $this->publishers[] = $publisher; + $publisher->setCountry($this); + } + + return $this; + } + + public function removePublisher(Publisher $publisher): self + { + if ($this->publishers->removeElement($publisher)) { + if ($publisher->getCountry() === $this) { + $publisher->setCountry(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Profile.php b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Profile.php new file mode 100644 index 0000000000..a86ad96527 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Profile.php @@ -0,0 +1,86 @@ + + * + * 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\IriFilterRelationsTest; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Profile entity for IriFilter relations testing - OneToOne owning side. + */ +#[ORM\Entity] +#[ORM\Table(name: 'iri_filter_relations_profile')] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'author' => new QueryParameter(filter: new IriFilter()), + 'authorPublisher' => new QueryParameter(filter: new IriFilter(), property: 'author.publisher'), + ] + ), + ] +)] +class Profile +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'text')] + private string $bio; + + #[ORM\OneToOne(targetEntity: Author::class, mappedBy: 'profile')] + private ?Author $author = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getBio(): string + { + return $this->bio; + } + + public function setBio(string $bio): self + { + $this->bio = $bio; + + return $this; + } + + public function getAuthor(): ?Author + { + return $this->author; + } + + public function setAuthor(?Author $author): self + { + if (null === $author && null !== $this->author) { + $this->author->setProfile(null); + } + + $this->author = $author; + + if (null !== $author && $author->getProfile() !== $this) { + $author->setProfile($this); + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Publisher.php b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Publisher.php new file mode 100644 index 0000000000..f1faa377eb --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Publisher.php @@ -0,0 +1,146 @@ + + * + * 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\IriFilterRelationsTest; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Publisher entity for IriFilter relations testing - mid-level for 3-level nesting. + */ +#[ORM\Entity] +#[ORM\Table(name: 'iri_filter_relations_publisher')] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'author' => new QueryParameter(filter: new IriFilter()), + 'book' => new QueryParameter(filter: new IriFilter()), + 'country' => new QueryParameter(filter: new IriFilter()), + ] + ), + ] +)] +class Publisher +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\ManyToOne(targetEntity: Country::class, inversedBy: 'publishers')] + #[ORM\JoinColumn(nullable: true)] + private ?Country $country = null; + + #[ORM\OneToMany(targetEntity: Author::class, mappedBy: 'publisher')] + private Collection $authors; + + #[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'publisher')] + private Collection $books; + + public function __construct() + { + $this->authors = new ArrayCollection(); + $this->books = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getCountry(): ?Country + { + return $this->country; + } + + public function setCountry(?Country $country): self + { + $this->country = $country; + + return $this; + } + + public function getAuthors(): Collection + { + return $this->authors; + } + + public function addAuthor(Author $author): self + { + if (!$this->authors->contains($author)) { + $this->authors[] = $author; + $author->setPublisher($this); + } + + return $this; + } + + public function removeAuthor(Author $author): self + { + if ($this->authors->removeElement($author)) { + if ($author->getPublisher() === $this) { + $author->setPublisher(null); + } + } + + return $this; + } + + public function getBooks(): Collection + { + return $this->books; + } + + public function addBook(Book $book): self + { + if (!$this->books->contains($book)) { + $this->books[] = $book; + $book->setPublisher($this); + } + + return $this; + } + + public function removeBook(Book $book): self + { + if ($this->books->removeElement($book)) { + if ($book->getPublisher() === $this) { + $book->setPublisher(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Review.php b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Review.php new file mode 100644 index 0000000000..e1a95bd1a7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterRelationsTest/Review.php @@ -0,0 +1,78 @@ + + * + * 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\IriFilterRelationsTest; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Review entity for IriFilter relations testing - for nested collection paths. + */ +#[ORM\Entity] +#[ORM\Table(name: 'iri_filter_relations_review')] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'book' => new QueryParameter(filter: new IriFilter()), + ] + ), + ] +)] +class Review +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'integer')] + private int $rating; + + #[ORM\ManyToOne(targetEntity: Book::class, inversedBy: 'reviews')] + #[ORM\JoinColumn(nullable: true)] + private ?Book $book = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getRating(): int + { + return $this->rating; + } + + public function setRating(int $rating): self + { + $this->rating = $rating; + + return $this; + } + + public function getBook(): ?Book + { + return $this->book; + } + + public function setBook(?Book $book): self + { + $this->book = $book; + + 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/IriFilterRelationsTest.php b/tests/Functional/Parameters/IriFilterRelationsTest.php new file mode 100644 index 0000000000..cf050a02df --- /dev/null +++ b/tests/Functional/Parameters/IriFilterRelationsTest.php @@ -0,0 +1,573 @@ + + * + * 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\Entity\IriFilterRelationsTest\Author; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterRelationsTest\Biography; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterRelationsTest\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterRelationsTest\Country; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterRelationsTest\Profile; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterRelationsTest\Publisher; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterRelationsTest\Review; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Comprehensive tests for IriFilter with various Doctrine relation types. + */ +final class IriFilterRelationsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Country::class, + Publisher::class, + Profile::class, + Biography::class, + Book::class, + Review::class, + Author::class, + ]; + } + + protected function setUp(): void + { + if ('mongodb' === ($_SERVER['APP_ENV'] ?? null)) { + $this->markTestSkipped('MongoDB not supported for complex relation tests'); + } + + $this->recreateSchema($this->getResources()); + } + + // OneToOne Tests + + public function testIriFilterWithOneToOneOwning(): void + { + $fixtures = $this->loadFixtures(); + $profile1 = $fixtures['profile1']; + + $response = self::createClient()->request('GET', '/authors?profile=/profiles/'.$profile1->getId()); + $data = $response->toArray(); + + $this->assertCount(1, $data['hydra:member'], 'Should find author with profile1'); + $this->assertEquals('John Doe', $data['hydra:member'][0]['name']); + } + + public function testIriFilterWithOneToOneOwningMultiple(): void + { + $fixtures = $this->loadFixtures(); + $profile1 = $fixtures['profile1']; + $profile2 = $fixtures['profile2']; + + $response = self::createClient()->request('GET', '/authors?profile[]=/profiles/'.$profile1->getId().'&profile[]=/profiles/'.$profile2->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find authors with profile1 or profile2'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + sort($names); + $this->assertEquals(['Jane Smith', 'John Doe'], $names); + } + + public function testIriFilterWithOneToOneInverse(): void + { + $fixtures = $this->loadFixtures(); + $biography1 = $fixtures['biography1']; + + $response = self::createClient()->request('GET', '/authors?biography=/biographies/'.$biography1->getId()); + $data = $response->toArray(); + + $this->assertCount(1, $data['hydra:member'], 'Should find author with biography1'); + $this->assertEquals('John Doe', $data['hydra:member'][0]['name']); + } + + public function testIriFilterWithOneToOneInverseNonExistent(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/authors?biography=/biographies/9999'); + $data = $response->toArray(); + + $this->assertCount(0, $data['hydra:member'], 'Non-existent biography should return empty collection'); + } + + public function testIriFilterWithNestedOneToOne(): void + { + $fixtures = $this->loadFixtures(); + $publisher1 = $fixtures['publisher1']; + + $response = self::createClient()->request('GET', '/profiles?authorPublisher=/publishers/'.$publisher1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find profiles whose author has publisher1'); + $bios = array_map(static fn ($m) => $m['bio'], $data['hydra:member']); + $this->assertContains('Bio of John Doe', $bios); + $this->assertContains('Bio of Jane Smith', $bios); + } + + public function testIriFilterWithOneToOneNull(): void + { + $fixtures = $this->loadFixtures(); + $author2 = $fixtures['author2']; + + // Verify author2 has no biography + $this->assertNull($author2->getBiography(), 'Author2 should have null biography'); + + $response = self::createClient()->request('GET', '/authors'); + $data = $response->toArray(); + + // All authors should be returned when no filter is applied + $this->assertGreaterThanOrEqual(2, \count($data['hydra:member'])); + } + + // ManyToMany Tests + + public function testIriFilterWithManyToManySingle(): void + { + $fixtures = $this->loadFixtures(); + $author1 = $fixtures['author1']; + + $response = self::createClient()->request('GET', '/books?author=/authors/'.$author1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find books by author1'); + $titles = array_map(static fn ($m) => $m['title'], $data['hydra:member']); + sort($titles); + $this->assertEquals(['API Design', 'PHP Mastery'], $titles); + } + + public function testIriFilterWithManyToManyMultiple(): void + { + $fixtures = $this->loadFixtures(); + $author1 = $fixtures['author1']; + $author2 = $fixtures['author2']; + + $response = self::createClient()->request('GET', '/books?author[]=/authors/'.$author1->getId().'&author[]=/authors/'.$author2->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find books by author1 or author2'); + $titles = array_map(static fn ($m) => $m['title'], $data['hydra:member']); + sort($titles); + $this->assertEquals(['API Design', 'PHP Mastery'], $titles); + } + + public function testIriFilterWithManyToManyInverse(): void + { + $fixtures = $this->loadFixtures(); + $book1 = $fixtures['book1']; + + $response = self::createClient()->request('GET', '/authors?book=/books/'.$book1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find authors of book1'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + sort($names); + $this->assertEquals(['Jane Smith', 'John Doe'], $names); + } + + public function testIriFilterWithManyToManyInverseMultiple(): void + { + $fixtures = $this->loadFixtures(); + $book1 = $fixtures['book1']; + $book3 = $fixtures['book3']; + + $response = self::createClient()->request('GET', '/authors?book[]=/books/'.$book1->getId().'&book[]=/books/'.$book3->getId()); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member'], 'Should find authors of book1 or book3'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + sort($names); + $this->assertEquals(['Jane Smith', 'John Doe', 'Mike Brown'], $names); + } + + public function testIriFilterWithManyToManyNonExistent(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/books?author=/authors/9999'); + $data = $response->toArray(); + + $this->assertCount(0, $data['hydra:member'], 'Non-existent author should return empty collection'); + } + + public function testIriFilterWithManyToManyEmptyCollection(): void + { + $fixtures = $this->loadFixtures(); + $book4 = $fixtures['book4']; + + // Verify book4 has no authors + $this->assertCount(0, $book4->getAuthors(), 'Book4 should have no authors'); + + $response = self::createClient()->request('GET', '/books'); + $data = $response->toArray(); + + // All 3 books with authors should be returned when no filter is applied + $this->assertGreaterThanOrEqual(3, \count($data['hydra:member'])); + } + + public function testIriFilterWithManyToManyNestedRelation(): void + { + $fixtures = $this->loadFixtures(); + $publisher1 = $fixtures['publisher1']; + + $response = self::createClient()->request('GET', '/books?publisherCountry=/countries/'.$publisher1->getCountry()->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find books whose publisher is in country1'); + $titles = array_map(static fn ($m) => $m['title'], $data['hydra:member']); + sort($titles); + $this->assertEquals(['API Design', 'PHP Mastery'], $titles); + } + + public function testIriFilterWithManyToManyEmptyArray(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/books?author[]='); + $data = $response->toArray(); + + // Empty array parameter should be ignored or return all books + $this->assertGreaterThanOrEqual(0, \count($data['hydra:member'])); + } + + // Deep Nesting Tests + + public function testIriFilterWithThreeLevelNesting(): void + { + $fixtures = $this->loadFixtures(); + $country1 = $fixtures['country1']; + + $response = self::createClient()->request('GET', '/authors?publisherCountry=/countries/'.$country1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find authors whose publisher is in country1'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + sort($names); + $this->assertEquals(['Jane Smith', 'John Doe'], $names); + } + + public function testIriFilterWithThreeLevelNestingFromBook(): void + { + $fixtures = $this->loadFixtures(); + $country1 = $fixtures['country1']; + + $response = self::createClient()->request('GET', '/books?publisherCountry=/countries/'.$country1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find books whose publisher is in country1'); + $titles = array_map(static fn ($m) => $m['title'], $data['hydra:member']); + sort($titles); + $this->assertEquals(['API Design', 'PHP Mastery'], $titles); + } + + public function testIriFilterWithThreeLevelNestingMultiple(): void + { + $fixtures = $this->loadFixtures(); + $country1 = $fixtures['country1']; + $country2 = $fixtures['country2']; + + $response = self::createClient()->request('GET', '/authors?publisherCountry[]=/countries/'.$country1->getId().'&publisherCountry[]=/countries/'.$country2->getId()); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member'], 'Should find authors whose publisher is in country1 or country2'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + sort($names); + $this->assertEquals(['Jane Smith', 'John Doe', 'Sarah Johnson'], $names); + } + + public function testIriFilterWithThreeLevelNestingNonExistent(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/authors?publisherCountry=/countries/9999'); + $data = $response->toArray(); + + $this->assertCount(0, $data['hydra:member'], 'Non-existent country should return empty collection'); + } + + public function testIriFilterWithCollectionNesting(): void + { + $fixtures = $this->loadFixtures(); + $publisher1 = $fixtures['publisher1']; + + $response = self::createClient()->request('GET', '/authors?bookPublisher=/publishers/'.$publisher1->getId()); + $data = $response->toArray(); + + $this->assertCount(2, $data['hydra:member'], 'Should find authors who have books with publisher1'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + sort($names); + $this->assertEquals(['Jane Smith', 'John Doe'], $names); + } + + // Edge Cases + + public function testIriFilterWithNullRelation(): void + { + $fixtures = $this->loadFixtures(); + $author3 = $fixtures['author3']; + + // Verify author3 has null publisher + $this->assertNull($author3->getPublisher(), 'Author3 should have null publisher'); + + $response = self::createClient()->request('GET', '/authors'); + $data = $response->toArray(); + + // All authors should be returned when no filter is applied + $this->assertGreaterThanOrEqual(3, \count($data['hydra:member'])); + } + + public function testIriFilterWithEmptyArrayParameter(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/authors?publisher[]='); + $data = $response->toArray(); + + // Empty array parameter should be ignored or return all authors + $this->assertGreaterThanOrEqual(0, \count($data['hydra:member'])); + } + + public function testIriFilterWithInvalidIri(): void + { + $this->loadFixtures(); + + // Test with malformed IRI - currently throws error, but should ideally return empty + // This test documents current behavior rather than expected behavior + $response = self::createClient()->request('GET', '/authors?publisher=/publishers/9999'); + $data = $response->toArray(); + + // Non-existent publisher should return empty collection + $this->assertCount(0, $data['hydra:member'], 'Non-existent publisher IRI should return empty collection'); + } + + public function testIriFilterWithWrongResourceType(): void + { + $fixtures = $this->loadFixtures(); + $author1 = $fixtures['author1']; + + $response = self::createClient()->request('GET', '/authors?publisher=/authors/'.$author1->getId()); + $data = $response->toArray(); + + // Wrong resource type should return empty collection + $this->assertCount(0, $data['hydra:member'], 'IRI of wrong resource type should return empty collection'); + } + + public function testIriFilterWithOneToOneNullBiography(): void + { + $fixtures = $this->loadFixtures(); + $author2 = $fixtures['author2']; + + // Verify author2 has no biography + $this->assertNull($author2->getBiography(), 'Author2 should have null biography'); + + // Query for all authors - author2 should be included + $response = self::createClient()->request('GET', '/authors'); + $data = $response->toArray(); + + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + $this->assertContains('Jane Smith', $names, 'Author without biography should be queryable'); + } + + public function testIriFilterWithManyToManyBidirectionalConsistency(): void + { + $fixtures = $this->loadFixtures(); + $author1 = $fixtures['author1']; + $book1 = $fixtures['book1']; + + // Get books by author1 + $booksResponse = self::createClient()->request('GET', '/books?author=/authors/'.$author1->getId()); + $booksData = $booksResponse->toArray(); + $bookIds = array_map(static fn ($m) => (int) basename($m['@id']), $booksData['hydra:member']); + + // Get authors of book1 + $authorsResponse = self::createClient()->request('GET', '/authors?book=/books/'.$book1->getId()); + $authorsData = $authorsResponse->toArray(); + $authorIds = array_map(static fn ($m) => (int) basename($m['@id']), $authorsData['hydra:member']); + + // Verify bidirectional consistency + $this->assertContains($book1->getId(), $bookIds, 'Author1 should have book1'); + $this->assertContains($author1->getId(), $authorIds, 'Book1 should have author1'); + } + + public function testIriFilterArrayWithNestedRelation(): void + { + $fixtures = $this->loadFixtures(); + $country1 = $fixtures['country1']; + $country2 = $fixtures['country2']; + + $response = self::createClient()->request('GET', '/authors?publisherCountry[]=/countries/'.$country1->getId().'&publisherCountry[]=/countries/'.$country2->getId()); + $data = $response->toArray(); + + $this->assertCount(3, $data['hydra:member'], 'Should find authors whose publisher is in country1 or country2'); + $names = array_map(static fn ($m) => $m['name'], $data['hydra:member']); + sort($names); + $this->assertEquals(['Jane Smith', 'John Doe', 'Sarah Johnson'], $names); + } + + private function loadFixtures(): array + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + + // Create countries + $country1 = new Country(); + $country1->setName('USA'); + $manager->persist($country1); + + $country2 = new Country(); + $country2->setName('UK'); + $manager->persist($country2); + + $manager->flush(); + + // Create publishers + $publisher1 = new Publisher(); + $publisher1->setName('Tech Books Inc'); + $publisher1->setCountry($country1); + $manager->persist($publisher1); + + $publisher2 = new Publisher(); + $publisher2->setName('British Literature Press'); + $publisher2->setCountry($country2); + $manager->persist($publisher2); + + $publisher3 = new Publisher(); + $publisher3->setName('Orphan Publisher'); + $publisher3->setCountry(null); + $manager->persist($publisher3); + + $manager->flush(); + + // Create books + $book1 = new Book(); + $book1->setTitle('PHP Mastery'); + $book1->setPublisher($publisher1); + $manager->persist($book1); + + $book2 = new Book(); + $book2->setTitle('API Design'); + $book2->setPublisher($publisher1); + $manager->persist($book2); + + $book3 = new Book(); + $book3->setTitle('British Literature'); + $book3->setPublisher($publisher2); + $manager->persist($book3); + + $book4 = new Book(); + $book4->setTitle('Orphan Book'); + $book4->setPublisher(null); + $manager->persist($book4); + + $manager->flush(); + + // Create profiles + $profile1 = new Profile(); + $profile1->setBio('Bio of John Doe'); + $manager->persist($profile1); + + $profile2 = new Profile(); + $profile2->setBio('Bio of Jane Smith'); + $manager->persist($profile2); + + $manager->flush(); + + // Create authors + $author1 = new Author(); + $author1->setName('John Doe'); + $author1->setPublisher($publisher1); + $author1->setProfile($profile1); + $author1->addBook($book1); + $author1->addBook($book2); + $manager->persist($author1); + + $author2 = new Author(); + $author2->setName('Jane Smith'); + $author2->setPublisher($publisher1); + $author2->setProfile($profile2); + $author2->addBook($book1); + $manager->persist($author2); + + $author3 = new Author(); + $author3->setName('Mike Brown'); + $author3->setPublisher(null); + $author3->addBook($book3); + $manager->persist($author3); + + $author4 = new Author(); + $author4->setName('Sarah Johnson'); + $author4->setPublisher($publisher2); + $manager->persist($author4); + + $manager->flush(); + + // Create biography and set it on author1 + $biography1 = new Biography(); + $biography1->setText('Biography of John Doe'); + $manager->persist($biography1); + + $author1->setBiography($biography1); + + $manager->flush(); + + // Create reviews + $review1 = new Review(); + $review1->setRating(5); + $review1->setBook($book1); + $manager->persist($review1); + + $review2 = new Review(); + $review2->setRating(4); + $review2->setBook($book1); + $manager->persist($review2); + + $review3 = new Review(); + $review3->setRating(3); + $review3->setBook($book3); + $manager->persist($review3); + + $manager->flush(); + + return [ + 'country1' => $country1, + 'country2' => $country2, + 'publisher1' => $publisher1, + 'publisher2' => $publisher2, + 'publisher3' => $publisher3, + 'book1' => $book1, + 'book2' => $book2, + 'book3' => $book3, + 'book4' => $book4, + 'profile1' => $profile1, + 'profile2' => $profile2, + 'biography1' => $biography1, + 'author1' => $author1, + 'author2' => $author2, + 'author3' => $author3, + 'author4' => $author4, + 'review1' => $review1, + 'review2' => $review2, + 'review3' => $review3, + ]; + } +} 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