Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/Doctrine/Odm/Filter/EndSearchFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Odm\Filter;

use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait;
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use MongoDB\BSON\Regex;

/**
* Filters the collection by the end of a string property, using a regular expression anchored at the end.
*/
final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
{
use BackwardCompatibleFilterDescriptionTrait;
use NestedPropertyHelperTrait;
use OpenApiFilterTrait;

public function __construct(private readonly bool $caseSensitive = true)
{
}

public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
$parameter = $context['parameter'];

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

$property = $parameter->getProperty();
$values = $parameter->getValue();
$match = $context['match'] = $context['match'] ??
$aggregationBuilder
->matchExpr();
$operator = $context['operator'] ?? 'addAnd';

$matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context);

if (!is_iterable($values)) {
$escapedValue = preg_quote($values, '/');
$match->{$operator}(
$aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i'))
);

return;
}

$or = $aggregationBuilder->matchExpr();
foreach ($values as $value) {
$escapedValue = preg_quote($value, '/');

$or->addOr(
$aggregationBuilder->matchExpr()
->field($matchField)
->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i'))
);
}

$match->{$operator}($or);
}
}
134 changes: 134 additions & 0 deletions src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Odm\Tests\Filter;

use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter;
use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase;
use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy;
use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy;
use ApiPlatform\Metadata\QueryParameter;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\DocumentManager;
use MongoDB\BSON\Regex;
use PHPUnit\Framework\TestCase;

class EndSearchFilterTest extends TestCase
{
private DocumentManager $manager;

protected function setUp(): void
{
$this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager();
}

public function testEndSearchSimpleProperty(): void
{
$filter = new EndSearchFilter();

$parameter = new QueryParameter(property: 'name', key: 'name');
$parameter->setValue('foo');
$aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder();

$context = [
'parameter' => $parameter,
'filters' => ['name' => 'foo'],
];

$filter->apply($aggregationBuilder, Dummy::class, null, $context);

// The filter populates $context['match'] with the match expression (no pipeline stage added)
$this->assertArrayHasKey('match', $context);
$this->assertEquals(
['$and' => [['name' => new Regex('foo$', '')]]],
$context['match']->getQuery()
);
$this->assertNoPipelineStages($aggregationBuilder);
}

public function testEndSearchCaseInsensitive(): void
{
$filter = new EndSearchFilter(caseSensitive: false);

$parameter = new QueryParameter(property: 'name', key: 'name');
$parameter->setValue('foo');
$aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder();

$context = [
'parameter' => $parameter,
'filters' => ['name' => 'foo'],
];

$filter->apply($aggregationBuilder, Dummy::class, null, $context);

$this->assertEquals(
['$and' => [['name' => new Regex('foo$', 'i')]]],
$context['match']->getQuery()
);
}

public function testEndSearchNestedProperty(): void
{
$filter = new EndSearchFilter();

$parameter = new QueryParameter(
property: 'relatedDummy.name',
key: 'relatedDummy.name',
extraProperties: [
'nested_properties_info' => ['relatedDummy.name' => [
'relation_segments' => ['relatedDummy'],
'relation_classes' => [Dummy::class],
'leaf_property' => 'name',
'leaf_class' => RelatedDummy::class,
'odm_segments' => [
[
'type' => 'reference',
'target_document' => RelatedDummy::class,
'is_owning_side' => true,
'mapped_by' => null,
],
],
]],
],
);
$parameter->setValue('bar');

$aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder();

$context = [
'parameter' => $parameter,
'filters' => ['relatedDummy.name' => 'bar'],
];

$filter->apply($aggregationBuilder, Dummy::class, null, $context);
$pipeline = $aggregationBuilder->getPipeline();

// Nested property adds $lookup + $unwind stages
$this->assertCount(2, $pipeline);
$this->assertArrayHasKey('$lookup', $pipeline[0]);
$this->assertArrayHasKey('$unwind', $pipeline[1]);

// The match expression is populated for the parameter extension to commit
$this->assertArrayHasKey('match', $context);
}

private function assertNoPipelineStages(Builder $aggregationBuilder): void
{
try {
$pipeline = $aggregationBuilder->getPipeline();
$this->assertEmpty($pipeline);
} catch (\OutOfRangeException) {
// No stages added — expected for simple property filters
}
}
}
83 changes: 83 additions & 0 deletions src/Doctrine/Orm/Filter/EndSearchFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;

/**
* Filters the collection by the end of a string property, using a `LIKE '%value'` clause.
*/
final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
{
use BackwardCompatibleFilterDescriptionTrait;
use NestedPropertyHelperTrait;
use OpenApiFilterTrait;

public function __construct(private readonly bool $caseSensitive = false)
{
}

public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$parameter = $context['parameter'];

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

$property = $parameter->getProperty();
$alias = $queryBuilder->getRootAliases()[0];
[$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter);
$field = $alias.'.'.$property;
$values = $parameter->getValue();

if (!is_iterable($values)) {
$parameterName = $queryNameGenerator->generateParameterName($property);
$queryBuilder->setParameter($parameterName, $this->formatLikeValue($values));

$likeExpression = $this->caseSensitive
? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\''
: 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\'';
$queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression);

return;
}

$likeExpressions = [];
foreach ($values as $val) {
$parameterName = $queryNameGenerator->generateParameterName($property);
$likeExpressions[] = $this->caseSensitive
? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\''
: 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\'';

$queryBuilder->setParameter($parameterName, $this->formatLikeValue($val));
}

$queryBuilder->{$context['whereClause'] ?? 'andWhere'}(
$queryBuilder->expr()->orX(...$likeExpressions)
);
}

private function formatLikeValue(string $value): string
{
return '%'.addcslashes($value, '\\%_');
}
}
10 changes: 10 additions & 0 deletions tests/Fixtures/TestBundle/Document/Chicken.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;

use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter;
use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter;
use ApiPlatform\Doctrine\Odm\Filter\ExactFilter;
use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter;
use ApiPlatform\Doctrine\Odm\Filter\IriFilter;
Expand Down Expand Up @@ -45,6 +46,15 @@
filter: new PartialSearchFilter(true),
property: 'name',
),
'nameEnd' => new QueryParameter(
filter: new EndSearchFilter(false),
property: 'name',
),
'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()),
'nameEndSensitive' => new QueryParameter(
filter: new EndSearchFilter(true),
property: 'name',
),
'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']),
'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']),
'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([
Expand Down
10 changes: 10 additions & 0 deletions tests/Fixtures/TestBundle/Entity/Chicken.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;

use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter;
use ApiPlatform\Doctrine\Orm\Filter\EndSearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter;
use ApiPlatform\Doctrine\Orm\Filter\IriFilter;
Expand Down Expand Up @@ -45,6 +46,15 @@
filter: new PartialSearchFilter(true),
property: 'name',
),
'nameEnd' => new QueryParameter(
filter: new EndSearchFilter(),
property: 'name',
),
'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()),
'nameEndSensitive' => new QueryParameter(
filter: new EndSearchFilter(true),
property: 'name',
),
'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']),
'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']),
'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([
Expand Down
Loading
Loading