From a3313f57782b9b3794f107b96a5c5f07c4e0cc42 Mon Sep 17 00:00:00 2001 From: Nathan Pesneau Date: Thu, 20 Nov 2025 10:34:59 +0100 Subject: [PATCH] refactor(state): replace :property placeholder with properties fixes #7478 --- .../Filter/PropertyAwareFilterInterface.php | 9 + ...opertyPlaceholderOpenApiParameterTrait.php | 12 +- .../Common/ParameterExtensionTrait.php | 63 ++++ .../Common/ParameterValueExtractorTrait.php | 5 +- .../Odm/Extension/ParameterExtension.php | 42 +-- src/Doctrine/Odm/composer.json | 2 +- .../Orm/Extension/ParameterExtension.php | 42 +-- src/Doctrine/Orm/composer.json | 2 +- src/GraphQl/Type/FieldsBuilder.php | 87 +++--- src/GraphQl/foo.diff | 289 ++++++++++++++++++ src/Hydra/State/Util/SearchHelperTrait.php | 41 +-- .../SparseFieldsetParameterProvider.php | 4 +- .../Extension/FilterQueryExtension.php | 2 +- .../JsonApi/SortFilterParameterProvider.php | 10 +- src/Laravel/Eloquent/Filter/OrderFilter.php | 18 +- .../State/ParameterValidatorProvider.php | 17 +- .../Tests/Eloquent/Filter/OrderFilterTest.php | 4 +- src/Laravel/workbench/app/Models/Slot.php | 1 + ...meterResourceMetadataCollectionFactory.php | 119 +++++--- ...rResourceMetadataCollectionFactoryTest.php | 10 +- src/State/Util/ParameterParserTrait.php | 17 ++ .../State/ParameterValidatorProvider.php | 12 +- .../Util/ParameterValidationConstraints.php | 10 - .../ValidateParameterBeforeProvider.php | 9 +- .../Document/SearchFilterParameter.php | 9 +- .../Entity/SearchFilterParameter.php | 9 +- .../ODMSearchFilterValueTransformer.php | 5 +- .../Filter/ODMSearchTextAndDateFilter.php | 5 +- .../Filter/SearchFilterValueTransformer.php | 4 +- .../Filter/SearchTextAndDateFilter.php | 4 +- .../Filter/SortComputedFieldFilter.php | 2 +- .../Functional/Doctrine/ComputedFieldTest.php | 4 +- tests/Functional/Parameters/DoctrineTest.php | 52 +++- 33 files changed, 654 insertions(+), 267 deletions(-) create mode 100644 src/Doctrine/Common/ParameterExtensionTrait.php create mode 100644 src/GraphQl/foo.diff diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php index aa0857cef20..4180765bfa3 100644 --- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -14,8 +14,12 @@ namespace ApiPlatform\Doctrine\Common\Filter; /** + * TODO: 5.x uncomment method. + * * @author Antoine Bluchet * + * @method ?array getProperties() + * * @experimental */ interface PropertyAwareFilterInterface @@ -24,4 +28,9 @@ interface PropertyAwareFilterInterface * @param string[] $properties */ public function setProperties(array $properties): void; + + // /** + // * @return string[] + // */ + // public function getProperties(): ?array; } diff --git a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php index f2f09e5758c..3acab18a05f 100644 --- a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php +++ b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php @@ -23,16 +23,6 @@ trait PropertyPlaceholderOpenApiParameterTrait */ public function getOpenApiParameters(Parameter $parameter): ?array { - if (str_contains($parameter->getKey(), ':property')) { - $parameters = []; - $key = str_replace('[:property]', '', $parameter->getKey()); - foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { - $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); - } - - return $parameters; - } - - return null; + return [new OpenApiParameter(name: $parameter->getKey(), in: 'query')]; } } diff --git a/src/Doctrine/Common/ParameterExtensionTrait.php b/src/Doctrine/Common/ParameterExtensionTrait.php new file mode 100644 index 00000000000..4d865a8730f --- /dev/null +++ b/src/Doctrine/Common/ParameterExtensionTrait.php @@ -0,0 +1,63 @@ + + * + * 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\Doctrine\Common; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Metadata\Parameter; +use Doctrine\Persistence\ManagerRegistry; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +trait ParameterExtensionTrait +{ + use ParameterValueExtractorTrait; + + protected ContainerInterface $filterLocator; + protected ?ManagerRegistry $managerRegistry = null; + protected ?LoggerInterface $logger = null; + + /** + * @param object $filter the filter instance to configure + * @param Parameter $parameter the operation parameter associated with the filter + */ + private function configureFilter(object $filter, Parameter $parameter): void + { + if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $filter->setManagerRegistry($this->managerRegistry); + } + + if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { + $filter->setLogger($this->logger); + } + + if ($filter instanceof PropertyAwareFilterInterface) { + $properties = []; + // Check if the filter has getProperties method (e.g., if it's an AbstractFilter) + if (method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check @see interface + $properties = $filter->getProperties() ?? []; + } + + $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); + foreach ($parameter->getProperties() ?? [$propertyKey] as $property) { + if (!isset($properties[$property])) { + $properties[$property] = $parameter->getFilterContext(); + } + } + + $filter->setProperties($properties); + } + } +} diff --git a/src/Doctrine/Common/ParameterValueExtractorTrait.php b/src/Doctrine/Common/ParameterValueExtractorTrait.php index b50dede8f76..62c18f6483c 100644 --- a/src/Doctrine/Common/ParameterValueExtractorTrait.php +++ b/src/Doctrine/Common/ParameterValueExtractorTrait.php @@ -23,10 +23,7 @@ trait ParameterValueExtractorTrait private function extractParameterValue(Parameter $parameter, mixed $value): array { $key = $parameter->getProperty() ?? $parameter->getKey(); - if (!str_contains($key, ':property')) { - return [$key => $value]; - } - return [str_replace('[:property]', '', $key) => $value]; + return [$key => $value]; } } diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index d68e6e9ed3b..d841fb9240e 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -13,11 +13,9 @@ namespace ApiPlatform\Doctrine\Odm\Extension; -use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; -use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; -use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; -use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; -use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; // Explicitly import PropertyAwareFilterInterface use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; @@ -32,13 +30,16 @@ */ final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface { - use ParameterValueExtractorTrait; + use ParameterExtensionTrait; public function __construct( - private readonly ContainerInterface $filterLocator, - private readonly ?ManagerRegistry $managerRegistry = null, - private readonly ?LoggerInterface $logger = null, + ContainerInterface $filterLocator, + ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, ) { + $this->filterLocator = $filterLocator; + $this->managerRegistry = $managerRegistry; + $this->logger = $logger; } /** @@ -66,28 +67,7 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass continue; } - if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { - $filter->setManagerRegistry($this->managerRegistry); - } - - if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { - $filter->setLogger($this->logger); - } - - if ($filter instanceof AbstractFilter && !$filter->getProperties()) { - $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); - - if (str_contains($propertyKey, ':property')) { - $extraProperties = $parameter->getExtraProperties()['_properties'] ?? []; - foreach (array_keys($extraProperties) as $property) { - $properties[$property] = $parameter->getFilterContext(); - } - } else { - $properties = [$propertyKey => $parameter->getFilterContext()]; - } - - $filter->setProperties($properties ?? []); - } + $this->configureFilter($filter, $parameter); $context['filters'] = $values; $context['parameter'] = $parameter; diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index cd1d2d93700..547dfc8f4e3 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2", + "api-platform/doctrine-common": "^4.2.9", "api-platform/metadata": "^4.2", "api-platform/state": "^4.2.4", "doctrine/mongodb-odm": "^2.10", diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index b7c8d8938b9..c21c1018ce9 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -13,11 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Extension; -use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; -use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; -use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; -use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; -use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; @@ -34,13 +30,16 @@ */ final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface { - use ParameterValueExtractorTrait; + use ParameterExtensionTrait; public function __construct( - private readonly ContainerInterface $filterLocator, - private readonly ?ManagerRegistry $managerRegistry = null, - private readonly ?LoggerInterface $logger = null, + ContainerInterface $filterLocator, + ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, ) { + $this->filterLocator = $filterLocator; + $this->managerRegistry = $managerRegistry; + $this->logger = $logger; } /** @@ -68,30 +67,7 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } - if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { - $filter->setManagerRegistry($this->managerRegistry); - } - - if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { - $filter->setLogger($this->logger); - } - - if ($filter instanceof PropertyAwareFilterInterface) { - $properties = []; - $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); - if ($filter instanceof AbstractFilter) { - $properties = $filter->getProperties() ?? []; - - if (str_contains($propertyKey, ':property')) { - $extraProperties = $parameter->getExtraProperties()['_properties'] ?? []; - foreach (array_keys($extraProperties) as $property) { - $properties[$property] = $parameter->getFilterContext(); - } - } - } - - $filter->setProperties($properties + [$propertyKey => $parameter->getFilterContext()]); - } + $this->configureFilter($filter, $parameter); $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 5a75a390a5a..8952b55530c 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2.0-alpha.3@alpha", + "api-platform/doctrine-common": "^4.2.9", "api-platform/metadata": "^4.2", "api-platform/state": "^4.2.4", "doctrine/orm": "^2.17 || ^3.0" diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 84da1ce9765..a07ae3ebdea 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -22,7 +22,6 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\InflectorInterface; -use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -356,6 +355,8 @@ private function parameterToObjectType(array $flattenFields, string $name): Inpu if (isset($fields[$key])) { if ($type instanceof ListOfType) { $key .= '_list'; + } elseif ($fields[$key]['type'] instanceof InputObjectType && !$type instanceof InputObjectType) { + continue; } } @@ -497,56 +498,64 @@ private function getResourceFieldConfiguration(?string $property, ?string $field */ private function getParameterArgs(Operation $operation, array $args = []): array { + $groups = []; + foreach ($operation->getParameters() ?? [] as $parameter) { $key = $parameter->getKey(); - if (!str_contains($key, ':property')) { - $args[$key] = ['type' => GraphQLType::string()]; - - if ($parameter->getRequired()) { - $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); + if (str_contains($key, '[')) { + $key = str_replace('.', $this->nestingSeparator, $key); + parse_str($key, $values); + $rootKey = key($values); + + $leafs = $values[$rootKey]; + $name = key($leafs); + + $filterLeafs = []; + if (($filterId = $parameter->getFilter()) && $this->filterLocator->has($filterId)) { + $filter = $this->filterLocator->get($filterId); + + if ($filter instanceof FilterInterface) { + $property = $parameter->getProperty() ?? $name; + $property = str_replace('.', $this->nestingSeparator, $property); + $description = $filter->getDescription($operation->getClass()); + + foreach ($description as $descKey => $descValue) { + $descKey = str_replace('.', $this->nestingSeparator, $descKey); + parse_str($descKey, $descValues); + if (isset($descValues[$property]) && \is_array($descValues[$property])) { + $filterLeafs = array_merge($filterLeafs, $descValues[$property]); + } + } + } } - continue; - } + if ($filterLeafs) { + $leafs[$name] = $filterLeafs; + } - if (!($filterId = $parameter->getFilter()) || !$this->filterLocator->has($filterId)) { + $groups[$rootKey][] = [ + 'name' => $name, + 'leafs' => $leafs[$name], + 'required' => $parameter->getRequired(), + 'description' => $parameter->getDescription(), + 'type' => 'string', + ]; continue; } - $filter = $this->filterLocator->get($filterId); - $parsedKey = explode('[:property]', $key); - $flattenFields = []; + $args[$key] = ['type' => GraphQLType::string()]; - if ($filter instanceof FilterInterface) { - foreach ($filter->getDescription($operation->getClass()) as $name => $value) { - $values = []; - parse_str($name, $values); - if (isset($values[$parsedKey[0]])) { - $values = $values[$parsedKey[0]]; - } - - $name = key($values); - $flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string']; - } - - $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]); + if ($parameter->getRequired()) { + $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); } + } - if ($filter instanceof OpenApiParameterFilterInterface) { - foreach ($filter->getOpenApiParameters($parameter) as $value) { - $values = []; - parse_str($value->getName(), $values); - if (isset($values[$parsedKey[0]])) { - $values = $values[$parsedKey[0]]; - } - - $name = key($values); - $flattenFields[] = ['name' => $name, 'required' => $value->getRequired(), 'description' => $value->getDescription(), 'leafs' => $values[$name], 'type' => $value->getSchema()['type'] ?? 'string']; - } - - $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0].$operation->getShortName().$operation->getName()); - } + foreach ($groups as $key => $flattenFields) { + $name = $key.$operation->getShortName().$operation->getName(); + $inputObject = $this->parameterToObjectType($flattenFields, $name); + $this->typesContainer->set($name, $inputObject); + $args[$key] = $inputObject; } return $args; diff --git a/src/GraphQl/foo.diff b/src/GraphQl/foo.diff new file mode 100644 index 00000000000..37938da2912 --- /dev/null +++ b/src/GraphQl/foo.diff @@ -0,0 +1,289 @@ +diff --git a/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php b/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php +new file mode 100644 +index 00000000000..d1974a32c55 +--- /dev/null ++++ b/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php +@@ -0,0 +1,31 @@ ++ ++ * ++ * 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\Doctrine\Common\Filter; ++ ++use ApiPlatform\Metadata\Operation; ++ ++/** ++ * Interface for filters that can be applied by a ParameterExtension. ++ * ++ * @author Antoine Bluchet ++ */ ++interface ParameterAwareFilterInterface ++{ ++ /** ++ * Applies the filter to the query. ++ * ++ * @param array $context ++ */ ++ public function apply(object $queryBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; ++} +diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +index aa0857cef20..1b041f480fb 100644 +--- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php ++++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +@@ -16,6 +16,8 @@ + /** + * @author Antoine Bluchet + * ++ * @method ?array getProperties() ++ * + * @experimental + */ + interface PropertyAwareFilterInterface +@@ -24,4 +26,9 @@ interface PropertyAwareFilterInterface + * @param string[] $properties + */ + public function setProperties(array $properties): void; ++ ++ // /** ++ // * @return string[] ++ // */ ++ // public function getProperties(): ?array; + } +diff --git a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php +index f2f09e5758c..3acab18a05f 100644 +--- a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php ++++ b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php +@@ -23,16 +23,6 @@ trait PropertyPlaceholderOpenApiParameterTrait + */ + public function getOpenApiParameters(Parameter $parameter): ?array + { +- if (str_contains($parameter->getKey(), ':property')) { +- $parameters = []; +- $key = str_replace('[:property]', '', $parameter->getKey()); +- foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { +- $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); +- } +- +- return $parameters; +- } +- +- return null; ++ return [new OpenApiParameter(name: $parameter->getKey(), in: 'query')]; + } + } +diff --git a/src/Doctrine/Common/ParameterExtensionTrait.php b/src/Doctrine/Common/ParameterExtensionTrait.php +new file mode 100644 +index 00000000000..4d865a8730f +--- /dev/null ++++ b/src/Doctrine/Common/ParameterExtensionTrait.php +@@ -0,0 +1,63 @@ ++ ++ * ++ * 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\Doctrine\Common; ++ ++use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; ++use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; ++use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; ++use ApiPlatform\Metadata\Parameter; ++use Doctrine\Persistence\ManagerRegistry; ++use Psr\Container\ContainerInterface; ++use Psr\Log\LoggerInterface; ++ ++trait ParameterExtensionTrait ++{ ++ use ParameterValueExtractorTrait; ++ ++ protected ContainerInterface $filterLocator; ++ protected ?ManagerRegistry $managerRegistry = null; ++ protected ?LoggerInterface $logger = null; ++ ++ /** ++ * @param object $filter the filter instance to configure ++ * @param Parameter $parameter the operation parameter associated with the filter ++ */ ++ private function configureFilter(object $filter, Parameter $parameter): void ++ { ++ if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { ++ $filter->setManagerRegistry($this->managerRegistry); ++ } ++ ++ if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { ++ $filter->setLogger($this->logger); ++ } ++ ++ if ($filter instanceof PropertyAwareFilterInterface) { ++ $properties = []; ++ // Check if the filter has getProperties method (e.g., if it's an AbstractFilter) ++ if (method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check @see interface ++ $properties = $filter->getProperties() ?? []; ++ } ++ ++ $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); ++ foreach ($parameter->getProperties() ?? [$propertyKey] as $property) { ++ if (!isset($properties[$property])) { ++ $properties[$property] = $parameter->getFilterContext(); ++ } ++ } ++ ++ $filter->setProperties($properties); ++ } ++ } ++} +diff --git a/src/Doctrine/Common/ParameterValueExtractorTrait.php b/src/Doctrine/Common/ParameterValueExtractorTrait.php +index b50dede8f76..62c18f6483c 100644 +--- a/src/Doctrine/Common/ParameterValueExtractorTrait.php ++++ b/src/Doctrine/Common/ParameterValueExtractorTrait.php +@@ -23,10 +23,7 @@ trait ParameterValueExtractorTrait + private function extractParameterValue(Parameter $parameter, mixed $value): array + { + $key = $parameter->getProperty() ?? $parameter->getKey(); +- if (!str_contains($key, ':property')) { +- return [$key => $value]; +- } + +- return [str_replace('[:property]', '', $key) => $value]; ++ return [$key => $value]; + } + } +diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php +index d68e6e9ed3b..d841fb9240e 100644 +--- a/src/Doctrine/Odm/Extension/ParameterExtension.php ++++ b/src/Doctrine/Odm/Extension/ParameterExtension.php +@@ -13,11 +13,9 @@ + + namespace ApiPlatform\Doctrine\Odm\Extension; + +-use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +-use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +-use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +-use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; +-use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; ++use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; ++use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; ++use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; // Explicitly import PropertyAwareFilterInterface + use ApiPlatform\Metadata\Operation; + use ApiPlatform\State\ParameterNotFound; + use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; +@@ -32,13 +30,16 @@ + */ + final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface + { +- use ParameterValueExtractorTrait; ++ use ParameterExtensionTrait; + + public function __construct( +- private readonly ContainerInterface $filterLocator, +- private readonly ?ManagerRegistry $managerRegistry = null, +- private readonly ?LoggerInterface $logger = null, ++ ContainerInterface $filterLocator, ++ ?ManagerRegistry $managerRegistry = null, ++ ?LoggerInterface $logger = null, + ) { ++ $this->filterLocator = $filterLocator; ++ $this->managerRegistry = $managerRegistry; ++ $this->logger = $logger; + } + + /** +@@ -66,28 +67,7 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass + continue; + } + +- if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { +- $filter->setManagerRegistry($this->managerRegistry); +- } +- +- if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { +- $filter->setLogger($this->logger); +- } +- +- if ($filter instanceof AbstractFilter && !$filter->getProperties()) { +- $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); +- +- if (str_contains($propertyKey, ':property')) { +- $extraProperties = $parameter->getExtraProperties()['_properties'] ?? []; +- foreach (array_keys($extraProperties) as $property) { +- $properties[$property] = $parameter->getFilterContext(); +- } +- } else { +- $properties = [$propertyKey => $parameter->getFilterContext()]; +- } +- +- $filter->setProperties($properties ?? []); +- } ++ $this->configureFilter($filter, $parameter); + + $context['filters'] = $values; + $context['parameter'] = $parameter; +diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php +index b7c8d8938b9..0264567a602 100644 +--- a/src/Doctrine/Orm/Extension/ParameterExtension.php ++++ b/src/Doctrine/Orm/Extension/ParameterExtension.php +@@ -13,11 +13,8 @@ + + namespace ApiPlatform\Doctrine\Orm\Extension; + +-use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +-use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; + use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +-use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +-use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; ++use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; // Explicitly import PropertyAwareFilterInterface + use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; + use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; + use ApiPlatform\Metadata\Operation; +@@ -34,13 +31,16 @@ + */ + final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface + { +- use ParameterValueExtractorTrait; ++ use ParameterExtensionTrait; + + public function __construct( +- private readonly ContainerInterface $filterLocator, +- private readonly ?ManagerRegistry $managerRegistry = null, +- private readonly ?LoggerInterface $logger = null, ++ ContainerInterface $filterLocator, ++ ?ManagerRegistry $managerRegistry = null, ++ ?LoggerInterface $logger = null, + ) { ++ $this->filterLocator = $filterLocator; ++ $this->managerRegistry = $managerRegistry; ++ $this->logger = $logger; + } + + /** +@@ -68,30 +68,7 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter + continue; + } + +- if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { +- $filter->setManagerRegistry($this->managerRegistry); +- } +- +- if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { +- $filter->setLogger($this->logger); +- } +- +- if ($filter instanceof PropertyAwareFilterInterface) { +- $properties = []; diff --git a/src/Hydra/State/Util/SearchHelperTrait.php b/src/Hydra/State/Util/SearchHelperTrait.php index 30c203f5621..9f87c2eddfa 100644 --- a/src/Hydra/State/Util/SearchHelperTrait.php +++ b/src/Hydra/State/Util/SearchHelperTrait.php @@ -55,18 +55,17 @@ private function getSearchMappingAndKeys(?Operation $operation = null, ?string $ } } - $params = $operation ? ($operation->getParameters() ?? []) : ($parameters ?? []); - - foreach ($params as $key => $parameter) { + foreach ($parameters ?? $operation?->getParameters() ?? [] as $key => $parameter) { if (!$parameter instanceof QueryParameterInterface || false === $parameter->getHydra()) { continue; } + // get possible mapping via the parameter's Filter if ($getFilter && ($filterId = $parameter->getFilter()) && \is_string($filterId) && ($filter = $getFilter($filterId))) { $filterDescription = $filter->getDescription($resourceClass); foreach ($filterDescription as $variable => $description) { - // // This is a practice induced by PHP and is not necessary when implementing URI template + // This is a practice induced by PHP and is not necessary when implementing URI if (str_ends_with((string) $variable, '[]')) { continue; } @@ -75,18 +74,23 @@ private function getSearchMappingAndKeys(?Operation $operation = null, ?string $ continue; } + // Ensure the filter description matches the property defined in the QueryParameter if (($prop = $parameter->getProperty()) && $descriptionProperty !== $prop) { continue; } - $k = str_replace(':property', $description['property'], $key); - $variable = str_replace($description['property'], $k, $variable); - $keys[] = $variable; - $m = new IriTemplateMapping(variable: $variable, property: $description['property'], required: $description['required']); - if (null !== ($required = $parameter->getRequired())) { - $m->required = $required; + $variableName = $variable; + + if ($prop && str_starts_with($variable, $prop)) { + $variableName = substr_replace($variable, $key, 0, \strlen($prop)); } - $mapping[] = $m; + + $keys[] = $variableName; + $mapping[] = new IriTemplateMapping( + variable: $variableName, + property: $descriptionProperty, + required: $parameter->getRequired() ?? $description['required'] ?? false + ); } if ($filterDescription) { @@ -94,21 +98,6 @@ private function getSearchMappingAndKeys(?Operation $operation = null, ?string $ } } - if (str_contains($key, ':property') && $parameter->getProperties()) { - $required = $parameter->getRequired(); - foreach ($parameter->getProperties() as $prop) { - $k = str_replace(':property', $prop, $key); - $m = new IriTemplateMapping(variable: $k, property: $prop); - $keys[] = $k; - if (null !== $required) { - $m->required = $required; - } - $mapping[] = $m; - } - - continue; - } - if (!($property = $parameter->getProperty())) { continue; } diff --git a/src/JsonApi/Filter/SparseFieldsetParameterProvider.php b/src/JsonApi/Filter/SparseFieldsetParameterProvider.php index 7ded8e9e765..1d4208f532f 100644 --- a/src/JsonApi/Filter/SparseFieldsetParameterProvider.php +++ b/src/JsonApi/Filter/SparseFieldsetParameterProvider.php @@ -26,7 +26,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con return null; } - $allowedProperties = $parameter->getExtraProperties()['_properties'] ?? []; + $allowedProperties = $parameter->getProperties() ?? []; $value = $parameter->getValue(); $normalizationContext = $operation->getNormalizationContext(); @@ -45,7 +45,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con } foreach (explode(',', $fields) as $f) { - if (\array_key_exists($f, $allowedProperties)) { + if (\in_array($f, $allowedProperties, true)) { $p[] = $f; } } diff --git a/src/Laravel/Eloquent/Extension/FilterQueryExtension.php b/src/Laravel/Eloquent/Extension/FilterQueryExtension.php index 40929ead511..c28ceaaaf4c 100644 --- a/src/Laravel/Eloquent/Extension/FilterQueryExtension.php +++ b/src/Laravel/Eloquent/Extension/FilterQueryExtension.php @@ -60,7 +60,7 @@ public function apply(Builder $builder, array $uriVariables, Operation $operatio $filter = $filterId instanceof FilterInterface ? $filterId : ($this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null); if ($filter instanceof FilterInterface) { - $builder = $filter->apply($builder, $values, $parameter->withKey($parameter->getExtraProperties()['_query_property'] ?? $parameter->getKey()), $context + ($parameter->getFilterContext() ?? [])); + $builder = $filter->apply($builder, $values, $parameter, $context + ($parameter->getFilterContext() ?? [])); } } diff --git a/src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php b/src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php index afa2e10674f..e036fe5dc25 100644 --- a/src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php +++ b/src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php @@ -26,7 +26,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con } $parameters = $operation->getParameters(); - $properties = $parameter->getExtraProperties()['_properties'] ?? []; + $properties = $parameter->getProperties() ?? []; $value = $parameter->getValue(); // most eloquent filters work with only a single value @@ -47,14 +47,12 @@ public function provide(Parameter $parameter, array $parameters = [], array $con $v = substr($v, 1); } - if (\array_key_exists($v, $properties)) { - $orderBy[$properties[$v]] = $dir; + if (\in_array($v, $properties, true)) { + $orderBy[$v] = $dir; } } - $parameters->add($parameter->getKey(), $parameter->withExtraProperties( - ['_api_values' => $orderBy] + $parameter->getExtraProperties() - )); + $parameters->add($parameter->getKey(), $parameter->setValue($orderBy)); return $operation->withParameters($parameters); } diff --git a/src/Laravel/Eloquent/Filter/OrderFilter.php b/src/Laravel/Eloquent/Filter/OrderFilter.php index 233356d48bb..937452b7f6f 100644 --- a/src/Laravel/Eloquent/Filter/OrderFilter.php +++ b/src/Laravel/Eloquent/Filter/OrderFilter.php @@ -31,7 +31,7 @@ final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, O public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder { if (!\is_string($values)) { - $properties = $parameter->getExtraProperties()['_properties'] ?? []; + $properties = $parameter->getProperties() ?? []; foreach ($values as $key => $value) { if (!isset($properties[$key])) { @@ -55,20 +55,10 @@ public function getSchema(Parameter $parameter): array } /** - * @return OpenApiParameter[]|null + * @return OpenApiParameter[] */ - public function getOpenApiParameters(Parameter $parameter): ?array + public function getOpenApiParameters(Parameter $parameter): array { - if (str_contains($parameter->getKey(), ':property')) { - $parameters = []; - $key = str_replace('[:property]', '', $parameter->getKey()); - foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { - $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); - } - - return $parameters; - } - - return null; + return [new OpenApiParameter(name: $parameter->getKey(), in: 'query')]; } } diff --git a/src/Laravel/State/ParameterValidatorProvider.php b/src/Laravel/State/ParameterValidatorProvider.php index d3f4fab3d01..72276824602 100644 --- a/src/Laravel/State/ParameterValidatorProvider.php +++ b/src/Laravel/State/ParameterValidatorProvider.php @@ -17,13 +17,12 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ProviderInterface; -use ApiPlatform\State\Util\ParameterParserTrait; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Request; /** - * Validates parameters using the Symfony validator. + * Validates parameters using the Laravel validator. * * @implements ProviderInterface * @@ -31,7 +30,6 @@ */ final class ParameterValidatorProvider implements ProviderInterface { - use ParameterParserTrait; use ValidationErrorTrait; /** @@ -54,6 +52,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $allConstraints = []; + foreach ($operation->getParameters() ?? [] as $parameter) { if (!$constraints = $parameter->getConstraints()) { continue; @@ -69,17 +68,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $value = null; } - // Basically renames our key from order[:property] to order.* to assign the rule properly (see https://laravel.com/docs/11.x/validation#rule-in) - if (str_contains($key, '[:property]')) { - $k = str_replace('[:property]', '', $key); - $allConstraints[$k.'.*'] = $constraints; - continue; - } + // Laravel Validator requires dot notation for nested rules (e.g., "sort.isActive"), + // not nested arrays. We convert HTTP bracket syntax "sort[isActive]" to "sort.isActive". + $ruleKey = str_replace(['[', ']'], ['.', ''], $key); - $allConstraints[$key] = $constraints; + $allConstraints[$ruleKey] = $constraints; } $validator = Validator::make($request->query->all(), $allConstraints); + if ($validator->fails()) { throw $this->getValidationError($validator, new ValidationException($validator)); } diff --git a/src/Laravel/Tests/Eloquent/Filter/OrderFilterTest.php b/src/Laravel/Tests/Eloquent/Filter/OrderFilterTest.php index ce4f1b6f333..348b7ead117 100644 --- a/src/Laravel/Tests/Eloquent/Filter/OrderFilterTest.php +++ b/src/Laravel/Tests/Eloquent/Filter/OrderFilterTest.php @@ -34,10 +34,10 @@ public function testQueryParameterWithCamelCaseProperty(): void DB::enableQueryLog(); $response = $this->get('/api/active_books?sort[isActive]=asc', ['Accept' => ['application/ld+json']]); $response->assertStatus(200); - $this->assertEquals(\DB::getQueryLog()[1]['query'], 'select * from "active_books" order by "isActive" asc limit 30 offset 0'); + $this->assertEquals(\DB::getQueryLog()[1]['query'], 'select * from "active_books" order by "is_active" asc limit 30 offset 0'); DB::flushQueryLog(); $response = $this->get('/api/active_books?sort[isActive]=desc', ['Accept' => ['application/ld+json']]); $response->assertStatus(200); - $this->assertEquals(DB::getQueryLog()[1]['query'], 'select * from "active_books" order by "isActive" desc limit 30 offset 0'); + $this->assertEquals(DB::getQueryLog()[1]['query'], 'select * from "active_books" order by "is_active" desc limit 30 offset 0'); } } diff --git a/src/Laravel/workbench/app/Models/Slot.php b/src/Laravel/workbench/app/Models/Slot.php index dfd4bc78e82..2f0a27d0d36 100644 --- a/src/Laravel/workbench/app/Models/Slot.php +++ b/src/Laravel/workbench/app/Models/Slot.php @@ -36,6 +36,7 @@ new Patch(), new Delete(), ], + graphQlOperations: [] )] class Slot extends Model { diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 53f60d411a0..b89106eb7c3 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\FilterInterface; @@ -43,7 +44,7 @@ final class ParameterResourceMetadataCollectionFactory implements ResourceMetada { use StateOptionsTrait; - private array $localPropertyCache; + private array $localPropertyCache = []; public function __construct( private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -95,7 +96,7 @@ public function create(string $resourceClass): ResourceMetadataCollection */ private function getProperties(string $resourceClass, ?Parameter $parameter = null): array { - $k = $resourceClass.($parameter?->getProperties() ? ($parameter->getKey() ?? '') : ''); + $k = $resourceClass.($parameter?->getProperties() ? ($parameter->getKey() ?? '') : '').(\is_string($parameter->getFilter()) ? $parameter->getFilter() : ''); if (isset($this->localPropertyCache[$k])) { return $this->localPropertyCache[$k]; } @@ -110,6 +111,22 @@ private function getProperties(string $resourceClass, ?Parameter $parameter = nu } } + if (($filter = $this->getFilterInstance($parameter->getFilter())) && $filter instanceof PropertyAwareFilterInterface) { + if (!method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check + trigger_deprecation('api-platform/core', 'In API Platform 5.0 "%s" will implement a method named "getProperties"', PropertyAwareFilterInterface::class); + $refl = new \ReflectionClass($filter); + $filterProperties = $refl->hasProperty('properties') ? $refl->getProperty('properties')->getValue($filter) : []; + } else { + $filterProperties = array_keys($filter->getProperties() ?? []); + } + + foreach ($filterProperties as $prop) { + if (!\in_array($prop, $propertyNames, true)) { + $propertyNames[] = $this->nameConverter?->denormalize($prop) ?? $prop; + } + } + } + $this->localPropertyCache[$k] = ['propertyNames' => $propertyNames, 'properties' => $properties]; return $this->localPropertyCache[$k]; @@ -119,40 +136,49 @@ private function getDefaultParameters(Operation $operation, string $resourceClas { $propertyNames = $properties = []; $parameters = $operation->getParameters() ?? new Parameters(); + + // First loop we look for the :property placeholder and replace its key foreach ($parameters as $key => $parameter) { - if (!$parameter->getKey()) { - $parameter = $parameter->withKey($key); + if (!str_contains($key, ':property')) { + continue; } ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter); - if (null === $parameter->getProvider() && (($f = $parameter->getFilter()) && $f instanceof ParameterProviderFilterInterface)) { - $parameters->add($key, $parameter->withProvider($f->getParameterProvider())); + $parameter = $parameter->withProperties($propertyNames); + + foreach ($propertyNames as $property) { + $converted = $this->nameConverter?->denormalize($property) ?? $property; + $finalKey = str_replace(':property', $converted, $key); + $parameters->add( + $finalKey, + $parameter->withProperty($converted)->withKey($finalKey) + ); } - if (':property' === $key) { - foreach ($propertyNames as $property) { - $converted = $this->nameConverter?->denormalize($property) ?? $property; - $propertyParameter = $this->setDefaults($converted, $parameter, $resourceClass, $properties, $operation); - $priority = $propertyParameter->getPriority() ?? $internalPriority--; - $parameters->add($converted, $propertyParameter->withPriority($priority)->withKey($converted)); - } + $parameters->remove($key, $parameter::class); + } - $parameters->remove($key, $parameter::class); - continue; + foreach ($parameters as $key => $parameter) { + if (!$parameter->getKey()) { + $parameter = $parameter->withKey($key); + } + + $filter = $this->getFilterInstance($parameter->getFilter()); + + // The filter has a parameter provider + if (null === $parameter->getProvider() && (($f = $parameter->getFilter()) && $f instanceof ParameterProviderFilterInterface)) { + $parameter = $parameter->withProvider($f->getParameterProvider()); } $key = $parameter->getKey() ?? $key; - if (str_contains($key, ':property') || ((($f = $parameter->getFilter()) && is_a($f, PropertiesAwareInterface::class, true)) || $parameter instanceof PropertiesAwareInterface)) { - $p = []; - foreach ($propertyNames as $prop) { - $p[$this->nameConverter?->denormalize($prop) ?? $prop] = $prop; - } + ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter); - $parameter = $parameter->withExtraProperties($parameter->getExtraProperties() + ['_properties' => $p]); + if ($filter instanceof PropertiesAwareInterface) { + $parameter = $parameter->withProperties($propertyNames); } - $parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties, $operation); + $parameter = $this->setDefaults($key, $parameter, $filter, $properties, $operation); // We don't do any type cast yet, a query parameter or an header is always a string or a list of strings if (null === $parameter->getNativeType()) { // this forces the type to be only a list @@ -192,24 +218,14 @@ private function getDefaultParameters(Operation $operation, string $resourceClas private function addFilterMetadata(Parameter $parameter): Parameter { - if (!($filterId = $parameter->getFilter())) { - return $parameter; - } - - if (!\is_object($filterId) && !$this->filterLocator->has($filterId)) { + if (!$filter = $this->getFilterInstance($parameter->getFilter())) { return $parameter; } - $filter = \is_object($filterId) ? $filterId : $this->filterLocator->get($filterId); - if ($filter instanceof ParameterProviderFilterInterface) { $parameter = $parameter->withProvider($filter::getParameterProvider()); } - if (!$filter) { - return $parameter; - } - if (null === $parameter->getSchema() && $filter instanceof JsonSchemaFilterInterface && $schema = $filter->getSchema($parameter)) { $parameter = $parameter->withSchema($schema); } @@ -224,36 +240,27 @@ private function addFilterMetadata(Parameter $parameter): Parameter /** * @param array $properties */ - private function setDefaults(string $key, Parameter $parameter, string $resourceClass, array $properties, Operation $operation): Parameter + private function setDefaults(string $key, Parameter $parameter, ?object $filter, array $properties, Operation $operation): Parameter { if (null === $parameter->getKey()) { $parameter = $parameter->withKey($key); } - $filter = $parameter->getFilter(); - if (\is_string($filter) && $this->filterLocator->has($filter)) { - $filter = $this->filterLocator->get($filter); - } - if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) { $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); } + $currentKey = $key; if (null === $parameter->getProperty() && isset($properties[$key])) { $parameter = $parameter->withProperty($key); } - if (null === $parameter->getProperty() && $this->nameConverter && ($nameConvertedKey = $this->nameConverter->normalize($key)) && isset($properties[$nameConvertedKey])) { - $parameter = $parameter->withProperty($key)->withExtraProperties(['_query_property' => $nameConvertedKey] + $parameter->getExtraProperties()); - $currentKey = $nameConvertedKey; - } - if ($this->nameConverter && $property = $parameter->getProperty()) { $parameter = $parameter->withProperty($this->nameConverter->normalize($property)); } if (isset($properties[$currentKey]) && ($eloquentRelation = ($properties[$currentKey]->getExtraProperties()['eloquent_relation'] ?? null)) && isset($eloquentRelation['foreign_key'])) { - $parameter = $parameter->withExtraProperties(['_query_property' => $eloquentRelation['foreign_key']] + $parameter->getExtraProperties()); + $parameter = $parameter->withProperty($eloquentRelation['foreign_key']); } $parameter = $this->addFilterMetadata($parameter); @@ -293,4 +300,26 @@ private function getLegacyFilterMetadata(Parameter $parameter, Operation $operat return $parameter; } + + /** + * TODO: 5.x use FilterInterface on Laravel eloquent filters. + * + * @return FilterInterface|object + */ + private function getFilterInstance(object|string|null $filter): ?object + { + if (!$filter) { + return null; + } + + if (\is_object($filter)) { + return $filter; + } + + if (!$this->filterLocator->has($filter)) { + return null; + } + + return $this->filterLocator->get($filter); + } } diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php index 95bb8f3cd6c..cd0c73c5693 100644 --- a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php @@ -35,7 +35,10 @@ public function testParameterFactory(): void $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'hydra', 'everywhere'])); $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); - $propertyMetadata->method('create')->willReturnOnConsecutiveCalls(new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true)); + $propertyMetadata->method('create')->willReturnOnConsecutiveCalls( + new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true), + new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true) + ); $filterLocator = $this->createStub(ContainerInterface::class); $filterLocator->method('has')->willReturn(true); $filterLocator->method('get')->willReturn(new class implements FilterInterface { @@ -79,7 +82,10 @@ public function testParameterFactoryNoFilter(): void $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'hydra', 'everywhere'])); $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); - $propertyMetadata->method('create')->willReturnOnConsecutiveCalls(new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true)); + $propertyMetadata->method('create')->willReturnOnConsecutiveCalls( + new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true), + new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true) + ); $filterLocator = $this->createStub(ContainerInterface::class); $filterLocator->method('has')->willReturn(false); $parameter = new ParameterResourceMetadataCollectionFactory( diff --git a/src/State/Util/ParameterParserTrait.php b/src/State/Util/ParameterParserTrait.php index a23ecbe8647..1c8808dd6c2 100644 --- a/src/State/Util/ParameterParserTrait.php +++ b/src/State/Util/ParameterParserTrait.php @@ -66,8 +66,25 @@ private function extractParameterValues(Parameter $parameter, array $values): mi $value = $values[$key] ?? new ParameterNotFound(); foreach ($accessors ?? [] as $accessor) { + if ($value instanceof ParameterNotFound) { + break; + } + if (\is_array($value) && isset($value[$accessor])) { $value = $value[$accessor]; + } elseif (\is_array($value) && array_is_list($value)) { + $l = []; + foreach ($value as $i) { + if (\is_array($i) && isset($i[$accessor])) { + $l[] = $i[$accessor]; + } + } + + if (!$l) { + $value = new ParameterNotFound(); + } else { + $value = $l; + } } else { $value = new ParameterNotFound(); } diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index 82611396e41..62af5b22c28 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -104,21 +104,13 @@ public function provide(Operation $operation, array $uriVariables = [], array $c // There's a `property` inside Parameter but it's used for hydra:search only as here we want the parameter name instead private function getProperty(Parameter $parameter, ConstraintViolationInterface $violation): string { - $key = $parameter->getKey(); - - if (str_contains($key, '[:property]')) { - return str_replace('[:property]', $violation->getPropertyPath(), $key); - } - - if (str_contains($key, ':property')) { - return str_replace(':property', $violation->getPropertyPath(), $key); - } - $openApi = $parameter->getOpenApi(); + if (false === $openApi) { $openApi = null; } + $key = $parameter->getKey(); if (\is_array($openApi)) { foreach ($openApi as $oa) { if ('deepObject' === $oa->getStyle() && ($oa->getName() === $key || str_starts_with($oa->getName(), $key.'['))) { diff --git a/src/Validator/Util/ParameterValidationConstraints.php b/src/Validator/Util/ParameterValidationConstraints.php index 4d329d2f118..0396c249207 100644 --- a/src/Validator/Util/ParameterValidationConstraints.php +++ b/src/Validator/Util/ParameterValidationConstraints.php @@ -21,7 +21,6 @@ use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints\AtLeastOneOf; use Symfony\Component\Validator\Constraints\Choice; -use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Constraints\Count; use Symfony\Component\Validator\Constraints\DivisibleBy; use Symfony\Component\Validator\Constraints\GreaterThan; @@ -101,15 +100,6 @@ public static function getParameterValidationConstraints(Parameter $parameter, ? $assertions[] = new Choice(choices: $schema['enum']); } - if ($properties = $parameter->getExtraProperties()['_properties'] ?? []) { - $fields = []; - foreach ($properties as $propertyName) { - $fields[$propertyName] = $assertions; - } - - return [new Collection(fields: $fields, allowMissingFields: true)]; - } - $isCollectionType = fn ($t) => $t instanceof CollectionType; $isCollection = $parameter->getNativeType()?->isSatisfiedBy($isCollectionType) ?? false; diff --git a/tests/Fixtures/TestBundle/ApiResource/ValidateParameterBeforeProvider.php b/tests/Fixtures/TestBundle/ApiResource/ValidateParameterBeforeProvider.php index 0c4ee224ef5..c6b657af389 100644 --- a/tests/Fixtures/TestBundle/ApiResource/ValidateParameterBeforeProvider.php +++ b/tests/Fixtures/TestBundle/ApiResource/ValidateParameterBeforeProvider.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\Collection; @@ -25,7 +26,13 @@ uriTemplate: 'query_parameter_validate_before_read', parameters: [ 'search' => new QueryParameter(constraints: [new NotBlank()]), - 'sort[:property]' => new QueryParameter(constraints: [new NotBlank(), new Collection(['id' => new Choice(choices: ['asc', 'desc'])], allowMissingFields: true)]), + 'sort' => new QueryParameter( + openApi: new Parameter(name: 'sort', in: 'query', style: 'deepObject'), + constraints: [ + new NotBlank(), + new Collection(['id' => new Choice(choices: ['asc', 'desc'])], allowMissingFields: true), + ] + ), ], provider: [self::class, 'provide'] )] diff --git a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php index 83a66431f54..f29268f455e 100644 --- a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\QueryCollection; @@ -33,6 +34,10 @@ 'searchExact[:property]' => new QueryParameter(filter: 'app_odm_search_filter_with_exact'), 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_odm_filter_date_and_search'), 'q' => new QueryParameter(property: 'hydra:freetextQuery'), + 'search[:property]' => new QueryParameter( + filter: new PartialSearchFilter(), + properties: ['foo', 'createdAt'] + ), ] )] #[QueryCollection( @@ -46,8 +51,8 @@ 'q' => new QueryParameter(property: 'hydra:freetextQuery'), ] )] -#[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(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_partial', properties: ['foo' => 'partial'])] +#[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_with_exact', properties: ['foo' => 'exact'])] #[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] diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index 533bb7a14b7..17247f85526 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; @@ -35,6 +36,10 @@ 'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'), 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), 'q' => new QueryParameter(property: 'hydra:freetextQuery'), + 'search[:property]' => new QueryParameter( + filter: new PartialSearchFilter(), + properties: ['foo', 'createdAt'] + ), ] )] #[QueryCollection( @@ -48,8 +53,8 @@ 'q' => new QueryParameter(property: 'hydra:freetextQuery'), ] )] -#[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(SearchFilterValueTransformer::class, alias: 'app_search_filter_partial', properties: ['foo' => 'partial'])] +#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_with_exact', properties: ['foo' => 'exact'])] #[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] diff --git a/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php index 19d76c00f33..5b431e59ecc 100644 --- a/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php +++ b/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php @@ -21,7 +21,7 @@ final class ODMSearchFilterValueTransformer implements FilterInterface { - public function __construct(#[Autowire('@api_platform.doctrine_mongodb.odm.search_filter.instance')] private readonly FilterInterface $searchFilter, private ?array $properties = null, private readonly ?string $key = null) + public function __construct(#[Autowire('@api_platform.doctrine_mongodb.odm.search_filter.instance')] private readonly FilterInterface $searchFilter, private ?array $properties = null) { } @@ -41,7 +41,6 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $this->searchFilter->setProperties($this->properties); } - $filterContext = ['filters' => $context['filters'][$this->key]] + $context; - $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $context); } } diff --git a/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php index 2655ef78378..9a92875f340 100644 --- a/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php +++ b/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php @@ -39,8 +39,7 @@ public function getDescription(string $resourceClass): array public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - $filterContext = ['filters' => $context['filters']['searchOnTextAndDate']] + $context; - $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); - $this->dateFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $context); + $this->dateFilter->apply($aggregationBuilder, $resourceClass, $operation, $context); } } diff --git a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php index 1d2484d1a87..0e0b7c3db8b 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -22,7 +22,7 @@ final class SearchFilterValueTransformer implements FilterInterface { - public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] private readonly FilterInterface $searchFilter, private ?array $properties = null, private readonly ?string $key = null) + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] private readonly FilterInterface $searchFilter, private ?array $properties = null) { } @@ -42,6 +42,6 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $this->searchFilter->setProperties($this->properties); } - $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters'][$this->key]] + $context); + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } } diff --git a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php index 9594c37ca2a..6ae3490366e 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -48,7 +48,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $this->dateFilter->setProperties($this->dateFilterProperties); } - $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); - $this->dateFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + $this->dateFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } } diff --git a/tests/Fixtures/TestBundle/Filter/SortComputedFieldFilter.php b/tests/Fixtures/TestBundle/Filter/SortComputedFieldFilter.php index ada73ce25fa..cbb307ceebc 100644 --- a/tests/Fixtures/TestBundle/Filter/SortComputedFieldFilter.php +++ b/tests/Fixtures/TestBundle/Filter/SortComputedFieldFilter.php @@ -34,7 +34,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } - $queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC'); + $queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue() ?? 'ASC'); } /** diff --git a/tests/Functional/Doctrine/ComputedFieldTest.php b/tests/Functional/Doctrine/ComputedFieldTest.php index 8a50fa1e766..2433425b5d9 100644 --- a/tests/Functional/Doctrine/ComputedFieldTest.php +++ b/tests/Functional/Doctrine/ComputedFieldTest.php @@ -45,7 +45,7 @@ public function testWrongOrder(): void $this->recreateSchema($this->getResources()); $this->loadFixtures(); - $res = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=wrong'); + $this->createClient()->request('GET', '/carts?sort[totalQuantity]=wrong'); $this->assertResponseStatusCodeSame(422); } @@ -60,7 +60,7 @@ public function testComputedField(): void $ascReq = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=asc'); - $asc = $ascReq->toArray(); + $asc = $ascReq->toArray(false); $this->assertArrayHasKey('view', $asc); $this->assertArrayHasKey('first', $asc['view']); diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index cf825a61676..0b10f44d802 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; final class DoctrineTest extends ApiTestCase { @@ -49,7 +50,7 @@ public function testDoctrineEntitySearchFilter(): void $this->assertEquals('bar', $a['hydra:member'][1]['foo']); $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => \sprintf('/%s{?foo,fooAlias,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:template' => \sprintf('/%s{?foo,fooAlias,q,order[id],order[foo],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],search[foo],search[createdAt],id,createdAt}', $route), ]], $a); $this->assertArraySubset(['@type' => 'IriTemplateMapping', 'variable' => 'fooAlias', 'property' => 'foo'], $a['hydra:search']['hydra:mapping'][1]); @@ -145,6 +146,55 @@ public function testStateOptions(): void $this->assertEquals('after', $a['hydra:member'][0]['name']); } + #[DataProvider('partialFilterParameterProviderForSearchFilterParameter')] + public function testPartialSearchFilterWithSearchFilterParameter(string $url, int $expectedCount, array $expectedFoos): void + { + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + + $response = self::createClient()->request('GET', $url); + + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $foos = array_map(fn ($item) => $item['foo'], $filteredItems); + sort($foos); + sort($expectedFoos); + + $this->assertSame($expectedFoos, $foos, 'The "foo" values do not match the expected values.'); + } + + public static function partialFilterParameterProviderForSearchFilterParameter(): \Generator + { + // Fixtures Recap (from DoctrineTest::loadFixtures with SearchFilterParameter): + // 3x foo = 'foo' + // 2x foo = 'bar' + // 1x foo = 'baz' + + yield 'partial match on foo (fo -> 3x foo)' => [ + '/search_filter_parameter?searchPartial[foo]=fo', + 3, + ['foo', 'foo', 'foo'], + ]; + + yield 'partial match on foo (ba -> 2x bar, 1x baz)' => [ + '/search_filter_parameter?searchPartial[foo]=ba', + 3, + ['bar', 'bar', 'baz'], + ]; + + yield 'partial match on foo (az -> 1x baz)' => [ + '/search_filter_parameter?searchPartial[foo]=az', + 1, + ['baz'], + ]; + } + public function loadFixtures(string $resourceClass): void { $container = static::$kernel->getContainer();