Last active
May 25, 2023 08:45
-
-
Save byhoratiss/6e359b7f28b85758496d302091b7ee8d to your computer and use it in GitHub Desktop.
Api Platform multiple SearchFilter strategies for a property
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use App\Filter\StrategyFilter; | |
/** | |
* @ApiFilter(StrategyFilter::class, properties={ | |
* "name"="exact|start", | |
* }) | |
*/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
services: | |
product.search_filter: | |
class: App\Filter\StrategyFilter | |
parent: 'api_platform.doctrine.orm.search_filter' | |
arguments: [ { 'name': 'start|exact' } ] | |
tags: [ 'api_platform.filter' ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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