Skip to content

Instantly share code, notes, and snippets.

@byhoratiss
Last active May 25, 2023 08:45
Show Gist options
  • Save byhoratiss/6e359b7f28b85758496d302091b7ee8d to your computer and use it in GitHub Desktop.
Save byhoratiss/6e359b7f28b85758496d302091b7ee8d to your computer and use it in GitHub Desktop.
Api Platform multiple SearchFilter strategies for a property
use App\Filter\StrategyFilter;
/**
* @ApiFilter(StrategyFilter::class, properties={
* "name"="exact|start",
* })
*/
services:
product.search_filter:
class: App\Filter\StrategyFilter
parent: 'api_platform.doctrine.orm.search_filter'
arguments: [ { 'name': 'start|exact' } ]
tags: [ 'api_platform.filter' ]
<?php
namespace App\Filter;
use ApiPlatform\Core\Api\IriConverterInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryBuilderHelper;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\QueryBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
class StrategyFilter extends SearchFilter
{
protected $propertiesWithMultipleStrategies = [];
/**
* {@inheritdoc}
*/
public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack = null, IriConverterInterface $iriConverter, PropertyAccessorInterface $propertyAccessor = null, LoggerInterface $logger = null, array $properties = null)
{
$props = [];
// Remap properties when multiple strategies were applied
foreach ($properties as $property => $strategies) {
// Property with only one strategy
if (strpos($strategies, '|') === false) {
$props[$property] = $strategies;
continue;
}
// Process multiple strategies
foreach (explode('|', $strategies) as $strategy) {
$propertyName = sprintf('%s[%s]', $property, $strategy);
$props[$propertyName] = $strategy;
// Store first defined strategy for BC
if (!isset($props[$property])) {
$props[$property] = $strategy;
// Avoid creating another filter with the first one
continue;
}
// Store this properties with multiple strategies for further usage
$this->propertiesWithMultipleStrategies[$propertyName] = [
'property' => $property,
'strategy' => $strategy,
];
}
}
parent::__construct($managerRegistry, $requestStack, $iriConverter, $propertyAccessor, $logger, $props);
}
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = parent::getDescription($resourceClass);
foreach ($this->propertiesWithMultipleStrategies as $parameterName => $mapping) {
$property = $mapping['property'];
$strategy = $mapping['strategy'];
if (!$this->isPropertyMapped($property, $resourceClass, true)) {
continue;
}
if ($this->isPropertyNested($property, $resourceClass)) {
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
$field = $propertyParts['field'];
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
} else {
$field = $property;
$metadata = $this->getClassMetadata($resourceClass);
}
if ($metadata->hasField($field)) {
$typeOfField = $this->getType($metadata->getTypeOfField($field));
$filterParameterNames = [$parameterName];
foreach ($filterParameterNames as $filterParameterName) {
$description[$filterParameterName] = [
'property' => $property,
'type' => $typeOfField,
'required' => false,
'strategy' => $strategy,
];
}
} elseif ($metadata->hasAssociation($field)) {
$filterParameterNames = [
$parameterName,
];
foreach ($filterParameterNames as $filterParameterName) {
$description[$filterParameterName] = [
'property' => $property,
'type' => 'string',
'required' => false,
'strategy' => self::STRATEGY_EXACT,
];
}
}
}
return $description;
}
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
if (
null === $value ||
!$this->isPropertyEnabled($property, $resourceClass) ||
!$this->isPropertyMapped($property, $resourceClass, true)
) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$field = $property;
$strategy = NULL;
if ($this->isPropertyNested($property, $resourceClass)) {
list($alias, $field, $associations) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass);
$metadata = $this->getNestedMetadata($resourceClass, $associations);
} else {
$metadata = $this->getClassMetadata($resourceClass);
}
if (is_array($value) && sizeof($value) == 1) {
$strategy = array_keys($value)[0];
$value = array_values($value)[0];
}
$values = $this->normalizeValues((array) $value);
if (empty($values)) {
$this->logger->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(sprintf('At least one value is required, multiple values should be in "%1$s[]=firstvalue&%1$s[]=secondvalue" format', $property)),
]);
return;
}
$caseSensitive = true;
if ($metadata->hasField($field)) {
if ('id' === $field) {
$values = array_map([$this, 'getIdFromValue'], $values);
}
if (!$this->hasValidValues($values, $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)),
]);
return;
}
if ( ! $strategy) {
$strategy = $this->properties[$property] ?? self::STRATEGY_EXACT;
}
// prefixing the strategy with i makes it case insensitive
if (0 === strpos($strategy, 'i')) {
$strategy = substr($strategy, 1);
$caseSensitive = false;
}
if (1 === \count($values)) {
$this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive);
return;
}
if (self::STRATEGY_EXACT !== $strategy) {
$this->logger->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(sprintf('"%s" strategy selected for "%s" property, but only "%s" strategy supports multiple values', $strategy, $property, self::STRATEGY_EXACT)),
]);
return;
}
$wrapCase = $this->createWrapCase($caseSensitive);
$valueParameter = $queryNameGenerator->generateParameterName($field);
$queryBuilder
->andWhere(sprintf($wrapCase('%s.%s').' IN (:%s)', $alias, $field, $valueParameter))
->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values));
}
// metadata doesn't have the field, nor an association on the field
if (!$metadata->hasAssociation($field)) {
return;
}
$values = array_map([$this, 'getIdFromValue'], $values);
if (!$this->hasValidValues($values, $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)),
]);
return;
}
$association = $field;
$valueParameter = $queryNameGenerator->generateParameterName($association);
if ($metadata->isCollectionValuedAssociation($association)) {
$associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $association);
$associationField = 'id';
} else {
$associationAlias = $alias;
$associationField = $field;
}
if (1 === \count($values)) {
$queryBuilder
->andWhere(sprintf('%s.%s = :%s', $associationAlias, $associationField, $valueParameter))
->setParameter($valueParameter, $values[0]);
} else {
$queryBuilder
->andWhere(sprintf('%s.%s IN (:%s)', $associationAlias, $associationField, $valueParameter))
->setParameter($valueParameter, $values);
}
}
/**
* Converts a Doctrine type in PHP type.
*
* @param string $doctrineType
*
* @return string
*/
private function getType(string $doctrineType): string
{
switch ($doctrineType) {
case Type::TARRAY:
return 'array';
case Type::BIGINT:
case Type::INTEGER:
case Type::SMALLINT:
return 'int';
case Type::BOOLEAN:
return 'bool';
case Type::DATE:
case Type::TIME:
case Type::DATETIME:
case Type::DATETIMETZ:
return \DateTimeInterface::class;
case Type::FLOAT:
return 'float';
}
if (\defined(Type::class.'::DATE_IMMUTABLE')) {
switch ($doctrineType) {
case Type::DATE_IMMUTABLE:
case Type::TIME_IMMUTABLE:
case Type::DATETIME_IMMUTABLE:
case Type::DATETIMETZ_IMMUTABLE:
return \DateTimeInterface::class;
}
}
return 'string';
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment