diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 17c664393fb..daacc1654b6 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, false, $context); - 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 2dde16d6ecc..2db2520451b 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 df4afe6c5d6..c64069e3d46 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, false, $context); + + $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 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..e75932eb609 --- /dev/null +++ b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmParameterResourceMetadataCollectionFactory.php @@ -0,0 +1,200 @@ + + * + * 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_property_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; + } + + $changed = false; + + foreach ($parameters as $key => $parameter) { + $extraProperties = $parameter->getExtraProperties(); + + // Handle singular nested_property_info + $nestedInfo = $extraProperties['nested_property_info'] ?? null; + if ($nestedInfo && !isset($nestedInfo['odm_segments'])) { + $odmSegments = $this->buildOdmSegments($nestedInfo); + if (null !== $odmSegments) { + $nestedInfo['odm_segments'] = $odmSegments; + $extraProperties['nested_property_info'] = $nestedInfo; + $changed = true; + } + } + + // Handle plural nested_properties_info (used by FreeTextQueryFilter) + $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; + $changed = true; + } + } + } + + $extraProperties['nested_properties_info'] = $nestedPropertiesInfo; + } + + if ($changed) { + $parameters->add($key, $parameter->withExtraProperties($extraProperties)); + } + } + + if ($changed) { + $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..594902a207f --- /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_property_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..68e10857c06 --- /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_property_info' => [ + '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_property_info' => [ + '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..2546ea6defa --- /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_property_info' => [ + '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_property_info' => [ + '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..a3fb52f73b5 --- /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_property_info' => [ + '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_property_info' => [ + '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_property_info' => [ + '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..54763439abf --- /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_property_info — parameter should be unchanged + $this->assertArrayNotHasKey('nested_property_info', $resultParameter->getExtraProperties()); + } + + public function testParameterWithNestedInfoGetsOdmSegments(): void + { + $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, + ], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_property_info']; + $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_property_info' => [ + '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_property_info']; + $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_property_info' => [ + '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_property_info']; + // 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_property_info' => [ + '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_property_info']; + $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/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/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/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(); + } +}