Skip to content

Commit 5e1233c

Browse files
authoredAug 22, 2024··
feat(laravel): search filter (#6534)
1 parent 86365be commit 5e1233c

20 files changed

+389
-69
lines changed
 

‎docs/guides/doctrine-search-filter.php

+8-5
Original file line numberDiff line numberDiff line change
@@ -120,21 +120,24 @@ public function testGetDocumentation(): void
120120
$this->assertJsonContains([
121121
'hydra:search' => [
122122
'@type' => 'hydra:IriTemplate',
123-
'hydra:template' => '/books.jsonld{?author,title}',
123+
'hydra:template' => '/books.jsonld{?id,title,author}',
124124
'hydra:variableRepresentation' => 'BasicRepresentation',
125125
'hydra:mapping' => [
126126
[
127127
'@type' => 'IriTemplateMapping',
128-
'variable' => 'author',
129-
'property' => 'author',
130-
'required' => false,
128+
'variable' => 'id',
129+
'property' => 'id',
131130
],
132131
[
133132
'@type' => 'IriTemplateMapping',
134133
'variable' => 'title',
135134
'property' => 'title',
136-
'required' => false,
137135
],
136+
[
137+
'@type' => 'IriTemplateMapping',
138+
'variable' => 'author',
139+
'property' => 'author',
140+
]
138141
],
139142
],
140143
]);

‎src/Laravel/ApiPlatformProvider.php

+75-29
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
4141
use ApiPlatform\Laravel\ApiResource\Error;
4242
use ApiPlatform\Laravel\Controller\ApiPlatformController;
43+
use ApiPlatform\Laravel\Eloquent\Extension\FilterQueryExtension;
44+
use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface;
45+
use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface as EloquentFilterInterface;
46+
use ApiPlatform\Laravel\Eloquent\Filter\SearchFilter;
4347
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentAttributePropertyMetadataFactory;
4448
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentAttributePropertyNameCollectionFactory;
4549
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory;
@@ -66,6 +70,7 @@
6670
use ApiPlatform\Laravel\State\SwaggerUiProcessor;
6771
use ApiPlatform\Laravel\State\ValidateProvider;
6872
use ApiPlatform\Metadata\Exception\NotExposedHttpException;
73+
use ApiPlatform\Metadata\FilterInterface;
6974
use ApiPlatform\Metadata\IdentifiersExtractor;
7075
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
7176
use ApiPlatform\Metadata\IriConverterInterface;
@@ -90,6 +95,7 @@
9095
use ApiPlatform\Metadata\Resource\Factory\LinkResourceMetadataCollectionFactory;
9196
use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory;
9297
use ApiPlatform\Metadata\Resource\Factory\OperationNameResourceMetadataCollectionFactory;
98+
use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory;
9399
use ApiPlatform\Metadata\Resource\Factory\PhpDocResourceMetadataCollectionFactory;
94100
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
95101
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
@@ -102,21 +108,26 @@
102108
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
103109
use ApiPlatform\OpenApi\Options;
104110
use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer;
111+
use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface;
112+
use ApiPlatform\Serializer\Filter\PropertyFilter;
105113
use ApiPlatform\Serializer\ItemNormalizer;
106114
use ApiPlatform\Serializer\JsonEncoder;
107115
use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory;
116+
use ApiPlatform\Serializer\Parameter\SerializerFilterParameterProvider;
108117
use ApiPlatform\Serializer\SerializerContextBuilder;
109118
use ApiPlatform\State\CallableProcessor;
110119
use ApiPlatform\State\CallableProvider;
111120
use ApiPlatform\State\Pagination\Pagination;
112121
use ApiPlatform\State\Pagination\PaginationOptions;
122+
use ApiPlatform\State\ParameterProviderInterface;
113123
use ApiPlatform\State\Processor\AddLinkHeaderProcessor;
114124
use ApiPlatform\State\Processor\RespondProcessor;
115125
use ApiPlatform\State\Processor\SerializeProcessor;
116126
use ApiPlatform\State\Processor\WriteProcessor;
117127
use ApiPlatform\State\ProcessorInterface;
118128
use ApiPlatform\State\Provider\ContentNegotiationProvider;
119129
use ApiPlatform\State\Provider\DeserializeProvider;
130+
use ApiPlatform\State\Provider\ParameterProvider;
120131
use ApiPlatform\State\Provider\ReadProvider;
121132
use ApiPlatform\State\ProviderInterface;
122133
use ApiPlatform\State\SerializerContextBuilderInterface;
@@ -247,38 +258,42 @@ public function register(): void
247258
// TODO: add cached metadata factories
248259
$this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, function (Application $app) use ($config) {
249260
return new EloquentResourceCollectionMetadataFactory(
250-
new AlternateUriResourceMetadataCollectionFactory(
251-
new FiltersResourceMetadataCollectionFactory(
252-
new FormatsResourceMetadataCollectionFactory(
253-
new InputOutputResourceMetadataCollectionFactory(
254-
new PhpDocResourceMetadataCollectionFactory(
255-
new OperationNameResourceMetadataCollectionFactory(
256-
new LinkResourceMetadataCollectionFactory(
257-
$app->make(LinkFactoryInterface::class),
258-
new UriTemplateResourceMetadataCollectionFactory(
261+
new ParameterResourceMetadataCollectionFactory(
262+
$this->app->make(PropertyNameCollectionFactoryInterface::class),
263+
new AlternateUriResourceMetadataCollectionFactory(
264+
new FiltersResourceMetadataCollectionFactory(
265+
new FormatsResourceMetadataCollectionFactory(
266+
new InputOutputResourceMetadataCollectionFactory(
267+
new PhpDocResourceMetadataCollectionFactory(
268+
new OperationNameResourceMetadataCollectionFactory(
269+
new LinkResourceMetadataCollectionFactory(
259270
$app->make(LinkFactoryInterface::class),
260-
$app->make(PathSegmentNameGeneratorInterface::class),
261-
new NotExposedOperationResourceMetadataCollectionFactory(
271+
new UriTemplateResourceMetadataCollectionFactory(
262272
$app->make(LinkFactoryInterface::class),
263-
new AttributesResourceMetadataCollectionFactory(
264-
null,
265-
$app->make(LoggerInterface::class),
266-
[
267-
'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/',
268-
],
269-
false
273+
$app->make(PathSegmentNameGeneratorInterface::class),
274+
new NotExposedOperationResourceMetadataCollectionFactory(
275+
$app->make(LinkFactoryInterface::class),
276+
new AttributesResourceMetadataCollectionFactory(
277+
null,
278+
$app->make(LoggerInterface::class),
279+
[
280+
'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/',
281+
],
282+
false
283+
)
270284
)
271285
)
272286
)
273287
)
274288
)
275-
)
276-
),
277-
$config->get('api-platform.formats'),
278-
$config->get('api-platform.patch_formats'),
289+
),
290+
$config->get('api-platform.formats'),
291+
$config->get('api-platform.patch_formats'),
292+
)
279293
)
280-
)
281-
),
294+
),
295+
$app->make(FilterInterface::class)
296+
)
282297
);
283298
});
284299

@@ -292,6 +307,22 @@ public function register(): void
292307

293308
$this->app->bind(OperationMetadataFactoryInterface::class, OperationMetadataFactory::class);
294309

310+
$this->app->tag([SearchFilter::class], EloquentFilterInterface::class);
311+
$this->app->tag([SearchFilter::class, PropertyFilter::class], FilterInterface::class);
312+
$this->app->singleton(FilterInterface::class, function (Application $app) {
313+
$tagged = iterator_to_array($app->tagged(FilterInterface::class));
314+
315+
return new ServiceLocator($tagged);
316+
});
317+
318+
$this->app->bind(FilterQueryExtension::class, function (Application $app) {
319+
$tagged = iterator_to_array($app->tagged(EloquentFilterInterface::class));
320+
321+
return new FilterQueryExtension(new ServiceLocator($tagged));
322+
});
323+
324+
$this->app->tag([FilterQueryExtension::class], QueryExtensionInterface::class);
325+
295326
$this->app->singleton(ItemProvider::class, function (Application $app) {
296327
$tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class));
297328

@@ -300,7 +331,7 @@ public function register(): void
300331
$this->app->singleton(CollectionProvider::class, function (Application $app) {
301332
$tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class));
302333

303-
return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app), new ServiceLocator($tagged));
334+
return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app), $app->tagged(QueryExtensionInterface::class), new ServiceLocator($tagged));
304335
});
305336
$this->app->tag([ItemProvider::class, CollectionProvider::class], ProviderInterface::class);
306337

