filter query on multiple fields in single query

Viewed 6441

My setup is Symfony 5 with the latest API-Platform version running on PHP 7.3. So I would like to be able to query both on name and username (maybe even email). Do I need to write a custom resolver?

This is what I've tried so far but this results in a WHERE name = $name AND username = $name.

query SearchUsers ($name: String!) {
  users(name: $name, username: $name) {
    edges {
       cursor
       node {
         id
         username
         email
         avatar
       }
     }
  }
}

My entity:

/**
 * @ApiResource
 * @ApiFilter(SearchFilter::class, properties={
 *   "name": "ipartial",
 *   "username": "ipartial",
 *   "email": "ipartial",
 * })
 *
 * @ORM\Table(name="users")
 * @ORM\Entity(repositoryClass="Domain\Repository\UserRepository")
 * @ORM\HasLifecycleCallbacks()
 */
class User
{
  private $name;
  private $username;
  private $email;
  // ... code omitted ...
}
3 Answers

I made such a custom filter for chapter 6 of my tutorial. I include its code below.

You can configure which properties it searches in the ApiFilter tag. In your case that would be:

 * @ApiFilter(SimpleSearchFilter::class, properties={"name", "username", "email"})

It splits the search string into words and searches each of the properties case insensitive for each word, so a query string like:

?simplesearch=Katch sQuash

will search in all specified properties both LOWER(..) LIKE '%katch%' OR LOWER(..) LIKE '%squash%'

Limitations: It may be limited to string properties (depending on the DB) and it does not sort by relevance.

The code:

// api/src/Filter/SimpleSearchFilter.php 
namespace App\Filter;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;

/**
 * Selects entities where each search term is found somewhere
 * in at least one of the specified properties.
 * Search terms must be separated by spaces.
 * Search is case insensitive.
 * All specified properties type must be string.
 * @package App\Filter
 */
class SimpleSearchFilter extends AbstractContextAwareFilter
{
    private $searchParameterName;

    /**
     * Add configuration parameter
     * {@inheritdoc}
     * @param string $searchParameterName The parameter whose value this filter searches for
     */
    public function __construct(ManagerRegistry $managerRegistry, ?RequestStack $requestStack = null, LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null, string $searchParameterName = 'simplesearch')
    {
        parent::__construct($managerRegistry, $requestStack, $logger, $properties, $nameConverter);

        $this->searchParameterName = $searchParameterName;
    }

    /** {@inheritdoc} */
    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [])
    {
        if (null === $value || $property !== $this->searchParameterName) {
            return;
        }

        $words = explode(' ', $value);
        foreach ($words as $word) {
            if (empty($word)) continue;

            $this->addWhere($queryBuilder, $word, $queryNameGenerator->generateParameterName($property));
        }
    }

    private function addWhere($queryBuilder, $word, $parameterName)
    {
        $alias = $queryBuilder->getRootAliases()[0];

        // Build OR expression
        $orExp = $queryBuilder->expr()->orX();
        foreach ($this->getProperties() as $prop => $ignoored) {
            $orExp->add($queryBuilder->expr()->like('LOWER('. $alias. '.' . $prop. ')', ':' . $parameterName));
        }

        $queryBuilder
            ->andWhere('(' . $orExp . ')')
            ->setParameter($parameterName, '%' . strtolower($word). '%');
    }

    /** {@inheritdoc} */
    public function getDescription(string $resourceClass): array
    {
        $props = $this->getProperties();
        if (null===$props) {
            throw new InvalidArgumentException('Properties must be specified');
        }
        return [
            $this->searchParameterName => [
                'property' => implode(', ', array_keys($props)),
                'type' => 'string',
                'required' => false,
                'swagger' => [
                    'description' => 'Selects entities where each search term is found somewhere in at least one of the specified properties',
                ]
            ]
        ];
    }

}

The service needs configuration in api/config/services.yaml

'App\Filter\SimpleSearchFilter':
    arguments:
        $searchParameterName: 'ignoored'

($searchParameterName can actually be configured from the @ApiFilter annotation)

Maybe you want let the clients choose how to combine filter criteria and logic. This can be done by nesting the filter criteria in "and" or "or, like:

/users/?or[username]=super&or[name]=john

This will return all users with "super" in their username OR "john" in their name. Or if you need more complex logic and multiple criteria for the same property:

/users/?and[name]=john&and[or][][email]=microsoft.com&and[or][][email]=apple.com

This will return all users with john in their names AND (microsoft.com or apple.com in their email address). Because of the nesting of or the criteria for the description are combined together through AND with the criterium for name, which must allways be true while only one of the criteria for the email needs to be true for a user to be returned.

