Skip to content

Commit b844993

Browse files
soyukaclaude
andcommitted
feat(doctrine): add ODM SortFilter and nested property support for parameter-based filters
| Q | A | ------------- | --- | Branch? | main | Tickets | ∅ | License | MIT | Doc PR | ∅ * Add NestedPropertyHelperTrait for ODM (parameter-based $lookup/$unwind) * Add SortFilter for ODM with nulls comparison support * Add nested property support to ExactFilter, IriFilter, PartialSearchFilter * Propagate nested_properties_info in FreeTextQueryFilter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 383a5fa commit b844993

7 files changed

Lines changed: 496 additions & 16 deletions

File tree

src/Doctrine/Odm/Filter/ExactFilter.php

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
1818
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
19+
use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait;
1920
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
2021
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
2122
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
@@ -32,6 +33,7 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterf
3233
{
3334
use BackwardCompatibleFilterDescriptionTrait;
3435
use ManagerRegistryAwareTrait;
36+
use NestedPropertyHelperTrait;
3537
use OpenApiFilterTrait;
3638

3739
/**
@@ -58,23 +60,28 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
5860
return;
5961
}
6062

61-
$classMetadata = $documentManager->getClassMetadata($resourceClass);
63+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter);
6264

63-
if (!$classMetadata->hasReference($property)) {
65+
$nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null;
66+
$leafClass = $nestedInfo['leaf_class'] ?? $resourceClass;
67+
$leafProperty = $nestedInfo['leaf_property'] ?? $property;
68+
$classMetadata = $documentManager->getClassMetadata($leafClass);
69+
70+
if (!$classMetadata->hasReference($leafProperty)) {
6471
$match
65-
->{$operator}($aggregationBuilder->matchExpr()->field($property)->{is_iterable($value) ? 'in' : 'equals'}($value));
72+
->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->{is_iterable($value) ? 'in' : 'equals'}($value));
6673

6774
return;
6875
}
6976

70-
$mapping = $classMetadata->getFieldMapping($property);
71-
$method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo';
77+
$mapping = $classMetadata->getFieldMapping($leafProperty);
78+
$method = $classMetadata->isSingleValuedAssociation($leafProperty) ? 'references' : 'includesReferenceTo';
7279

7380
if (is_iterable($value)) {
7481
$or = $aggregationBuilder->matchExpr();
7582

7683
foreach ($value as $v) {
77-
$or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v)));
84+
$or->addOr($aggregationBuilder->matchExpr()->field($matchField)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v)));
7885
}
7986

8087
$match->{$operator}($or);
@@ -85,7 +92,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
8592
$match
8693
->{$operator}(
8794
$aggregationBuilder->matchExpr()
88-
->field($property)
95+
->field($matchField)
8996
->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $value))
9097
);
9198
}

src/Doctrine/Odm/Filter/FreeTextQueryFilter.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,17 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
4646

4747
$parameter = $context['parameter'];
4848
foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) {
49-
$newContext = ['parameter' => $parameter->withProperty($property), 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context;
49+
$subParameter = $parameter->withProperty($property);
50+
51+
$nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? [];
52+
if (isset($nestedPropertiesInfo[$property])) {
53+
$subParameter = $subParameter->withExtraProperties([
54+
...$subParameter->getExtraProperties(),
55+
'nested_property_info' => $nestedPropertiesInfo[$property],
56+
]);
57+
}
58+
59+
$newContext = ['parameter' => $subParameter, 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context;
5060
$this->filter->apply(
5161
$aggregationBuilder,
5262
$resourceClass,

src/Doctrine/Odm/Filter/IriFilter.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
1818
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
19+
use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait;
1920
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
2021
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
2122
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
@@ -33,6 +34,7 @@ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterfac
3334
{
3435
use BackwardCompatibleFilterDescriptionTrait;
3536
use ManagerRegistryAwareTrait;
37+
use NestedPropertyHelperTrait;
3638
use OpenApiFilterTrait;
3739

3840
/**
@@ -57,19 +59,25 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
5759
return;
5860
}
5961

60-
$classMetadata = $documentManager->getClassMetadata($resourceClass);
6162
$property = $parameter->getProperty();
62-
if (!$classMetadata->hasReference($property)) {
63+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter);
64+
65+
$nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null;
66+
$leafClass = $nestedInfo['leaf_class'] ?? $resourceClass;
67+
$leafProperty = $nestedInfo['leaf_property'] ?? $property;
68+
$classMetadata = $documentManager->getClassMetadata($leafClass);
69+
70+
if (!$classMetadata->hasReference($leafProperty)) {
6371
return;
6472
}
6573

66-
$method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo';
74+
$method = $classMetadata->isSingleValuedAssociation($leafProperty) ? 'references' : 'includesReferenceTo';
6775

6876
if (is_iterable($value)) {
6977
$or = $aggregationBuilder->matchExpr();
7078

7179
foreach ($value as $v) {
72-
$or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($v));
80+
$or->addOr($aggregationBuilder->matchExpr()->field($matchField)->{$method}($v));
7381
}
7482

7583
$match->{$operator}($or);
@@ -81,7 +89,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
8189
->{$operator}(
8290
$aggregationBuilder
8391
->matchExpr()
84-
->field($property)
92+
->field($matchField)
8593
->{$method}($value)
8694
);
8795
}

src/Doctrine/Odm/Filter/PartialSearchFilter.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313

1414
namespace ApiPlatform\Doctrine\Odm\Filter;
1515

16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
1618
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
19+
use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait;
1720
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
1821
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1922
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
@@ -24,9 +27,11 @@
2427
/**
2528
* @author Vincent Amstoutz <vincent.amstoutz.dev@gmail.com>
2629
*/
27-
final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
30+
final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface
2831
{
2932
use BackwardCompatibleFilterDescriptionTrait;
33+
use ManagerRegistryAwareTrait;
34+
use NestedPropertyHelperTrait;
3035
use OpenApiFilterTrait;
3136

3237
public function __construct(private readonly bool $caseSensitive = true)
@@ -48,10 +53,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
4853
->matchExpr();
4954
$operator = $context['operator'] ?? 'addAnd';
5055

56+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter);
57+
5158
if (!is_iterable($values)) {
5259
$escapedValue = preg_quote($values, '/');
5360
$match->{$operator}(
54-
$aggregationBuilder->matchExpr()->field($property)->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i'))
61+
$aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i'))
5562
);
5663

5764
return;
@@ -63,7 +70,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
6370

6471
$or->addOr(
6572
$aggregationBuilder->matchExpr()
66-
->field($property)
73+
->field($matchField)
6774
->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i'))
6875
);
6976
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Odm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
19+
use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface;
20+
use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait;
21+
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
22+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
23+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
24+
use ApiPlatform\Metadata\Operation;
25+
use ApiPlatform\Metadata\Parameter;
26+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
27+
28+
/**
29+
* Parameter-based order filter for sorting a collection by a property.
30+
*
31+
* Unlike {@see OrderFilter}, this filter does not extend AbstractFilter and is designed
32+
* exclusively for use with Parameters (QueryParameter).
33+
*
34+
* Usage: `new QueryParameter(filter: new SortFilter(), property: 'department.name')`.
35+
*
36+
* @author Antoine Bluchet <soyuka@gmail.com>
37+
*/
38+
final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface
39+
{
40+
use BackwardCompatibleFilterDescriptionTrait;
41+
use ManagerRegistryAwareTrait;
42+
use NestedPropertyHelperTrait;
43+
use OpenApiFilterTrait;
44+
45+
public function __construct(
46+
private readonly ?string $nullsComparison = null,
47+
) {
48+
}
49+
50+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
51+
{
52+
$parameter = $context['parameter'] ?? null;
53+
if (null === $parameter) {
54+
return;
55+
}
56+
57+
$value = $context['filters'][$parameter->getProperty() ?? ''] ?? null;
58+
if (null === $value) {
59+
return;
60+
}
61+
62+
$direction = strtoupper($value);
63+
if (!\in_array($direction, ['ASC', 'DESC'], true)) {
64+
return;
65+
}
66+
67+
$property = $parameter->getProperty();
68+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, true);
69+
70+
$mongoDirection = 'ASC' === $direction ? 1 : -1;
71+
72+
if (null !== $nullsComparison = $this->nullsComparison) {
73+
$nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction] ?? null;
74+
if (null !== $nullsDirection) {
75+
$nullRankField = \sprintf('_null_rank_%s', str_replace('.', '_', $matchField));
76+
$mongoNullsDirection = 'ASC' === $nullsDirection ? 1 : -1;
77+
78+
$aggregationBuilder->addFields()
79+
->field($nullRankField)
80+
->cond(
81+
$aggregationBuilder->expr()->eq('$'.$matchField, null),
82+
0,
83+
1
84+
);
85+
86+
$aggregationBuilder->sort(
87+
$context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$nullRankField => $mongoNullsDirection]
88+
);
89+
}
90+
}
91+
92+
$aggregationBuilder->sort(
93+
$context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$matchField => $mongoDirection]
94+
);
95+
}
96+
97+
/**
98+
* @return array<string, mixed>
99+
*/
100+
public function getSchema(Parameter $parameter): array
101+
{
102+
return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']];
103+
}
104+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Odm;
15+
16+
use ApiPlatform\Metadata\Parameter;
17+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
18+
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbOdmClassMetadata;
19+
use Doctrine\ODM\MongoDB\Mapping\MappingException;
20+
use Doctrine\Persistence\ManagerRegistry;
21+
22+
/**
23+
* Helper trait for handling nested properties in parameter-based filters.
24+
*
25+
* @author Antoine Bluchet <soyuka@gmail.com>
26+
*/
27+
trait NestedPropertyHelperTrait
28+
{
29+
abstract protected function getManagerRegistry(): ?ManagerRegistry;
30+
31+
/**
32+
* Adds the necessary lookups for a nested property using parameter metadata.
33+
*
34+
* @throws MappingException
35+
*
36+
* @return string The aliased field name to use in match/sort expressions
37+
*/
38+
protected function addNestedParameterLookups(string $property, Builder $aggregationBuilder, Parameter $parameter, bool $preserveNullAndEmptyArrays = false): string
39+
{
40+
$extraProperties = $parameter->getExtraProperties();
41+
$nestedInfo = $extraProperties['nested_property_info'] ?? null;
42+
43+
if (!$nestedInfo) {
44+
return $property;
45+
}
46+
47+
$relationSegments = $nestedInfo['relation_segments'] ?? [];
48+
$relationClasses = $nestedInfo['relation_classes'] ?? [];
49+
$leafProperty = $nestedInfo['leaf_property'] ?? $property;
50+
51+
if (!$relationSegments) {
52+
return $property;
53+
}
54+
55+
$alias = '';
56+
57+
foreach ($relationSegments as $i => $association) {
58+
$class = $relationClasses[$i] ?? null;
59+
if (!$class) {
60+
break;
61+
}
62+
63+
$manager = $this->getManagerRegistry()->getManagerForClass($class);
64+
if (!$manager) {
65+
break;
66+
}
67+
68+
$classMetadata = $manager->getClassMetadata($class);
69+
70+
if (!$classMetadata instanceof MongoDbOdmClassMetadata) {
71+
break;
72+
}
73+
74+
if ($classMetadata->hasReference($association)) {
75+
$propertyAlias = "{$association}_lkup";
76+
$localField = "$alias$association";
77+
$alias .= $propertyAlias;
78+
$referenceMapping = $classMetadata->getFieldMapping($association);
79+
80+
if (($isOwningSide = $referenceMapping['isOwningSide']) && MongoDbOdmClassMetadata::REFERENCE_STORE_AS_ID !== $referenceMapping['storeAs']) {
81+
throw MappingException::cannotLookupDbRefReference($classMetadata->getReflectionClass()->getShortName(), $association);
82+
}
83+
if (!$isOwningSide) {
84+
if (isset($referenceMapping['repositoryMethod']) || !isset($referenceMapping['mappedBy'])) {
85+
throw MappingException::repositoryMethodLookupNotAllowed($classMetadata->getReflectionClass()->getShortName(), $association);
86+
}
87+
88+
$targetClassMetadata = $manager->getClassMetadata($referenceMapping['targetDocument']);
89+
if ($targetClassMetadata instanceof MongoDbOdmClassMetadata && MongoDbOdmClassMetadata::REFERENCE_STORE_AS_ID !== $targetClassMetadata->getFieldMapping($referenceMapping['mappedBy'])['storeAs']) {
90+
throw MappingException::cannotLookupDbRefReference($classMetadata->getReflectionClass()->getShortName(), $association);
91+
}
92+
}
93+
94+
$aggregationBuilder->lookup($classMetadata->getAssociationTargetClass($association))
95+
->localField($isOwningSide ? $localField : '_id')
96+
->foreignField($isOwningSide ? '_id' : $referenceMapping['mappedBy'])
97+
->alias($alias);
98+
$aggregationBuilder->unwind("\$$alias")
99+
->preserveNullAndEmptyArrays($preserveNullAndEmptyArrays);
100+
101+
$alias .= '.';
102+
} elseif ($classMetadata->hasEmbed($association)) {
103+
$alias = "$alias$association.";
104+
}
105+
}
106+
107+
return "$alias$leafProperty";
108+
}
109+
}

0 commit comments

Comments
 (0)