From cdc82bf07ac140ea3c380b506f1ae72264478360 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Feb 2026 15:31:53 +0100 Subject: [PATCH] feat(doctrine): add ODM SortFilter and nested property support for parameter-based filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | main | Tickets | ∅ | License | MIT | Doc PR | ∅ * Add NestedPropertyHelperTrait for ODM (parameter-based $lookup/$unwind) * Add SortFilter for ODM with nulls comparison support * Add nested property support to ExactFilter, IriFilter, PartialSearchFilter * Propagate nested_properties_info in FreeTextQueryFilter Co-Authored-By: Claude Opus 4.6 --- src/Doctrine/Odm/Filter/ExactFilter.php | 22 +- .../Odm/Filter/FreeTextQueryFilter.php | 12 +- src/Doctrine/Odm/Filter/IriFilter.php | 19 +- .../Odm/Filter/PartialSearchFilter.php | 8 +- src/Doctrine/Odm/Filter/SortFilter.php | 99 +++++ ...meterResourceMetadataCollectionFactory.php | 192 ++++++++++ .../Odm/NestedPropertyHelperTrait.php | 90 +++++ .../Odm/Tests/Filter/ExactFilterTest.php | 197 ++++++++++ .../Tests/Filter/PartialSearchFilterTest.php | 160 +++++++++ .../Odm/Tests/Filter/SortFilterTest.php | 340 ++++++++++++++++++ ...rResourceMetadataCollectionFactoryTest.php | 249 +++++++++++++ .../Orm/Filter/AbstractUuidFilter.php | 3 +- .../Orm/Filter/FreeTextQueryFilter.php | 2 +- src/Doctrine/Orm/Filter/IriFilter.php | 86 +++-- src/Doctrine/Orm/Filter/SortFilter.php | 14 +- ...meterResourceMetadataCollectionFactory.php | 172 +++++++++ .../Orm/NestedPropertyHelperTrait.php | 2 +- ...rResourceMetadataCollectionFactoryTest.php | 285 +++++++++++++++ .../Eloquent/Filter/NestedPropertyTrait.php | 3 +- src/Laravel/Eloquent/Filter/OrderFilter.php | 3 +- ...meterResourceMetadataCollectionFactory.php | 10 +- src/Metadata/Parameter.php | 1 - ...meterResourceMetadataCollectionFactory.php | 24 +- ...rResourceMetadataCollectionFactoryTest.php | 14 +- .../Resources/config/doctrine_mongodb_odm.php | 8 + .../Bundle/Resources/config/doctrine_orm.php | 8 + .../FilterNestedTest/FilterCompany.php | 52 +++ .../FilterNestedTest/FilterDepartment.php | 67 ++++ .../FilterNestedTest/FilterEmployee.php | 99 +++++ .../FilterNestedTest/FilterEmployee.php | 2 + .../Functional/Parameters/SortFilterTest.php | 254 +++++++++++++ 31 files changed, 2423 insertions(+), 74 deletions(-) create mode 100644 src/Doctrine/Odm/Filter/SortFilter.php create mode 100644 src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory.php create mode 100644 src/Doctrine/Odm/NestedPropertyHelperTrait.php create mode 100644 src/Doctrine/Odm/Tests/Filter/ExactFilterTest.php create mode 100644 src/Doctrine/Odm/Tests/Filter/PartialSearchFilterTest.php create mode 100644 src/Doctrine/Odm/Tests/Filter/SortFilterTest.php create mode 100644 src/Doctrine/Odm/Tests/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactoryTest.php create mode 100644 src/Doctrine/Orm/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php create mode 100644 src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php create mode 100644 tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterCompany.php create mode 100644 tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterDepartment.php create mode 100644 tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterEmployee.php create mode 100644 tests/Functional/Parameters/SortFilterTest.php diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 03d2b3bb914..399843a582d 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -32,6 +33,7 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterf { use BackwardCompatibleFilterDescriptionTrait; use ManagerRegistryAwareTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; /** @@ -58,24 +60,30 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } - $classMetadata = $documentManager->getClassMetadata($resourceClass); + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); - if (!$classMetadata->hasReference($property)) { + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + $nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null; + $leafClass = $nestedInfo['leaf_class'] ?? $resourceClass; + $leafProperty = $nestedInfo['leaf_property'] ?? $property; + $classMetadata = $documentManager->getClassMetadata($leafClass); + + if (!$classMetadata->hasReference($leafProperty)) { $comparisonMethod = $context['comparisonMethod'] ?? (is_iterable($value) ? 'in' : 'equals'); $match - ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{$comparisonMethod}($value)); + ->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->{$comparisonMethod}($value)); return; } - $mapping = $classMetadata->getFieldMapping($property); - $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; + $mapping = $classMetadata->getFieldMapping($leafProperty); + $method = $classMetadata->isSingleValuedAssociation($leafProperty) ? 'references' : 'includesReferenceTo'; if (is_iterable($value)) { $or = $aggregationBuilder->matchExpr(); foreach ($value as $v) { - $or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v))); + $or->addOr($aggregationBuilder->matchExpr()->field($matchField)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v))); } $match->{$operator}($or); @@ -86,7 +94,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $match ->{$operator}( $aggregationBuilder->matchExpr() - ->field($property) + ->field($matchField) ->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $value)) ); } diff --git a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php index 2dde16d6ecc..6f96a8587fc 100644 --- a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php @@ -46,7 +46,17 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $parameter = $context['parameter']; foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { - $newContext = ['parameter' => $parameter->withProperty($property), 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context; + $subParameter = $parameter->withProperty($property); + + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + if (isset($nestedPropertiesInfo[$property])) { + $subParameter = $subParameter->withExtraProperties([ + ...$subParameter->getExtraProperties(), + 'nested_properties_info' => [$property => $nestedPropertiesInfo[$property]], + ]); + } + + $newContext = ['parameter' => $subParameter, 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context; $this->filter->apply( $aggregationBuilder, $resourceClass, diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index df4afe6c5d6..d35df856248 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -33,6 +34,7 @@ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterfac { use BackwardCompatibleFilterDescriptionTrait; use ManagerRegistryAwareTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; /** @@ -57,19 +59,26 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } - $classMetadata = $documentManager->getClassMetadata($resourceClass); $property = $parameter->getProperty(); - if (!$classMetadata->hasReference($property)) { + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + $nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null; + $leafClass = $nestedInfo['leaf_class'] ?? $resourceClass; + $leafProperty = $nestedInfo['leaf_property'] ?? $property; + $classMetadata = $documentManager->getClassMetadata($leafClass); + + if (!$classMetadata->hasReference($leafProperty)) { return; } - $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; + $method = $classMetadata->isSingleValuedAssociation($leafProperty) ? 'references' : 'includesReferenceTo'; if (is_iterable($value)) { $or = $aggregationBuilder->matchExpr(); foreach ($value as $v) { - $or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($v)); + $or->addOr($aggregationBuilder->matchExpr()->field($matchField)->{$method}($v)); } $match->{$operator}($or); @@ -81,7 +90,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ->{$operator}( $aggregationBuilder ->matchExpr() - ->field($property) + ->field($matchField) ->{$method}($value) ); } diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index f5dcba3a137..392a2ab6ab9 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -27,6 +28,7 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; public function __construct(private readonly bool $caseSensitive = true) @@ -48,10 +50,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ->matchExpr(); $operator = $context['operator'] ?? 'addAnd'; + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + if (!is_iterable($values)) { $escapedValue = preg_quote($values, '/'); $match->{$operator}( - $aggregationBuilder->matchExpr()->field($property)->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i')) + $aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i')) ); return; @@ -63,7 +67,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $or->addOr( $aggregationBuilder->matchExpr() - ->field($property) + ->field($matchField) ->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i')) ); } diff --git a/src/Doctrine/Odm/Filter/SortFilter.php b/src/Doctrine/Odm/Filter/SortFilter.php new file mode 100644 index 00000000000..abadd4926cc --- /dev/null +++ b/src/Doctrine/Odm/Filter/SortFilter.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * 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(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter'] ?? null; + if (null === $parameter) { + return; + } + + $value = $parameter->getValue(null); + if (!\is_string($value)) { + return; + } + + $direction = strtoupper($value); + if (!\in_array($direction, ['ASC', 'DESC'], true)) { + return; + } + + $property = $parameter->getProperty(); + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, true, $context); + + $mongoDirection = 'ASC' === $direction ? 1 : -1; + + if (null !== $nullsComparison = $this->nullsComparison) { + $nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction] ?? null; + if (null !== $nullsDirection) { + $nullRankField = \sprintf('_null_rank_%s', str_replace('.', '_', $matchField)); + $mongoNullsDirection = 'ASC' === $nullsDirection ? 1 : -1; + + $aggregationBuilder->addFields() + ->field($nullRankField) + ->cond( + $aggregationBuilder->expr()->eq('$'.$matchField, null), + 0, + 1 + ); + + $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$nullRankField => $mongoNullsDirection]; + } + } + + $aggregationBuilder->sort( + $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$matchField => $mongoDirection] + ); + } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']]; + } +} diff --git a/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory.php b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..8bce8b8ddb5 --- /dev/null +++ b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Metadata\Resource; + +use ApiPlatform\Doctrine\Odm\State\Options; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbOdmClassMetadata; +use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\Persistence\ManagerRegistry; + +/** + * Enriches nested_properties_info with ODM-specific mapping data (odm_segments) + * so that filters don't need ManagerRegistry at runtime. + * + * @author Antoine Bluchet + */ +final class DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + use StateOptionsTrait; + + public function __construct( + private readonly ManagerRegistry $managerRegistry, + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + + foreach ($resourceMetadataCollection as $i => $resourceMetadata) { + $operations = $resourceMetadata->getOperations(); + + if ($operations) { + foreach ($operations as $operationName => $operation) { + $operation = $this->enrichOperation($operation, $resourceClass); + $operations->add($operationName, $operation); + } + + $resourceMetadata = $resourceMetadata->withOperations($operations); + } + + $graphQlOperations = $resourceMetadata->getGraphQlOperations(); + + if ($graphQlOperations) { + foreach ($graphQlOperations as $operationName => $graphQlOperation) { + $graphQlOperation = $this->enrichOperation($graphQlOperation, $resourceClass); + $graphQlOperations[$operationName] = $graphQlOperation; + } + + $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); + } + + $resourceMetadataCollection[$i] = $resourceMetadata; + } + + return $resourceMetadataCollection; + } + + private function enrichOperation(Operation $operation, string $resourceClass): Operation + { + $parameters = $operation->getParameters(); + if (!$parameters) { + return $operation; + } + + $documentClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); + if (!$this->managerRegistry->getManagerForClass($documentClass) instanceof DocumentManager) { + return $operation; + } + + $operationChanged = false; + + foreach ($parameters as $key => $parameter) { + $extraProperties = $parameter->getExtraProperties(); + $parameterChanged = false; + + $nestedPropertiesInfo = $extraProperties['nested_properties_info'] ?? null; + if ($nestedPropertiesInfo) { + foreach ($nestedPropertiesInfo as $propPath => $propNestedInfo) { + if (!isset($propNestedInfo['odm_segments'])) { + $odmSegments = $this->buildOdmSegments($propNestedInfo); + if (null !== $odmSegments) { + $nestedPropertiesInfo[$propPath]['odm_segments'] = $odmSegments; + $parameterChanged = true; + } + } + } + + if ($parameterChanged) { + $extraProperties['nested_properties_info'] = $nestedPropertiesInfo; + } + } + + if ($parameterChanged) { + $parameters->add($key, $parameter->withExtraProperties($extraProperties)); + $operationChanged = true; + } + } + + if ($operationChanged) { + $operation = $operation->withParameters($parameters); + } + + return $operation; + } + + /** + * @param array{relation_segments: list, relation_classes: list, leaf_property?: string, leaf_class?: class-string} $nestedInfo + * + * @throws MappingException + * + * @return list|null + */ + private function buildOdmSegments(array $nestedInfo): ?array + { + $relationSegments = $nestedInfo['relation_segments'] ?? []; + $relationClasses = $nestedInfo['relation_classes'] ?? []; + + if (!$relationSegments) { + return null; + } + + $odmSegments = []; + + foreach ($relationSegments as $i => $association) { + $class = $relationClasses[$i] ?? null; + if (!$class) { + break; + } + + $manager = $this->managerRegistry->getManagerForClass($class); + if (!$manager) { + break; + } + + $classMetadata = $manager->getClassMetadata($class); + if (!$classMetadata instanceof MongoDbOdmClassMetadata) { + break; + } + + if ($classMetadata->hasReference($association)) { + $referenceMapping = $classMetadata->getFieldMapping($association); + $isOwningSide = $referenceMapping['isOwningSide']; + + if ($isOwningSide && MongoDbOdmClassMetadata::REFERENCE_STORE_AS_ID !== $referenceMapping['storeAs']) { + throw MappingException::cannotLookupDbRefReference($classMetadata->getReflectionClass()->getShortName(), $association); + } + + if (!$isOwningSide) { + if (isset($referenceMapping['repositoryMethod']) || !isset($referenceMapping['mappedBy'])) { + throw MappingException::repositoryMethodLookupNotAllowed($classMetadata->getReflectionClass()->getShortName(), $association); + } + + $targetClassMetadata = $manager->getClassMetadata($referenceMapping['targetDocument']); + if ($targetClassMetadata instanceof MongoDbOdmClassMetadata && MongoDbOdmClassMetadata::REFERENCE_STORE_AS_ID !== $targetClassMetadata->getFieldMapping($referenceMapping['mappedBy'])['storeAs']) { + throw MappingException::cannotLookupDbRefReference($classMetadata->getReflectionClass()->getShortName(), $association); + } + } + + $odmSegments[] = [ + 'type' => 'reference', + 'target_document' => $classMetadata->getAssociationTargetClass($association), + 'is_owning_side' => $isOwningSide, + 'mapped_by' => $isOwningSide ? null : ($referenceMapping['mappedBy'] ?? null), + ]; + } elseif ($classMetadata->hasEmbed($association)) { + $odmSegments[] = [ + 'type' => 'embed', + 'target_document' => $classMetadata->getAssociationTargetClass($association), + ]; + } + } + + return $odmSegments ?: null; + } +} diff --git a/src/Doctrine/Odm/NestedPropertyHelperTrait.php b/src/Doctrine/Odm/NestedPropertyHelperTrait.php new file mode 100644 index 00000000000..e713382bece --- /dev/null +++ b/src/Doctrine/Odm/NestedPropertyHelperTrait.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm; + +use ApiPlatform\Metadata\Parameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * Helper trait for handling nested properties in parameter-based filters. + * + * Builds $lookup/$unwind pipeline stages from precomputed ODM mapping data + * (odm_segments) stored in parameter extra properties at metadata-time. + * + * @author Antoine Bluchet + */ +trait NestedPropertyHelperTrait +{ + /** + * Adds the necessary lookups for a nested property using precomputed parameter metadata. + * + * @param array $context Shared context for lookup deduplication across filters within the same request + * + * @return string The aliased field name to use in match/sort expressions + */ + protected function addNestedParameterLookups(string $property, Builder $aggregationBuilder, Parameter $parameter, bool $preserveNullAndEmptyArrays = false, array &$context = []): string + { + $extraProperties = $parameter->getExtraProperties(); + $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; + + if (!$nestedInfo) { + return $property; + } + + $odmSegments = $nestedInfo['odm_segments'] ?? []; + $relationSegments = $nestedInfo['relation_segments'] ?? []; + $leafProperty = $nestedInfo['leaf_property'] ?? $property; + + if (!$odmSegments || !$relationSegments) { + return $property; + } + + $alias = ''; + + foreach ($odmSegments as $i => $segment) { + $association = $relationSegments[$i] ?? null; + if (!$association) { + break; + } + + if ('reference' === $segment['type']) { + $propertyAlias = "{$association}_lkup"; + $localField = "$alias$association"; + $alias .= $propertyAlias; + + $isOwningSide = $segment['is_owning_side']; + $targetDocument = $segment['target_document']; + $mappedBy = $segment['mapped_by'] ?? null; + + // Deduplication: skip $lookup/$unwind if already added for this alias + if (!isset($context['_odm_lookups'][$alias])) { + $aggregationBuilder->lookup($targetDocument) + ->localField($isOwningSide ? $localField : '_id') + ->foreignField($isOwningSide ? '_id' : $mappedBy) + ->alias($alias); + $aggregationBuilder->unwind("\$$alias") + ->preserveNullAndEmptyArrays($preserveNullAndEmptyArrays); + + $context['_odm_lookups'][$alias] = true; + } + + $alias .= '.'; + } elseif ('embed' === $segment['type']) { + $alias = "$alias$association."; + } + } + + return "$alias$leafProperty"; + } +} diff --git a/src/Doctrine/Odm/Tests/Filter/ExactFilterTest.php b/src/Doctrine/Odm/Tests/Filter/ExactFilterTest.php new file mode 100644 index 00000000000..af10c2eaf01 --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/ExactFilterTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Tests\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\ThirdLevel; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; + +class ExactFilterTest extends TestCase +{ + private DocumentManager $manager; + private ManagerRegistry $managerRegistry; + + protected function setUp(): void + { + $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); + + $managerRegistry = $this->createStub(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->willReturn($this->manager); + $this->managerRegistry = $managerRegistry; + } + + public function testExactFilterSimpleProperty(): void + { + $filter = new ExactFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + // The filter populates $context['match'] with the match expression (no pipeline stage added) + $this->assertArrayHasKey('match', $context); + $this->assertNoPipelineStages($aggregationBuilder); + } + + public function testExactFilterNestedProperty(): void + { + $filter = new ExactFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter->setValue('bar'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.name' => 'bar'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // Nested property adds $lookup + $unwind stages + $this->assertCount(2, $pipeline); + + $this->assertEquals([ + '$lookup' => [ + 'from' => 'RelatedDummy', + 'localField' => 'relatedDummy', + 'foreignField' => '_id', + 'as' => 'relatedDummy_lkup', + ], + ], $pipeline[0]); + + $this->assertArrayHasKey('$unwind', $pipeline[1]); + + // The match expression is populated for the parameter extension to commit + $this->assertArrayHasKey('match', $context); + } + + public function testExactFilterMultiHopNestedProperty(): void + { + $filter = new ExactFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter( + property: 'relatedDummy.thirdLevel.level', + key: 'relatedDummy.thirdLevel.level', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.thirdLevel.level' => [ + 'relation_segments' => ['relatedDummy', 'thirdLevel'], + 'relation_classes' => [Dummy::class, RelatedDummy::class], + 'leaf_property' => 'level', + 'leaf_class' => ThirdLevel::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + [ + 'type' => 'reference', + 'target_document' => ThirdLevel::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter->setValue(3); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.thirdLevel.level' => 3], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // 2 lookup+unwind pairs = 4 stages + $this->assertCount(4, $pipeline); + + $this->assertEquals([ + '$lookup' => [ + 'from' => 'RelatedDummy', + 'localField' => 'relatedDummy', + 'foreignField' => '_id', + 'as' => 'relatedDummy_lkup', + ], + ], $pipeline[0]); + + $this->assertArrayHasKey('$unwind', $pipeline[1]); + + $this->assertEquals([ + '$lookup' => [ + 'from' => 'ThirdLevel', + 'localField' => 'relatedDummy_lkup.thirdLevel', + 'foreignField' => '_id', + 'as' => 'relatedDummy_lkup.thirdLevel_lkup', + ], + ], $pipeline[2]); + + $this->assertArrayHasKey('$unwind', $pipeline[3]); + + $this->assertArrayHasKey('match', $context); + } + + private function assertNoPipelineStages(Builder $aggregationBuilder): void + { + try { + $pipeline = $aggregationBuilder->getPipeline(); + $this->assertEmpty($pipeline); + } catch (\OutOfRangeException) { + // No stages added — expected for simple property filters + } + } +} diff --git a/src/Doctrine/Odm/Tests/Filter/PartialSearchFilterTest.php b/src/Doctrine/Odm/Tests/Filter/PartialSearchFilterTest.php new file mode 100644 index 00000000000..d176b1006ea --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/PartialSearchFilterTest.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Tests\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use PHPUnit\Framework\TestCase; + +class PartialSearchFilterTest extends TestCase +{ + private DocumentManager $manager; + + protected function setUp(): void + { + $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); + } + + public function testPartialSearchSimpleProperty(): void + { + $filter = new PartialSearchFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + // The filter populates $context['match'] with the match expression (no pipeline stage added) + $this->assertArrayHasKey('match', $context); + $this->assertNoPipelineStages($aggregationBuilder); + } + + public function testPartialSearchNestedProperty(): void + { + $filter = new PartialSearchFilter(); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter->setValue('bar'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.name' => 'bar'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // Nested property adds $lookup + $unwind stages + $this->assertCount(2, $pipeline); + + $this->assertEquals([ + '$lookup' => [ + 'from' => 'RelatedDummy', + 'localField' => 'relatedDummy', + 'foreignField' => '_id', + 'as' => 'relatedDummy_lkup', + ], + ], $pipeline[0]); + + $this->assertArrayHasKey('$unwind', $pipeline[1]); + + // The match expression is populated for the parameter extension to commit + $this->assertArrayHasKey('match', $context); + } + + public function testPartialSearchNestedPropertyCaseInsensitive(): void + { + $filter = new PartialSearchFilter(caseSensitive: false); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter->setValue('bar'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.name' => 'bar'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // Same $lookup/$unwind structure regardless of case sensitivity + $this->assertCount(2, $pipeline); + $this->assertArrayHasKey('$lookup', $pipeline[0]); + $this->assertArrayHasKey('$unwind', $pipeline[1]); + $this->assertArrayHasKey('match', $context); + } + + private function assertNoPipelineStages(Builder $aggregationBuilder): void + { + try { + $pipeline = $aggregationBuilder->getPipeline(); + $this->assertEmpty($pipeline); + } catch (\OutOfRangeException) { + // No stages added — expected for simple property filters + } + } +} diff --git a/src/Doctrine/Odm/Tests/Filter/SortFilterTest.php b/src/Doctrine/Odm/Tests/Filter/SortFilterTest.php new file mode 100644 index 00000000000..d104bac3670 --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/SortFilterTest.php @@ -0,0 +1,340 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Tests\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; +use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\ThirdLevel; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\DocumentManager; +use PHPUnit\Framework\TestCase; + +class SortFilterTest extends TestCase +{ + private DocumentManager $manager; + + protected function setUp(): void + { + $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); + } + + public function testSortAscending(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $parameter = $parameter->setValue('asc'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + $this->assertEquals([ + ['$sort' => ['name' => 1]], + ], $pipeline); + } + + public function testSortDescending(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $parameter = $parameter->setValue('DESC'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + $this->assertEquals([ + ['$sort' => ['name' => -1]], + ], $pipeline); + } + + public function testInvalidDirection(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $parameter = $parameter->setValue('invalid'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $pipeline = []; + try { + $pipeline = $aggregationBuilder->getPipeline(); + } catch (\OutOfRangeException) { + } + + $this->assertEmpty($pipeline); + } + + public function testNullParameter(): void + { + $filter = new SortFilter(); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = []; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $pipeline = []; + try { + $pipeline = $aggregationBuilder->getPipeline(); + } catch (\OutOfRangeException) { + } + + $this->assertEmpty($pipeline); + } + + public function testNullValue(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $pipeline = []; + try { + $pipeline = $aggregationBuilder->getPipeline(); + } catch (\OutOfRangeException) { + } + + $this->assertEmpty($pipeline); + } + + public function testNestedPropertySort(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'order[relatedDummy.name]', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter = $parameter->setValue('asc'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + $this->assertEquals([ + [ + '$lookup' => [ + 'from' => 'RelatedDummy', + 'localField' => 'relatedDummy', + 'foreignField' => '_id', + 'as' => 'relatedDummy_lkup', + ], + ], + [ + '$unwind' => [ + 'path' => '$relatedDummy_lkup', + 'preserveNullAndEmptyArrays' => true, + ], + ], + [ + '$sort' => ['relatedDummy_lkup.name' => 1], + ], + ], $pipeline); + } + + public function testNullsComparison(): void + { + $filter = new SortFilter(nullsComparison: 'nulls_smallest'); + + $parameter = new QueryParameter(property: 'dummyDate', key: 'order[dummyDate]'); + $parameter = $parameter->setValue('asc'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // nulls_smallest + ASC => nulls direction ASC (1), single combined $sort stage + $this->assertCount(2, $pipeline); + $this->assertArrayHasKey('$addFields', $pipeline[0]); + $this->assertArrayHasKey('_null_rank_dummyDate', $pipeline[0]['$addFields']); + $this->assertEquals(['$sort' => ['_null_rank_dummyDate' => 1, 'dummyDate' => 1]], $pipeline[1]); + } + + public function testGetSchema(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + + $this->assertEquals( + ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']], + $filter->getSchema($parameter) + ); + } + + public function testMultiHopNestedPropertySort(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter( + property: 'relatedDummy.thirdLevel.level', + key: 'order[relatedDummy.thirdLevel.level]', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.thirdLevel.level' => [ + 'relation_segments' => ['relatedDummy', 'thirdLevel'], + 'relation_classes' => [Dummy::class, RelatedDummy::class], + 'leaf_property' => 'level', + 'leaf_class' => ThirdLevel::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + [ + 'type' => 'reference', + 'target_document' => ThirdLevel::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter = $parameter->setValue('asc'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // 2 lookup+unwind pairs + 1 sort = 5 stages + $this->assertCount(5, $pipeline); + + $this->assertEquals([ + '$lookup' => [ + 'from' => 'RelatedDummy', + 'localField' => 'relatedDummy', + 'foreignField' => '_id', + 'as' => 'relatedDummy_lkup', + ], + ], $pipeline[0]); + + $this->assertArrayHasKey('$unwind', $pipeline[1]); + + $this->assertEquals([ + '$lookup' => [ + 'from' => 'ThirdLevel', + 'localField' => 'relatedDummy_lkup.thirdLevel', + 'foreignField' => '_id', + 'as' => 'relatedDummy_lkup.thirdLevel_lkup', + ], + ], $pipeline[2]); + + $this->assertArrayHasKey('$unwind', $pipeline[3]); + + $this->assertEquals([ + '$sort' => ['relatedDummy_lkup.thirdLevel_lkup.level' => 1], + ], $pipeline[4]); + } + + public function testLookupDeduplication(): void + { + $filter = new SortFilter(); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'order[relatedDummy.name]', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter = $parameter->setValue('asc'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + // Shared context simulating a prior filter having already added the lookup + $context = [ + 'parameter' => $parameter, + '_odm_lookups' => ['relatedDummy_lkup' => true], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // Only $sort should be present — no $lookup/$unwind since they were deduplicated + $this->assertCount(1, $pipeline); + $this->assertEquals(['$sort' => ['relatedDummy_lkup.name' => 1]], $pipeline[0]); + } +} diff --git a/src/Doctrine/Odm/Tests/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactoryTest.php b/src/Doctrine/Odm/Tests/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactoryTest.php new file mode 100644 index 00000000000..1a2dc6b192d --- /dev/null +++ b/src/Doctrine/Odm/Tests/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,249 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Tests\Metadata\Resource; + +use ApiPlatform\Doctrine\Odm\Metadata\Resource\DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory; +use ApiPlatform\Doctrine\Odm\State\Options; +use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\EmbeddableDummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; + +class DoctrineMongoDbOdmParameterResourceMetadataCollectionFactoryTest extends TestCase +{ + private DocumentManager $manager; + private ManagerRegistry $managerRegistry; + + protected function setUp(): void + { + $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); + + $managerRegistry = $this->createStub(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->willReturn($this->manager); + $this->managerRegistry = $managerRegistry; + } + + public function testParameterWithoutNestedInfoPassedThrough(): void + { + $parameter = new QueryParameter(property: 'name', key: 'name'); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + // No nested_properties_info — parameter should be unchanged + $this->assertArrayNotHasKey('nested_properties_info', $resultParameter->getExtraProperties()); + } + + public function testParameterWithNestedInfoGetsOdmSegments(): void + { + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.name']; + $this->assertArrayHasKey('odm_segments', $nestedInfo); + $this->assertCount(1, $nestedInfo['odm_segments']); + + $segment = $nestedInfo['odm_segments'][0]; + $this->assertSame('reference', $segment['type']); + $this->assertSame(RelatedDummy::class, $segment['target_document']); + $this->assertTrue($segment['is_owning_side']); + $this->assertNull($segment['mapped_by']); + } + + public function testEmbeddedDocumentProducesEmbedType(): void + { + $parameter = new QueryParameter( + property: 'embeddedDummy.dummyName', + key: 'embeddedDummy.dummyName', + extraProperties: [ + 'nested_properties_info' => ['embeddedDummy.dummyName' => [ + 'relation_segments' => ['embeddedDummy'], + 'relation_classes' => [RelatedDummy::class], + 'leaf_property' => 'dummyName', + 'leaf_class' => EmbeddableDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, RelatedDummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(RelatedDummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['embeddedDummy.dummyName']; + $this->assertArrayHasKey('odm_segments', $nestedInfo); + + $segment = $nestedInfo['odm_segments'][0]; + $this->assertSame('embed', $segment['type']); + $this->assertSame(EmbeddableDummy::class, $segment['target_document']); + } + + public function testAlreadyEnrichedParameterNotProcessedAgain(): void + { + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + ['type' => 'reference', 'target_document' => RelatedDummy::class, 'is_owning_side' => true, 'mapped_by' => null], + ], + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.name']; + // odm_segments should still be the original — not re-processed + $this->assertCount(1, $nestedInfo['odm_segments']); + } + + public function testNonOdmManagedClassSkippedGracefully(): void + { + $managerRegistry = $this->createStub(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->willReturn(null); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn($collection); + + $factory = new DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory($managerRegistry, $decorated); + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + // No odm_segments should be added since the class isn't managed by ODM + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.name']; + $this->assertArrayNotHasKey('odm_segments', $nestedInfo); + } + + public function testNestedPropertiesInfoEnrichedForFreeTextQueryFilter(): void + { + $parameter = new QueryParameter( + property: null, + key: 'search', + extraProperties: [ + 'nested_properties_info' => [ + 'relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ], + ], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedPropertiesInfo = $resultParameter->getExtraProperties()['nested_properties_info']; + $this->assertArrayHasKey('odm_segments', $nestedPropertiesInfo['relatedDummy.name']); + $this->assertSame('reference', $nestedPropertiesInfo['relatedDummy.name']['odm_segments'][0]['type']); + } + + private function createFactory(ResourceMetadataCollection $collection): DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory + { + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn($collection); + + return new DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory($this->managerRegistry, $decorated); + } + + private function createCollectionWithParameter(QueryParameter $parameter, string $resourceClass): ResourceMetadataCollection + { + $parameters = new Parameters(); + $parameters->add($parameter->getKey(), $parameter); + + $operation = (new GetCollection())->withClass($resourceClass)->withStateOptions(new Options(documentClass: $resourceClass))->withParameters($parameters); + $operations = new Operations(); + $operations->add('_api_'.$resourceClass.'_GetCollection', $operation); + + $resource = (new ApiResource())->withOperations($operations)->withClass($resourceClass); + + return new ResourceMetadataCollection($resourceClass, [$resource]); + } + + private function getFirstParameter(ResourceMetadataCollection $collection): QueryParameter + { + foreach ($collection as $resource) { + foreach ($resource->getOperations() as $operation) { + foreach ($operation->getParameters() as $parameter) { + if (!$parameter instanceof QueryParameter) { + continue; + } + + return $parameter; + } + } + } + + $this->fail('No parameter found in collection'); + } +} diff --git a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php index 40b2a40ff07..043d7c15a62 100644 --- a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php @@ -91,7 +91,8 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder [$alias, $field] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); // Get the target resource class for nested properties - $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + $nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null; $targetResourceClass = $nestedInfo['leaf_class'] ?? $resourceClass; $metadata = $this->getClassMetadata($targetResourceClass); diff --git a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php index 2a17847723c..b2f1f06261c 100644 --- a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php @@ -58,7 +58,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q if (isset($nestedPropertiesInfo[$property])) { $subParameter = $subParameter->withExtraProperties([ ...$subParameter->getExtraProperties(), - 'nested_property_info' => $nestedPropertiesInfo[$property], + 'nested_properties_info' => [$property => $nestedPropertiesInfo[$property]], ]); } diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 7bd09631144..4cac01d21b6 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -49,25 +49,16 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $parameterName = $queryNameGenerator->generateParameterName($property); - // 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']); - } + // Use precomputed ORM leaf metadata when available (nested properties), + // otherwise resolve at runtime by walking the association chain. + $ormLeafMetadata = $this->getOrmLeafMetadata($parameter); - // 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 (null === $ormLeafMetadata) { + $ormLeafMetadata = $this->resolveLeafMetadataAtRuntime($queryBuilder, $resourceClass, $parameter->getProperty(), $property); + } - if ($isCollectionAssociation) { + // Collection associations (OneToMany/ManyToMany) require a JOIN to compare individual elements. + if ($ormLeafMetadata['is_collection_valued']) { $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); if (is_iterable($value)) { @@ -83,6 +74,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } + // Single-valued associations can be compared directly. $propertyExpr = \sprintf('%s.%s', $alias, $property); if (is_iterable($value)) { @@ -98,16 +90,66 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q // 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]); + $targetClass = $ormLeafMetadata['association_target_class']; + $em = $queryBuilder->getEntityManager(); + $targetMetadata = $em->getClassMetadata($targetClass); $identifierValues = $targetMetadata->getIdentifierValues($value); - $queryBuilder->setParameter($parameterName, reset($identifierValues), $idType); + $queryBuilder->setParameter($parameterName, reset($identifierValues), $ormLeafMetadata['identifier_type']); } public static function getParameterProvider(): string { return IriConverterParameterProvider::class; } + + /** + * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null + */ + private function getOrmLeafMetadata(mixed $parameter): ?array + { + $extraProperties = $parameter->getExtraProperties(); + $nestedPropertiesInfo = $extraProperties['nested_properties_info'] ?? null; + if (!$nestedPropertiesInfo) { + return null; + } + + $info = $nestedPropertiesInfo[$parameter->getProperty()] ?? null; + + return $info['orm_leaf_metadata'] ?? null; + } + + /** + * Resolves leaf metadata at runtime by walking the association chain. + * Used as fallback when precomputed orm_leaf_metadata is not available. + * + * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string} + */ + private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): array + { + $em = $queryBuilder->getEntityManager(); + $metadata = $em->getClassMetadata($resourceClass); + $segments = explode('.', $originalProperty); + + for ($i = 0, $count = \count($segments) - 1; $i < $count; ++$i) { + $associationMapping = $metadata->getAssociationMapping($segments[$i]); + $metadata = $em->getClassMetadata($associationMapping['targetEntity']); + } + + $isCollectionValued = $metadata->isCollectionValuedAssociation($leafProperty); + $associationMapping = $metadata->getAssociationMapping($leafProperty); + $targetClass = $associationMapping['targetEntity']; + + $identifierType = null; + if (!$isCollectionValued) { + $targetMetadata = $em->getClassMetadata($targetClass); + $idFieldNames = $targetMetadata->getIdentifierFieldNames(); + $identifierType = $targetMetadata->getTypeOfField($idFieldNames[0]); + } + + return [ + 'is_collection_valued' => $isCollectionValued, + 'association_target_class' => $targetClass, + 'identifier_type' => $identifierType, + ]; + } } diff --git a/src/Doctrine/Orm/Filter/SortFilter.php b/src/Doctrine/Orm/Filter/SortFilter.php index 9ec3b579b9d..c1bf315bdfe 100644 --- a/src/Doctrine/Orm/Filter/SortFilter.php +++ b/src/Doctrine/Orm/Filter/SortFilter.php @@ -53,8 +53,8 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } - $value = $context['filters'][$parameter->getProperty() ?? ''] ?? null; - if (null === $value) { + $value = $parameter->getValue(null); + if (!\is_string($value)) { return; } @@ -69,10 +69,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q [$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); + $nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction] ?? null; + if (null !== $nullsDirection) { + $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); diff --git a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..be342c758ce --- /dev/null +++ b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php @@ -0,0 +1,172 @@ + + * + * 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\Metadata\Resource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; + +/** + * Enriches nested_properties_info with ORM-specific leaf metadata (orm_leaf_metadata) + * so that filters don't need to resolve association chains at runtime. + * + * @author Antoine Bluchet + */ +final class DoctrineOrmParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + use StateOptionsTrait; + + public function __construct( + private readonly ManagerRegistry $managerRegistry, + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + + foreach ($resourceMetadataCollection as $i => $resourceMetadata) { + $operations = $resourceMetadata->getOperations(); + + if ($operations) { + foreach ($operations as $operationName => $operation) { + $operation = $this->enrichOperation($operation, $resourceClass); + $operations->add($operationName, $operation); + } + + $resourceMetadata = $resourceMetadata->withOperations($operations); + } + + $graphQlOperations = $resourceMetadata->getGraphQlOperations(); + + if ($graphQlOperations) { + foreach ($graphQlOperations as $operationName => $graphQlOperation) { + $graphQlOperation = $this->enrichOperation($graphQlOperation, $resourceClass); + $graphQlOperations[$operationName] = $graphQlOperation; + } + + $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); + } + + $resourceMetadataCollection[$i] = $resourceMetadata; + } + + return $resourceMetadataCollection; + } + + private function enrichOperation(Operation $operation, string $resourceClass): Operation + { + $parameters = $operation->getParameters(); + if (!$parameters) { + return $operation; + } + + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); + if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface) { + return $operation; + } + + $operationChanged = false; + + foreach ($parameters as $key => $parameter) { + $extraProperties = $parameter->getExtraProperties(); + $parameterChanged = false; + + $nestedPropertiesInfo = $extraProperties['nested_properties_info'] ?? null; + if ($nestedPropertiesInfo) { + foreach ($nestedPropertiesInfo as $propPath => $propNestedInfo) { + if (!isset($propNestedInfo['orm_leaf_metadata'])) { + $ormLeafMetadata = $this->buildOrmLeafMetadata($propNestedInfo); + if (null !== $ormLeafMetadata) { + $nestedPropertiesInfo[$propPath]['orm_leaf_metadata'] = $ormLeafMetadata; + $parameterChanged = true; + } + } + } + + if ($parameterChanged) { + $extraProperties['nested_properties_info'] = $nestedPropertiesInfo; + } + } + + if ($parameterChanged) { + $parameters->add($key, $parameter->withExtraProperties($extraProperties)); + $operationChanged = true; + } + } + + if ($operationChanged) { + $operation = $operation->withParameters($parameters); + } + + return $operation; + } + + /** + * @param array{leaf_property?: string, leaf_class?: class-string} $nestedInfo + * + * @return array{has_field: bool, has_association: bool, is_collection_valued: bool, is_inverse_side: bool, association_target_class: ?string, identifier_field: ?string, identifier_type: ?string}|null + */ + private function buildOrmLeafMetadata(array $nestedInfo): ?array + { + $leafClass = $nestedInfo['leaf_class'] ?? null; + $leafProperty = $nestedInfo['leaf_property'] ?? null; + + if (!$leafClass || !$leafProperty) { + return null; + } + + $manager = $this->managerRegistry->getManagerForClass($leafClass); + if (!$manager instanceof EntityManagerInterface) { + return null; + } + + $classMetadata = $manager->getClassMetadata($leafClass); + + $result = [ + 'has_field' => $classMetadata->hasField($leafProperty), + 'has_association' => $classMetadata->hasAssociation($leafProperty), + 'is_collection_valued' => false, + 'is_inverse_side' => false, + 'association_target_class' => null, + 'identifier_field' => null, + 'identifier_type' => null, + ]; + + if (!$result['has_association']) { + return $result; + } + + $result['is_collection_valued'] = $classMetadata->isCollectionValuedAssociation($leafProperty); + + $associationMapping = $classMetadata->getAssociationMapping($leafProperty); + $targetClass = $associationMapping['targetEntity']; + $result['association_target_class'] = $targetClass; + $result['is_inverse_side'] = !($associationMapping['isOwningSide'] ?? true); + + $targetMetadata = $manager->getClassMetadata($targetClass); + $idFieldNames = $targetMetadata->getIdentifierFieldNames(); + if ($idFieldNames) { + $result['identifier_field'] = $idFieldNames[0]; + $result['identifier_type'] = $targetMetadata->getTypeOfField($idFieldNames[0]); + } + + return $result; + } +} diff --git a/src/Doctrine/Orm/NestedPropertyHelperTrait.php b/src/Doctrine/Orm/NestedPropertyHelperTrait.php index 1abb8be8d88..da2d45b7232 100644 --- a/src/Doctrine/Orm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Orm/NestedPropertyHelperTrait.php @@ -40,7 +40,7 @@ protected function addNestedParameterJoins( ?string $joinType = null, ): array { $extraProperties = $parameter->getExtraProperties(); - $nestedInfo = $extraProperties['nested_property_info'] ?? null; + $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { return [$alias, $property]; diff --git a/src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php b/src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php new file mode 100644 index 00000000000..b10ada2b03f --- /dev/null +++ b/src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,285 @@ + + * + * 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\Tests\Metadata\Resource; + +use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmParameterResourceMetadataCollectionFactory; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\RelatedDummy; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\ThirdLevel; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\MockObject\Stub; +use PHPUnit\Framework\TestCase; + +class DoctrineOrmParameterResourceMetadataCollectionFactoryTest extends TestCase +{ + private ManagerRegistry $managerRegistry; + private EntityManagerInterface&Stub $entityManager; + + protected function setUp(): void + { + $this->entityManager = $this->createStub(EntityManagerInterface::class); + + $this->managerRegistry = $this->createStub(ManagerRegistry::class); + $this->managerRegistry->method('getManagerForClass')->willReturn($this->entityManager); + } + + public function testParameterWithoutNestedInfoPassedThrough(): void + { + $parameter = new QueryParameter(property: 'name', key: 'name'); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $this->assertArrayNotHasKey('nested_properties_info', $resultParameter->getExtraProperties()); + } + + public function testParameterWithNestedInfoGetsOrmLeafMetadata(): void + { + $leafMetadata = new ClassMetadata(RelatedDummy::class); + $leafMetadata->mapField(['fieldName' => 'name', 'type' => 'string', 'columnName' => 'name']); + $leafMetadata->mapManyToOne(['fieldName' => 'thirdLevel', 'targetEntity' => ThirdLevel::class, 'joinColumns' => [['name' => 'third_level_id', 'referencedColumnName' => 'id']]]); + + $targetMetadata = new ClassMetadata(ThirdLevel::class); + $targetMetadata->mapField(['fieldName' => 'id', 'type' => 'integer', 'columnName' => 'id', 'id' => true]); + $targetMetadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + + $this->entityManager->method('getClassMetadata')->willReturnMap([ + [RelatedDummy::class, $leafMetadata], + [ThirdLevel::class, $targetMetadata], + ]); + + $parameter = new QueryParameter( + property: 'relatedDummy.thirdLevel', + key: 'relatedDummy.thirdLevel', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.thirdLevel' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'thirdLevel', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.thirdLevel']; + $this->assertArrayHasKey('orm_leaf_metadata', $nestedInfo); + + $ormLeaf = $nestedInfo['orm_leaf_metadata']; + $this->assertFalse($ormLeaf['has_field']); + $this->assertTrue($ormLeaf['has_association']); + $this->assertFalse($ormLeaf['is_collection_valued']); + $this->assertFalse($ormLeaf['is_inverse_side']); + $this->assertSame(ThirdLevel::class, $ormLeaf['association_target_class']); + $this->assertSame('id', $ormLeaf['identifier_field']); + $this->assertSame('integer', $ormLeaf['identifier_type']); + } + + public function testCollectionValuedAssociationDetected(): void + { + $leafMetadata = new ClassMetadata(Dummy::class); + $leafMetadata->mapField(['fieldName' => 'id', 'type' => 'integer', 'columnName' => 'id', 'id' => true]); + $leafMetadata->mapManyToMany(['fieldName' => 'relatedDummies', 'targetEntity' => RelatedDummy::class, 'joinTable' => ['name' => 'dummy_related', 'joinColumns' => [['name' => 'dummy_id', 'referencedColumnName' => 'id']], 'inverseJoinColumns' => [['name' => 'related_id', 'referencedColumnName' => 'id']]]]); + + $targetMetadata = new ClassMetadata(RelatedDummy::class); + $targetMetadata->mapField(['fieldName' => 'id', 'type' => 'integer', 'columnName' => 'id', 'id' => true]); + $targetMetadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + + $this->entityManager->method('getClassMetadata')->willReturnMap([ + [Dummy::class, $leafMetadata], + [RelatedDummy::class, $targetMetadata], + ]); + + $parameter = new QueryParameter( + property: 'parent.relatedDummies', + key: 'parent.relatedDummies', + extraProperties: [ + 'nested_properties_info' => ['parent.relatedDummies' => [ + 'relation_segments' => ['parent'], + 'relation_classes' => [RelatedDummy::class], + 'leaf_property' => 'relatedDummies', + 'leaf_class' => Dummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, RelatedDummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(RelatedDummy::class); + $resultParameter = $this->getFirstParameter($result); + + $ormLeaf = $resultParameter->getExtraProperties()['nested_properties_info']['parent.relatedDummies']['orm_leaf_metadata']; + $this->assertTrue($ormLeaf['has_association']); + $this->assertTrue($ormLeaf['is_collection_valued']); + $this->assertSame(RelatedDummy::class, $ormLeaf['association_target_class']); + } + + public function testFieldPropertyProducesFieldMetadata(): void + { + $leafMetadata = new ClassMetadata(RelatedDummy::class); + $leafMetadata->mapField(['fieldName' => 'name', 'type' => 'string', 'columnName' => 'name']); + + $this->entityManager->method('getClassMetadata')->willReturnMap([ + [RelatedDummy::class, $leafMetadata], + ]); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $ormLeaf = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.name']['orm_leaf_metadata']; + $this->assertTrue($ormLeaf['has_field']); + $this->assertFalse($ormLeaf['has_association']); + $this->assertFalse($ormLeaf['is_collection_valued']); + $this->assertNull($ormLeaf['association_target_class']); + } + + public function testAlreadyEnrichedParameterNotProcessedAgain(): void + { + $parameter = new QueryParameter( + property: 'relatedDummy.thirdLevel', + key: 'relatedDummy.thirdLevel', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.thirdLevel' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'thirdLevel', + 'leaf_class' => RelatedDummy::class, + 'orm_leaf_metadata' => [ + 'has_field' => false, + 'has_association' => true, + 'is_collection_valued' => false, + 'is_inverse_side' => false, + 'association_target_class' => ThirdLevel::class, + 'identifier_field' => 'id', + 'identifier_type' => 'integer', + ], + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.thirdLevel']; + $this->assertSame('integer', $nestedInfo['orm_leaf_metadata']['identifier_type']); + } + + public function testNonOrmManagedClassSkippedGracefully(): void + { + $managerRegistry = $this->createStub(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->willReturn(null); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn($collection); + + $factory = new DoctrineOrmParameterResourceMetadataCollectionFactory($managerRegistry, $decorated); + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.name']; + $this->assertArrayNotHasKey('orm_leaf_metadata', $nestedInfo); + } + + private function createFactory(ResourceMetadataCollection $collection): DoctrineOrmParameterResourceMetadataCollectionFactory + { + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn($collection); + + return new DoctrineOrmParameterResourceMetadataCollectionFactory($this->managerRegistry, $decorated); + } + + private function createCollectionWithParameter(QueryParameter $parameter, string $resourceClass): ResourceMetadataCollection + { + $parameters = new Parameters(); + $parameters->add($parameter->getKey(), $parameter); + + $operation = (new GetCollection())->withClass($resourceClass)->withStateOptions(new Options(entityClass: $resourceClass))->withParameters($parameters); + $operations = new Operations(); + $operations->add('_api_'.$resourceClass.'_GetCollection', $operation); + + $resource = (new ApiResource())->withOperations($operations)->withClass($resourceClass); + + return new ResourceMetadataCollection($resourceClass, [$resource]); + } + + private function getFirstParameter(ResourceMetadataCollection $collection): QueryParameter + { + foreach ($collection as $resource) { + foreach ($resource->getOperations() as $operation) { + foreach ($operation->getParameters() as $parameter) { + if (!$parameter instanceof QueryParameter) { + continue; + } + + return $parameter; + } + } + } + + $this->fail('No parameter found in collection'); + } +} diff --git a/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php b/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php index 99f674440d5..0730ac4b1aa 100644 --- a/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php +++ b/src/Laravel/Eloquent/Filter/NestedPropertyTrait.php @@ -35,7 +35,8 @@ private function addNestedParameterJoins( callable $condition, string $whereClause = 'where', ): Builder { - $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + $nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null; if (!$nestedInfo) { // No nested property, use simple where clause diff --git a/src/Laravel/Eloquent/Filter/OrderFilter.php b/src/Laravel/Eloquent/Filter/OrderFilter.php index 0856299149a..72f23fe9771 100644 --- a/src/Laravel/Eloquent/Filter/OrderFilter.php +++ b/src/Laravel/Eloquent/Filter/OrderFilter.php @@ -50,7 +50,8 @@ public function apply(Builder $builder, mixed $values, Parameter $parameter, arr return $builder; } - $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + $nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null; if (!$nestedInfo || 0 === \count($nestedInfo['relation_segments'])) { return $builder->orderBy($this->getQueryProperty($parameter), $direction); diff --git a/src/Laravel/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Laravel/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 4055be6c049..81e29a48c3a 100644 --- a/src/Laravel/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Laravel/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -57,7 +57,8 @@ public function create(string $resourceClass): ResourceMetadataCollection } $extraProperties = $parameter->getExtraProperties(); - $nestedInfo = $extraProperties['nested_property_info'] ?? null; + $nestedPropertiesInfo = $extraProperties['nested_properties_info'] ?? []; + $nestedInfo = $nestedPropertiesInfo[$property] ?? null; if (!$nestedInfo && $modelClass) { $nestedInfo = $this->buildNestedPropertyInfo($property, $modelClass); @@ -65,7 +66,7 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $extraProperties['nested_property_info'] = $nestedInfo; + $extraProperties['nested_properties_info'] = [$property => $nestedInfo]; $parameters = $parameters->add( $key, $parameter->withProperty($property)->withExtraProperties($extraProperties) @@ -83,7 +84,8 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $extraProperties['nested_property_info'] = $nestedInfo; + $nestedPropertiesInfo[$property] = $nestedInfo; + $extraProperties['nested_properties_info'] = $nestedPropertiesInfo; $parameters = $parameters->add($key, $parameter->withExtraProperties($extraProperties)); $modified = true; } @@ -107,7 +109,7 @@ public function create(string $resourceClass): ResourceMetadataCollection * @param string $property The nested property path (e.g., "product.productVariations.variantName") * @param string $modelClass The starting model class * - * @return array|null The nested_property_info array, or null if traversal fails + * @return array|null The NestedPropertyInfo array, or null if traversal fails */ private function buildNestedPropertyInfo(string $property, string $modelClass): ?array { diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index e513705893d..06684abd45c 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -30,7 +30,6 @@ abstract class Parameter * } * * @param array{ - * nested_property_info?: NestedPropertyInfo, * nested_properties_info?: array, * ... * }|array $extraProperties diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 613d1a22409..99219d150ec 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -367,42 +367,38 @@ private function setDefaults(string $key, Parameter $parameter, ?object $filter, } // Transfer nested property metadata from ApiProperty to Parameter + // Always use nested_properties_info (plural) as a map keyed by original property path $propertyKey = $parameter->getProperty() ?? $key; + $nestedPropertiesInfo = []; if (isset($properties[$propertyKey])) { $apiPropertyExtraProps = $properties[$propertyKey]->getExtraProperties(); if (isset($apiPropertyExtraProps['nested_property_info'])) { $nestedInfo = $apiPropertyExtraProps['nested_property_info']; - $parameter = $parameter->withExtraProperties([ - ...$parameter->getExtraProperties(), - 'nested_property_info' => $nestedInfo, - ]); + $nestedPropertiesInfo[$propertyKey] = $nestedInfo; $fullPath = implode('.', [...$nestedInfo['converted_relation_segments'], $nestedInfo['leaf_property']]); $parameter = $parameter->withProperty($fullPath); } } else { - // For parameters with plural properties (e.g. FreeTextQueryFilter), build a map - // so that sub-parameters created via withProperty() can look up their nested info - $nestedPropertiesInfo = []; foreach ($properties as $propPath => $apiProperty) { $apiPropExtra = $apiProperty->getExtraProperties(); if (isset($apiPropExtra['nested_property_info'])) { $nestedPropertiesInfo[$propPath] = $apiPropExtra['nested_property_info']; } } + } - if ($nestedPropertiesInfo) { - $parameter = $parameter->withExtraProperties([ - ...$parameter->getExtraProperties(), - 'nested_properties_info' => $nestedPropertiesInfo, - ]); - } + if ($nestedPropertiesInfo) { + $parameter = $parameter->withExtraProperties([ + ...$parameter->getExtraProperties(), + 'nested_properties_info' => $nestedPropertiesInfo, + ]); } if ($this->nameConverter && $property = $parameter->getProperty()) { // Skip name conversion if we already have nested property info $paramExtraProps = $parameter->getExtraProperties(); - if (!isset($paramExtraProps['nested_property_info'])) { + if (!isset($paramExtraProps['nested_properties_info'])) { $parameter = $parameter->withProperty($this->nameConverter->normalize($property)); } } diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php index af1d329dcac..612e197e417 100644 --- a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php @@ -365,9 +365,9 @@ public function testNestedPropertyInfoOnSingularProperty(): void $this->assertNotNull($param, 'Parameter product.name should exist'); $extra = $param->getExtraProperties(); - $this->assertArrayHasKey('nested_property_info', $extra); + $this->assertArrayHasKey('nested_properties_info', $extra); - $info = $extra['nested_property_info']; + $info = $extra['nested_properties_info']['product.name']; $this->assertSame(['product'], $info['relation_segments']); $this->assertSame(['product'], $info['converted_relation_segments']); $this->assertSame('name', $info['leaf_property']); @@ -386,9 +386,9 @@ public function testNestedPropertyInfoOnDeeplyNestedProperty(): void $this->assertNotNull($param, 'Parameter product.productVariations.variantName should exist'); $extra = $param->getExtraProperties(); - $this->assertArrayHasKey('nested_property_info', $extra); + $this->assertArrayHasKey('nested_properties_info', $extra); - $info = $extra['nested_property_info']; + $info = $extra['nested_properties_info']['product.productVariations.variantName']; $this->assertSame(['product', 'productVariations'], $info['relation_segments']); $this->assertSame(['product', 'product_variations'], $info['converted_relation_segments']); $this->assertSame('variant_name', $info['leaf_property']); @@ -407,9 +407,9 @@ public function testNestedPropertyInfoOnExpandedPlaceholderParameter(): void $this->assertNotNull($searchProductName, 'Parameter search[product.name] should exist'); $extra = $searchProductName->getExtraProperties(); - $this->assertArrayHasKey('nested_property_info', $extra); + $this->assertArrayHasKey('nested_properties_info', $extra); - $info = $extra['nested_property_info']; + $info = $extra['nested_properties_info']['product.name']; $this->assertSame(['product'], $info['relation_segments']); $this->assertSame('name', $info['leaf_property']); $this->assertSame(NestedTestProduct::class, $info['leaf_class']); @@ -424,7 +424,7 @@ public function testSimplePropertyHasNoNestedPropertyInfo(): void $param = $parameters->get('name'); $this->assertNotNull($param); - $this->assertArrayNotHasKey('nested_property_info', $param->getExtraProperties()); + $this->assertArrayNotHasKey('nested_properties_info', $param->getExtraProperties()); } } diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php index 17d22bbf589..97097e030f0 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php @@ -27,6 +27,7 @@ use ApiPlatform\Doctrine\Odm\Filter\RangeFilter; use ApiPlatform\Doctrine\Odm\Filter\SearchFilter; use ApiPlatform\Doctrine\Odm\Metadata\Property\DoctrineMongoDbOdmPropertyMetadataFactory; +use ApiPlatform\Doctrine\Odm\Metadata\Resource\DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory; use ApiPlatform\Doctrine\Odm\Metadata\Resource\DoctrineMongoDbOdmResourceCollectionMetadataFactory; use ApiPlatform\Doctrine\Odm\PropertyInfo\DoctrineExtractor; use ApiPlatform\Doctrine\Odm\Serializer\DoctrineOdmOperationResourceClassResolver; @@ -226,6 +227,13 @@ service('api_platform.doctrine.odm.metadata.resource.metadata_collection_factory.inner'), ]); + $services->set('api_platform.doctrine.odm.metadata.resource.parameter_metadata_collection_factory', DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory::class) + ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 999) + ->args([ + service('doctrine_mongodb'), + service('api_platform.doctrine.odm.metadata.resource.parameter_metadata_collection_factory.inner'), + ]); + $services->set('api_platform.doctrine.odm.links_handler', LinksHandler::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 4a2c6772e3d..b23351bcab3 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -34,6 +34,7 @@ use ApiPlatform\Doctrine\Orm\Filter\UuidFilter; use ApiPlatform\Doctrine\Orm\Metadata\Property\DoctrineOrmPropertyMetadataFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory; +use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmParameterResourceMetadataCollectionFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory; use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOrmOperationResourceClassResolver; use ApiPlatform\Doctrine\Orm\State\CollectionProvider; @@ -277,6 +278,13 @@ service('api_platform.doctrine.orm.metadata.resource.link_factory.inner'), ]); + $services->set('api_platform.doctrine.orm.metadata.resource.parameter_metadata_collection_factory', DoctrineOrmParameterResourceMetadataCollectionFactory::class) + ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 998) + ->args([ + service('doctrine'), + service('api_platform.doctrine.orm.metadata.resource.parameter_metadata_collection_factory.inner'), + ]); + $services->set('api_platform.doctrine.orm.links_handler', LinksHandler::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterCompany.php b/tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterCompany.php new file mode 100644 index 00000000000..8f7d71652f9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterCompany.php @@ -0,0 +1,52 @@ + + * + * 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\FilterNestedTest; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + ] +)] +class FilterCompany +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $name; + + public function getId(): ?string + { + 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/Document/FilterNestedTest/FilterDepartment.php b/tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterDepartment.php new file mode 100644 index 00000000000..86133f2591c --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterDepartment.php @@ -0,0 +1,67 @@ + + * + * 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\FilterNestedTest; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + ] +)] +class FilterDepartment +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $name; + + #[ODM\ReferenceOne(targetDocument: FilterCompany::class, storeAs: 'id')] + private FilterCompany $company; + + public function getId(): ?string + { + 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/Document/FilterNestedTest/FilterEmployee.php b/tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterEmployee.php new file mode 100644 index 00000000000..447c0eb7403 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilterNestedTest/FilterEmployee.php @@ -0,0 +1,99 @@ + + * + * 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\FilterNestedTest; + +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +#[ODM\Document] +#[ApiResource( + operations: [ + new GetCollection( + paginationItemsPerPage: 10, + parameters: [ + 'department' => new QueryParameter(filter: new IriFilter()), + + 'departmentCompany' => new QueryParameter(filter: new IriFilter(), 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)), + 'orderCompanyName' => new QueryParameter(filter: new SortFilter(), property: 'department.company.name', nativeType: new BuiltinType(TypeIdentifier::STRING)), + ] + ), + ] +)] +class FilterEmployee +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $name; + + #[ODM\Field(type: 'date_immutable', nullable: true)] + private ?\DateTimeImmutable $hireDate = null; + + #[ODM\ReferenceOne(targetDocument: FilterDepartment::class, storeAs: 'id')] + private FilterDepartment $department; + + public function getId(): ?string + { + 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/FilterNestedTest/FilterEmployee.php b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterEmployee.php index 63eafed7dbf..b279832b7fc 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterEmployee.php +++ b/tests/Fixtures/TestBundle/Entity/FilterNestedTest/FilterEmployee.php @@ -32,6 +32,7 @@ #[ApiResource( operations: [ new GetCollection( + paginationItemsPerPage: 10, parameters: [ 'department' => new QueryParameter(filter: new IriFilter()), 'departmentId' => new QueryParameter(filter: new UuidFilter(), property: 'department'), @@ -43,6 +44,7 @@ '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)), + 'orderCompanyName' => new QueryParameter(filter: new SortFilter(), property: 'department.company.name', nativeType: new BuiltinType(TypeIdentifier::STRING)), ] ), ] diff --git a/tests/Functional/Parameters/SortFilterTest.php b/tests/Functional/Parameters/SortFilterTest.php new file mode 100644 index 00000000000..fb0bad15f97 --- /dev/null +++ b/tests/Functional/Parameters/SortFilterTest.php @@ -0,0 +1,254 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilterNestedTest\FilterCompany as DocumentFilterCompany; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilterNestedTest\FilterDepartment as DocumentFilterDepartment; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilterNestedTest\FilterEmployee as DocumentFilterEmployee; +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; + +final class SortFilterTest 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]; + } + + protected function setUp(): void + { + $employeeClass = $this->isMongoDB() ? DocumentFilterEmployee::class : FilterEmployee::class; + $departmentClass = $this->isMongoDB() ? DocumentFilterDepartment::class : FilterDepartment::class; + $companyClass = $this->isMongoDB() ? DocumentFilterCompany::class : FilterCompany::class; + + $this->recreateSchema([$employeeClass, $departmentClass, $companyClass]); + $this->loadFixtures(); + } + + public function testSortByName(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderName=asc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + $this->assertSame(['Alice', 'Bob', 'Charlie', 'David'], $names); + } + + public function testSortByNameDesc(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderName=desc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + $this->assertSame(['David', 'Charlie', 'Bob', 'Alice'], $names); + } + + public function testSortByNestedDepartmentName(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderDepartmentName=asc&orderName=asc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + // Engineering (Alice, Bob) then Sales (Charlie, David) + $this->assertSame(['Alice', 'Bob', 'Charlie', 'David'], $names); + } + + public function testSortByNestedDepartmentNameDesc(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderDepartmentName=desc&orderName=asc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + // Sales (Charlie, David) then Engineering (Alice, Bob) + $this->assertSame(['Charlie', 'David', 'Alice', 'Bob'], $names); + } + + public function testSortByHireDateNullsFirst(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderHireDate=asc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + // David has null hireDate -> first (NULLS_ALWAYS_FIRST) + $this->assertSame('David', $names[0]); + } + + public function testSortByHireDateNullsLast(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderHireDateNullsLast=asc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + // David has null hireDate -> last (NULLS_ALWAYS_LAST) + $this->assertSame('David', $names[3]); + } + + public function testSortByMultiHopCompanyName(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderCompanyName=asc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + // Acme Corp employees first, then Globex Inc employees + $acmeNames = \array_slice($names, 0, 2); + $globexNames = \array_slice($names, 2, 2); + sort($acmeNames); + sort($globexNames); + $this->assertSame(['Alice', 'Bob'], $acmeNames); + $this->assertSame(['Charlie', 'David'], $globexNames); + } + + public function testSortByMultiHopCompanyNameDesc(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderCompanyName=desc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + + // Globex Inc employees first, then Acme Corp employees + $globexNames = \array_slice($names, 0, 2); + $acmeNames = \array_slice($names, 2, 2); + sort($globexNames); + sort($acmeNames); + $this->assertSame(['Charlie', 'David'], $globexNames); + $this->assertSame(['Alice', 'Bob'], $acmeNames); + } + + public function testLookupDeduplicationSortAndIriFilter(): void + { + // Get the engineering department IRI + $response = self::createClient()->request('GET', '/filter_departments'); + $this->assertResponseIsSuccessful(); + $departments = $response->toArray()['hydra:member']; + $engineeringIri = $departments[0]['@id']; + + // Apply both IRI filter on department and sort by department.name + // This should NOT produce duplicate $lookup/$unwind stages + $response = self::createClient()->request('GET', '/filter_employees?department='.$engineeringIri.'&orderDepartmentName=asc'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + sort($names); + + $this->assertSame(['Alice', 'Bob'], $names); + } + + public function testSortInvalidValueReturnsValidationError(): void + { + $response = self::createClient()->request('GET', '/filter_employees?orderName=invalid'); + $this->assertResponseStatusCodeSame(422); + } + + public function testIriFilterOnDepartment(): void + { + $response = self::createClient()->request('GET', '/filter_departments'); + $this->assertResponseIsSuccessful(); + $departments = $response->toArray()['hydra:member']; + $engineeringIri = $departments[0]['@id']; + + $response = self::createClient()->request('GET', '/filter_employees?department='.$engineeringIri); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $names = array_map(static fn ($item) => $item['name'], $data['hydra:member']); + sort($names); + + $this->assertSame(['Alice', 'Bob'], $names); + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $companyClass = $this->isMongoDB() ? DocumentFilterCompany::class : FilterCompany::class; + $departmentClass = $this->isMongoDB() ? DocumentFilterDepartment::class : FilterDepartment::class; + $employeeClass = $this->isMongoDB() ? DocumentFilterEmployee::class : FilterEmployee::class; + + $acme = new $companyClass(); + $acme->setName('Acme Corp'); + $manager->persist($acme); + + $globex = new $companyClass(); + $globex->setName('Globex Inc'); + $manager->persist($globex); + + $manager->flush(); + + $engineering = new $departmentClass(); + $engineering->setName('Engineering'); + $engineering->setCompany($acme); + $manager->persist($engineering); + + $sales = new $departmentClass(); + $sales->setName('Sales'); + $sales->setCompany($globex); + $manager->persist($sales); + + $manager->flush(); + + $alice = new $employeeClass(); + $alice->setName('Alice'); + $alice->setDepartment($engineering); + $alice->setHireDate(new \DateTimeImmutable('2023-01-15')); + $manager->persist($alice); + + $bob = new $employeeClass(); + $bob->setName('Bob'); + $bob->setDepartment($engineering); + $bob->setHireDate(new \DateTimeImmutable('2023-06-01')); + $manager->persist($bob); + + $charlie = new $employeeClass(); + $charlie->setName('Charlie'); + $charlie->setDepartment($sales); + $charlie->setHireDate(new \DateTimeImmutable('2024-01-10')); + $manager->persist($charlie); + + $david = new $employeeClass(); + $david->setName('David'); + $david->setDepartment($sales); + $david->setHireDate(null); + $manager->persist($david); + + $manager->flush(); + } +}