Skip to content

Commit 9f98aff

Browse files
soyukaclaude
andauthored
feat(doctrine): add ODM SortFilter and nested property support for parameter-based filters (#7780)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 516ee3a commit 9f98aff

31 files changed

+2423
-74
lines changed

src/Doctrine/Odm/Filter/ExactFilter.php

Lines changed: 15 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,24 +60,30 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
5860
return;
5961
}
6062

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

63-
if (!$classMetadata->hasReference($property)) {
65+
$nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? [];
66+
$nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null;
67+
$leafClass = $nestedInfo['leaf_class'] ?? $resourceClass;
68+
$leafProperty = $nestedInfo['leaf_property'] ?? $property;
69+
$classMetadata = $documentManager->getClassMetadata($leafClass);
70+
71+
if (!$classMetadata->hasReference($leafProperty)) {
6472
$comparisonMethod = $context['comparisonMethod'] ?? (is_iterable($value) ? 'in' : 'equals');
6573
$match
66-
->{$operator}($aggregationBuilder->matchExpr()->field($property)->{$comparisonMethod}($value));
74+
->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->{$comparisonMethod}($value));
6775

6876
return;
6977
}
7078

71-
$mapping = $classMetadata->getFieldMapping($property);
72-
$method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo';
79+
$mapping = $classMetadata->getFieldMapping($leafProperty);
80+
$method = $classMetadata->isSingleValuedAssociation($leafProperty) ? 'references' : 'includesReferenceTo';
7381

7482
if (is_iterable($value)) {
7583
$or = $aggregationBuilder->matchExpr();
7684

7785
foreach ($value as $v) {
78-
$or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v)));
86+
$or->addOr($aggregationBuilder->matchExpr()->field($matchField)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v)));
7987
}
8088

8189
$match->{$operator}($or);
@@ -86,7 +94,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
8694
$match
8795
->{$operator}(
8896
$aggregationBuilder->matchExpr()
89-
->field($property)
97+
->field($matchField)
9098
->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $value))
9199
);
92100
}

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_properties_info' => [$property => $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: 14 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,26 @@ 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, false, $context);
64+
65+
$nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? [];
66+
$nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null;
67+
$leafClass = $nestedInfo['leaf_class'] ?? $resourceClass;
68+
$leafProperty = $nestedInfo['leaf_property'] ?? $property;
69+
$classMetadata = $documentManager->getClassMetadata($leafClass);
70+
71+
if (!$classMetadata->hasReference($leafProperty)) {
6372
return;
6473
}
6574

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

6877
if (is_iterable($value)) {
6978
$or = $aggregationBuilder->matchExpr();
7079

7180
foreach ($value as $v) {
72-
$or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($v));
81+
$or->addOr($aggregationBuilder->matchExpr()->field($matchField)->{$method}($v));
7382
}
7483

7584
$match->{$operator}($or);
@@ -81,7 +90,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
8190
->{$operator}(
8291
$aggregationBuilder
8392
->matchExpr()
84-
->field($property)
93+
->field($matchField)
8594
->{$method}($value)
8695
);
8796
}

src/Doctrine/Odm/Filter/PartialSearchFilter.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Doctrine\Odm\Filter;
1515

1616
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
17+
use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait;
1718
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
1819
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1920
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
@@ -27,6 +28,7 @@
2728
final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
2829
{
2930
use BackwardCompatibleFilterDescriptionTrait;
31+
use NestedPropertyHelperTrait;
3032
use OpenApiFilterTrait;
3133

3234
public function __construct(private readonly bool $caseSensitive = true)
@@ -48,10 +50,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
4850
->matchExpr();
4951
$operator = $context['operator'] ?? 'addAnd';
5052

53+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context);
54+
5155
if (!is_iterable($values)) {
5256
$escapedValue = preg_quote($values, '/');
5357
$match->{$operator}(
54-
$aggregationBuilder->matchExpr()->field($property)->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i'))
58+
$aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i'))
5559
);
5660

5761
return;
@@ -63,7 +67,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
6367

6468
$or->addOr(
6569
$aggregationBuilder->matchExpr()
66-
->field($property)
70+
->field($matchField)
6771
->equals(new Regex($escapedValue, $this->caseSensitive ? '' : 'i'))
6872
);
6973
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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\OpenApiFilterTrait;
17+
use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface;
18+
use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait;
19+
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
20+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
21+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
22+
use ApiPlatform\Metadata\Operation;
23+
use ApiPlatform\Metadata\Parameter;
24+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
25+
26+
/**
27+
* Parameter-based order filter for sorting a collection by a property.
28+
*
29+
* Unlike {@see OrderFilter}, this filter does not extend AbstractFilter and is designed
30+
* exclusively for use with Parameters (QueryParameter).
31+
*
32+
* Usage: `new QueryParameter(filter: new SortFilter(), property: 'department.name')`.
33+
*
34+
* @author Antoine Bluchet <soyuka@gmail.com>
35+
*/
36+
final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
37+
{
38+
use BackwardCompatibleFilterDescriptionTrait;
39+
use NestedPropertyHelperTrait;
40+
use OpenApiFilterTrait;
41+
42+
public function __construct(
43+
private readonly ?string $nullsComparison = null,
44+
) {
45+
}
46+
47+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
48+
{
49+
$parameter = $context['parameter'] ?? null;
50+
if (null === $parameter) {
51+
return;
52+
}
53+
54+
$value = $parameter->getValue(null);
55+
if (!\is_string($value)) {
56+
return;
57+
}
58+
59+
$direction = strtoupper($value);
60+
if (!\in_array($direction, ['ASC', 'DESC'], true)) {
61+
return;
62+
}
63+
64+
$property = $parameter->getProperty();
65+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, true, $context);
66+
67+
$mongoDirection = 'ASC' === $direction ? 1 : -1;
68+
69+
if (null !== $nullsComparison = $this->nullsComparison) {
70+
$nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction] ?? null;
71+
if (null !== $nullsDirection) {
72+
$nullRankField = \sprintf('_null_rank_%s', str_replace('.', '_', $matchField));
73+
$mongoNullsDirection = 'ASC' === $nullsDirection ? 1 : -1;
74+
75+
$aggregationBuilder->addFields()
76+
->field($nullRankField)
77+
->cond(
78+
$aggregationBuilder->expr()->eq('$'.$matchField, null),
79+
0,
80+
1
81+
);
82+
83+
$context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$nullRankField => $mongoNullsDirection];
84+
}
85+
}
86+
87+
$aggregationBuilder->sort(
88+
$context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$matchField => $mongoDirection]
89+
);
90+
}
91+
92+
/**
93+
* @return array<string, mixed>
94+
*/
95+
public function getSchema(Parameter $parameter): array
96+
{
97+
return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']];
98+
}
99+
}

0 commit comments

Comments
 (0)