Skip to content

Commit

Permalink
feat(doctrine): parameter filter extension (#6248)
Browse files Browse the repository at this point in the history
* feat(doctrine): parameter filtering

* feat(graphql): parameter graphql arguments
  • Loading branch information
soyuka committed Mar 29, 2024
1 parent e427bba commit 842030d
Show file tree
Hide file tree
Showing 19 changed files with 866 additions and 19 deletions.
27 changes: 27 additions & 0 deletions src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php
@@ -0,0 +1,27 @@
<?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;

/**
* @author Antoine Bluchet <soyuka@gmail.com>
*
* @experimental
*/
interface PropertyAwareFilterInterface
{
/**
* @param string[] $properties
*/
public function setProperties(array $properties): void;
}
71 changes: 71 additions & 0 deletions src/Doctrine/Odm/Extension/ParameterExtension.php
@@ -0,0 +1,71 @@
<?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\Odm\Extension;

use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Psr\Container\ContainerInterface;

/**
* Reads operation parameters and execute its filter.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface
{
public function __construct(private readonly ContainerInterface $filterLocator)
{
}

private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void
{
foreach ($operation->getParameters() ?? [] as $parameter) {
$values = $parameter->getExtraProperties()['_api_values'] ?? [];
if (!$values) {
continue;
}

if (null === ($filterId = $parameter->getFilter())) {
continue;
}

$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
if ($filter instanceof FilterInterface) {
$filterContext = ['filters' => $values];
$filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext);
// update by reference
if (isset($filterContext['mongodb_odm_sort_fields'])) {
$context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields'];
}
}
}
}

/**
* {@inheritdoc}
*/
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
$this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context);
}

/**
* {@inheritdoc}
*/
public function applyToItem(Builder $aggregationBuilder, string $resourceClass, array $identifiers, ?Operation $operation = null, array &$context = []): void
{
$this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context);
}
}
11 changes: 10 additions & 1 deletion src/Doctrine/Odm/Filter/AbstractFilter.php
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Doctrine\Odm\Filter;

use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
use ApiPlatform\Metadata\Operation;
Expand All @@ -29,7 +30,7 @@
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
abstract class AbstractFilter implements FilterInterface
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
{
use MongoDbOdmPropertyHelperTrait;
use PropertyHelperTrait;
Expand Down Expand Up @@ -65,6 +66,14 @@ protected function getProperties(): ?array
return $this->properties;
}

/**
* @param string[] $properties
*/
public function setProperties(array $properties): void
{
$this->properties = $properties;
}

protected function getLogger(): LoggerInterface
{
return $this->logger;
Expand Down
70 changes: 70 additions & 0 deletions src/Doctrine/Orm/Extension/ParameterExtension.php
@@ -0,0 +1,70 @@
<?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\Extension;

use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Psr\Container\ContainerInterface;

/**
* Reads operation parameters and execute its filter.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private readonly ContainerInterface $filterLocator)
{
}

/**
* @param array<string, mixed> $context
*/
private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
foreach ($operation->getParameters() ?? [] as $parameter) {
$values = $parameter->getExtraProperties()['_api_values'] ?? [];
if (!$values) {
continue;
}

if (null === ($filterId = $parameter->getFilter())) {
continue;
}

$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
if ($filter instanceof FilterInterface) {
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values] + $context);
}
}
}

/**
* {@inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}

/**
* {@inheritdoc}
*/
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
}
11 changes: 10 additions & 1 deletion src/Doctrine/Orm/Filter/AbstractFilter.php
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Doctrine\Orm\Filter;

use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
Expand All @@ -23,7 +24,7 @@
use Psr\Log\NullLogger;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

abstract class AbstractFilter implements FilterInterface
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
{
use OrmPropertyHelperTrait;
use PropertyHelperTrait;
Expand Down Expand Up @@ -64,6 +65,14 @@ protected function getLogger(): LoggerInterface
return $this->logger;
}

/**
* @param string[] $properties
*/
public function setProperties(array $properties): void
{
$this->properties = $properties;
}

