Skip to content

Commit 7bfc697

Browse files
committed
feat(doctrine): search filters like laravel eloquent filters
1 parent f67f6f1 commit 7bfc697

17 files changed

+934
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
use ApiPlatform\Metadata\Parameter;
17+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
18+
19+
trait IriSearchFilterTrait
20+
{
21+
public function getDescription(string $resourceClass): array
22+
{
23+
$description = [];
24+
25+
$properties = $this->getProperties();
26+
if (null === $properties) {
27+
$metadata = $this->getClassMetadata($resourceClass);
28+
$fieldNames = array_fill_keys($metadata->getFieldNames(), null);
29+
$associationNames = array_fill_keys($metadata->getAssociationNames(), null);
30+
31+
$properties = array_merge($fieldNames, $associationNames);
32+
}
33+
34+
foreach ($properties as $property => $strategy) {
35+
if (!$this->isPropertyMapped($property, $resourceClass, true)) {
36+
continue;
37+
}
38+
39+
if ($this->isPropertyNested($property, $resourceClass)) {
40+
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
41+
$field = $propertyParts['field'];
42+
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
43+
} else {
44+
$field = $property;
45+
$metadata = $this->getClassMetadata($resourceClass);
46+
}
47+
48+
$propertyName = $this->normalizePropertyName($property);
49+
if ($metadata->hasField($field)) {
50+
$typeOfField = $this->getType($metadata->getTypeOfField($field));
51+
$filterParameterNames = [$propertyName];
52+
53+
foreach ($filterParameterNames as $filterParameterName) {
54+
$description[$filterParameterName] = [
55+
'property' => $propertyName,
56+
'type' => $typeOfField,
57+
'required' => false,
58+
'strategy' => $strategy,
59+
'is_collection' => str_ends_with((string) $filterParameterName, '[]'),
60+
];
61+
}
62+
} elseif ($metadata->hasAssociation($field)) {
63+
$filterParameterNames = [
64+
$propertyName,
65+
$propertyName.'[]',
66+
];
67+
68+
foreach ($filterParameterNames as $filterParameterName) {
69+
$description[$filterParameterName] = [
70+
'property' => $propertyName,
71+
'type' => 'string',
72+
'required' => false,
73+
'strategy' => 'exact',
74+
'is_collection' => str_ends_with((string) $filterParameterName, '[]'),
75+
];
76+
}
77+
}
78+
}
79+
80+
return $description;
81+
}
82+
83+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
84+
{
85+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
86+
}
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\IriSearchFilterTrait;
17+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
20+
use ApiPlatform\Metadata\PropertiesFilterInterface;
21+
use ApiPlatform\State\Provider\IriConverterParameterProvider;
22+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
23+
use Doctrine\ODM\MongoDB\Mapping\MappingException;
24+
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
25+
use Doctrine\Persistence\ManagerRegistry;
26+
use Psr\Log\LoggerInterface;
27+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
28+
29+
class IriSearchFilter extends AbstractFilter implements OpenApiParameterFilterInterface, PropertiesFilterInterface, ParameterProviderFilterInterface
30+
{
31+
use IriSearchFilterTrait;
32+
33+
public function __construct(
34+
?ManagerRegistry $managerRegistry = null,
35+
?LoggerInterface $logger = null,
36+
?array $properties = null,
37+
?NameConverterInterface $nameConverter = null,
38+
) {
39+
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
40+
}
41+
42+
/**
43+
* @throws MappingException
44+
*/
45+
public function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
46+
{
47+
if (
48+
null === $value
49+
|| !$this->isPropertyEnabled($property, $resourceClass)
50+
|| !$this->isPropertyMapped($property, $resourceClass, true)
51+
) {
52+
return;
53+
}
54+
55+
$extraProperties = $operation?->getExtraProperties();
56+
$resource = $extraProperties['_value'] ?? null;
57+
58+
if (!$resource) {
59+
$this->logger->warning(\sprintf('No resource found for property "%s".', $property));
60+
61+
return;
62+
}
63+
64+
$aggregationBuilder
65+
->match()
66+
->field($property)
67+
->equals($resource->getId());
68+
}
69+
70+
/**
71+
* {@inheritdoc}
72+
*/
73+
protected function getType(string $doctrineType): string
74+
{
75+
// TODO: remove constantes deprecations when doctrine/dbal:3 support is removed
76+
return match ($doctrineType) {
77+
MongoDbType::INT, MongoDbType::INTEGER => 'int',
78+
MongoDbType::BOOL, MongoDbType::BOOLEAN => 'bool',
79+
MongoDbType::DATE, MongoDbType::DATE_IMMUTABLE => \DateTimeInterface::class,
80+
MongoDbType::FLOAT => 'float',
81+
default => 'string',
82+
};
83+
}
84+
85+
public static function getParameterProvider(): string
86+
{
87+
return IriConverterParameterProvider::class;
88+
}
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\IriSearchFilterTrait;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
21+
use ApiPlatform\Metadata\PropertiesFilterInterface;
22+
use ApiPlatform\State\Provider\IriConverterParameterProvider;
23+
use Doctrine\DBAL\Types\Types;
24+
use Doctrine\ORM\QueryBuilder;
25+
use Doctrine\Persistence\ManagerRegistry;
26+
use Psr\Log\LoggerInterface;
27+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
28+
29+
class IriSearchFilter extends AbstractFilter implements OpenApiParameterFilterInterface, PropertiesFilterInterface, ParameterProviderFilterInterface
30+
{
31+
use IriSearchFilterTrait;
32+
33+
public function __construct(
34+
?ManagerRegistry $managerRegistry = null,
35+
?LoggerInterface $logger = null,
36+
?array $properties = null,
37+
?NameConverterInterface $nameConverter = null,
38+
) {
39+
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
40+
}
41+
42+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
43+
{
44+
if (
45+
null === $value
46+
|| !$this->isPropertyEnabled($property, $resourceClass)
47+
|| !$this->isPropertyMapped($property, $resourceClass, true)
48+
) {
49+
return;
50+
}
51+
52+
$extraProperties = $operation?->getExtraProperties();
53+
$resource = $extraProperties['_value'] ?? null;
54+
if (!$resource) {
55+
$this->logger->warning(\sprintf('No resource found for property "%s".', $property));
56+
57+
return;
58+
}
59+
60+
$alias = $queryBuilder->getRootAliases()[0];
61+
$parameterName = $queryNameGenerator->generateParameterName($property);
62+
63+
$queryBuilder
64+
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
65+
->setParameter($parameterName, $resource->getId());
66+
}
67+
68+
/**
69+
* {@inheritdoc}
70+
*/
71+
public function getType(string $doctrineType): string
72+
{
73+
// TODO: remove this test when doctrine/dbal:3 support is removed
74+
if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) {
75+
return 'array';
76+
}
77+
78+
return match ($doctrineType) {
79+
Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int',
80+
Types::BOOLEAN => 'bool',
81+
Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE => \DateTimeInterface::class,
82+
Types::FLOAT => 'float',
83+
default => 'string',
84+
};
85+
}
86+
87+
public static function getParameterProvider(): string
88+
{
89+
return IriConverterParameterProvider::class;
90+
}
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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\Metadata;
15+
16+
interface ParameterProviderFilterInterface
17+
{
18+
public static function getParameterProvider(): string;
19+
}
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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\Metadata;
15+
16+
interface PropertiesFilterInterface
17+
{
18+
}

src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
2323
use ApiPlatform\Metadata\Operation;
2424
use ApiPlatform\Metadata\Parameter;
25+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
2526
use ApiPlatform\Metadata\Parameters;
27+
use ApiPlatform\Metadata\PropertiesFilterInterface;
2628
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2729
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2830
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
@@ -129,7 +131,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas
129131

130132
$key = $parameter->getKey() ?? $key;
131133

132-
if (str_contains($key, ':property')) {
134+
if (str_contains($key, ':property') || (($f = $parameter->getFilter()) && $f instanceof PropertiesFilterInterface)) {
133135
$p = [];
134136
foreach ($propertyNames as $prop) {
135137
$p[$this->nameConverter?->denormalize($prop) ?? $prop] = $prop;
@@ -154,6 +156,10 @@ private function addFilterMetadata(Parameter $parameter): Parameter
154156

155157
$filter = \is_object($filterId) ? $filterId : $this->filterLocator->get($filterId);
156158

159+
if ($filter instanceof ParameterProviderFilterInterface) {
160+
$parameter = $parameter->withProvider($filter::getParameterProvider());
161+
}
162+
157163
if (!$filter) {
158164
return $parameter;
159165
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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\State\Provider;
15+
16+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
17+
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
18+
use ApiPlatform\Metadata\IriConverterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\State\ParameterProviderInterface;
22+
use Psr\Log\LoggerInterface;
23+
24+
final readonly class IriConverterParameterProvider implements ParameterProviderInterface
25+
{
26+
public function __construct(
27+
private IriConverterInterface $iriConverter,
28+
private LoggerInterface $logger,
29+
) {
30+
}
31+
32+
public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
33+
{
34+
$operation = $context['operation'] ?? null;
35+
$value = $parameter->getValue();
36+
if (!$value) {
37+
return $operation;
38+
}
39+
40+
$iri = $context['request']->getRequestUri() ?? null;
41+
if (null === $iri) {
42+
return $operation;
43+
}
44+
45+
try {
46+
$resource = $this->iriConverter->getResourceFromIri($value, $context);
47+
48+
$operation = $operation->withExtraProperties(array_merge(
49+
$operation->getExtraProperties() ?? [],
50+
['_value' => $resource]
51+
));
52+
} catch (InvalidArgumentException|ItemNotFoundException $e) {
53+
$this->logger->error(\sprintf('Invalid IRI "%s": %s', $iri, $e->getMessage()));
54+
55+
return null;
56+
}
57+
58+
return $operation;
59+
}
60+
}

0 commit comments

Comments
 (0)