From 2f6f281dd7b08d3dafbc060644e4c5c183f91987 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 Aug 2024 14:55:55 +0200 Subject: [PATCH] feat(laravel): search filter --- docs/guides/doctrine-search-filter.php | 13 ++- src/Laravel/ApiPlatformProvider.php | 104 +++++++++++++----- .../Extension/FilterQueryExtension.php | 59 ++++++++++ .../Extension/QueryExtensionInterface.php | 30 +++++ .../Eloquent/Filter/FilterInterface.php | 30 +++++ src/Laravel/Eloquent/Filter/SearchFilter.php | 35 ++++++ .../Eloquent/State/CollectionProvider.php | 9 +- src/Laravel/FilterLocator.php | 31 ------ src/Laravel/Tests/EloquentTest.php | 35 ++++++ src/Laravel/workbench/app/Models/Book.php | 3 + ...meterResourceMetadataCollectionFactory.php | 14 ++- ...ResourceMetadataCollectionFactoryTests.php | 4 +- .../Resources/config/metadata/resource.xml | 1 + .../Document/SearchFilterParameter.php | 2 + .../Entity/SearchFilterParameter.php | 2 + .../Filter/QueryParameterFilter.php | 34 ++++++ .../Filter/QueryParameterOdmFilter.php | 33 ++++++ tests/Fixtures/app/config/config_doctrine.yml | 3 + tests/Fixtures/app/config/config_mongodb.yml | 3 + tests/Functional/Parameters/DoctrineTest.php | 13 ++- 20 files changed, 389 insertions(+), 69 deletions(-) create mode 100644 src/Laravel/Eloquent/Extension/FilterQueryExtension.php create mode 100644 src/Laravel/Eloquent/Extension/QueryExtensionInterface.php create mode 100644 src/Laravel/Eloquent/Filter/FilterInterface.php create mode 100644 src/Laravel/Eloquent/Filter/SearchFilter.php delete mode 100644 src/Laravel/FilterLocator.php create mode 100644 src/Laravel/Tests/EloquentTest.php create mode 100644 tests/Fixtures/TestBundle/Filter/QueryParameterFilter.php create mode 100644 tests/Fixtures/TestBundle/Filter/QueryParameterOdmFilter.php diff --git a/docs/guides/doctrine-search-filter.php b/docs/guides/doctrine-search-filter.php index d9f0cd21abb..331c494f50f 100644 --- a/docs/guides/doctrine-search-filter.php +++ b/docs/guides/doctrine-search-filter.php @@ -120,21 +120,24 @@ public function testGetDocumentation(): void $this->assertJsonContains([ 'hydra:search' => [ '@type' => 'hydra:IriTemplate', - 'hydra:template' => '/books.jsonld{?author,title}', + 'hydra:template' => '/books.jsonld{?id,title,author}', 'hydra:variableRepresentation' => 'BasicRepresentation', 'hydra:mapping' => [ [ '@type' => 'IriTemplateMapping', - 'variable' => 'author', - 'property' => 'author', - 'required' => false, + 'variable' => 'id', + 'property' => 'id', ], [ '@type' => 'IriTemplateMapping', 'variable' => 'title', 'property' => 'title', - 'required' => false, ], + [ + '@type' => 'IriTemplateMapping', + 'variable' => 'author', + 'property' => 'author', + ] ], ], ]); diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9d4c1efb673..5363a1a735a 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -40,6 +40,10 @@ use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Laravel\ApiResource\Error; use ApiPlatform\Laravel\Controller\ApiPlatformController; +use ApiPlatform\Laravel\Eloquent\Extension\FilterQueryExtension; +use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface; +use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface as EloquentFilterInterface; +use ApiPlatform\Laravel\Eloquent\Filter\SearchFilter; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentAttributePropertyMetadataFactory; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentAttributePropertyNameCollectionFactory; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory; @@ -66,6 +70,7 @@ use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\ValidateProvider; use ApiPlatform\Metadata\Exception\NotExposedHttpException; +use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\IdentifiersExtractor; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; @@ -90,6 +95,7 @@ use ApiPlatform\Metadata\Resource\Factory\LinkResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\OperationNameResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\PhpDocResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; @@ -102,14 +108,18 @@ use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; +use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\JsonEncoder; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory; +use ApiPlatform\Serializer\Parameter\SerializerFilterParameterProvider; use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; use ApiPlatform\State\CallableProvider; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\State\Processor\SerializeProcessor; @@ -117,6 +127,7 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Provider\ContentNegotiationProvider; use ApiPlatform\State\Provider\DeserializeProvider; +use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; @@ -247,38 +258,42 @@ public function register(): void // TODO: add cached metadata factories $this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, function (Application $app) use ($config) { return new EloquentResourceCollectionMetadataFactory( - new AlternateUriResourceMetadataCollectionFactory( - new FiltersResourceMetadataCollectionFactory( - new FormatsResourceMetadataCollectionFactory( - new InputOutputResourceMetadataCollectionFactory( - new PhpDocResourceMetadataCollectionFactory( - new OperationNameResourceMetadataCollectionFactory( - new LinkResourceMetadataCollectionFactory( - $app->make(LinkFactoryInterface::class), - new UriTemplateResourceMetadataCollectionFactory( + new ParameterResourceMetadataCollectionFactory( + $this->app->make(PropertyNameCollectionFactoryInterface::class), + new AlternateUriResourceMetadataCollectionFactory( + new FiltersResourceMetadataCollectionFactory( + new FormatsResourceMetadataCollectionFactory( + new InputOutputResourceMetadataCollectionFactory( + new PhpDocResourceMetadataCollectionFactory( + new OperationNameResourceMetadataCollectionFactory( + new LinkResourceMetadataCollectionFactory( $app->make(LinkFactoryInterface::class), - $app->make(PathSegmentNameGeneratorInterface::class), - new NotExposedOperationResourceMetadataCollectionFactory( + new UriTemplateResourceMetadataCollectionFactory( $app->make(LinkFactoryInterface::class), - new AttributesResourceMetadataCollectionFactory( - null, - $app->make(LoggerInterface::class), - [ - 'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/', - ], - false + $app->make(PathSegmentNameGeneratorInterface::class), + new NotExposedOperationResourceMetadataCollectionFactory( + $app->make(LinkFactoryInterface::class), + new AttributesResourceMetadataCollectionFactory( + null, + $app->make(LoggerInterface::class), + [ + 'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/', + ], + false + ) ) ) ) ) ) - ) - ), - $config->get('api-platform.formats'), - $config->get('api-platform.patch_formats'), + ), + $config->get('api-platform.formats'), + $config->get('api-platform.patch_formats'), + ) ) - ) - ), + ), + $app->make(FilterInterface::class) + ) ); }); @@ -292,6 +307,22 @@ public function register(): void $this->app->bind(OperationMetadataFactoryInterface::class, OperationMetadataFactory::class); + $this->app->tag([SearchFilter::class], EloquentFilterInterface::class); + $this->app->tag([SearchFilter::class, PropertyFilter::class], FilterInterface::class); + $this->app->singleton(FilterInterface::class, function (Application $app) { + $tagged = iterator_to_array($app->tagged(FilterInterface::class)); + + return new ServiceLocator($tagged); + }); + + $this->app->bind(FilterQueryExtension::class, function (Application $app) { + $tagged = iterator_to_array($app->tagged(EloquentFilterInterface::class)); + + return new FilterQueryExtension(new ServiceLocator($tagged)); + }); + + $this->app->tag([FilterQueryExtension::class], QueryExtensionInterface::class); + $this->app->singleton(ItemProvider::class, function (Application $app) { $tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class)); @@ -300,7 +331,7 @@ public function register(): void $this->app->singleton(CollectionProvider::class, function (Application $app) { $tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class)); - return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app), new ServiceLocator($tagged)); + return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app), $app->tagged(QueryExtensionInterface::class), new ServiceLocator($tagged)); }); $this->app->tag([ItemProvider::class, CollectionProvider::class], ProviderInterface::class); @@ -326,8 +357,24 @@ public function register(): void return new DeserializeProvider($app->make(JsonApiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class)); }); + $this->app->tag([PropertyFilter::class], SerializerFilterInterface::class); + + $this->app->singleton(SerializerFilterParameterProvider::class, function (Application $app) { + $tagged = iterator_to_array($app->tagged(SerializerFilterInterface::class)); + + return new SerializerFilterParameterProvider(new ServiceLocator($tagged)); + }); + + $this->app->tag([SerializerFilterParameterProvider::class], ParameterProviderInterface::class); + + $this->app->singleton(ParameterProvider::class, function (Application $app) { + $tagged = iterator_to_array($app->tagged(ParameterProviderInterface::class)); + + return new ParameterProvider($app->make(DeserializeProvider::class), new ServiceLocator($tagged)); + }); + $this->app->singleton(AccessCheckerProvider::class, function (Application $app) { - return new AccessCheckerProvider($app->make(DeserializeProvider::class), $app->make(ResourceAccessCheckerInterface::class)); + return new AccessCheckerProvider($app->make(ParameterProvider::class), $app->make(ResourceAccessCheckerInterface::class)); }); $this->app->singleton(ContentNegotiationProvider::class, function (Application $app) use ($config) { @@ -339,6 +386,7 @@ public function register(): void $this->app->tag([RemoveProcessor::class, PersistProcessor::class], ProcessorInterface::class); $this->app->singleton(CallableProcessor::class, function (Application $app) { $tagged = iterator_to_array($app->tagged(ProcessorInterface::class)); + // TODO: tag SwaggerUiProcessor instead? $tagged['api_platform.swagger_ui.processor'] = $app->make(SwaggerUiProcessor::class); return new CallableProcessor(new ServiceLocator($tagged)); @@ -503,8 +551,6 @@ public function register(): void 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')); }); - $this->app->singleton(FilterLocator::class, FilterLocator::class); - $this->app->singleton(EntrypointAction::class, function (Application $app) { return new EntrypointAction($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), ['jsonld' => ['application/ld+json']]); }); @@ -539,7 +585,7 @@ public function register(): void $app->make(PropertyNameCollectionFactoryInterface::class), $app->make(PropertyMetadataFactoryInterface::class), $app->make(SchemaFactoryInterface::class), - $app->make(FilterLocator::class), + $app->make(FilterInterface::class), $config->get('api-platform.formats'), null, // ?Options $openApiOptions = null, $app->make(PaginationOptions::class), // ?PaginationOptions $paginationOptions = null, diff --git a/src/Laravel/Eloquent/Extension/FilterQueryExtension.php b/src/Laravel/Eloquent/Extension/FilterQueryExtension.php new file mode 100644 index 00000000000..39b864816e5 --- /dev/null +++ b/src/Laravel/Eloquent/Extension/FilterQueryExtension.php @@ -0,0 +1,59 @@ + + * + * 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\Laravel\Eloquent\Extension; + +use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ParameterNotFound; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Psr\Container\ContainerInterface; + +final readonly class FilterQueryExtension implements QueryExtensionInterface +{ + public function __construct( + private ContainerInterface $filterLocator + ) { + } + + /** + * @param Builder $builder + * @param array $uriVariables + * @param array $context + * + * @return Builder + */ + public function apply(Builder $builder, array $uriVariables, Operation $operation, $context = []): Builder + { + $context['uri_variables'] = $uriVariables; + $context['operation'] = $operation; + + foreach ($operation->getParameters() ?? [] as $parameter) { + if (!($values = $parameter->getValue()) || $values instanceof ParameterNotFound) { + continue; + } + + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $builder = $filter->apply($builder, $values, $parameter, $context); + } + } + + return $builder; + } +} diff --git a/src/Laravel/Eloquent/Extension/QueryExtensionInterface.php b/src/Laravel/Eloquent/Extension/QueryExtensionInterface.php new file mode 100644 index 00000000000..1b36f27dbf5 --- /dev/null +++ b/src/Laravel/Eloquent/Extension/QueryExtensionInterface.php @@ -0,0 +1,30 @@ + + * + * 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\Laravel\Eloquent\Extension; + +use ApiPlatform\Metadata\Operation; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; + +interface QueryExtensionInterface +{ + /** + * @param Builder $builder + * @param array $uriVariables + * @param array $context + * + * @return Builder + */ + public function apply(Builder $builder, array $uriVariables, Operation $operation, $context = []): Builder; +} diff --git a/src/Laravel/Eloquent/Filter/FilterInterface.php b/src/Laravel/Eloquent/Filter/FilterInterface.php new file mode 100644 index 00000000000..01a753b112a --- /dev/null +++ b/src/Laravel/Eloquent/Filter/FilterInterface.php @@ -0,0 +1,30 @@ + + * + * 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\Laravel\Eloquent\Filter; + +use ApiPlatform\Metadata\FilterInterface as MetadataFilterInterface; +use ApiPlatform\Metadata\Parameter; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; + +interface FilterInterface extends MetadataFilterInterface +{ + /** + * @param Builder $builder + * @param array $context + * + * @return Builder + */ + public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder; +} diff --git a/src/Laravel/Eloquent/Filter/SearchFilter.php b/src/Laravel/Eloquent/Filter/SearchFilter.php new file mode 100644 index 00000000000..de36014618a --- /dev/null +++ b/src/Laravel/Eloquent/Filter/SearchFilter.php @@ -0,0 +1,35 @@ + + * + * 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\Laravel\Eloquent\Filter; + +use ApiPlatform\Metadata\Parameter; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; + +final class SearchFilter implements FilterInterface +{ + /** + * @param Builder $builder + * @param array $context + */ + public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder + { + return $builder->where($parameter->getProperty(), $values); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/src/Laravel/Eloquent/State/CollectionProvider.php b/src/Laravel/Eloquent/State/CollectionProvider.php index a24e71eeeb0..8b7807db3cc 100644 --- a/src/Laravel/Eloquent/State/CollectionProvider.php +++ b/src/Laravel/Eloquent/State/CollectionProvider.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Laravel\Eloquent\State; +use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface; use ApiPlatform\Laravel\Eloquent\Paginator; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; @@ -31,11 +32,13 @@ final class CollectionProvider implements ProviderInterface use LinksHandlerLocatorTrait; /** - * @param LinksHandlerInterface $linksHandler + * @param LinksHandlerInterface $linksHandler + * @param iterable $queryExtensions */ public function __construct( private readonly Pagination $pagination, private readonly LinksHandlerInterface $linksHandler, + private iterable $queryExtensions = [], ?ContainerInterface $handleLinksLocator = null, ) { $this->handleLinksLocator = $handleLinksLocator; @@ -56,6 +59,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $query = $this->linksHandler->handleLinks($model->query(), $uriVariables, ['operation' => $operation, 'modelClass' => $operation->getClass()] + $context); } + foreach ($this->queryExtensions as $extension) { + $query = $extension->apply($query, $uriVariables, $operation, $context); + } + if (false === $this->pagination->isEnabled($operation, $context)) { return $query->get(); } diff --git a/src/Laravel/FilterLocator.php b/src/Laravel/FilterLocator.php deleted file mode 100644 index f11ca239c9b..00000000000 --- a/src/Laravel/FilterLocator.php +++ /dev/null @@ -1,31 +0,0 @@ - - * - * 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\Laravel; - -use Psr\Container\ContainerInterface; - -final class FilterLocator implements ContainerInterface -{ - private $filters = []; - - public function get(string $id): mixed - { - return $this->filters[$id] ?? null; - } - - public function has(string $id): bool - { - return isset($this->filters[$id]); - } -} diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php new file mode 100644 index 00000000000..6ce4ce0162a --- /dev/null +++ b/src/Laravel/Tests/EloquentTest.php @@ -0,0 +1,35 @@ + + * + * 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\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class EloquentTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + public function testSearchFilter(): void + { + $response = $this->get('/api/books', ['accept' => ['application/ld+json']]); + $book = $response->json()['hydra:member'][0]; + + $response = $this->get('/api/books?isbn='.$book['isbn'], ['accept' => ['application/ld+json']]); + $this->assertSame($response->json()['hydra:member'][0], $book); + } +} diff --git a/src/Laravel/workbench/app/Models/Book.php b/src/Laravel/workbench/app/Models/Book.php index f3dd6b63aa1..070d94cf5b7 100644 --- a/src/Laravel/workbench/app/Models/Book.php +++ b/src/Laravel/workbench/app/Models/Book.php @@ -13,7 +13,9 @@ namespace Workbench\App\Models; +use ApiPlatform\Laravel\Eloquent\Filter\SearchFilter; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -25,6 +27,7 @@ paginationItemsPerPage: 5, rules: BookFormRequest::class )] +#[QueryParameter(key: ':property', filter: SearchFilter::class)] class Book extends Model { use HasFactory; diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index fba1119570a..8b6b2e22074 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; @@ -44,7 +45,7 @@ */ final class ParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { - public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null) + public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null) { } @@ -59,6 +60,17 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($operations as $operationName => $operation) { $parameters = $operation->getParameters() ?? new Parameters(); foreach ($parameters as $key => $parameter) { + if (':property' === $key) { + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { + $parameter = $this->setDefaults($property, $parameter, $resourceClass); + $priority = $parameter->getPriority() ?? $internalPriority--; + $parameters->add($property, $parameter->withPriority($priority)->withProperty($property)->withKey($property)); + } + + $parameters->remove($key, $parameter::class); + continue; + } + $key = $parameter->getKey() ?? $key; $parameter = $this->setDefaults($key, $parameter, $resourceClass); $priority = $parameter->getPriority() ?? $internalPriority--; diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php index 99712574328..285f1c4bf0c 100644 --- a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Resource\Factory\AttributesResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory; @@ -27,6 +28,7 @@ class ParameterResourceMetadataCollectionFactoryTests extends TestCase { public function testParameterFactory(): void { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); $filterLocator = $this->createStub(ContainerInterface::class); $filterLocator->method('has')->willReturn(true); $filterLocator->method('get')->willReturn(new class implements FilterInterface { @@ -48,7 +50,7 @@ public function getDescription(string $resourceClass): array ]; } }); - $parameter = new ParameterResourceMetadataCollectionFactory(new AttributesResourceMetadataCollectionFactory(), $filterLocator); + $parameter = new ParameterResourceMetadataCollectionFactory($nameCollection, new AttributesResourceMetadataCollectionFactory(), $filterLocator); $operation = $parameter->create(WithParameter::class)->getOperation('collection'); $this->assertInstanceOf(Parameters::class, $parameters = $operation->getParameters()); $hydraParameter = $parameters->get('hydra', QueryParameter::class); diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index a5fb6c9fdd5..03540a90714 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -75,6 +75,7 @@ + diff --git a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php index 606c79cefff..c659fab8af4 100644 --- a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchFilterValueTransformer; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchTextAndDateFilter; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterOdmFilter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[GetCollection( @@ -47,6 +48,7 @@ #[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])] #[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])] #[ApiFilter(ODMSearchTextAndDateFilter::class, alias: 'app_odm_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])] +#[QueryParameter(key: ':property', filter: QueryParameterOdmFilter::class)] #[ODM\Document] class SearchFilterParameter { diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index 133e98adcd1..a6d93e99eda 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterFilter; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchFilterValueTransformer; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter; use Doctrine\ORM\Mapping as ORM; @@ -47,6 +48,7 @@ #[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])] #[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])] #[ApiFilter(SearchTextAndDateFilter::class, alias: 'app_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])] +#[QueryParameter(key: ':property', filter: QueryParameterFilter::class)] #[ORM\Entity] class SearchFilterParameter { diff --git a/tests/Fixtures/TestBundle/Filter/QueryParameterFilter.php b/tests/Fixtures/TestBundle/Filter/QueryParameterFilter.php new file mode 100644 index 00000000000..a15bada5cc6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/QueryParameterFilter.php @@ -0,0 +1,34 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +final class QueryParameterFilter implements FilterInterface +{ + public function getDescription(string $resourceClass): array + { + return []; + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($context['filters'] as $prop => $value) { + $queryBuilder->andWhere(\sprintf('o.%s = :%1$s', $prop))->setParameter($prop, $value); + } + } +} diff --git a/tests/Fixtures/TestBundle/Filter/QueryParameterOdmFilter.php b/tests/Fixtures/TestBundle/Filter/QueryParameterOdmFilter.php new file mode 100644 index 00000000000..0182675eb73 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/QueryParameterOdmFilter.php @@ -0,0 +1,33 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +final class QueryParameterOdmFilter implements FilterInterface +{ + public function getDescription(string $resourceClass): array + { + return []; + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + foreach ($context['filters'] as $prop => $value) { + $aggregationBuilder->match()->field($prop)->equals($value); + } + } +} diff --git a/tests/Fixtures/app/config/config_doctrine.yml b/tests/Fixtures/app/config/config_doctrine.yml index 929fb6289ec..0d84c92c9b0 100644 --- a/tests/Fixtures/app/config/config_doctrine.yml +++ b/tests/Fixtures/app/config/config_doctrine.yml @@ -145,3 +145,6 @@ services: parent: 'api_platform.doctrine.orm.order_filter' arguments: [ { id: 'ASC', foo: 'DESC' } ] tags: [ 'api_platform.filter' ] + + ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterFilter: + tags: [ 'api_platform.filter' ] diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index 975a11aab8d..f948ea309b8 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -174,3 +174,6 @@ services: parent: 'api_platform.doctrine_mongodb.odm.order_filter' arguments: [ { id: 'ASC', foo: 'DESC' } ] tags: [ 'api_platform.filter' ] + + ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterOdmFilter: + tags: [ 'api_platform.filter' ] diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index 3d1b577d5bf..794edfa8566 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -45,7 +45,7 @@ public function testDoctrineEntitySearchFilter(): void $this->assertEquals('bar', $a['hydra:member'][1]['foo']); $this->assertArraySubset(['hydra:search' => [ - '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), + '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), 'hydra:mapping' => [ ['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo'], ], @@ -93,6 +93,17 @@ public function testGraphQl(): void $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']); } + public function testPropertyPlaceholderFilter(): void + { + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $route = 'search_filter_parameter'; + $response = self::createClient()->request('GET', $route.'?foo=baz'); + $a = $response->toArray(); + $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); + } + /** * @param class-string $resource */