/**
* Determines whether the given property is enabled.
*/
Expand Down
112 changes: 107 additions & 5 deletions src/GraphQl/Type/FieldsBuilder.php
Expand Up @@ -290,9 +290,111 @@ public function resolveResourceArgs(array $args, Operation $operation): array
$args[$id]['type'] = $this->typeConverter->resolveType($arg['type']);
}

/*
* This is @experimental, read the comment on the parameterToObjectType function as additional information.
*/
foreach ($operation->getParameters() ?? [] as $parameter) {
$key = $parameter->getKey();

if (str_contains($key, ':property')) {
if (!($filterId = $parameter->getFilter()) || !$this->filterLocator->has($filterId)) {
continue;
}

$parsedKey = explode('[:property]', $key);
$flattenFields = [];
foreach ($this->filterLocator->get($filterId)->getDescription($operation->getClass()) as $key => $value) {
$values = [];
parse_str($key, $values);
if (isset($values[$parsedKey[0]])) {
$values = $values[$parsedKey[0]];
}

$name = key($values);
$flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string'];
}

$args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]);
continue;
}

$args[$key] = ['type' => GraphQLType::string()];

if ($parameter->getRequired()) {
$args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']);
}
}

return $args;
}

/**
* Transform the result of a parse_str to a GraphQL object type.
* We should consider merging getFilterArgs and this, `getFilterArgs` uses `convertType` whereas we assume that parameters have only scalar types.
* Note that this method has a lower complexity then the `getFilterArgs` one.
* TODO: Is there a use case with an argument being a complex type (eg: a Resource, Enum etc.)?
*
* @param array<array{name: string, required: bool|null, description: string|null, leafs: string|array, type: string}> $flattenFields
*/
private function parameterToObjectType(array $flattenFields, string $name): InputObjectType
{
$fields = [];
foreach ($flattenFields as $field) {
$key = $field['name'];
$type = $this->getParameterType(\in_array($field['type'], Type::$builtinTypes, true) ? new Type($field['type'], !$field['required']) : new Type('object', !$field['required'], $field['type']));

if (\is_array($l = $field['leafs'])) {
if (0 === key($l)) {
$key = $key;
$type = GraphQLType::listOf($type);
} else {
$n = [];
foreach ($field['leafs'] as $l => $value) {
$n[] = ['required' => null, 'name' => $l, 'leafs' => $value, 'type' => 'string', 'description' => null];
}

$type = $this->parameterToObjectType($n, $key);
if (isset($fields[$key]) && ($t = $fields[$key]['type']) instanceof InputObjectType) {
$t = $fields[$key]['type'];
$t->config['fields'] = array_merge($t->config['fields'], $type->config['fields']);
$type = $t;
}
}
}

if ($field['required']) {
$type = GraphQLType::nonNull($type);
}

if (isset($fields[$key])) {
if ($type instanceof ListOfType) {
$key .= '_list';
}
}

$fields[$key] = ['type' => $type, 'name' => $key];
}

return new InputObjectType(['name' => $name, 'fields' => $fields]);
}

/**
* A simplified version of convert type that does not support resources.
*/
private function getParameterType(Type $type): GraphQLType
{
return match ($type->getBuiltinType()) {
Type::BUILTIN_TYPE_BOOL => GraphQLType::boolean(),
Type::BUILTIN_TYPE_INT => GraphQLType::int(),
Type::BUILTIN_TYPE_FLOAT => GraphQLType::float(),
Type::BUILTIN_TYPE_STRING => GraphQLType::string(),
Type::BUILTIN_TYPE_ARRAY => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])),
Type::BUILTIN_TYPE_ITERABLE => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])),
Type::BUILTIN_TYPE_OBJECT => GraphQLType::string(),
default => GraphQLType::string(),
};
}

/**
* Get the field configuration of a resource.
*
Expand Down Expand Up @@ -450,9 +552,9 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
}
}

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

if (str_ends_with($key, '[]')) {
Expand All @@ -467,8 +569,8 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
if (\array_key_exists($key, $parsed) && \is_array($parsed[$key])) {
$parsed = [$key => ''];
}
array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void {
$value = $graphqlFilterType;
array_walk_recursive($parsed, static function (&$v) use ($graphqlFilterType): void {
$v = $graphqlFilterType;
});
$args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key);
}
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

0 comments on commit 842030d

Please sign in to comment.