diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 17c664393f..9823671684 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,23 +60,28 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } - $classMetadata = $documentManager->getClassMetadata($resourceClass); + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter); - if (!$classMetadata->hasReference($property)) { + $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + $leafClass = $nestedInfo['leaf_class'] ?? $resourceClass; + $leafProperty = $nestedInfo['leaf_property'] ?? $property; + $classMetadata = $documentManager->getClassMetadata($leafClass); + + if (!$classMetadata->hasReference($leafProperty)) { $match - ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{is_iterable($value) ? 'in' : 'equals'}($value)); + ->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->{is_iterable($value) ? 'in' : 'equals'}($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); @@ -85,7 +92,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 2dde16d6ec..2db2520451 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_property_info' => $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 df4afe6c5d..9f21ddad4a 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,25 @@ 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); + + $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? 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 +89,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 f5dcba3a13..23001e6621 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -13,7 +13,10 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +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; @@ -24,9 +27,11 @@ /** * @author Vincent Amstoutz */ -final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface { use BackwardCompatibleFilterDescriptionTrait; + use ManagerRegistryAwareTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; public function __construct(private readonly bool $caseSensitive = true) @@ -48,10 +53,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ->matchExpr(); $operator = $context['operator'] ?? 'addAnd'; + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter); + 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 +70,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 0000000000..3534a4ad07 --- /dev/null +++ b/src/Doctrine/Odm/Filter/SortFilter.php @@ -0,0 +1,102 @@ + + * + * 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\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +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, ManagerRegistryAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use ManagerRegistryAwareTrait; + 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 = $context['filters'][$parameter->getProperty() ?? ''] ?? null; + if (null === $value) { + return; + } + + $direction = strtoupper($value); + if (!\in_array($direction, ['ASC', 'DESC'], true)) { + return; + } + + $property = $parameter->getProperty(); + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, true); + + $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/NestedPropertyHelperTrait.php b/src/Doctrine/Odm/NestedPropertyHelperTrait.php new file mode 100644 index 0000000000..3a312f0c6d --- /dev/null +++ b/src/Doctrine/Odm/NestedPropertyHelperTrait.php @@ -0,0 +1,109 @@ + + * + * 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; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbOdmClassMetadata; +use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\Persistence\ManagerRegistry; + +/** + * Helper trait for handling nested properties in parameter-based filters. + * + * @author Antoine Bluchet + */ +trait NestedPropertyHelperTrait +{ + abstract protected function getManagerRegistry(): ManagerRegistry; + + /** + * Adds the necessary lookups for a nested property using parameter metadata. + * + * @throws MappingException + * + * @return string The aliased field name to use in match/sort expressions + */ + protected function addNestedParameterLookups(string $property, Builder $aggregationBuilder, Parameter $parameter, bool $preserveNullAndEmptyArrays = false): string + { + $extraProperties = $parameter->getExtraProperties(); + $nestedInfo = $extraProperties['nested_property_info'] ?? null; + + if (!$nestedInfo) { + return $property; + } + + $relationSegments = $nestedInfo['relation_segments'] ?? []; + $relationClasses = $nestedInfo['relation_classes'] ?? []; + $leafProperty = $nestedInfo['leaf_property'] ?? $property; + + if (!$relationSegments) { + return $property; + } + + $alias = ''; + + foreach ($relationSegments as $i => $association) { + $class = $relationClasses[$i] ?? null; + if (!$class) { + break; + } + + $manager = $this->getManagerRegistry()->getManagerForClass($class); + if (!$manager) { + break; + } + + $classMetadata = $manager->getClassMetadata($class); + + if (!$classMetadata instanceof MongoDbOdmClassMetadata) { + break; + } + + if ($classMetadata->hasReference($association)) { + $propertyAlias = "{$association}_lkup"; + $localField = "$alias$association"; + $alias .= $propertyAlias; + $referenceMapping = $classMetadata->getFieldMapping($association); + + if (($isOwningSide = $referenceMapping['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); + } + } + + $aggregationBuilder->lookup($classMetadata->getAssociationTargetClass($association)) + ->localField($isOwningSide ? $localField : '_id') + ->foreignField($isOwningSide ? '_id' : $referenceMapping['mappedBy']) + ->alias($alias); + $aggregationBuilder->unwind("\$$alias") + ->preserveNullAndEmptyArrays($preserveNullAndEmptyArrays); + + $alias .= '.'; + } elseif ($classMetadata->hasEmbed($association)) { + $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 0000000000..79b903674f --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/ExactFilterTest.php @@ -0,0 +1,117 @@ + + * + * 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\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_property_info' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ], + ], + ); + $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); + } + + 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 0000000000..412d7c895a --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/PartialSearchFilterTest.php @@ -0,0 +1,153 @@ + + * + * 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 Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; + +class PartialSearchFilterTest 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 testPartialSearchSimpleProperty(): void + { + $filter = new PartialSearchFilter(); + $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 testPartialSearchNestedProperty(): void + { + $filter = new PartialSearchFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_property_info' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ], + ], + ); + $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); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_property_info' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ], + ], + ); + $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 0000000000..809c855b5f --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/SortFilterTest.php @@ -0,0 +1,234 @@ + + * + * 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\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; + +class SortFilterTest 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 testSortAscending(): void + { + $filter = new SortFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'asc'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + $this->assertEquals([ + ['$sort' => ['name' => 1]], + ], $pipeline); + } + + public function testSortDescending(): void + { + $filter = new SortFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'DESC'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + $this->assertEquals([ + ['$sort' => ['name' => -1]], + ], $pipeline); + } + + public function testInvalidDirection(): void + { + $filter = new SortFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'invalid'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $pipeline = []; + try { + $pipeline = $aggregationBuilder->getPipeline(); + } catch (\OutOfRangeException) { + } + + $this->assertEmpty($pipeline); + } + + public function testNullParameter(): void + { + $filter = new SortFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'filters' => ['name' => 'asc'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $pipeline = []; + try { + $pipeline = $aggregationBuilder->getPipeline(); + } catch (\OutOfRangeException) { + } + + $this->assertEmpty($pipeline); + } + + public function testNullValue(): void + { + $filter = new SortFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter(property: 'name', key: 'order[name]'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => [], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $pipeline = []; + try { + $pipeline = $aggregationBuilder->getPipeline(); + } catch (\OutOfRangeException) { + } + + $this->assertEmpty($pipeline); + } + + public function testNestedPropertySort(): void + { + $filter = new SortFilter(); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'order[relatedDummy.name]', + extraProperties: [ + 'nested_property_info' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ], + ], + ); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.name' => 'asc'], + ]; + + $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'); + $filter->setManagerRegistry($this->managerRegistry); + + $parameter = new QueryParameter(property: 'dummyDate', key: 'order[dummyDate]'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['dummyDate' => 'asc'], + ]; + + $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) + ); + } +} diff --git a/src/Doctrine/Orm/Filter/SortFilter.php b/src/Doctrine/Orm/Filter/SortFilter.php index 9ec3b579b9..2cc45e4afa 100644 --- a/src/Doctrine/Orm/Filter/SortFilter.php +++ b/src/Doctrine/Orm/Filter/SortFilter.php @@ -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);