Skip to content

Commit 842030d

Browse files
authored
feat(doctrine): parameter filter extension (#6248)
* feat(doctrine): parameter filtering * feat(graphql): parameter graphql arguments
1 parent e427bba commit 842030d

19 files changed

+866
-19
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Common\Filter;
15+
16+
/**
17+
* @author Antoine Bluchet <[email protected]>
18+
*
19+
* @experimental
20+
*/
21+
interface PropertyAwareFilterInterface
22+
{
23+
/**
24+
* @param string[] $properties
25+
*/
26+
public function setProperties(array $properties): void;
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Odm\Extension;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
17+
use ApiPlatform\Metadata\Operation;
18+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
19+
use Psr\Container\ContainerInterface;
20+
21+
/**
22+
* Reads operation parameters and execute its filter.
23+
*
24+
* @author Antoine Bluchet <[email protected]>
25+
*/
26+
final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface
27+
{
28+
public function __construct(private readonly ContainerInterface $filterLocator)
29+
{
30+
}
31+
32+
private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void
33+
{
34+
foreach ($operation->getParameters() ?? [] as $parameter) {
35+
$values = $parameter->getExtraProperties()['_api_values'] ?? [];
36+
if (!$values) {
37+
continue;
38+
}
39+
40+
if (null === ($filterId = $parameter->getFilter())) {
41+
continue;
42+
}
43+
44+
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
45+
if ($filter instanceof FilterInterface) {
46+
$filterContext = ['filters' => $values];
47+
$filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext);
48+
// update by reference
49+
if (isset($filterContext['mongodb_odm_sort_fields'])) {
50+
$context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields'];
51+
}
52+
}
53+
}
54+
}
55+
56+
/**
57+
* {@inheritdoc}
58+
*/
59+
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
60+
{
61+
$this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context);
62+
}
63+
64+
/**
65+
* {@inheritdoc}
66+
*/
67+
public function applyToItem(Builder $aggregationBuilder, string $resourceClass, array $identifiers, ?Operation $operation = null, array &$context = []): void
68+
{
69+
$this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context);
70+
}
71+
}

src/Doctrine/Odm/Filter/AbstractFilter.php

+10-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Doctrine\Odm\Filter;
1515

16+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
1617
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
1718
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
1819
use ApiPlatform\Metadata\Operation;
@@ -29,7 +30,7 @@
2930
*
3031
* @author Alan Poulain <[email protected]>
3132
*/
32-
abstract class AbstractFilter implements FilterInterface
33+
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
3334
{
3435
use MongoDbOdmPropertyHelperTrait;
3536
use PropertyHelperTrait;
@@ -65,6 +66,14 @@ protected function getProperties(): ?array
6566
return $this->properties;
6667
}
6768

69+
/**
70+
* @param string[] $properties
71+
*/
72+
public function setProperties(array $properties): void
73+
{
74+
$this->properties = $properties;
75+
}
76+
6877
protected function getLogger(): LoggerInterface
6978
{
7079
return $this->logger;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Extension;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\Operation;
19+
use Doctrine\ORM\QueryBuilder;
20+
use Psr\Container\ContainerInterface;
21+
22+
/**
23+
* Reads operation parameters and execute its filter.
24+
*
25+
* @author Antoine Bluchet <[email protected]>
26+
*/
27+
final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
28+
{
29+
public function __construct(private readonly ContainerInterface $filterLocator)
30+
{
31+
}
32+
33+
/**
34+
* @param array<string, mixed> $context
35+
*/
36+
private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
37+
{
38+
foreach ($operation->getParameters() ?? [] as $parameter) {
39+
$values = $parameter->getExtraProperties()['_api_values'] ?? [];
40+
if (!$values) {
41+
continue;
42+
}
43+
44+
if (null === ($filterId = $parameter->getFilter())) {
45+
continue;
46+
}
47+
48+
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
49+
if ($filter instanceof FilterInterface) {
50+
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values] + $context);
51+
}
52+
}
53+
}
54+
55+
/**
56+
* {@inheritdoc}
57+
*/
58+
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
59+
{
60+
$this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
61+
}
62+
63+
/**
64+
* {@inheritdoc}
65+
*/
66+
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
67+
{
68+
$this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
69+
}
70+
}

src/Doctrine/Orm/Filter/AbstractFilter.php

+10-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Doctrine\Orm\Filter;
1515

16+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
1617
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
1718
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
1819
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
@@ -23,7 +24,7 @@
2324
use Psr\Log\NullLogger;
2425
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2526

26-
abstract class AbstractFilter implements FilterInterface
27+
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
2728
{
2829
use OrmPropertyHelperTrait;
2930
use PropertyHelperTrait;
@@ -64,6 +65,14 @@ protected function getLogger(): LoggerInterface
6465
return $this->logger;
6566
}
6667

68+
/**
69+
* @param string[] $properties
70+
*/
71+
public function setProperties(array $properties): void
72+
{
73+
$this->properties = $properties;
74+
}
75+
6776
/**
6877
* Determines whether the given property is enabled.
6978
*/

src/GraphQl/Type/FieldsBuilder.php

+107-5
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,111 @@ public function resolveResourceArgs(array $args, Operation $operation): array
290290
$args[$id]['type'] = $this->typeConverter->resolveType($arg['type']);
291291
}
292292

