Skip to content

Commit b0f6dbd

Browse files
authored
feat(doctrine): add EndSearchFilter primary for ORM and ODM (#8319)
1 parent 2b5031d commit b0f6dbd

6 files changed

Lines changed: 529 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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\Odm\NestedPropertyHelperTrait;
18+
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
19+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
20+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
21+
use ApiPlatform\Metadata\Operation;
22+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
23+
use MongoDB\BSON\Regex;
24+
25+
/**
26+
* Filters the collection by the end of a string property, using a regular expression anchored at the end.
27+
*/
28+
final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
29+
{
30+
use BackwardCompatibleFilterDescriptionTrait;
31+
use NestedPropertyHelperTrait;
32+
use OpenApiFilterTrait;
33+
34+
public function __construct(private readonly bool $caseSensitive = true)
35+
{
36+
}
37+
38+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
39+
{
40+
$parameter = $context['parameter'];
41+
42+
if (null === $parameter->getProperty()) {
43+
throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey()));
44+
}
45+
46+
$property = $parameter->getProperty();
47+
$values = $parameter->getValue();
48+
$match = $context['match'] = $context['match'] ??
49+
$aggregationBuilder
50+
->matchExpr();
51+
$operator = $context['operator'] ?? 'addAnd';
52+
53+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context);
54+
55+
if (!is_iterable($values)) {
56+
$escapedValue = preg_quote($values, '/');
57+
$match->{$operator}(
58+
$aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i'))
59+
);
60+
61+
return;
62+
}
63+
64+
$or = $aggregationBuilder->matchExpr();
65+
foreach ($values as $value) {
66+
$escapedValue = preg_quote($value, '/');
67+
68+
$or->addOr(
69+
$aggregationBuilder->matchExpr()
70+
->field($matchField)
71+
->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i'))
72+
);
73+
}
74+
75+
$match->{$operator}($or);
76+
}
77+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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\Tests\Filter;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter;
17+
use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase;
18+
use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy;
19+
use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy;
20+
use ApiPlatform\Metadata\QueryParameter;
21+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
22+
use Doctrine\ODM\MongoDB\DocumentManager;
23+
use MongoDB\BSON\Regex;
24+
use PHPUnit\Framework\TestCase;
25+
26+
class EndSearchFilterTest extends TestCase
27+
{
28+
private DocumentManager $manager;
29+
30+
protected function setUp(): void
31+
{
32+
$this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager();
33+
}
34+
35+
public function testEndSearchSimpleProperty(): void
36+
{
37+
$filter = new EndSearchFilter();
38+
39+
$parameter = new QueryParameter(property: 'name', key: 'name');
40+
$parameter->setValue('foo');
41+
$aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder();
42+
43+
$context = [
44+
'parameter' => $parameter,
45+
'filters' => ['name' => 'foo'],
46+
];
47+
48+
$filter->apply($aggregationBuilder, Dummy::class, null, $context);
49+
50+
// The filter populates $context['match'] with the match expression (no pipeline stage added)
51+
$this->assertArrayHasKey('match', $context);
52+
$this->assertEquals(
53+
['$and' => [['name' => new Regex('foo$', '')]]],
54+
$context['match']->getQuery()
55+
);
56+
$this->assertNoPipelineStages($aggregationBuilder);
57+
}
58+
59+
public function testEndSearchCaseInsensitive(): void
60+
{
61+
$filter = new EndSearchFilter(caseSensitive: false);
62+
63+
$parameter = new QueryParameter(property: 'name', key: 'name');
64+
$parameter->setValue('foo');
65+
$aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder();
66+
67+
$context = [
68+
'parameter' => $parameter,
69+
'filters' => ['name' => 'foo'],
70+
];
71+
72+
$filter->apply($aggregationBuilder, Dummy::class, null, $context);
73+
74+
$this->assertEquals(
75+
['$and' => [['name' => new Regex('foo$', 'i')]]],
76+
$context['match']->getQuery()
77+
);
78+
}
79+
80+
public function testEndSearchNestedProperty(): void
81+
{
82+
$filter = new EndSearchFilter();
83+
84+
$parameter = new QueryParameter(
85+
property: 'relatedDummy.name',
86+
key: 'relatedDummy.name',
87+
extraProperties: [
88+
'nested_properties_info' => ['relatedDummy.name' => [
89+
'relation_segments' => ['relatedDummy'],
90+
'relation_classes' => [Dummy::class],
91+
'leaf_property' => 'name',
92+
'leaf_class' => RelatedDummy::class,
93+
'odm_segments' => [
94+
[
95+
'type' => 'reference',
96+
'target_document' => RelatedDummy::class,
97+
'is_owning_side' => true,
98+
'mapped_by' => null,
99+
],
100+
],
101+
]],
102+
],
103+
);
104+
$parameter->setValue('bar');
105+
106+
$aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder();
107+
108+
$context = [
109+
'parameter' => $parameter,
110+
'filters' => ['relatedDummy.name' => 'bar'],
111+
];
112+
113+
$filter->apply($aggregationBuilder, Dummy::class, null, $context);
114+
$pipeline = $aggregationBuilder->getPipeline();
115+
116+
// Nested property adds $lookup + $unwind stages
117+
$this->assertCount(2, $pipeline);
118+
$this->assertArrayHasKey('$lookup', $pipeline[0]);
119+
$this->assertArrayHasKey('$unwind', $pipeline[1]);
120+
121+
// The match expression is populated for the parameter extension to commit
122+
$this->assertArrayHasKey('match', $context);
123+
}
124+
125+
private function assertNoPipelineStages(Builder $aggregationBuilder): void
126+
{
127+
try {
128+
$pipeline = $aggregationBuilder->getPipeline();
129+
$this->assertEmpty($pipeline);
130+
} catch (\OutOfRangeException) {
131+
// No stages added — expected for simple property filters
132+
}
133+
}
134+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
17+
use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait;
18+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
19+
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
20+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
21+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
22+
use ApiPlatform\Metadata\Operation;
23+
use Doctrine\ORM\QueryBuilder;
24+
25+
/**
26+
* Filters the collection by the end of a string property, using a `LIKE '%value'` clause.
27+
*/
28+
final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
29+
{
30+
use BackwardCompatibleFilterDescriptionTrait;
31+
use NestedPropertyHelperTrait;
32+
use OpenApiFilterTrait;
33+
34+
public function __construct(private readonly bool $caseSensitive = false)
35+
{
36+
}
37+
38+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
39+
{
40+
$parameter = $context['parameter'];
41+
42+
if (null === $parameter->getProperty()) {
43+
throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey()));
44+
}
45+
46+
$property = $parameter->getProperty();
47+
$alias = $queryBuilder->getRootAliases()[0];
48+
[$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter);
49+
$field = $alias.'.'.$property;
50+
$values = $parameter->getValue();
51+
52+
if (!is_iterable($values)) {
53+
$parameterName = $queryNameGenerator->generateParameterName($property);
54+
$queryBuilder->setParameter($parameterName, $this->formatLikeValue($values));
55+
56+
$likeExpression = $this->caseSensitive
57+
? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\''
58+
: 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\'';
59+
$queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression);
60+
61+
return;
62+
}
63+
64+
$likeExpressions = [];
65+
foreach ($values as $val) {
66+
$parameterName = $queryNameGenerator->generateParameterName($property);
67+
$likeExpressions[] = $this->caseSensitive
68+
? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\''
69+
: 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\'';
70+
71+
$queryBuilder->setParameter($parameterName, $this->formatLikeValue($val));
72+
}
73+
74+
$queryBuilder->{$context['whereClause'] ?? 'andWhere'}(
75+
$queryBuilder->expr()->orX(...$likeExpressions)
76+
);
77+
}
78+
79+
private function formatLikeValue(string $value): string
80+
{
81+
return '%'.addcslashes($value, '\\%_');
82+
}
83+
}

