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.