293+
/*
294+
* This is @experimental, read the comment on the parameterToObjectType function as additional information.
295+
*/
296+
foreach ($operation->getParameters() ?? [] as $parameter) {
297+
$key = $parameter->getKey();
298+
299+
if (str_contains($key, ':property')) {
300+
if (!($filterId = $parameter->getFilter()) || !$this->filterLocator->has($filterId)) {
301+
continue;
302+
}
303+
304+
$parsedKey = explode('[:property]', $key);
305+
$flattenFields = [];
306+
foreach ($this->filterLocator->get($filterId)->getDescription($operation->getClass()) as $key => $value) {
307+
$values = [];
308+
parse_str($key, $values);
309+
if (isset($values[$parsedKey[0]])) {
310+
$values = $values[$parsedKey[0]];
311+
}
312+
313+
$name = key($values);
314+
$flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string'];
315+
}
316+
317+
$args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]);
318+
continue;
319+
}
320+
321+
$args[$key] = ['type' => GraphQLType::string()];
322+
323+
if ($parameter->getRequired()) {
324+
$args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']);
325+
}
326+
}
327+
293328
return $args;
294329
}
295330

331+
/**
332+
* Transform the result of a parse_str to a GraphQL object type.
333+
* We should consider merging getFilterArgs and this, `getFilterArgs` uses `convertType` whereas we assume that parameters have only scalar types.
334+
* Note that this method has a lower complexity then the `getFilterArgs` one.
335+
* TODO: Is there a use case with an argument being a complex type (eg: a Resource, Enum etc.)?
336+
*
337+
* @param array<array{name: string, required: bool|null, description: string|null, leafs: string|array, type: string}> $flattenFields
338+
*/
339+
private function parameterToObjectType(array $flattenFields, string $name): InputObjectType
340+
{
341+
$fields = [];
342+
foreach ($flattenFields as $field) {
343+
$key = $field['name'];
344+
$type = $this->getParameterType(\in_array($field['type'], Type::$builtinTypes, true) ? new Type($field['type'], !$field['required']) : new Type('object', !$field['required'], $field['type']));
345+
346+
if (\is_array($l = $field['leafs'])) {
347+
if (0 === key($l)) {
348+
$key = $key;
349+
$type = GraphQLType::listOf($type);
350+
} else {
351+
$n = [];
352+
foreach ($field['leafs'] as $l => $value) {
353+
$n[] = ['required' => null, 'name' => $l, 'leafs' => $value, 'type' => 'string', 'description' => null];
354+
}
355+
356+
$type = $this->parameterToObjectType($n, $key);
357+
if (isset($fields[$key]) && ($t = $fields[$key]['type']) instanceof InputObjectType) {
358+
$t = $fields[$key]['type'];
359+
$t->config['fields'] = array_merge($t->config['fields'], $type->config['fields']);
360+
$type = $t;
361+
}
362+
}
363+
}
364+
365+
if ($field['required']) {
366+
$type = GraphQLType::nonNull($type);
367+
}
368+
369+
if (isset($fields[$key])) {
370+
if ($type instanceof ListOfType) {
371+
$key .= '_list';
372+
}
373+
}
374+
375+
$fields[$key] = ['type' => $type, 'name' => $key];
376+
}
377+
378+
return new InputObjectType(['name' => $name, 'fields' => $fields]);
379+
}
380+
381+
/**
382+
* A simplified version of convert type that does not support resources.
383+
*/
384+
private function getParameterType(Type $type): GraphQLType
385+
{
386+
return match ($type->getBuiltinType()) {
387+
Type::BUILTIN_TYPE_BOOL => GraphQLType::boolean(),
388+
Type::BUILTIN_TYPE_INT => GraphQLType::int(),
389+
Type::BUILTIN_TYPE_FLOAT => GraphQLType::float(),
390+
Type::BUILTIN_TYPE_STRING => GraphQLType::string(),
391+
Type::BUILTIN_TYPE_ARRAY => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])),
392+
Type::BUILTIN_TYPE_ITERABLE => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])),
393+
Type::BUILTIN_TYPE_OBJECT => GraphQLType::string(),
394+
default => GraphQLType::string(),
395+
};
396+
}
397+
296398
/**
297399
* Get the field configuration of a resource.
298400
*
@@ -450,9 +552,9 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
450552
}
451553
}
452554

453-
foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $value) {
454-
$nullable = isset($value['required']) ? !$value['required'] : true;
455-
$filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']);
555+
foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $description) {
556+
$nullable = isset($description['required']) ? !$description['required'] : true;
557+
$filterType = \in_array($description['type'], Type::$builtinTypes, true) ? new Type($description['type'], $nullable) : new Type('object', $nullable, $description['type']);
456558
$graphqlFilterType = $this->convertType($filterType, false, $resourceOperation, $rootOperation, $resourceClass, $rootResource, $property, $depth);
457559

458560
if (str_ends_with($key, '[]')) {
@@ -467,8 +569,8 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
467569
if (\array_key_exists($key, $parsed) && \is_array($parsed[$key])) {
468570
$parsed = [$key => ''];
469571
}
470-
array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void {
471-
$value = $graphqlFilterType;
572+
array_walk_recursive($parsed, static function (&$v) use ($graphqlFilterType): void {
573+
$v = $graphqlFilterType;
472574
});
473575
$args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key);
474576
}

src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public function process(ContainerBuilder $container): void
5252
*/
5353
private function createFilterDefinitions(\ReflectionClass $resourceReflectionClass, ContainerBuilder $container): void
5454
{
55-
foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass]) {
55+
foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass, $filterAttribute]) {
5656
if ($container->has($id)) {
5757
continue;
5858
}
@@ -69,6 +69,10 @@ private function createFilterDefinitions(\ReflectionClass $resourceReflectionCla
6969
}
7070

7171
$definition->addTag(self::TAG_FILTER_NAME);
72+
if ($filterAttribute->alias) {
73+
$definition->addTag(self::TAG_FILTER_NAME, ['id' => $filterAttribute->alias]);
74+
}
75+
7276
$definition->setAutowired(true);
7377

7478
$parameterNames = [];

0 commit comments

Comments
 (0)