Skip to content

Instantly share code, notes, and snippets.

@alexislefebvre
Forked from masseelch/FullTextSearchFilter.php
Last active July 29, 2023 09:52
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alexislefebvre/fcbbb9104c787b9ccb739ce3bb5cfe06 to your computer and use it in GitHub Desktop.
Save alexislefebvre/fcbbb9104c787b9ccb739ce3bb5cfe06 to your computer and use it in GitHub Desktop.
(Kind of a) api-platform full-text search filter.
// Use it like this.
/api?search=this_is_my_search_string%20this_is_the_second_term_of_my_search_string
<?php
/**
* @ApiResource()
* @ApiFilter(FullTextSearchFilter::class, properties={
* "id": "exact",
* "task": "partial",
* "client.name": "start"
* })
* @ORM\Entity(repositoryClass=EntityRepository::class)
*/
class Job
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity=Client::class, inversedBy="entites")
* @ORM\JoinColumn(nullable=false)
*/
private $client;
public function getId(): ?int
{
return $this->id;
}
public function getTask(): ?string
{
return $this->task;
}
public function setTask(string $task): self
{
$this->task = $task;
return $this;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): self
{
$this->client = $client;
return $this;
}
}
<?php
namespace App\Filter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use Doctrine\ORM\QueryBuilder;
class FullTextSearchFilter extends SearchFilter
{
private const PROPERTY_NAME = 'search';
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
// This filter will work with the 'search'-query-parameter only.
if ($property !== self::PROPERTY_NAME) {
return;
}
$orExpressions = [];
// Split the $value at spaces.
// For each term 'or' all given properties by strategy.
// 'And' all 'or'-parts.
$terms = explode(" ", $value);
foreach ($terms as $index => $term) {
foreach ($this->properties as $property => $strategy) {
$strategy = $strategy ?? self::STRATEGY_EXACT;
$alias = $queryBuilder->getRootAliases()[0];
$field = $property;
$associations = [];
if ($this->isPropertyNested($property, $resourceClass)) {
[$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass);
}
$caseSensitive = true;
$metadata = $this->getNestedMetadata($resourceClass, $associations);
if ($metadata->hasField($field)) {
if ('id' === $field) {
$term = $this->getIdFromValue($term);
}
if (!$this->hasValidValues((array)$term, $this->getDoctrineFieldType($property, $resourceClass))) {
$this->logger->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)),
]);
continue;
}
// prefixing the strategy with i makes it case insensitive
if (0 === strpos($strategy, 'i')) {
$strategy = substr($strategy, 1);
$caseSensitive = false;
}
$orExpressions[$index][] = $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $term, $caseSensitive);
}
}
}
$exprBuilder = $queryBuilder->expr();
foreach ($orExpressions as $expr) {
$queryBuilder->andWhere($exprBuilder->orX(...$expr));
}
}
/**
* {@inheritDoc}
*/
protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $value, bool $caseSensitive)
{
$wrapCase = $this->createWrapCase($caseSensitive);
$valueParameter = $queryNameGenerator->generateParameterName($field);
$exprBuilder = $queryBuilder->expr();
$queryBuilder->setParameter($valueParameter, $value);
switch ($strategy) {
case null:
case self::STRATEGY_EXACT:
return $exprBuilder->eq($wrapCase("$alias.$field"), $wrapCase(":$valueParameter"));
case self::STRATEGY_PARTIAL:
return $exprBuilder->like($wrapCase("$alias.$field"), $exprBuilder->concat("'%'", $wrapCase(":$valueParameter"), "'%'"));
case self::STRATEGY_START:
return $exprBuilder->like($wrapCase("$alias.$field"), $exprBuilder->concat($wrapCase(":$valueParameter"), "'%'"));
case self::STRATEGY_END:
return $exprBuilder->like($wrapCase("$alias.$field"), $exprBuilder->concat("'%'", $wrapCase(":$valueParameter")));
case self::STRATEGY_WORD_START:
return $exprBuilder->orX(
$exprBuilder->like($wrapCase("$alias.$field"), $exprBuilder->concat($wrapCase(":$valueParameter"), "'%'")),
$exprBuilder->like($wrapCase("$alias.$field"), $exprBuilder->concat("'%'", $wrapCase(":$valueParameter")))
);
default:
throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy));
}
}
}
@hosseinsalemi
Copy link

In Api Platform 3.0

<?php

namespace App\Filter;

use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface;
use ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Operation;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

class FullTextSearchFilter extends AbstractFilter implements SearchFilterInterface
{
    use SearchFilterTrait;

    public const DOCTRINE_INTEGER_TYPE = Types::INTEGER;

    private const PROPERTY_NAME = 'search';

    protected function getIriConverter(): IriConverterInterface
    {
        return $this->iriConverter;
    }

