Skip to content

Commit e3b56c8

Browse files
committed
feat(doctrine): search filters like laravel eloquent filters
1 parent 716a43b commit e3b56c8

19 files changed

+1237
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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\ExactSearchFilterTrait;
17+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
18+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
19+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
20+
use ApiPlatform\Metadata\Operation;
21+
use Doctrine\ORM\QueryBuilder;
22+
use Doctrine\Persistence\ManagerRegistry;
23+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
24+
25+
final class ExactSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface
26+
{
27+
use ExactSearchFilterTrait;
28+
use FilterInterfaceTrait;
29+
30+
public function __construct(
31+
private ?ManagerRegistry $managerRegistry = null,
32+
private readonly ?array $properties = null,
33+
private readonly ?NameConverterInterface $nameConverter = null,
34+
) {
35+
}
36+
37+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
38+
{
39+
if (
40+
null === $value
41+
|| !$this->isPropertyEnabled($property, $resourceClass)
42+
|| !$this->isPropertyMapped($property, $resourceClass, true)
43+
) {
44+
return;
45+
}
46+
47+
$alias = $queryBuilder->getRootAliases()[0];
48+
$parameterName = $queryNameGenerator->generateParameterName($property);
49+
50+
$queryBuilder
51+
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
52+
->setParameter($parameterName, $value);
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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\PropertyHelperTrait;
17+
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
18+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
19+
use ApiPlatform\Metadata\Exception\RuntimeException;
20+
use ApiPlatform\Metadata\Operation;
21+
use Doctrine\ORM\QueryBuilder;
22+
use Doctrine\Persistence\ManagerRegistry;
23+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
24+
25+
trait FilterInterfaceTrait
26+
{
27+
use OrmPropertyHelperTrait;
28+
use PropertyHelperTrait;
29+
30+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
31+
{
32+
foreach ($context['filters'] as $property => $value) {
33+
$this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
34+
}
35+
}
36+
37+
public function getDescription(string $resourceClass): array
38+
{
39+
throw new RuntimeException('Not implemented.');
40+
}
41+
42+
/**
43+
* Determines whether the given property is enabled.
44+
*/
45+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
46+
{
47+
if (null === $this->properties) {
48+
// to ensure sanity, nested properties must still be explicitly enabled
49+
return !$this->isPropertyNested($property, $resourceClass);
50+
}
51+
52+
return \array_key_exists($property, $this->properties);
53+
}
54+
55+
protected function denormalizePropertyName(string|int $property): string
56+
{
57+
if (!$this->nameConverter instanceof NameConverterInterface) {
58+
return (string) $property;
59+
}
60+
61+
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
62+
}
63+
64+
public function hasManagerRegistry(): bool
65+
{
66+
return $this->managerRegistry instanceof ManagerRegistry;
67+
}
68+
69+
public function getManagerRegistry(): ManagerRegistry
70+
{
71+
return $this->managerRegistry;
72+
}
73+
74+
public function setManagerRegistry(ManagerRegistry $managerRegistry): void
75+
{
76+
$this->managerRegistry = $managerRegistry;
77+
}
78+
}
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\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
22+
use ApiPlatform\Metadata\PropertiesAwareInterface;
23+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
24+
use ApiPlatform\State\Provider\IriConverterParameterProvider;
25+
use Doctrine\DBAL\Types\Types;
26+
use Doctrine\ORM\QueryBuilder;
27+
use Doctrine\Persistence\ManagerRegistry;
28+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
29+
30+
final class IriSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface, PropertiesAwareInterface, ParameterProviderFilterInterface
31+
{
32+
use FilterInterfaceTrait;
33+
34+
public function __construct(
35+
private ?ManagerRegistry $managerRegistry = null,
36+
private readonly ?array $properties = null,
37+
private readonly ?NameConverterInterface $nameConverter = null,
38+
) {
39+
}
40+
41+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, 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+
$value = $context['parameter']->getValue();
52+
53+
$alias = $queryBuilder->getRootAliases()[0];
54+
$parameterName = $queryNameGenerator->generateParameterName($property);
55+
56+
$queryBuilder
57+
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
58+
->setParameter($parameterName, $value);
59+
}
60+
61+
/**
62+
* {@inheritdoc}
63+
*/
64+
public function getType(string $doctrineType): string
65+
{
66+
// TODO: remove this test when doctrine/dbal:3 support is removed
67+
if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) {
68+
return 'array';
69+
}
70+
71+
return match ($doctrineType) {
72+
Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int',
73+
Types::BOOLEAN => 'bool',
74+
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,
75+
Types::FLOAT => 'float',
76+
default => 'string',
77+
};
78+
}
79+
80+
public static function getParameterProvider(): string
81+
{
82+
return IriConverterParameterProvider::class;
83+
}
84+
85+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
86+
{
87+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
88+
}
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
22+
use Doctrine\ORM\QueryBuilder;
23+
use Doctrine\Persistence\ManagerRegistry;
24+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
25+
26+
final class PartialSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface
27+
{
28+
use FilterInterfaceTrait;
29+
30+
public function __construct(
31+
private ?ManagerRegistry $managerRegistry = null,
32+
private readonly ?array $properties = null,
33+
private readonly ?NameConverterInterface $nameConverter = null,
34+
) {
35+
}
36+
37+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
38+
{
39+
if (
40+
null === $value
41+
|| !$this->isPropertyEnabled($property, $resourceClass)
42+
|| !$this->isPropertyMapped($property, $resourceClass, true)
43+
) {
44+
return;
45+
}
46+
47+
$alias = $queryBuilder->getRootAliases()[0];
48+
$parameterName = $queryNameGenerator->generateParameterName($property);
49+
50+
$queryBuilder
51+
->andWhere(\sprintf('%s.%s LIKE :%s', $alias, $property, $parameterName))
52+
->setParameter($parameterName, '%'.$value.'%');
53+
}
54+
55+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
56+
{
57+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
58+
}
59+
}

src/Metadata/Parameter.php

+7
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ public function getValue(mixed $default = new ParameterNotFound()): mixed
127127
return $this->extraProperties['_api_values'] ?? $default;
128128
}
129129