tests/Fixtures/TestBundle/Document/Chicken.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;
1515

1616
use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter;
17+
use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter;
1718
use ApiPlatform\Doctrine\Odm\Filter\ExactFilter;
1819
use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter;
1920
use ApiPlatform\Doctrine\Odm\Filter\IriFilter;
@@ -45,6 +46,15 @@
4546
filter: new PartialSearchFilter(true),
4647
property: 'name',
4748
),
49+
'nameEnd' => new QueryParameter(
50+
filter: new EndSearchFilter(false),
51+
property: 'name',
52+
),
53+
'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()),
54+
'nameEndSensitive' => new QueryParameter(
55+
filter: new EndSearchFilter(true),
56+
property: 'name',
57+
),
4858
'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']),
4959
'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']),
5060
'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([

tests/Fixtures/TestBundle/Entity/Chicken.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
1515

1616
use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter;
17+
use ApiPlatform\Doctrine\Orm\Filter\EndSearchFilter;
1718
use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
1819
use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter;
1920
use ApiPlatform\Doctrine\Orm\Filter\IriFilter;
@@ -45,6 +46,15 @@
4546
filter: new PartialSearchFilter(true),
4647
property: 'name',
4748
),
49+
'nameEnd' => new QueryParameter(
50+
filter: new EndSearchFilter(),
51+
property: 'name',
52+
),
53+
'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()),
54+
'nameEndSensitive' => new QueryParameter(
55+
filter: new EndSearchFilter(true),
56+
property: 'name',
57+
),
4858
'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']),
4959
'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']),
5060
'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([

0 commit comments

Comments
 (0)