Skip to content

Instantly share code, notes, and snippets.

@Tersoal
Created October 1, 2020 09:02
Show Gist options
  • Star 19 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Tersoal/d45b0cc75cadf72cd7c0e49b892809b3 to your computer and use it in GitHub Desktop.
Save Tersoal/d45b0cc75cadf72cd7c0e49b892809b3 to your computer and use it in GitHub Desktop.
Full text search for API Platform with one or more strings
<?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;
/**
* inspired from :
* - https://gist.github.com/masseelch/47931f3a745409f8f44c69efa9ecb05c
* - https://gist.github.com/renta/b6ece3fec7896440fe52a9ec0e76571a
* - https://gist.github.com/masacc/94df641b3cb9814cbdaeb3f158d2e1f7
*
* how to use :
* - add classAnnotation :
* ApiFilter(FullTextSearchFilter::class, properties={
* "search_example1"={
* "property1": "partial",
* "property2": "exact"
* },
* "search_example2"={
* "property1": "partial",
* "property3": "partial"
* }
* })
* - use filter in query string as:
* + `/api/myresources?search_example1=String%20with%20spaces` => this will search "String with spaces"
* + `/api/myresources?search_example1%5B%5D=String%20with%20spaces` => this will search "String with spaces"
* + `/api/myresources?search_example1%5B%5D=String&search_example1%5B%5D=with&search_example1%5B%5D=spaces` => this will search "String" or "with" or "spaces"
*/
class FullTextSearchFilter extends SearchFilter
{
private const PROPERTY_NAME_PREFIX = 'search_';
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
if (0 !== strpos($property, self::PROPERTY_NAME_PREFIX)) {
return;
}
if (false === isset($this->properties[$property])) {
return;
}
$values = $this->normalizeValues((array) $value, $property);
if (null === $values) {
return;
}
$orExpressions = [];
foreach ($values as $index => $value) {
foreach ($this->properties[$property] as $propertyName => $strategy) {
$strategy = $strategy ?? self::STRATEGY_EXACT;
$alias = $queryBuilder->getRootAliases()[0];
$field = $propertyName;
$associations = [];
if ($this->isPropertyNested($propertyName, $resourceClass)) {
[$alias, $field, $associations] = $this->addJoinsForNestedProperty($propertyName, $alias, $queryBuilder, $queryNameGenerator, $resourceClass);
}
$caseSensitive = true;
$metadata = $this->getNestedMetadata($resourceClass, $associations);
if ($metadata->hasField($field)) {
if ('id' === $field) {
$value = $this->getIdFromValue($value);
}
if (!$this->hasValidValues((array)$value, $this->getDoctrineFieldType($propertyName, $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[] = $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $value, $caseSensitive);
}
}
}
$queryBuilder->andWhere($queryBuilder->expr()->orX(...$orExpressions));
}
/**
* {@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));
}
}
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$descriptions = [];
foreach ($this->properties as $filterName => $properties) {
$propertyNames = [];
foreach ($properties as $property => $strategy) {
if (!$this->isPropertyMapped($property, $resourceClass, true)) {
continue;
}
$propertyNames[] = $this->normalizePropertyName($property);
}
$filterParameterName = $filterName . '[]';
$descriptions[$filterParameterName] = [
'property' => $filterName,
'type' => 'string',
'required' => false,
'is_collection' => true,
'openapi' => [
'description' => 'Search involves the fields: ' . implode(', ', $propertyNames),
],
];
}
return $descriptions;
}
}
@bamboriz
Copy link

bamboriz commented Oct 1, 2020

Happy to give this a try too !

@fkizewski
Copy link

Thank for your work, i use it now !

@thomas-seres
Copy link

Thanks, I'll give it a try !

@maquejp
Copy link

maquejp commented Oct 7, 2020

Hello,
I have implemented but the API returns me the whole records...

use App\Filter\FullTextSearchFilter;
...
/**
 * ...
 * @ApiFilter(FullTextSearchFilter::class, properties={
 *     "searchIt"={
 *         "familyName": "ipartial",
 *         "givenName": "ipartial",
 *         "email": "ipartial"
 *     }
 * })
 */

API Request: http://localhost:8080/persons?searchIt%5B%5D=maq (or http://localhost:8080/persons?searchIt=maq&page=1) returns me the all the requests. I am supposed to receive one record.

Database: MySQL
API over Symfony 5.1.6

Any idea?

@thomas-seres
Copy link

thomas-seres commented Oct 7, 2020

@maquejp it's because you must use the prefix "search_"

It's as this in the code :
private const PROPERTY_NAME_PREFIX = 'search_';

instead of "searchIt" use "search_it"

@maquejp
Copy link

maquejp commented Oct 7, 2020

Indeed better with the right naming 👍

@irmantas
Copy link

Thanks @Tersoal for this :) you saved a lot of work for me 🍺

If someone struggling to setup this filter in YAML here is an example

# services.yaml

# first define FullTextSearchFilter as service
app_fulltext_filter:
        parent: 'api_platform.doctrine.orm.search_filter'
        class: App\Filter\FullTextSearchFilter
        autowire: false
        autoconfigure: false
        public: false

#next define filter service
user.search_filter:
        parent: 'app_fulltext_filter'
        arguments: [ { search_for: { email: 'ipartial', name: 'ipartial' } } ]
        tags: [ 'api_platform.filter' ]
        autowire: false
        autoconfigure: false
        public: false
# in your resource configuration
# config/api_platform/resources/user.yaml
App\Entity\User:
    itemOperations: ~
    collectionOperations:
         get:
             filters:  [ 'user.search_filter' ]

@kevinG73
Copy link

@Tersoal it supports Filtering by a relation's property ? I tried

 *     "search_images"={
 *         "images.id": "exact"
 *     }

but it doesn't work

@Tersoal
Copy link
Author

Tersoal commented Nov 10, 2020

For me it works like a charm, I have relations in my app.

This makes no sense for me, you don't need a full text search for an only one field, you do?

@kevinG73
Copy link

@Tersoal it's because i want to use multiple id to get the result , I mean : WHERE id IN (value1, value2, ...)
is it possible without custom filter ?

@Tersoal
Copy link
Author

Tersoal commented Nov 10, 2020

I think so... Have you tried to send id param as array?

https://api-platform.com/docs/core/filters/#search-filter

Note: Search filters with the exact strategy can have multiple values for the same property (in this case the condition will be similar to a SQL IN clause).

Syntax: ?property[]=foo&property[]=bar

@jeffsta9
Copy link

Does the trick nicely! Many thanks for your extended work on this :)

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