Skip to content

Instantly share code, notes, and snippets.

@renta
Last active August 19, 2021 17:39
Show Gist options
  • Save renta/b6ece3fec7896440fe52a9ec0e76571a to your computer and use it in GitHub Desktop.
Save renta/b6ece3fec7896440fe52a9ec0e76571a to your computer and use it in GitHub Desktop.
Custom OR filter for an Api Platform
<?php
namespace App\Filter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
final class OrSearchFilter extends AbstractContextAwareFilter
{
private const FILTER_KEY = 'orSearch';
/**
* Passes a property through the filter.
*
* @param string $property
* @param $value
* @param QueryBuilder $queryBuilder
* @param QueryNameGeneratorInterface $queryNameGenerator
* @param string $resourceClass
* @param string|null $operationName
*/
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null
): void {
if (null === $value || false === strpos($property, self::FILTER_KEY)) {
return;
}
$parameterName = $queryNameGenerator->generateParameterName($property);
$search = [];
$mappedJoins = [];
foreach ($this->properties as $groupName => $fields) {
foreach ($fields as $field) {
$joins = explode('.', $field);
for ($lastAlias = 'o', $i = 0, $num = \count($joins); $i < $num; $i++) {
$currentAlias = $joins[$i];
if ($i === $num - 1) {
$search[] = "LOWER({$lastAlias}.{$currentAlias}) LIKE LOWER(:{$parameterName})";
} else {
$join = "{$lastAlias}.{$currentAlias}";
if (!\in_array($join, $mappedJoins, true)) {
$queryBuilder->leftJoin($join, $currentAlias);
$mappedJoins[] = $join;
}
}
$lastAlias = $currentAlias;
}
}
}
$queryBuilder->andWhere(implode(' OR ', $search));
$queryBuilder->setParameter($parameterName, '%' . $value . '%');
}
/**
* Gets the description of this filter for the given resource.
*
* Returns an array with the filter parameter names as keys and array with the following data as values:
* - property: the property where the filter is applied
* - type: the type of the filter
* - required: if this filter is required
* - strategy: the used strategy
* - swagger (optional): additional parameters for the path operation,
* e.g. 'swagger' => [
* 'description' => 'My Description',
* 'name' => 'My Name',
* 'type' => 'integer',
* ]
* The description can contain additional data specific to a filter.
*
* @see \ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer::getFiltersParameters
*
* @param string $resourceClass
*
* @return array
*/
public function getDescription(string $resourceClass): array
{
$description = [];
foreach ($this->properties as $groupName => $fields) {
$description[self::FILTER_KEY . '_' . $groupName] = [
'property' => self::FILTER_KEY,
'type' => 'string',
'required' => false,
'swagger' => ['description' => 'OrSearchFilter on ' . implode(', ', $fields)],
];
}
return $description;
}
}
services:
#... other services
App\Filter\OrSearchFilter:
<?php
namespace App\Entity;
use App\Filter\OrSearchFilter;
use ApiPlatform\Core\Annotation\ApiFilter;
//...more imports here
/**
* @ApiFilter(
* OrSearchFilter::class, properties={
* "fullname": {"firstName", "lastName"}
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/
class User implements UserInterface
{
//...more fields here
/**
* @Assert\Length(min="1", max="100")
* @ORM\Column(type="string", length=100, nullable=true)
*/
private $firstName;
/**
* @Assert\Length(min="1", max="100")
* @ORM\Column(type="string", length=100, nullable=true)
*/
private $lastName;
@ELepolt
Copy link

ELepolt commented Sep 4, 2018

I really appreciate all the work you did for this, it fits perfectly with my needs. However, I'm seeing that the filter isn't being hit. I've added the service and everything. Am I correct in that the query would be the likes of

api/user?fullName=john doe

@renta
Copy link
Author

renta commented Oct 2, 2018