To make this work within your app create a file FilterLogic.php in your api src/Filter folder (create this folder if you don't have one yet) with the following content:

<?php

namespace App\Filter;

use ApiPlatform\Core\Api\FilterCollection;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

/**
 * Combines existing API Platform ORM Filters with AND and OR.
 * For usage and limitations see https://gist.github.com/metaclass-nl/790a5c8e9064f031db7d3379cc47c794
 * Copyright (c) MetaClass, Groningen, 2021. MIT License
 */
class FilterLogic extends AbstractContextAwareFilter
{
    /** @var ResourceMetadataFactoryInterface  */
    private $resourceMetadataFactory;
    /** @var ContainerInterface|FilterCollection  */
    private $filterLocator;
    /** @var string Filter classes must match this to be applied with logic */
    private $classExp;

    /**
     * @param ResourceMetadataFactoryInterface $resourceMetadataFactory
     * @param ContainerInterface|FilterCollection $filterLocator
     * @param $regExp string Filter classes must match this to be applied with logic
     * {@inheritdoc}
     */
    public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, $filterLocator, string $classExp='//', ManagerRegistry $managerRegistry, RequestStack $requestStack=null, LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null)
    {
        parent::__construct($managerRegistry, $requestStack, $logger, $properties, $nameConverter);
        $this->resourceMetadataFactory = $resourceMetadataFactory;
        $this->filterLocator = $filterLocator;
        $this->classExp = $classExp;
    }

    /** {@inheritdoc } */
    public function getDescription(string $resourceClass): array
    {
        // No description
        return [];
    }

    /**
     * {@inheritdoc}
     * @throws ResourceClassNotFoundException
     * @throws \LogicException if assumption proves wrong
     */
    protected function filterProperty(string $parameter, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [])
    {
        $filters = $this->getFilters($resourceClass, $operationName);

        if ($parameter == 'and') {
            $newWhere = $this->applyLogic($filters, 'and', $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);
            $queryBuilder->andWhere($newWhere);
        }
        if ($parameter == 'or') {
            $newWhere = $this->applyLogic($filters, 'or', $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);
            $queryBuilder->orWhere($newWhere);
        }
    }

    /**
     * Applies filters in compound logic context
     * @param FilterInterface[] $filters to apply in context of $operator
     * @param string $operator 'and' or 'or
     * @return mixed Valid argument for Expr\Andx::add and Expr\Orx::add
     * @throws \LogicException if assumption proves wrong
     */
    private function applyLogic($filters, $operator, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context)
    {
        $oldWhere = $queryBuilder->getDQLPart('where');

        // replace by marker expression
        $marker = new Expr\Func('NOT', []);
        $queryBuilder->add('where', $marker);

        $subFilters = $context['filters'][$operator];
        // print json_encode($subFilters, JSON_PRETTY_PRINT);
        $assoc = [];
        $logic = [];
        foreach ($subFilters as $key => $value) {
            if (ctype_digit((string) $key)) {
                // allows the same filter to be applied several times, usually with different arguments
                $subcontext = $context; //copies
                $subcontext['filters'] = $value;
                $this->applyFilters($filters, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $subcontext);

                // apply logic seperately
                if (isset($value['and'])) {
                    $logic[]['and'] =  $value['and'];
                }if (isset($value['or'])) {
                    $logic[]['or'] =  $value['or'];
                }
            } elseif (in_array($key, ['and', 'or'])) {
                $logic[][$key] = $value;
            } else {
                $assoc[$key] = $value;
            }
        }

        // Process $assoc
        $subcontext = $context; //copies
        $subcontext['filters'] = $assoc;
        $this->applyFilters($filters, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $subcontext);

        $newWhere = $queryBuilder->getDQLPart('where');
        $queryBuilder->add('where', $oldWhere); //restores old where

        // force $operator logic upon $newWhere
        if ($operator == 'and') {
            $adaptedPart = $this->adaptWhere(Expr\Andx::class, $newWhere, $marker);
        } else {
            $adaptedPart = $this->adaptWhere(Expr\Orx::class, $newWhere, $marker);
        }

        // Process logic
        foreach ($logic as $eachLogic) {
            $subcontext = $context; //copies
            $subcontext['filters'] = $eachLogic;
            $newWhere = $this->applyLogic($filters, key($eachLogic), $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $subcontext);
            $adaptedPart->add($newWhere); // empty expressions are ignored by ::add
        }

        return $adaptedPart; // may be empty
    }

    private function applyFilters($filters, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context)
    {
        foreach ($filters as $filter) {
            $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);
        }
    }

    /**
     * ASSUMPTION: filters do not use QueryBuilder::where or QueryBuilder::add
     * and create semantically complete expressions in the sense that expressions
     * added to the QueryBundle through ::andWhere or ::orWhere do not depend
     * on one another so that the intended logic is not compromised if they are
     * recombined with the others by either Doctrine\ORM\Query\Expr\Andx
     * or Doctrine\ORM\Query\Expr\Orx.
     *
     * Replace $where by an instance of $expClass.
     * andWhere and orWhere allways add their args at the end of existing or
     * new logical expressions, so we started with a marker expression
     * to become the deepest first part. The marker should not be returned
     * @param string $expClass
     * @param Expr\Andx | Expr\Orx $where Result from applying filters
     * @param Expr\Func $marker Marks the end of logic resulting from applying filters
     * @return Expr\Andx | Expr\Orx Instance of $expClass
     * @throws \LogicException if assumption proves wrong
     */
    private function adaptWhere($expClass, $where, $marker)
    {
        if ($where === $marker) {
            // Filters did nothing
             return new $expClass([]);
        }
 
        if (!$where instanceof Expr\Andx && !$where instanceof Expr\Orx) {
            // A filter used QueryBuilder::where or QueryBuilder::add or otherwise
            throw new \LogicException("Assumpion failure, unexpected Expression: ". $where);
        }
        $parts = $where->getParts();
        if (empty($parts)) {
            // A filter used QueryBuilder::where or QueryBuilder::add or otherwise
            throw new \LogicException("Assumpion failure, marker not found");
        }

        if ($parts[0] === $marker) {
            // Marker found, recursion ends here
            array_shift($parts);
        } else {
            $parts[0] = $this->adaptWhere($expClass, $parts[0], $marker);
        }
        return new $expClass($parts);
    }

    /**
     * @param string $resourceClass
     * @param string $operationName
     * @return FilterInterface[] From resource except $this and OrderFilters
     * @throws ResourceClassNotFoundException
     */
    protected function getFilters($resourceClass, $operationName)
    {
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
        $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true);

        $result = [];
        foreach ($resourceFilters as $filterId) {
            $filter = $this->filterLocator->has($filterId)
                ? $this->filterLocator->get($filterId)
                :  null;
            if ($filter instanceof FilterInterface
                && !($filter instanceof OrderFilter)
                && $filter !== $this
                && preg_match($this->classExp, get_class($filter))
            ) {
                $result[$filterId] = $filter;
            }
        }
        return $result;
    }
}

