Skip to content

Commit fc01fcd

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

File tree

14 files changed

+875
-0
lines changed

14 files changed

+875
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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+
use Doctrine\DBAL\Types\Types;
19+
20+
trait IriSearchFilterTrait
21+
{
22+
public function getDescription(string $resourceClass): array
23+
{
24+
$description = [];
25+
26+
$properties = $this->getProperties();
27+
if (null === $properties) {
28+
$metadata = $this->getClassMetadata($resourceClass);
29+
$fieldNames = array_fill_keys($metadata->getFieldNames(), null);
30+
$associationNames = array_fill_keys($metadata->getAssociationNames(), null);
31+
32+
$properties = array_merge($fieldNames, $associationNames);
33+
}
34+
35+
foreach ($properties as $property => $strategy) {
36+
if (!$this->isPropertyMapped($property, $resourceClass, true)) {
37+
continue;
38+
}
39+
40+
if ($this->isPropertyNested($property, $resourceClass)) {
41+
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
42+
$field = $propertyParts['field'];
43+
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
44+
} else {
45+
$field = $property;
46+
$metadata = $this->getClassMetadata($resourceClass);
47+
}
48+
49+
$propertyName = $this->normalizePropertyName($property);
50+
if ($metadata->hasField($field)) {
51+
$typeOfField = $this->getType($metadata->getTypeOfField($field));
52+
$filterParameterNames = [$propertyName];
53+
54+
foreach ($filterParameterNames as $filterParameterName) {
55+
$description[$filterParameterName] = [
56+
'property' => $propertyName,
57+
'type' => $typeOfField,
58+
'required' => false,
59+
'strategy' => $strategy,
60+
'is_collection' => str_ends_with((string) $filterParameterName, '[]'),
61+
];
62+
}
63+
} elseif ($metadata->hasAssociation($field)) {
64+
$filterParameterNames = [
65+
$propertyName,
66+
$propertyName.'[]',
67+
];
68+
69+
foreach ($filterParameterNames as $filterParameterName) {
70+
$description[$filterParameterName] = [
71+
'property' => $propertyName,
72+
'type' => 'string',
73+
'required' => false,
74+
'strategy' => 'exact',
75+
'is_collection' => str_ends_with((string) $filterParameterName, '[]'),
76+
];
77+
}
78+
}
79+
}
80+
81+
return $description;
82+
}
83+
84+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
85+
{
86+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
87+
}
88+
89+
/**
90+
* {@inheritdoc}
91+
*/
92+
public function getType(string $doctrineType): string
93+
{
94+
// TODO: remove this test when doctrine/dbal:3 support is removed
95+
if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) {
96+
return 'array';
97+
}
98+
99+
return match ($doctrineType) {
100+
Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int',
101+
Types::BOOLEAN => 'bool',
102+
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,
103+
Types::FLOAT => 'float',
104+
default => 'string',
105+
};
106+
}
107+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 Doctrine\ODM\MongoDB\Aggregation\Builder;
20+
use Doctrine\ODM\MongoDB\Mapping\MappingException;
21+
use Doctrine\Persistence\ManagerRegistry;
22+
use Psr\Log\LoggerInterface;
23+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
24+
25+
class IriSearchFilter extends AbstractFilter implements OpenApiParameterFilterInterface
26+
{
27+
use IriSearchFilterTrait;
28+
29+
public function __construct(
30+
?ManagerRegistry $managerRegistry = null,
31+
?LoggerInterface $logger = null,
32+
?array $properties = null,
33+
?NameConverterInterface $nameConverter = null,
34+
) {
35+
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
36+
}
37+
38+
/**
39+
* @throws MappingException
40+
*/
41+
public function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
42+
{
43+
if (
44+
null === $value
45+
|| !$this->isPropertyEnabled($property, $resourceClass)
46+
|| !$this->isPropertyMapped($property, $resourceClass, true)
47+
) {
48+
return;
49+
}
50+
51+
$extraProperties = $operation?->getExtraProperties();
52+
$resource = $extraProperties['_value'] ?? null;
53+
if (!$resource) {
54+
$this->logger->warning(\sprintf('No resource found for property "%s".', $property));
55+
56+
return;
57+
}
58+
59+
$extraProperties = $operation?->getExtraProperties();
60+
$resource = $extraProperties['_value'] ?? null;
61+
if (!$resource) {
62+
$this->logger->warning(\sprintf('No resource found for property "%s".', $property));
63+
64+
return;
65+
}
66+
67+
$matchField = $property;
68+
if ($this->isPropertyNested($property, $resourceClass)) {
69+
[$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass);
70+
}
71+
72+
$aggregationBuilder
73+
->match()
74+
->field($matchField)
75+
->equals($resource->getId());
76+
}
77+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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 Doctrine\ORM\QueryBuilder;
21+
use Doctrine\Persistence\ManagerRegistry;
22+
use Psr\Log\LoggerInterface;
23+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
24+
25+
class IriSearchFilter extends AbstractFilter implements OpenApiParameterFilterInterface
26+
{
27+
use IriSearchFilterTrait;
28+
29+
public function __construct(
30+
?ManagerRegistry $managerRegistry = null,
31+
?LoggerInterface $logger = null,
32+
?array $properties = null,
33+
?NameConverterInterface $nameConverter = null,
34+
) {
35+
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
36+
}
37+
38+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
39+
{
40+
if (
41+
null === $value
42+
|| !$this->isPropertyEnabled($property, $resourceClass)
43+
|| !$this->isPropertyMapped($property, $resourceClass, true)
44+
) {
45+
return;
46+
}
47+
48+
$extraProperties = $operation?->getExtraProperties();
49+
$resource = $extraProperties['_value'] ?? null;
50+
if (!$resource) {
51+
$this->logger->warning(\sprintf('No resource found for property "%s".', $property));
52+
53+
return;
54+
}
55+
56+
$alias = $queryBuilder->getRootAliases()[0];
57+
$parameterName = $queryNameGenerator->generateParameterName($property);
58+
59+
$queryBuilder
60+
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
61+
->setParameter($parameterName, $resource->getId());
62+
}
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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\Doctrine\Orm\Filter\IriSearchFilter;
17+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
18+
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
19+
use ApiPlatform\Metadata\IriConverterInterface;
20+
use ApiPlatform\Metadata\Operation;
21+
use ApiPlatform\Metadata\Parameter;
22+
use ApiPlatform\State\ParameterProviderInterface;
23+
use Psr\Log\LoggerInterface;
24+
25+
final readonly class IriConverterParameterProvider implements ParameterProviderInterface
26+
{
27+
public function __construct(
28+
private IriConverterInterface $iriConverter,
29+
private LoggerInterface $logger,
30+
) {
31+
}
32+
33+
public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
34+
{
35+
$operation = $context['operation'] ?? null;
36+
$value = $parameter->getValue();
37+
$filter = $parameter->getFilter();
38+
39+
if (!$parameter->getValue() || !$filter instanceof IriSearchFilter) {
40+
return $operation;
41+
}
42+
43+
$iri = $context['request']->getRequestUri() ?? null;
44+
if (null === $iri) {
45+
return $operation;
46+
}
47+
48+
try {
49+
$resource = $this->iriConverter->getResourceFromIri($value, $context);
50+
51+
$operation = $operation->withExtraProperties(array_merge(
52+
$operation->getExtraProperties() ?? [],
53+
['_value' => $resource]
54+
));
55+
} catch (InvalidArgumentException|ItemNotFoundException $e) {
56+
$this->logger->error(\sprintf('Invalid IRI "%s": %s', $iri, $e->getMessage()));
57+
58+
return null;
59+
}
60+
61+
return $operation;
62+
}
63+
}

src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml

+13
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@
4747
<argument type="collection"></argument>
4848
</service>
4949

50+
<service id="api_platform.doctrine_mongodb.odm.iri_search_filter" class="ApiPlatform\Doctrine\Odm\Filter\IriSearchFilter" public="false" abstract="true">
51+
<argument type="service" id="api_platform.iri_converter"/>
52+
<argument type="service" id="logger" on-invalid="ignore"/>
53+
<argument type="service" id="doctrine_mongodb"/>
54+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
55+
</service>
56+
57+
<service id="ApiPlatform\Doctrine\Odm\Filter\IriSearchFilter" alias="api_platform.doctrine_mongodb.odm.iri_search_filter"/>
58+
59+
<service id="api_platform.doctrine_mongodb.odm.iri_search_filter.instance" parent="api_platform.doctrine_mongodb.odm.iri_search_filter">
60+
<argument type="collection"/>
61+
</service>
62+
5063
<service id="api_platform.doctrine_mongodb.odm.boolean_filter" class="ApiPlatform\Doctrine\Odm\Filter\BooleanFilter" public="false" abstract="true">
5164
<argument type="service" id="doctrine_mongodb" />
5265
<argument type="service" id="logger" on-invalid="ignore" />

src/Symfony/Bundle/Resources/config/doctrine_orm.xml

+8
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,14 @@
199199
<argument type="collection"></argument>
200200
</service>
201201

202+
<service id="api_platform.doctrine.orm.iri_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\IriSearchFilter" public="false">
203+
<argument type="service" id="doctrine"/>
204+
<argument type="service" id="logger" on-invalid="ignore"/>
205+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
206+
207+
<tag name="api_platform.filter" priority="-100"/>
208+
</service>
209+
202210
<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">
203211
<argument type="service" id="doctrine" />
204212
<argument type="service" id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory.inner" />

src/Symfony/Bundle/Resources/config/state/provider.xml

+8
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
<argument type="service" id="api_platform.serializer.context_builder" />
1919
</service>
2020

21+
<service id="api_platform.state_provider.iri_converter_parameter" class="ApiPlatform\State\Provider\IriConverterParameterProvider" public="false">
22+
<argument type="service" id="api_platform.iri_converter"/>
23+
<argument type="service" id="logger" />
24+
<argument type="service" id="api_platform.api.identifiers_extractor" />
25+
26+
<tag name="api_platform.parameter_provider" key="api_platform.state_provider.iri_converter_parameter" priority="-895" />
27+
</service>
28+
2129
<service id="api_platform.state_provider.deserialize" class="ApiPlatform\State\Provider\DeserializeProvider" decorates="api_platform.state_provider.main" decoration-priority="300">
2230
<argument type="service" id="api_platform.state_provider.deserialize.inner" />
2331
<argument type="service" id="api_platform.serializer" />

src/Symfony/Bundle/Resources/config/symfony/events.xml

+8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@
3333
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="4" />
3434
</service>
3535

36+
<service id="api_platform.state_provider.iri_converter_parameter" class="ApiPlatform\State\Provider\IriConverterParameterProvider" public="false">
37+
<argument type="service" id="api_platform.iri_converter"/>
38+
<argument type="service" id="logger" />
39+
<argument type="service" id="api_platform.api.identifiers_extractor" />
40+
41+
<tag name="api_platform.parameter_provider" key="api_platform.state_provider.iri_converter_parameter" priority="-895" />
42+
</service>
43+
3644
<service id="api_platform.state_provider.deserialize" class="ApiPlatform\State\Provider\DeserializeProvider">
3745
<argument>null</argument>
3846
<argument type="service" id="api_platform.serializer" />

0 commit comments

Comments
 (0)