Skip to content

Commit 4bf850f

Browse files
authored
feat(doctrine): add StartSearchFilter and WordStartSearchFilter (ORM + ODM) (#8328)
1 parent 48bc56e commit 4bf850f

8 files changed

Lines changed: 754 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 beginning of a string property, using a regular expression anchored at the start.
27+
*/
28+
final class StartSearchFilter 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: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 a word boundary prefix, matching documents that contain a word starting with the value,
27+
* using a regular expression anchored at the start of the string or at a word boundary.
28+
*/
29+
final class WordStartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
30+
{
31+
use BackwardCompatibleFilterDescriptionTrait;
32+
use NestedPropertyHelperTrait;
33+
use OpenApiFilterTrait;
34+
35+
public function __construct(private readonly bool $caseSensitive = true)
36+
{
37+
}
38+
39+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
40+
{
41+
$parameter = $context['parameter'];
42+
43+
if (null === $parameter->getProperty()) {
44+
throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey()));
45+
}
46+
47+
$property = $parameter->getProperty();
48+
$values = $parameter->getValue();
49+
$match = $context['match'] = $context['match'] ??
50+
$aggregationBuilder
51+
->matchExpr();
52+
$operator = $context['operator'] ?? 'addAnd';
53+
54+
$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context);
55+
56+
if (!is_iterable($values)) {
57+
$match->{$operator}(
58+
$aggregationBuilder->matchExpr()->field($matchField)->equals($this->createRegex($values))
59+
);
60+
61+
return;
62+
}
63+
64+
$or = $aggregationBuilder->matchExpr();
65+
foreach ($values as $value) {
66+
$or->addOr(
67+
$aggregationBuilder->matchExpr()
68+
->field($matchField)
69+
->equals($this->createRegex($value))
70+
);
71+
}
72+
73+
$match->{$operator}($or);
74+
}
75+
76+
private function createRegex(string $value): Regex
77+
{
78+
$escapedValue = preg_quote($value, '/');
79+
80+
return new Regex('(^'.$escapedValue.'|\s'.$escapedValue.')', $this->caseSensitive ? '' : 'i');
81+
}
82+
}
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 beginning of a string property, using a `LIKE 'value%'` clause.
27+
*/
28+
final class StartSearchFilter 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+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 a word boundary prefix, matching fields that contain a word starting with the value,
27+
* using a `LIKE 'value%' OR LIKE '% value%'` clause.
28+
*/
29+
final class WordStartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
30+
{
31+
use BackwardCompatibleFilterDescriptionTrait;
32+
use NestedPropertyHelperTrait;
33+
use OpenApiFilterTrait;
34+
35+
public function __construct(private readonly bool $caseSensitive = false)
36+
{
37+
}
38+
39+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
40+
{
41+
$parameter = $context['parameter'];
42+
43+
if (null === $parameter->getProperty()) {
44+
throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey()));
45+
}
46+
47+
$property = $parameter->getProperty();
48+
$alias = $queryBuilder->getRootAliases()[0];
49+
[$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter);
50+
$field = $alias.'.'.$property;
51+
$values = $parameter->getValue();
52+
53+
if (!is_iterable($values)) {
54+
$values = [$values];
55+
}
56+
57+
$expressions = [];
58+
foreach ($values as $val) {
59+
$startName = $queryNameGenerator->generateParameterName($property);
60+
$wordName = $queryNameGenerator->generateParameterName($property);
61+
62+
$expressions[] = $queryBuilder->expr()->orX(
63+
$this->createLikeExpression($field, $startName),
64+
$this->createLikeExpression($field, $wordName),
65+
);
66+
67+
$queryBuilder->setParameter($startName, $this->formatStartValue($val));
68+
$queryBuilder->setParameter($wordName, $this->formatWordValue($val));
69+
}
70+
71+
$queryBuilder->{$context['whereClause'] ?? 'andWhere'}(
72+
$queryBuilder->expr()->orX(...$expressions)
73+
);
74+
}
75+
76+
private function createLikeExpression(string $field, string $parameterName): string
77+
{
78+
return $this->caseSensitive
79+
? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\''
80+
: 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\'';
81+
}
82+
83+
private function formatStartValue(string $value): string
84+
{
85+
return addcslashes($value, '\\%_').'%';
86+
}
87+
88+
private function formatWordValue(string $value): string
89+
{
90+
return '% '.addcslashes($value, '\\%_').'%';
91+
}
92+
}

tests/Fixtures/TestBundle/Document/Chicken.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
use ApiPlatform\Doctrine\Odm\Filter\IriFilter;
2121
use ApiPlatform\Doctrine\Odm\Filter\OrFilter;
2222
use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter;
23+
use ApiPlatform\Doctrine\Odm\Filter\StartSearchFilter;
24+
use ApiPlatform\Doctrine\Odm\Filter\WordStartSearchFilter;
2325
use ApiPlatform\Metadata\ApiResource;
2426
use ApiPlatform\Metadata\Get;
2527
use ApiPlatform\Metadata\GetCollection;
@@ -55,6 +57,20 @@
5557
filter: new EndSearchFilter(true),
5658
property: 'name',
5759
),
60+
'nameStart' => new QueryParameter(
61+
filter: new StartSearchFilter(false),
62+
property: 'name',
63+
),
64+
'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()),
65+
'nameStartSensitive' => new QueryParameter(
66+
filter: new StartSearchFilter(true),
67+
property: 'name',
68+
),
69+
'nameWordStart' => new QueryParameter(
70+
filter: new WordStartSearchFilter(false),
71+
property: 'name',
72+
),
73+
'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()),
5874
'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']),
5975
'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']),
6076
'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([

tests/Fixtures/TestBundle/Entity/Chicken.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
use ApiPlatform\Doctrine\Orm\Filter\IriFilter;
2121
use ApiPlatform\Doctrine\Orm\Filter\OrFilter;
2222
use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter;
23+
use ApiPlatform\Doctrine\Orm\Filter\StartSearchFilter;
24+
use ApiPlatform\Doctrine\Orm\Filter\WordStartSearchFilter;
2325
use ApiPlatform\Metadata\ApiResource;
2426
use ApiPlatform\Metadata\Get;
2527
use ApiPlatform\Metadata\GetCollection;
@@ -55,6 +57,20 @@
5557
filter: new EndSearchFilter(true),
5658
property: 'name',
5759
),
60+
'nameStart' => new QueryParameter(
61+
filter: new StartSearchFilter(),
62+
property: 'name',
63+
),
64+
'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()),
65+
'nameStartSensitive' => new QueryParameter(
66+
filter: new StartSearchFilter(true),
67+
property: 'name',
68+
),
69+
'nameWordStart' => new QueryParameter(
70+
filter: new WordStartSearchFilter(),
71+
property: 'name',
72+
),
73+
'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()),
5874
'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']),
5975
'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']),
6076
'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([

0 commit comments

Comments
 (0)