Then add the following service configuration to your api config/services.yml:

'App\Filter\FilterLogic':
    class: 'App\Filter\FilterLogic'
    arguments:
        - '@api_platform.metadata.resource.metadata_factory'
        - '@api_platform.filter_locator'
    public: false
    abstract: true
    autoconfigure: false

Finally adapt your entity like this:

use App\Filter\FilterLogic;
/**
 * @ApiResource
 * @ApiFilter(SearchFilter::class, properties={
 *   "name": "ipartial",
 *   "username": "ipartial",
 *   "email": "ipartial",
 * })
 * @ApiFilter(FilterLogic.class)
 *

You can apply it in other classes as well just by adding the @ApiFilter annotation.

You can in/exclude filters by class name by configuring classExp. For example:

* @ApiFilter(FilterLogic::class, arguments={"classExp"="/ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Filter\\+/"})

will only apply API Platform ORM Filters in logic context.

It is important that the annotation with the FilterLogic class is the last @ApiFilter annotation. The normal filtering will still work as usual: filters decide how to apply themselves to the QueryBuilder. If all use ::andWhere, like the built in filters of Api Platform, the order of the ApiFilter attribute/annotation does not matter, but if some use other methods a different order may yield different results. FilterLogic uses orWhere for "or" so the order matters. If it is the last filter its logic expressions will become the topmost ones, therefore defining the primary logic.

Limitations

Works with built in filters of Api Platform, except for DateFilter with EXCLUDE_NULL. This DateFilter subclass may fix it.

Assumes that filters create semantically complete expressions in the sense that expressions added to the QueryBundle through ::andWhere or ::orWhere do not depend on one another so that the intended logic is not compromised if they are recombined with the others by either Doctrine\ORM\Query\Expr\Andx or Doctrine\ORM\Query\Expr\Orx.

May Fail if a filter uses QueryBuilder::where or ::add.

You are advised to check the code of all custom and third party Filters and not to combine those that use QueryBuilder::where or ::add with FilterLogic or that produce complex logic that is not semantically complete. For an example of semantically complete and incomplete expressions see DateFilterTest.

The built in filters of Api Platform IMHO contain a bug with respect to the JOINs they generate. As a result, combining them with OR does not work as expected with properties nested over to-many and nullable associations. My FilterBundle provides workarounds, but they do change the behavior of ExistsFilter =false.

Related