Skip to content

Instantly share code, notes, and snippets.

@masseelch
Last active September 19, 2022 19:43
Show Gist options
  • Save masseelch/47931f3a745409f8f44c69efa9ecb05c to your computer and use it in GitHub Desktop.
Save masseelch/47931f3a745409f8f44c69efa9ecb05c 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));
}
}
}
@masacc
Copy link

masacc commented Aug 31, 2020

Thanks @masseelch !
In case someone need something a little bit different, I made a mix from this gist and another one (https://gist.github.com/renta/b6ece3fec7896440fe52a9ec0e76571a) :

https://gist.github.com/masacc/94df641b3cb9814cbdaeb3f158d2e1f7

@fabienlege
Copy link

Great !
very usefull
thank's for sharing ;)

@Romain
Copy link

Romain commented Sep 22, 2020

It works like a charm. Thanks a lot!

@Tersoal
Copy link

Tersoal commented Sep 29, 2020

Hi. Great job! I've done some refactoring to allow to search a string with spaces, or multiple strings.

EDITED: the code was wrong.

I've created a gist with correct one, allowing multiple string to search and multiple search options. Also the filters are added to swagger doc.

https://gist.github.com/Tersoal/d45b0cc75cadf72cd7c0e49b892809b3

This is inspired in this gist and also:

https://gist.github.com/renta/b6ece3fec7896440fe52a9ec0e76571a
https://gist.github.com/masacc/94df641b3cb9814cbdaeb3f158d2e1f7

@wengtytt
Copy link

It works well in REST. Is there any way to support this filter in graphql?

@masseelch
Copy link
Author

It works well in REST. Is there any way to support this filter in graphql?

I do not use graphql, therefore i do not know how to achieve this. I am sorry.

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