@ELepolt
In my case a query string for the OrSearch full name filter looks like this:
https://localhost:8443/api/guides?orSearch_fullname=John
In the case of a first and a last name (https://localhost:8443/api/guides?orSearch_fullname=John%20Dow) this filter would not work, because it's construct the query something like:
FROM user u WHERE first_name LIKE "John Dow" OR last_name LIKE "John Dow"
And in the table there are only "John" or "Dow" in each column.

So, a task could be solved in several ways:

  1. Use db fulltext search. I think that it's a preferable way.
  2. Explode a query string and make something like:
    FROM user u WHERE first_name LIKE "John" OR first_name LIKE "Dow" OR last_name LIKE "John" OR last_name LIKE "Dow" (welcome, another custom filter!)
    I don't like the second one because it could lead to db-slow queries and looks to hacky and unprofessional.

@byhoratiss
Copy link

Thank you for all this @renta ! I've a issue trying to make it work with YML configuration files.
Can't figure out how to specify the "properties" option in YML, any ideas?

resources:
    App\Entity\Product:
        shortName: 'product'
        attributes:
            pagination_client_enabled: true
            filters:
              - 'App\Filter\OrSearchFilter': { properties: { fullname: { 'firstName', 'lastName' } } }

@byhoratiss
Copy link

byhoratiss commented Nov 12, 2018

I've found the answer about how to use this with YAML files and I'm leaving it here to other users.

  • You need to register the "OrSearchFilter" class as a Service.
services:
    'App\Filter\OrSearchFilter':
        tags: [ 'api_platform.filter' ]

If you use the @ApiFilter() annotation, you're done, but if you're using YAML files, you need to register a new Service for your Filter definition:

services:
    my_awesome_filters.or_filter:
        parent: 'App\Filter\OrSearchFilter'
        arguments: [ '@doctrine', '@request_stack', '@?logger', { propertyThatWillBeUsedAsParameter: [ 'propertyOne', 'anotherAwesomeProperty' ] } ]
        tags: [ 'api_platform.filter' ]

And use the ID of the service "my_awesome_filters.or_filter" as the example in your "filters" definition inside ApiPlatform resources.

resources:
    App\Entity\Product:
        shortName: 'product'
        attributes:
            filters:
              - "my_awesome_filters.or_filter"

@sneakyx
Copy link

sneakyx commented Mar 4, 2019

Hi,
thanks for your work, but can't get it running.
The array $fields on Line 41 is always null.
I wanted to filter by companies zip f.e. all companies with zip=12345 or 67890
My config:
services.yml
my.filter.or_filter: class: AppBundle\Filters\OrFilter tags: [ 'api_platform.filter' ]
my entity

<?php

namespace AppBundle\Entity\Blog;

use ApiPlatform\Core\Annotation\ApiFilter;
use AppBundle\Entity\Registration\User;
...
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\HasLifecycleCallbacks;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\NumericFilter;
use AppBundle\Filters\OrFilter;

/**
 * Post
 *
 * @ApiResource(attributes={
 *     "normalization_context"={"groups"={"api_read", "api_admin_read"}},
 *     "filters"={"post.search_filter"}
 * })
 * @ApiFilter(OrFilter::class, properties={"company.zip"})
 * @ORM\Table(name="post")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\Blog\PostRepository")
 * @ORM\HasLifecycleCallbacks
 *
 */

my request:
localhost:8000/api/v1/posts?orSearch_company.zip=12345.67890

the dump shows this:

array:1 [
"company.zip" => null
]

What am I doing wrong?

Thanks in advance!
sneaky

@kgonella
Copy link

kgonella commented Apr 2, 2019

thanks for your share !

@next-sentence
Copy link

@sneakyx

I wanted to filter by companies zip f.e. all companies with zip=12345 or 67890

you can use default SearchFilter

localhost:8000/api/v1/posts?company.zip[]=12345&company.zip[]=67890

read docs

@masacc
Copy link

masacc commented Aug 31, 2020

Thanks @renta !

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

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

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