Skip to content

Commit d6400f4

Browse files
committed
feat(doctrine): add ODM ChainFilter and guard ODM Exact/PartialSearch filters
Mirror the ORM ChainFilter for MongoDB ODM. Add the operator-map guard to the ODM ExactFilter and PartialSearchFilter so they self-select by value shape inside a ChainFilter — an associative operator-map array belongs to ComparisonFilter/DateFilter, not equality.
1 parent 5fc56f5 commit d6400f4

5 files changed

Lines changed: 233 additions & 1 deletion

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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\LoggerAwareInterface;
17+
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
19+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
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+
* Applies several filters to a single parameter; each filter self-selects by the value shape it supports.
28+
*/
29+
final class ChainFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface
30+
{
31+
use BackwardCompatibleFilterDescriptionTrait;
32+
use LoggerAwareTrait;
33+
use ManagerRegistryAwareTrait;
34+
35+
/**
36+
* @param iterable<FilterInterface> $filters
37+
*/
38+
public function __construct(private readonly iterable $filters)
39+
{
40+
}
41+
42+
/**
43+
* @param-out array<string, mixed> $context
44+
*/
45+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
46+
{
47+
foreach ($this->filters as $filter) {
48+
if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry() && $this->hasManagerRegistry()) {
49+
$filter->setManagerRegistry($this->getManagerRegistry());
50+
}
51+
52+
if ($filter instanceof LoggerAwareInterface && !$filter->hasLogger() && $this->hasLogger()) {
53+
$filter->setLogger($this->getLogger());
54+
}
55+
56+
$filter->apply($aggregationBuilder, $resourceClass, $operation, $context);
57+
}
58+
}
59+
60+
public function getOpenApiParameters(Parameter $parameter): array
61+
{
62+
$parameters = [];
63+
foreach ($this->filters as $filter) {
64+
if ($filter instanceof OpenApiParameterFilterInterface) {
65+
$parameters = [...$parameters, ...(array) $filter->getOpenApiParameters($parameter)];
66+
}
67+
}
68+
69+
return $parameters;
70+
}
71+
}

src/Doctrine/Odm/Filter/ExactFilter.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,18 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterf
4343
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
4444
{
4545
$parameter = $context['parameter'];
46+
$value = $parameter->getValue();
47+
48+
if (\is_array($value) && !array_is_list($value)) {
49+
// associative arrays are operator-maps owned by ComparisonFilter/DateFilter, not equality
50+
return;
51+
}
4652

4753
if (null === $parameter->getProperty()) {
4854
throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey()));
4955
}
5056

5157
$property = $parameter->getProperty();
52-
$value = $parameter->getValue();
5358
$operator = $context['operator'] ?? 'addAnd';
5459
$match = $context['match'] = $context['match'] ??
5560
$aggregationBuilder

src/Doctrine/Odm/Filter/PartialSearchFilter.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
4545

4646
$property = $parameter->getProperty();
4747
$values = $parameter->getValue();
48+
49+
if (\is_array($values) && !array_is_list($values)) {
50+
// associative arrays are operator-maps owned by ComparisonFilter/DateFilter, not equality
51+
return;
52+
}
53+
4854
$match = $context['match'] = $context['match'] ??
4955
$aggregationBuilder
5056
->matchExpr();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\ChainFilter;
17+
use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter;
18+
use ApiPlatform\Doctrine\Odm\Filter\ExactFilter;
19+
use ApiPlatform\Metadata\ApiResource;
20+
use ApiPlatform\Metadata\GetCollection;
21+
use ApiPlatform\Metadata\QueryParameter;
22+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
23+
use Symfony\Component\TypeInfo\Type\BuiltinType;
24+
use Symfony\Component\TypeInfo\TypeIdentifier;
25+
26+
#[ApiResource]
27+
#[GetCollection(
28+
paginationEnabled: false,
29+
parameters: [
30+
'quantity' => new QueryParameter(
31+
filter: new ChainFilter([
32+
new ExactFilter(),
33+
new ComparisonFilter(new ExactFilter()),
34+
]),
35+
property: 'quantity',
36+
nativeType: new BuiltinType(TypeIdentifier::INT),
37+
castToNativeType: true,
38+
),
39+
],
40+
)]
41+
#[ODM\Document]
42+
class ChainFilterParameter
43+
{
44+
public function __construct(
45+
#[ODM\Id(type: 'int', strategy: 'INCREMENT')]
46+
public ?int $id = null,
47+
48+
#[ODM\Field(type: 'int')]
49+
public ?int $quantity = null,
50+
) {
51+
}
52+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChainFilterParameter;
18+
use ApiPlatform\Tests\RecreateSchemaTrait;
19+
use ApiPlatform\Tests\SetupClassResourcesTrait;
20+
21+
final class ChainFilterMongoDbTest extends ApiTestCase
22+
{
23+
use RecreateSchemaTrait;
24+
use SetupClassResourcesTrait;
25+
26+
protected static ?bool $alwaysBootKernel = false;
27+
28+
/**
29+
* @return class-string[]
30+
*/
31+
public static function getResources(): array
32+
{
33+
return [ChainFilterParameter::class];
34+
}
35+
36+
protected function setUp(): void
37+
{
38+
if (!$this->isMongoDB()) {
39+
$this->markTestSkipped('Only tested with mongodb.');
40+
}
41+
42+
$this->recreateSchema([ChainFilterParameter::class]);
43+
$this->loadFixtures();
44+
}
45+
46+
public function testExactMatch(): void
47+
{
48+
$response = self::createClient()->request('GET', '/chain_filter_parameters?quantity=10');
49+
$this->assertResponseIsSuccessful();
50+
$quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']);
51+
$this->assertSame([10], $quantities);
52+
}
53+
54+
public function testLessThan(): void
55+
{
56+
$response = self::createClient()->request('GET', '/chain_filter_parameters?quantity[lt]=10');
57+
$this->assertResponseIsSuccessful();
58+
$quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']);
59+
sort($quantities);
60+
$this->assertSame([5], $quantities);
61+
}
62+
63+
public function testGreaterThanOrEqual(): void
64+
{
65+
$response = self::createClient()->request('GET', '/chain_filter_parameters?quantity[gte]=10');
66+
$this->assertResponseIsSuccessful();
67+
$quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']);
68+
sort($quantities);
69+
$this->assertSame([10, 15], $quantities);
70+
}
71+
72+
public function testOpenApiParametersMergeBothFilters(): void
73+
{
74+
$response = self::createClient()->request('GET', '/docs', [
75+
'headers' => ['Accept' => 'application/vnd.openapi+json'],
76+
]);
77+
$this->assertResponseIsSuccessful();
78+
$openApiDoc = $response->toArray();
79+
80+
$parameters = $openApiDoc['paths']['/chain_filter_parameters']['get']['parameters'];
81+
$parameterNames = array_column($parameters, 'name');
82+
83+
foreach (['quantity', 'quantity[gt]', 'quantity[gte]', 'quantity[lt]', 'quantity[lte]', 'quantity[ne]'] as $expectedName) {
84+
$this->assertContains($expectedName, $parameterNames, \sprintf('Expected parameter "%s" in OpenAPI documentation', $expectedName));
85+
}
86+
}
87+
88+
private function loadFixtures(): void
89+
{
90+
$manager = $this->getManager();
91+
92+
foreach ([5, 10, 15] as $quantity) {
93+
$manager->persist(new ChainFilterParameter(quantity: $quantity));
94+
}
95+
96+
$manager->flush();
97+
}
98+
}

0 commit comments

Comments
 (0)