130+
public function setValue(mixed $value): static
131+
{
132+
$this->extraProperties['_api_values'] = $value;
133+
134+
return $this;
135+
}
136+
130137
/**
131138
* @return array<string, mixed>
132139
*/

src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

+1
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource
196196
if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) {
197197
$parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider');
198198
}
199+
199200
$currentKey = $key;
200201
if (null === $parameter->getProperty() && isset($properties[$key])) {
201202
$parameter = $parameter->withProperty($key);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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\IdentifiersExtractor;
18+
use ApiPlatform\Metadata\IriConverterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\State\ParameterProviderInterface;
22+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
23+
24+
final readonly class IriConverterParameterProvider implements ParameterProviderInterface
25+
{
26+
public function __construct(
27+
private IriConverterInterface $iriConverter,
28+
private PropertyAccessorInterface $propertyAccessor,
29+
private ?IdentifiersExtractor $identifiersExtractor = null,
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+
if (!$value) {
38+
return $operation;
39+
}
40+
41+
$id = $this->getIdFromValue($value);
42+
$parameter->setValue($id);
43+
44+
return $operation;
45+
}
46+
47+
protected function getIdFromValue(string $value): mixed
48+
{
49+
try {
50+
$item = $this->iriConverter->getResourceFromIri($value, ['fetch_data' => false]);
51+
52+
if (null === $this->identifiersExtractor) {
53+
return $this->propertyAccessor->getValue($item, 'id');
54+
}
55+
56+
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item);
57+
58+
return 1 === \count($identifiers) ? array_pop($identifiers) : $identifiers;
59+
} catch (InvalidArgumentException) {
60+
// Do nothing, return the raw value
61+
}
62+
63+
return $value;
64+
}
65+
}

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

+21
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,27 @@
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 key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
205+
206+
<tag name="api_platform.filter" priority="-100"/>
207+
</service>
208+
209+
<service id="api_platform.doctrine.orm.exact_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\ExactSearchFilter" public="false">
210+
<argument type="service" id="doctrine"/>
211+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
212+
213+
<tag name="api_platform.filter" priority="-100"/>
214+
</service>
215+
216+
<service id="api_platform.doctrine.orm.partial_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter" public="false">
217+
<argument type="service" id="doctrine"/>
218+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
219+
220+
<tag name="api_platform.filter" priority="-100"/>
221+
</service>
222+
202223
<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">
203224
<argument type="service" id="doctrine" />
204225
<argument type="service" id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory.inner" />

0 commit comments

Comments
 (0)