@@ -326,8 +357,24 @@ public function register(): void
326357
return new DeserializeProvider($app->make(JsonApiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class));
327358
});
328359

360+
$this->app->tag([PropertyFilter::class], SerializerFilterInterface::class);
361+
362+
$this->app->singleton(SerializerFilterParameterProvider::class, function (Application $app) {
363+
$tagged = iterator_to_array($app->tagged(SerializerFilterInterface::class));
364+
365+
return new SerializerFilterParameterProvider(new ServiceLocator($tagged));
366+
});
367+
368+
$this->app->tag([SerializerFilterParameterProvider::class], ParameterProviderInterface::class);
369+
370+
$this->app->singleton(ParameterProvider::class, function (Application $app) {
371+
$tagged = iterator_to_array($app->tagged(ParameterProviderInterface::class));
372+
373+
return new ParameterProvider($app->make(DeserializeProvider::class), new ServiceLocator($tagged));
374+
});
375+
329376
$this->app->singleton(AccessCheckerProvider::class, function (Application $app) {
330-
return new AccessCheckerProvider($app->make(DeserializeProvider::class), $app->make(ResourceAccessCheckerInterface::class));
377+
return new AccessCheckerProvider($app->make(ParameterProvider::class), $app->make(ResourceAccessCheckerInterface::class));
331378
});
332379

333380
$this->app->singleton(ContentNegotiationProvider::class, function (Application $app) use ($config) {
@@ -339,6 +386,7 @@ public function register(): void
339386
$this->app->tag([RemoveProcessor::class, PersistProcessor::class], ProcessorInterface::class);
340387
$this->app->singleton(CallableProcessor::class, function (Application $app) {
341388
$tagged = iterator_to_array($app->tagged(ProcessorInterface::class));
389+
// TODO: tag SwaggerUiProcessor instead?
342390
$tagged['api_platform.swagger_ui.processor'] = $app->make(SwaggerUiProcessor::class);
343391

344392
return new CallableProcessor(new ServiceLocator($tagged));
@@ -503,8 +551,6 @@ public function register(): void
503551
return new DocumentationAction($app->make(ResourceNameCollectionFactoryInterface::class), $config->get('api-platform.title') ?? '', $config->get('api-platform.description') ?? '', $config->get('api-platform.version') ?? '', $app->make(OpenApiFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $app->make(Negotiator::class), $config->get('api-platform.docs_formats'));
504552
});
505553

506-
$this->app->singleton(FilterLocator::class, FilterLocator::class);
507-
508554
$this->app->singleton(EntrypointAction::class, function (Application $app) {
509555
return new EntrypointAction($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), ['jsonld' => ['application/ld+json']]);
510556
});
@@ -539,7 +585,7 @@ public function register(): void
539585
$app->make(PropertyNameCollectionFactoryInterface::class),
540586
$app->make(PropertyMetadataFactoryInterface::class),
541587
$app->make(SchemaFactoryInterface::class),
542-
$app->make(FilterLocator::class),
588+
$app->make(FilterInterface::class),
543589
$config->get('api-platform.formats'),
544590
null, // ?Options $openApiOptions = null,
545591
$app->make(PaginationOptions::class), // ?PaginationOptions $paginationOptions = null,
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 <dunglas@gmail.com>
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\Laravel\Eloquent\Extension;
15+
16+
use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\State\ParameterNotFound;
19+
use Illuminate\Database\Eloquent\Builder;
20+
use Illuminate\Database\Eloquent\Model;
21+
use Psr\Container\ContainerInterface;
22+
23+
final readonly class FilterQueryExtension implements QueryExtensionInterface
24+
{
25+
public function __construct(
26+
private ContainerInterface $filterLocator
27+
) {
28+
}
29+
30+
/**
31+
* @param Builder<Model> $builder
32+
* @param array<string, string> $uriVariables
33+
* @param array<string, mixed> $context
34+
*
35+
* @return Builder<Model>
36+
*/
37+
public function apply(Builder $builder, array $uriVariables, Operation $operation, $context = []): Builder
38+
{
39+
$context['uri_variables'] = $uriVariables;
40+
$context['operation'] = $operation;
41+
42+
foreach ($operation->getParameters() ?? [] as $parameter) {
43+
if (!($values = $parameter->getValue()) || $values instanceof ParameterNotFound) {
44+
continue;
45+
}
46+
47+
if (null === ($filterId = $parameter->getFilter())) {
48+
continue;
49+
}
50+
51+
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
52+
if ($filter instanceof FilterInterface) {
53+
$builder = $filter->apply($builder, $values, $parameter, $context);
54+
}
55+
}
56+
57+
return $builder;
58+
}
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\Eloquent\Extension;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use Illuminate\Database\Eloquent\Builder;
18+
use Illuminate\Database\Eloquent\Model;
19+
20+
interface QueryExtensionInterface
21+
{
22+
/**
23+
* @param Builder<Model> $builder
24+
* @param array<string, string> $uriVariables
25+
* @param array<string, mixed> $context
26+
*
27+
* @return Builder<Model>
28+
*/
29+
public function apply(Builder $builder, array $uriVariables, Operation $operation, $context = []): Builder;
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\Eloquent\Filter;
15+
16+
use ApiPlatform\Metadata\FilterInterface as MetadataFilterInterface;
17+
use ApiPlatform\Metadata\Parameter;
18+
use Illuminate\Database\Eloquent\Builder;
19+
use Illuminate\Database\Eloquent\Model;
20+
21+
interface FilterInterface extends MetadataFilterInterface
22+
{
23+
/**
24+
* @param Builder<Model> $builder
25+
* @param array<string, mixed> $context
26+
*
27+
* @return Builder<Model>
28+
*/
29+
public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder;
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\Eloquent\Filter;
15+
16+
use ApiPlatform\Metadata\Parameter;
17+
use Illuminate\Database\Eloquent\Builder;
18+
use Illuminate\Database\Eloquent\Model;
19+
20+
final class SearchFilter implements FilterInterface
21+
{
22+
/**
23+
* @param Builder<Model> $builder
24+
* @param array<string, mixed> $context
25+
*/
26+
public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder
27+
{
28+
return $builder->where($parameter->getProperty(), $values);
29+
}
30+
31+
public function getDescription(string $resourceClass): array
32+
{
33+
return [];
34+
}
35+
}

‎src/Laravel/Eloquent/State/CollectionProvider.php

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

1414
namespace ApiPlatform\Laravel\Eloquent\State;
1515

16+
use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface;
1617
use ApiPlatform\Laravel\Eloquent\Paginator;
1718
use ApiPlatform\Metadata\Exception\RuntimeException;
1819
use ApiPlatform\Metadata\HttpOperation;
@@ -31,11 +32,13 @@ final class CollectionProvider implements ProviderInterface
3132
use LinksHandlerLocatorTrait;
3233

3334
/**
34-
* @param LinksHandlerInterface<Model> $linksHandler
35+
* @param LinksHandlerInterface<Model> $linksHandler
36+
* @param iterable<QueryExtensionInterface> $queryExtensions
3537
*/
3638
public function __construct(
3739
private readonly Pagination $pagination,
3840
private readonly LinksHandlerInterface $linksHandler,
41+
private iterable $queryExtensions = [],
3942
?ContainerInterface $handleLinksLocator = null,
4043
) {
4144
$this->handleLinksLocator = $handleLinksLocator;
@@ -56,6 +59,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
5659
$query = $this->linksHandler->handleLinks($model->query(), $uriVariables, ['operation' => $operation, 'modelClass' => $operation->getClass()] + $context);
5760
}
5861

62+
foreach ($this->queryExtensions as $extension) {
63+
$query = $extension->apply($query, $uriVariables, $operation, $context);
64+
}
65+
5966
if (false === $this->pagination->isEnabled($operation, $context)) {
6067
return $query->get();
6168
}

‎src/Laravel/FilterLocator.php

-31
This file was deleted.

‎src/Laravel/Tests/EloquentTest.php

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\Tests;
15+
16+
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
17+
use Illuminate\Foundation\Testing\RefreshDatabase;
18+
use Orchestra\Testbench\Concerns\WithWorkbench;
19+
use Orchestra\Testbench\TestCase;
20+
21+
class EloquentTest extends TestCase
22+
{
23+
use ApiTestAssertionsTrait;
24+
use RefreshDatabase;
25+
use WithWorkbench;
26+
27+
public function testSearchFilter(): void
28+
{
29+
$response = $this->get('/api/books', ['accept' => ['application/ld+json']]);
30+
$book = $response->json()['hydra:member'][0];
31+
32+
$response = $this->get('/api/books?isbn='.$book['isbn'], ['accept' => ['application/ld+json']]);
33+
$this->assertSame($response->json()['hydra:member'][0], $book);
34+
}
35+
}

‎src/Laravel/workbench/app/Models/Book.php

+3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
namespace Workbench\App\Models;
1515

16+
use ApiPlatform\Laravel\Eloquent\Filter\SearchFilter;
1617
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\QueryParameter;
1719
use Illuminate\Database\Eloquent\Concerns\HasUlids;
1820
use Illuminate\Database\Eloquent\Factories\HasFactory;
1921
use Illuminate\Database\Eloquent\Model;
@@ -25,6 +27,7 @@
2527
paginationItemsPerPage: 5,
2628
rules: BookFormRequest::class
2729
)]
30+
#[QueryParameter(key: ':property', filter: SearchFilter::class)]
2831
class Book extends Model
2932
{
3033
use HasFactory;

‎src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

+13-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\Metadata\HttpOperation;
1818
use ApiPlatform\Metadata\Parameter;
1919
use ApiPlatform\Metadata\Parameters;
20+
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2021
use ApiPlatform\Metadata\QueryParameter;
2122
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2223
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
@@ -44,7 +45,7 @@
4445
*/
4546
final class ParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
4647
{
47-
public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null)
48+
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null)
4849
{
4950
}
5051

@@ -59,6 +60,17 @@ public function create(string $resourceClass): ResourceMetadataCollection
5960
foreach ($operations as $operationName => $operation) {
6061
$parameters = $operation->getParameters() ?? new Parameters();
6162
foreach ($parameters as $key => $parameter) {
63+
if (':property' === $key) {
64+
foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
65+
$parameter = $this->setDefaults($property, $parameter, $resourceClass);
66+
$priority = $parameter->getPriority() ?? $internalPriority--;
67+
$parameters->add($property, $parameter->withPriority($priority)->withProperty($property)->withKey($property));
68+
}
69+
70+
$parameters->remove($key, $parameter::class);
71+
continue;
72+
}
73+
6274
$key = $parameter->getKey() ?? $key;
6375
$parameter = $this->setDefaults($key, $parameter, $resourceClass);
6476
$priority = $parameter->getPriority() ?? $internalPriority--;

‎src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Metadata\FilterInterface;
1717
use ApiPlatform\Metadata\Parameters;
18+
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
1819
use ApiPlatform\Metadata\QueryParameter;
1920
use ApiPlatform\Metadata\Resource\Factory\AttributesResourceMetadataCollectionFactory;
2021
use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory;
@@ -27,6 +28,7 @@ class ParameterResourceMetadataCollectionFactoryTests extends TestCase
2728
{
2829
public function testParameterFactory(): void
2930
{
31+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
3032
$filterLocator = $this->createStub(ContainerInterface::class);
3133
$filterLocator->method('has')->willReturn(true);
3234
$filterLocator->method('get')->willReturn(new class implements FilterInterface {
@@ -48,7 +50,7 @@ public function getDescription(string $resourceClass): array
4850
];
4951
}
5052
});
51-
$parameter = new ParameterResourceMetadataCollectionFactory(new AttributesResourceMetadataCollectionFactory(), $filterLocator);
53+
$parameter = new ParameterResourceMetadataCollectionFactory($nameCollection, new AttributesResourceMetadataCollectionFactory(), $filterLocator);
5254
$operation = $parameter->create(WithParameter::class)->getOperation('collection');
5355
$this->assertInstanceOf(Parameters::class, $parameters = $operation->getParameters());
5456
$hydraParameter = $parameters->get('hydra', QueryParameter::class);

‎src/Symfony/Bundle/Resources/config/metadata/resource.xml

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
</service>
7676

7777
<service id="api_platform.metadata.resource.metadata_collection_factory.parameter" class="ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" public="false" decoration-priority="1000">
78+
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
7879
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory.parameter.inner" />
7980
<argument type="service" id="api_platform.filter_locator" />
8081
</service>

‎tests/Fixtures/TestBundle/Document/SearchFilterParameter.php

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ApiPlatform\Metadata\QueryParameter;
2020
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchFilterValueTransformer;
2121
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchTextAndDateFilter;
22+
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterOdmFilter;
2223
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
2324

2425
#[GetCollection(
@@ -47,6 +48,7 @@
4748
#[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])]
4849
#[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])]
4950
#[ApiFilter(ODMSearchTextAndDateFilter::class, alias: 'app_odm_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])]
51+
#[QueryParameter(key: ':property', filter: QueryParameterOdmFilter::class)]
5052
#[ODM\Document]
5153
class SearchFilterParameter
5254
{

‎tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\Metadata\GetCollection;
1818
use ApiPlatform\Metadata\GraphQl\QueryCollection;
1919
use ApiPlatform\Metadata\QueryParameter;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterFilter;
2021
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchFilterValueTransformer;
2122
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter;
2223
use Doctrine\ORM\Mapping as ORM;
@@ -47,6 +48,7 @@
4748
#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])]
4849
#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])]
4950
#[ApiFilter(SearchTextAndDateFilter::class, alias: 'app_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])]
51+
#[QueryParameter(key: ':property', filter: QueryParameterFilter::class)]
5052
#[ORM\Entity]
5153
class SearchFilterParameter
5254
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Filter;
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+
21+
final class QueryParameterFilter implements FilterInterface
22+
{
23+
public function getDescription(string $resourceClass): array
24+
{
25+
return [];
26+
}
27+
28+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
29+
{
30+
foreach ($context['filters'] as $prop => $value) {
31+
$queryBuilder->andWhere(\sprintf('o.%s = :%1$s', $prop))->setParameter($prop, $value);
32+
}
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\Filter;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
17+
use ApiPlatform\Metadata\Operation;
18+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
19+
20+
final class QueryParameterOdmFilter implements FilterInterface
21+
{
22+
public function getDescription(string $resourceClass): array
23+
{
24+
return [];
25+
}
26+
27+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
28+
{
29+
foreach ($context['filters'] as $prop => $value) {
30+
$aggregationBuilder->match()->field($prop)->equals($value);
31+
}
32+
}
33+
}

‎tests/Fixtures/app/config/config_doctrine.yml

+3
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,6 @@ services:
145145
parent: 'api_platform.doctrine.orm.order_filter'
146146
arguments: [ { id: 'ASC', foo: 'DESC' } ]
147147
tags: [ 'api_platform.filter' ]
148+
149+
ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterFilter:
150+
tags: [ 'api_platform.filter' ]

‎tests/Fixtures/app/config/config_mongodb.yml

+3
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,6 @@ services:
174174
parent: 'api_platform.doctrine_mongodb.odm.order_filter'
175175
arguments: [ { id: 'ASC', foo: 'DESC' } ]
176176
tags: [ 'api_platform.filter' ]
177+
178+
ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterOdmFilter:
179+
tags: [ 'api_platform.filter' ]

‎tests/Functional/Parameters/DoctrineTest.php

+12-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public function testDoctrineEntitySearchFilter(): void
4545
$this->assertEquals('bar', $a['hydra:member'][1]['foo']);
4646

4747
$this->assertArraySubset(['hydra:search' => [
48-
'hydra:template' => \sprintf('/%s{?foo,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q}', $route),
48+
'hydra:template' => \sprintf('/%s{?foo,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q,id,createdAt}', $route),
4949
'hydra:mapping' => [
5050
['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo'],
5151
],
@@ -93,6 +93,17 @@ public function testGraphQl(): void
9393
$this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']);
9494
}
9595

96+
public function testPropertyPlaceholderFilter(): void
97+
{
98+
$resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class;
99+
$this->recreateSchema([$resource]);
100+
$this->loadFixtures($resource);
101+
$route = 'search_filter_parameter';
102+
$response = self::createClient()->request('GET', $route.'?foo=baz');
103+
$a = $response->toArray();
104+
$this->assertEquals($a['hydra:member'][0]['foo'], 'baz');
105+
}
106+
96107
/**
97108
* @param class-string $resource
98109
*/

0 commit comments

Comments
 (0)
Please sign in to comment.