Skip to content

Commit

Permalink
make doctrine filters decorable
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Mar 23, 2024
1 parent 04158c2 commit 26d37d2
Show file tree
Hide file tree
Showing 14 changed files with 194 additions and 16 deletions.
6 changes: 6 additions & 0 deletions src/Doctrine/Orm/Extension/ParameterExtension.php
Expand Up @@ -47,6 +47,12 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter
}

$parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters');

$parsedKey = explode('[:property]', $key);
if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) {
$key = $parsedKey[0];
}

if (!isset($parameters[$key])) {
continue;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Doctrine/Orm/Filter/AbstractFilter.php
Expand Up @@ -29,7 +29,7 @@ abstract class AbstractFilter implements FilterInterface
use PropertyHelperTrait;
protected LoggerInterface $logger;

public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null)
public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, public ?array $properties = null, protected ?NameConverterInterface $nameConverter = null)
{
$this->logger = $logger ?? new NullLogger();
}
Expand Down
16 changes: 13 additions & 3 deletions src/Hydra/Serializer/CollectionFiltersNormalizer.php
Expand Up @@ -168,9 +168,15 @@ private function getSearch(string $resourceClass, array $parts, array $filters,
continue;
}

if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter())) {
$filter = $this->getFilter($filterId);
foreach ($filter->getDescription($resourceClass) as $variable => $description) {
if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter()) && ($filter = $this->getFilter($filterId))) {
foreach ($filter->getDescription($resourceClass) ?? [] as $variable => $description) {
// This is a practice induced by PHP and is not necessary when implementing URI template
if (str_ends_with((string) $variable, '[]')) {
continue;
}

$k = str_replace(':property', $description['property'], $key);
$variable = str_replace($description['property'], $k, $variable);
$variables[] = $variable;
$m = ['@type' => 'IriTemplateMapping', 'variable' => $variable, 'property' => $description['property'], 'required' => $description['required']];
if (null !== ($required = $parameter->getRequired())) {
Expand All @@ -182,6 +188,10 @@ private function getSearch(string $resourceClass, array $parts, array $filters,
continue;
}

if (!$property) {
continue;
}

$m = ['@type' => 'IriTemplateMapping', 'variable' => $key, 'property' => $property];
$variables[] = $key;
if (null !== ($required = $parameter->getRequired())) {
Expand Down
2 changes: 2 additions & 0 deletions src/Metadata/ApiFilter.php
Expand Up @@ -26,13 +26,15 @@ final class ApiFilter
{
/**
* @param string|class-string<FilterInterface>|class-string<LegacyFilterInterface> $filterClass
* @param string $alias a service alias to be referenced in a Parameter
*/
public function __construct(
public string $filterClass,
public ?string $id = null,
public ?string $strategy = null,
public array $properties = [],
public array $arguments = [],
public ?string $alias = null,
) {
if (!is_a($this->filterClass, FilterInterface::class, true) && !is_a($this->filterClass, LegacyFilterInterface::class, true)) {
throw new InvalidArgumentException(sprintf('The filter class "%s" does not implement "%s". Did you forget a use statement?', $this->filterClass, FilterInterface::class));
Expand Down
2 changes: 2 additions & 0 deletions src/Metadata/FilterInterface.php
Expand Up @@ -63,6 +63,8 @@ interface FilterInterface
* The description can contain additional data specific to a filter.
*
* @see \ApiPlatform\OpenApi\Factory\OpenApiFactory::getFiltersParameters
*
* @return array<string, array{property: string, type: string, required: bool, strategy: string, is_collection: bool, openapi: array<string, mixed>, schema: array<string, mixed>}>
*/
public function getDescription(string $resourceClass): array;
}
Expand Down
Expand Up @@ -47,7 +47,14 @@ public function create(string $resourceClass): ResourceMetadataCollection
throw new ResourceClassNotFoundException(sprintf('Resource "%s" not found.', $resourceClass));
}

$filters = array_keys($this->readFilterAttributes($reflectionClass));
$classFilters = $this->readFilterAttributes($reflectionClass);
$filters = [];

foreach ($classFilters as $id => [$args, $filterClass, $attribute]) {
if (!$attribute->alias) {
$filters[] = $id;
}
}

foreach ($resourceMetadataCollection as $i => $resource) {
foreach ($operations = $resource->getOperations() ?? [] as $operationName => $operation) {
Expand Down
10 changes: 5 additions & 5 deletions src/Metadata/Util/AttributeFilterExtractorTrait.php
Expand Up @@ -75,18 +75,18 @@ private function getFilterProperties(ApiFilter $filterAttribute, \ReflectionClas
/**
* Reads filter attribute from a ReflectionClass.
*
* @return array Key is the filter id. It has two values, properties and the ApiFilter instance
* @return array<string, array{array<string, mixed>, class-string, ApiFilter}> indexed by the filter id, the filter tuple has the filter arguments, the filter class and the ApiFilter attribute instance
*/
private function readFilterAttributes(\ReflectionClass $reflectionClass): array
{
$filters = [];

foreach ($this->getFilterAttributes($reflectionClass) as $filterAttribute) {
$filterClass = $filterAttribute->filterClass;
$id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id);
$id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id ?? $filterAttribute->alias);

if (!isset($filters[$id])) {
$filters[$id] = [$filterAttribute->arguments, $filterClass];
$filters[$id] = [$filterAttribute->arguments, $filterClass, $filterAttribute];
}

if ($properties = $this->getFilterProperties($filterAttribute, $reflectionClass)) {
Expand All @@ -97,10 +97,10 @@ private function readFilterAttributes(\ReflectionClass $reflectionClass): array
foreach ($reflectionClass->getProperties() as $reflectionProperty) {
foreach ($this->getFilterAttributes($reflectionProperty) as $filterAttribute) {
$filterClass = $filterAttribute->filterClass;
$id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id);
$id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id ?? $filterAttribute->alias);

if (!isset($filters[$id])) {
$filters[$id] = [$filterAttribute->arguments, $filterClass];
$filters[$id] = [$filterAttribute->arguments, $filterClass, $filterAttribute];
}

if ($properties = $this->getFilterProperties($filterAttribute, $reflectionClass, $reflectionProperty)) {
Expand Down
Expand Up @@ -52,7 +52,7 @@ public function process(ContainerBuilder $container): void
*/
private function createFilterDefinitions(\ReflectionClass $resourceReflectionClass, ContainerBuilder $container): void
{
foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass]) {
foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass, $filterAttribute]) {
if ($container->has($id)) {
continue;
}
Expand All @@ -69,6 +69,10 @@ private function createFilterDefinitions(\ReflectionClass $resourceReflectionCla
}

$definition->addTag(self::TAG_FILTER_NAME);
if ($filterAttribute->alias) {
$definition->addTag(self::TAG_FILTER_NAME, ['id' => $filterAttribute->alias]);
}

$definition->setAutowired(true);

$parameterNames = [];
Expand Down
22 changes: 22 additions & 0 deletions src/Symfony/Bundle/Resources/config/doctrine_orm.xml
Expand Up @@ -35,34 +35,49 @@
<argument key="$orderNullsComparison">%api_platform.collection.order_nulls_comparison%</argument>
</service>
<service id="ApiPlatform\Doctrine\Orm\Filter\OrderFilter" alias="api_platform.doctrine.orm.order_filter" />
<service id="api_platform.doctrine.orm.date_filter.instance" parent="api_platform.doctrine.orm.date_filter">
<argument type="collection"></argument>
</service>

<service id="api_platform.doctrine.orm.range_filter" class="ApiPlatform\Doctrine\Orm\Filter\RangeFilter" public="false" abstract="true">
<argument type="service" id="doctrine" />
<argument type="service" id="logger" on-invalid="ignore" />
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore" />
</service>
<service id="ApiPlatform\Doctrine\Orm\Filter\RangeFilter" alias="api_platform.doctrine.orm.range_filter" />
<service id="api_platform.doctrine.orm.range_filter.instance" parent="api_platform.doctrine.orm.range_filter">
<argument type="collection"></argument>
</service>

<service id="api_platform.doctrine.orm.date_filter" class="ApiPlatform\Doctrine\Orm\Filter\DateFilter" public="false" abstract="true">
<argument type="service" id="doctrine" />
<argument type="service" id="logger" on-invalid="ignore" />
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore" />
</service>
<service id="ApiPlatform\Doctrine\Orm\Filter\DateFilter" alias="api_platform.doctrine.orm.date_filter" />
<service id="api_platform.doctrine.orm.date_filter.instance" parent="api_platform.doctrine.orm.date_filter">
<argument type="collection"></argument>
</service>

<service id="api_platform.doctrine.orm.boolean_filter" class="ApiPlatform\Doctrine\Orm\Filter\BooleanFilter" public="false" abstract="true">
<argument type="service" id="doctrine" />
<argument type="service" id="logger" on-invalid="ignore" />
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore" />
</service>
<service id="ApiPlatform\Doctrine\Orm\Filter\BooleanFilter" alias="api_platform.doctrine.orm.boolean_filter" />
<service id="api_platform.doctrine.orm.boolean_filter.instance" parent="api_platform.doctrine.orm.boolean_filter">
<argument type="collection"></argument>
</service>

<service id="api_platform.doctrine.orm.numeric_filter" class="ApiPlatform\Doctrine\Orm\Filter\NumericFilter" public="false" abstract="true">
<argument type="service" id="doctrine" />
<argument type="service" id="logger" on-invalid="ignore" />
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore" />
</service>
<service id="ApiPlatform\Doctrine\Orm\Filter\NumericFilter" alias="api_platform.doctrine.orm.numeric_filter" />
<service id="api_platform.doctrine.orm.numeric_filter.instance" parent="api_platform.doctrine.orm.numeric_filter">
<argument type="collection"></argument>
</service>

<service id="api_platform.doctrine.orm.exists_filter" class="ApiPlatform\Doctrine\Orm\Filter\ExistsFilter" public="false" abstract="true">
<argument type="service" id="doctrine" />
Expand All @@ -71,6 +86,9 @@
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore" />
</service>
<service id="ApiPlatform\Doctrine\Orm\Filter\ExistsFilter" alias="api_platform.doctrine.orm.exists_filter" />
<service id="api_platform.doctrine.orm.exists_filter.instance" parent="api_platform.doctrine.orm.exists_filter">
<argument type="collection"></argument>
</service>

<!-- Doctrine Query extensions -->

Expand Down Expand Up @@ -166,6 +184,10 @@
</service>
<service id="ApiPlatform\Doctrine\Orm\Filter\SearchFilter" alias="api_platform.doctrine.orm.search_filter" />

<service id="api_platform.doctrine.orm.search_filter.instance" parent="api_platform.doctrine.orm.search_filter">
<argument type="collection"></argument>
</service>

<service id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory" class="ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" decoration-priority="40">
<argument type="service" id="doctrine" />
<argument type="service" id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory.inner" />
Expand Down
2 changes: 1 addition & 1 deletion tests/Fixtures/TestBundle/ApiResource/WithParameter.php
Expand Up @@ -50,7 +50,7 @@
)]
class WithParameter
{
public static int $counter = 1;
protected static int $counter = 1;
public int $id = 1;

#[Groups(['a'])]
Expand Down
26 changes: 25 additions & 1 deletion tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php
Expand Up @@ -13,17 +13,28 @@

namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;

use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchFilterValueTransformer;
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter;
use Doctrine\ORM\Mapping as ORM;

#[GetCollection(
uriTemplate: 'search_filter_parameter{._format}',
parameters: [
'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'),
'order' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'),
'order[:property]' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'),

'searchPartial[:property]' => new QueryParameter(filter: 'app_search_filter_partial'),
'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'),
'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'),
'q' => new QueryParameter(property: 'hydra:freetextQuery'),
]
)]
#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])]
#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])]
#[ApiFilter(SearchTextAndDateFilter::class, alias: 'app_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])]
#[ORM\Entity]
class SearchFilterParameter
{
Expand All @@ -37,6 +48,9 @@ class SearchFilterParameter
#[ORM\Column(type: 'string')]
private string $foo = '';

#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $createdAt = null;

public function getId(): ?int
{
return $this->id;
Expand All @@ -51,4 +65,14 @@ public function setFoo(string $foo): void
{
$this->foo = $foo;
}

public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}

public function setCreatedAt(\DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
}
39 changes: 39 additions & 0 deletions tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php
@@ -0,0 +1,39 @@
<?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\Tests\Fixtures\TestBundle\Filter;

use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class SearchFilterValueTransformer implements FilterInterface
{
public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, ?array $properties = null, private readonly ?string $key = null)
{
$searchFilter->properties = $properties;
}

// This function is only used to hook in documentation generators (supported by Swagger and Hydra)
public function getDescription(string $resourceClass): array
{
return $this->searchFilter->getDescription($resourceClass);
}

public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters'][$this->key]] + $context);
}
}
41 changes: 41 additions & 0 deletions tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php
@@ -0,0 +1,41 @@
<?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\Tests\Fixtures\TestBundle\Filter;

use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class SearchTextAndDateFilter implements FilterInterface
{
public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = [])
{
$searchFilter->properties = $searchFilterProperties;
$dateFilter->properties = $dateFilterProperties;
}

// This function is only used to hook in documentation generators (supported by Swagger and Hydra)
public function getDescription(string $resourceClass): array
{
return array_merge($this->searchFilter->getDescription($resourceClass), $this->dateFilter->getDescription($resourceClass));
}

public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context);
$this->dateFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context);
}
}

0 comments on commit 26d37d2

Please sign in to comment.