Skip to content

Doctrine Orm Filter Partial Filter #7912

@SalvadorCardona

Description

@SalvadorCardona

API Platform version(s) affected: 4.3.1

Description
I have a CompanyCategories entity with a name field, and in my front end, I need my users to consume this API.

Previously, I was using...

#[ApiFilter(SearchFilter::class, strategy: 'ipartial')]
       public string           $name,

and it works well, and I replaced this search with the parameters of the GetCollection resource.

new GetCollection(
            paginationItemsPerPage: 100,
            order: ['name' => 'ASC'],
            parameters: [
                'name' => new QueryParameter(filter: new AppPartialSearchFilter(caseSensitive: false)),
                'isFavorite' => new QueryParameter(filter: new BooleanFilter()),
            ]
        ),

The problem

Before, when I searched for name = "edu", the "éducator" category would appear, but now it doesn't work anymore; I have to search for "edu".

The accent "é" of "éducateur' is keeped for the search

name=edu&itemsPerPage=5

How to reproduce
The problem is that now the sensitivity of the case respects accents.

Possible Solution
I came up with a solution with Claude that works but isn't exactly pretty while we wait for a fix.

<?php

declare(strict_types=1);

namespace App\Infrastructure\ApiPlatform\Filter;

use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
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\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
use Doctrine\ORM\QueryBuilder;

/**
 * Partial search filter with accent and case insensitivity support (PostgreSQL unaccent).
 */
final class AppPartialSearchFilter 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()));
        }

        $queryBuilder->getEntityManager()
            ->getConfiguration()
            ->addCustomStringFunction('unaccent', UnaccentFunction::class);

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

        $normalize = $this->createNormalize($this->caseSensitive);

        if (!is_iterable($values)) {
            $parameterName = ':' . $queryNameGenerator->generateParameterName($property);
            $queryBuilder->setParameter($parameterName, $this->normalizeValue($values, $this->caseSensitive));

            $queryBuilder->{$context['whereClause'] ?? 'andWhere'}(
                $queryBuilder->expr()->like(
                    $normalize($aliasedField),
                    (string) $queryBuilder->expr()->concat("'%'", $parameterName, "'%'")
                )
            );

            return;
        }

        $ors = [];
        foreach ($values as $val) {
            $parameterName = ':' . $queryNameGenerator->generateParameterName($property);
            $queryBuilder->setParameter($parameterName, $this->normalizeValue($val, $this->caseSensitive));

            $ors[] = $queryBuilder->expr()->like(
                $normalize($aliasedField),
                (string) $queryBuilder->expr()->concat("'%'", $parameterName, "'%'")
            );
        }

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

    private function createNormalize(bool $caseSensitive): \Closure
    {
        return static function (string $expr) use ($caseSensitive): string {
            $expr = sprintf('unaccent(%s)', $expr);

            if (!$caseSensitive) {
                $expr = sprintf('LOWER(%s)', $expr);
            }

            return $expr;
        };
    }

    private function normalizeValue(string $value, bool $caseSensitive): string
    {
        if (!$caseSensitive) {
            return strtolower($value);
        }

        return $value;
    }
}

/**
 * Doctrine DQL function for PostgreSQL unaccent().
 * Registered dynamically by AppPartialSearchFilter — no doctrine.yaml config needed.
 */
final class UnaccentFunction extends FunctionNode
{
    private Node $value;

    public function parse(Parser $parser): void
    {
        $parser->match(TokenType::T_IDENTIFIER);
        $parser->match(TokenType::T_OPEN_PARENTHESIS);
        $this->value = $parser->StringPrimary();
        $parser->match(TokenType::T_CLOSE_PARENTHESIS);
    }

    public function getSql(SqlWalker $sqlWalker): string
    {
        return 'unaccent(' . $sqlWalker->walkStringPrimary($this->value) . ')';
    }
}

Otherwise, thank you for all your work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions