Skip to content

Commit

Permalink
feat(doctrine): add new filter for filtering an entity using PHP back…
Browse files Browse the repository at this point in the history
…ed enum, resolves api-platform#6506 (api-platform#6547)
  • Loading branch information
mremi authored and soyuka committed Aug 30, 2024
1 parent 4c29bf8 commit 404ecbb
Show file tree
Hide file tree
Showing 13 changed files with 529 additions and 1 deletion.
98 changes: 98 additions & 0 deletions src/Doctrine/Common/Filter/BackedEnumFilterTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Common\Filter;

use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use Psr\Log\LoggerInterface;

/**
* Trait for filtering the collection by backed enum values.
*
* Filters collection on equality of backed enum properties.
*
* For each property passed, if the resource does not have such property or if
* the value is not one of cases the property is ignored.
*
* @author Rémi Marseille <marseille.remi@gmail.com>
*/
trait BackedEnumFilterTrait
{
use PropertyHelperTrait;

/**
* @var array<string, string>
*/
private array $enumTypes;

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

$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}

foreach ($properties as $property => $unused) {
if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isBackedEnumField($property, $resourceClass)) {
continue;
}
$propertyName = $this->normalizePropertyName($property);
$description[$propertyName] = [
'property' => $propertyName,
'type' => 'string',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => array_map(fn (\BackedEnum $case) => $case->value, $this->enumTypes[$property]::cases()),
],
];
}

return $description;
}

abstract protected function getProperties(): ?array;

abstract protected function getLogger(): LoggerInterface;

abstract protected function normalizePropertyName(string $property): string;

/**
* Determines whether the given property refers to a backed enum field.
*/
abstract protected function isBackedEnumField(string $property, string $resourceClass): bool;

private function normalizeValue($value, string $property): mixed
{
$values = array_map(fn (\BackedEnum $case) => $case->value, $this->enumTypes[$property]::cases());

if (\in_array($value, $values, true)) {
return $value;
}

$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid backed enum value for "%s" property, expected one of ( "%s" )',
$property,
implode('" | "', $values)
)),
]);

return null;
}
}
178 changes: 178 additions & 0 deletions src/Doctrine/Orm/Filter/BackedEnumFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\Filter\BackedEnumFilterTrait;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\FieldMapping;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;

/**
* The backed enum filter allows you to search on backed enum fields and values.
*
* Note: it is possible to filter on properties and relations too.
*
* Syntax: `?property=foo`.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter;
*
* #[ApiResource]
* #[ApiFilter(BackedEnumFilter::class, properties: ['status'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.backed_enum_filter:
* parent: 'api_platform.doctrine.orm.backed_enum_filter'
* arguments: [ { status: ~ } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.backed_enum_filter']
* ```
*
* ```xml
* <?xml version="1.0" encoding="UTF-8" ?>
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.backed_enum_filter" parent="api_platform.doctrine.orm.backed_enum_filter">
* <argument type="collection">
* <argument key="status"/>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.backed_enum_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?status=published`.
*
* @author Rémi Marseille <marseille.remi@gmail.com>
*/
final class BackedEnumFilter extends AbstractFilter
{
use BackedEnumFilterTrait;

/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if (
!$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass)
|| !$this->isBackedEnumField($property, $resourceClass)
) {
return;
}

$value = $this->normalizeValue($value, $property);
if (null === $value) {
return;
}

$alias = $queryBuilder->getRootAliases()[0];
$field = $property;

if ($this->isPropertyNested($property, $resourceClass)) {
[$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN);
}

$valueParameter = $queryNameGenerator->generateParameterName($field);

$queryBuilder
->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter))
->setParameter($valueParameter, $value);
}

/**
* {@inheritdoc}
*/
protected function isBackedEnumField(string $property, string $resourceClass): bool
{
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);

if (!$metadata instanceof ClassMetadata) {
return false;
}

$fieldMapping = $metadata->fieldMappings[$propertyParts['field']];

// Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object
if ($fieldMapping instanceof FieldMapping) {
$fieldMapping = (array) $fieldMapping;
}

if (!$enumType = $fieldMapping['enumType']) {
return false;
}

if (!($enumType::cases()[0] ?? null) instanceof \BackedEnum) {
return false;
}

$this->enumTypes[$property] = $enumType;

return true;
}
}
49 changes: 49 additions & 0 deletions src/Doctrine/Orm/Tests/Filter/BackedEnumFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Tests\Filter;

use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter;
use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase;
use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy;

/**
* @author Rémi Marseille <marseille.remi@gmail.com>
*/
final class BackedEnumFilterTest extends DoctrineOrmFilterTestCase
{
use BackedEnumFilterTestTrait;

protected string $filterClass = BackedEnumFilter::class;

public static function provideApplyTestData(): array
{
return array_merge_recursive(
self::provideApplyTestArguments(),
[
'valid case' => [
\sprintf('SELECT o FROM %s o WHERE o.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class),
],
'invalid case' => [
\sprintf('SELECT o FROM %s o', Dummy::class),
],
'valid case for nested property' => [
\sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class),
],
'invalid case for nested property' => [
\sprintf('SELECT o FROM %s o', Dummy::class),
],
]
);
}
}
Loading

0 comments on commit 404ecbb

Please sign in to comment.