    protected function getPropertyAccessor(): PropertyAccessorInterface
    {
        return $this->propertyAccessor;
    }

    protected function getType(string $doctrineType): string
    {
        return 'string';
    }

    protected function filterProperty(
        string $property,
        $value,
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        Operation $operation = null,
        array $context = [],
    ): void {
        // This filter will work with the 'search'-query-parameter only.
        if (self::PROPERTY_NAME !== $property) {
            return;
        }

        $orExpressions = [];

        // Split the $value at spaces.
        // For each term 'or' all given properties by strategy.
        // 'And' all 'or'-parts.
        $terms = explode(' ', $value);

        foreach ($terms as $index => $term) {
            foreach ($this->properties as $property => $strategy) {
                $strategy = $strategy ?? self::STRATEGY_EXACT;
                $alias = $queryBuilder->getRootAliases()[0];
                $field = $property;

                $associations = [];
                if ($this->isPropertyNested($property, $resourceClass)) {
                    [$alias, $field, $associations] = $this->addJoinsForNestedProperty(
                        $property,
                        $alias,
                        $queryBuilder,
                        $queryNameGenerator,
                        $resourceClass,
                        Join::LEFT_JOIN,
                    );
                }

                $caseSensitive = true;
                $metadata = $this->getNestedMetadata($resourceClass, $associations);

                if ($metadata->hasField($field)) {
                    if ('id' === $field) {
                        $term = $this->getIdFromValue($term);
                    }

                    if (!$this->hasValidValues((array) $term, $this->getDoctrineFieldType($property, $resourceClass))) {
                        $this->logger->notice('Invalid filter ignored', [
                            'exception' => new InvalidArgumentException(
                                sprintf('Values for field "%s" are not valid according to the doctrine type.', $field),
                            ),
                        ]);
                        continue;
                    }

                    // prefixing the strategy with i makes it case insensitive
                    if (str_starts_with($strategy, 'i')) {
                        $strategy = substr($strategy, 1);
                        $caseSensitive = false;
                    }

                    $orExpressions[$index][] = $this->addWhereByStrategy(
                        $strategy,
                        $queryBuilder,
                        $queryNameGenerator,
                        $alias,
                        $field,
                        $term,
                        $caseSensitive,
                    );
                }
            }
        }

        $exprBuilder = $queryBuilder->expr();
        foreach ($orExpressions as $expr) {
            $queryBuilder->andWhere($exprBuilder->orX(...$expr));
        }
    }

    protected function createWrapCase(bool $caseSensitive): \Closure
    {
        return static function (string $expr) use ($caseSensitive): string {
            if ($caseSensitive) {
                return $expr;
            }

            return sprintf('LOWER(%s)', $expr);
        };
    }

    protected function addWhereByStrategy(
        string $strategy,
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $alias,
        string $field,
        $value,
        bool $caseSensitive,
    ) {
        $wrapCase = $this->createWrapCase($caseSensitive);
        $valueParameter = $queryNameGenerator->generateParameterName($field);
        $exprBuilder = $queryBuilder->expr();

        $queryBuilder->setParameter($valueParameter, $value);

        return match ($strategy) {
            null, self::STRATEGY_EXACT => $exprBuilder->eq(
                $wrapCase("{$alias}.{$field}"),
                $wrapCase(":{$valueParameter}"),
            ),
            self::STRATEGY_PARTIAL => $exprBuilder->like(
                $wrapCase("{$alias}.{$field}"),
                $exprBuilder->concat("'%'", $wrapCase(":{$valueParameter}"), "'%'"),
            ),
            self::STRATEGY_START => $exprBuilder->like(
                $wrapCase("{$alias}.{$field}"),
                $exprBuilder->concat($wrapCase(":{$valueParameter}"), "'%'"),
            ),
            self::STRATEGY_END => $exprBuilder->like(
                $wrapCase("{$alias}.{$field}"),
                $exprBuilder->concat("'%'", $wrapCase(":{$valueParameter}")),
            ),
            self::STRATEGY_WORD_START => $exprBuilder->orX(
                $exprBuilder->like(
                    $wrapCase("{$alias}.{$field}"),
                    $exprBuilder->concat($wrapCase(":{$valueParameter}"), "'%'"),
                ),
                $exprBuilder->like(
                    $wrapCase("{$alias}.{$field}"),
                    $exprBuilder->concat("'%'", $wrapCase(":{$valueParameter}")),
                ),
            ),
            default => throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)),
        };
    }

    public function getDescription(string $resourceClass): array
    {
        if (!$this->properties) {
            return [];
        }

        $description = [];
        foreach ($this->properties as $property => $strategy) {
            $description['search'] = [
                'property' => $property,
                'type' => 'string',
                'required' => false,
            ];
        }

        return $description;
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment