From 4df2c127ed4aaaf2f5037beac4f1d54c3c89d8ac Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 2 Feb 2024 22:45:18 +0100 Subject: [PATCH 001/152] chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --- Extension/EagerLoadingExtension.php | 6 +++--- Extension/FilterEagerLoadingExtension.php | 2 +- Extension/FilterExtension.php | 2 +- Extension/OrderExtension.php | 2 +- Extension/PaginationExtension.php | 10 +++++----- Extension/QueryCollectionExtensionInterface.php | 2 +- Extension/QueryItemExtensionInterface.php | 2 +- Extension/QueryResultCollectionExtensionInterface.php | 4 ++-- Extension/QueryResultItemExtensionInterface.php | 4 ++-- Filter/AbstractFilter.php | 6 +++--- Filter/BooleanFilter.php | 2 +- Filter/DateFilter.php | 4 ++-- Filter/ExistsFilter.php | 6 +++--- Filter/FilterInterface.php | 2 +- Filter/NumericFilter.php | 4 ++-- Filter/OrderFilter.php | 6 +++--- Filter/RangeFilter.php | 2 +- Filter/SearchFilter.php | 4 ++-- State/CollectionProvider.php | 2 +- State/ItemProvider.php | 2 +- Util/QueryBuilderHelper.php | 4 ++-- 21 files changed, 39 insertions(+), 39 deletions(-) diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index dc6de30..49f63b4 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -48,7 +48,7 @@ public function __construct(private readonly PropertyNameCollectionFactoryInterf /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } @@ -56,7 +56,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator /** * The context may contain serialization groups which helps defining joined entities that are readable. */ - public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void { $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } @@ -107,7 +107,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ * * @throws RuntimeException when the max number of joins has been reached */ - private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, int $currentDepth = null, string $parentAssociation = null): void + private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): void { if ($joinCount > $this->maxJoins) { throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the "api_platform.eager_loading.max_joins" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the "enable_max_depth" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).'); diff --git a/Extension/FilterEagerLoadingExtension.php b/Extension/FilterEagerLoadingExtension.php index 1afd8b2..31170db 100644 --- a/Extension/FilterEagerLoadingExtension.php +++ b/Extension/FilterEagerLoadingExtension.php @@ -36,7 +36,7 @@ public function __construct(private readonly bool $forceEager = true, private re /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { if (null === $resourceClass) { throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); diff --git a/Extension/FilterExtension.php b/Extension/FilterExtension.php index 551a405..298eff2 100644 --- a/Extension/FilterExtension.php +++ b/Extension/FilterExtension.php @@ -36,7 +36,7 @@ public function __construct(private readonly ContainerInterface $filterLocator) /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { if (null === $resourceClass) { throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); diff --git a/Extension/OrderExtension.php b/Extension/OrderExtension.php index 65c7034..a383383 100644 --- a/Extension/OrderExtension.php +++ b/Extension/OrderExtension.php @@ -35,7 +35,7 @@ public function __construct(private readonly ?string $order = null) /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { if (null === $resourceClass) { throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); diff --git a/Extension/PaginationExtension.php b/Extension/PaginationExtension.php index b23285f..80513e4 100644 --- a/Extension/PaginationExtension.php +++ b/Extension/PaginationExtension.php @@ -43,7 +43,7 @@ public function __construct(private readonly ManagerRegistry $managerRegistry, p /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (null === $pagination = $this->getPagination($queryBuilder, $operation, $context)) { return; @@ -59,7 +59,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator /** * {@inheritdoc} */ - public function supportsResult(string $resourceClass, Operation $operation = null, array $context = []): bool + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool { if ($context['graphql_operation_name'] ?? false) { return $this->pagination->isGraphQlEnabled($operation, $context); @@ -71,7 +71,7 @@ public function supportsResult(string $resourceClass, Operation $operation = nul /** * {@inheritdoc} */ - public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, Operation $operation = null, array $context = []): iterable + public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): iterable { $query = $queryBuilder->getQuery(); @@ -125,7 +125,7 @@ private function addCountToContext(QueryBuilder $queryBuilder, array $context): /** * Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator. */ - private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, Operation $operation = null, array $context = []): bool + private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, ?Operation $operation = null, array $context = []): bool { $fetchJoinCollection = $operation?->getPaginationFetchJoinCollection(); @@ -158,7 +158,7 @@ private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryB /** * Determines whether the Doctrine ORM Paginator should use output walkers. */ - private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, Operation $operation = null, array $context = []): bool + private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, ?Operation $operation = null, array $context = []): bool { $useOutputWalkers = $operation?->getPaginationUseOutputWalkers(); diff --git a/Extension/QueryCollectionExtensionInterface.php b/Extension/QueryCollectionExtensionInterface.php index cb38613..191c2ac 100644 --- a/Extension/QueryCollectionExtensionInterface.php +++ b/Extension/QueryCollectionExtensionInterface.php @@ -25,5 +25,5 @@ */ interface QueryCollectionExtensionInterface { - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void; + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/Extension/QueryItemExtensionInterface.php b/Extension/QueryItemExtensionInterface.php index f337ebc..4d7aaed 100644 --- a/Extension/QueryItemExtensionInterface.php +++ b/Extension/QueryItemExtensionInterface.php @@ -25,5 +25,5 @@ */ interface QueryItemExtensionInterface { - public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void; + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void; } diff --git a/Extension/QueryResultCollectionExtensionInterface.php b/Extension/QueryResultCollectionExtensionInterface.php index 7f74cac..fe996d2 100644 --- a/Extension/QueryResultCollectionExtensionInterface.php +++ b/Extension/QueryResultCollectionExtensionInterface.php @@ -27,7 +27,7 @@ */ interface QueryResultCollectionExtensionInterface extends QueryCollectionExtensionInterface { - public function supportsResult(string $resourceClass, Operation $operation = null, array $context = []): bool; + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool; - public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, Operation $operation = null, array $context = []): iterable; + public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): iterable; } diff --git a/Extension/QueryResultItemExtensionInterface.php b/Extension/QueryResultItemExtensionInterface.php index 7cc513e..4c23d77 100644 --- a/Extension/QueryResultItemExtensionInterface.php +++ b/Extension/QueryResultItemExtensionInterface.php @@ -26,7 +26,7 @@ */ interface QueryResultItemExtensionInterface extends QueryItemExtensionInterface { - public function supportsResult(string $resourceClass, Operation $operation = null, array $context = []): bool; + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool; - public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, Operation $operation = null, array $context = []): ?object; + public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): ?object; } diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index d2b2b5d..c1deb1c 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -29,7 +29,7 @@ abstract class AbstractFilter implements FilterInterface use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) + public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) { $this->logger = $logger ?? new NullLogger(); } @@ -37,7 +37,7 @@ public function __construct(protected ManagerRegistry $managerRegistry, LoggerIn /** * {@inheritdoc} */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($context['filters'] as $property => $value) { $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); @@ -47,7 +47,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * Passes a property through the filter. */ - abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void; + abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; protected function getManagerRegistry(): ManagerRegistry { diff --git a/Filter/BooleanFilter.php b/Filter/BooleanFilter.php index cb040c4..2f230c4 100644 --- a/Filter/BooleanFilter.php +++ b/Filter/BooleanFilter.php @@ -117,7 +117,7 @@ final class BooleanFilter extends AbstractFilter /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index d895cc6..6cbd8d2 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -138,7 +138,7 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { // Expect $values to be an array having the period as keys and the date value as values if ( @@ -220,7 +220,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query /** * Adds the where clause according to the chosen null management. */ - protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, mixed $value, string $nullManagement = null, DBALType|string $type = null): void + protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, mixed $value, ?string $nullManagement = null, DBALType|string|null $type = null): void { $type = (string) $type; $value = $this->normalizeValue($value, $operator); diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index f7f2cba..db0db4b 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -114,7 +114,7 @@ final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface { use ExistsFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, LoggerInterface $logger = null, array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, NameConverterInterface $nameConverter = null) + public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { parent::__construct($managerRegistry, $logger, $properties, $nameConverter); @@ -124,7 +124,7 @@ public function __construct(ManagerRegistry $managerRegistry, LoggerInterface $l /** * {@inheritdoc} */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); @@ -134,7 +134,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) diff --git a/Filter/FilterInterface.php b/Filter/FilterInterface.php index 50e02b1..4cfa337 100644 --- a/Filter/FilterInterface.php +++ b/Filter/FilterInterface.php @@ -28,5 +28,5 @@ interface FilterInterface extends BaseFilterInterface /** * Applies the filter. */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void; + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index bbcd1d6..c27e3f6 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -126,7 +126,7 @@ final class NumericFilter extends AbstractFilter /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) @@ -164,7 +164,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB /** * {@inheritdoc} */ - protected function getType(string $doctrineType = null): string + protected function getType(?string $doctrineType = null): string { if (null === $doctrineType || Types::DECIMAL === $doctrineType) { return 'string'; diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index abece57..bf40b54 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -197,7 +197,7 @@ final class OrderFilter extends AbstractFilter implements OrderFilterInterface { use OrderFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) + public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) { if (null !== $properties) { $properties = array_map(static function ($propertyOptions) { @@ -220,7 +220,7 @@ public function __construct(ManagerRegistry $managerRegistry, string $orderParam /** * {@inheritdoc} */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (isset($context['filters']) && !isset($context['filters'][$this->orderParameterName])) { return; @@ -240,7 +240,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $direction, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $direction, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) { return; diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index c2e0aaf..70c3e68 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -112,7 +112,7 @@ final class RangeFilter extends AbstractFilter implements RangeFilterInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !\is_array($values) diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index 0ea0858..7fecabb 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -141,7 +141,7 @@ final class SearchFilter extends AbstractFilter implements SearchFilterInterface public const DOCTRINE_INTEGER_TYPE = Types::INTEGER; - public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface|LegacyIriConverterInterface $iriConverter, PropertyAccessorInterface $propertyAccessor = null, LoggerInterface $logger = null, array $properties = null, IdentifiersExtractorInterface|LegacyIdentifiersExtractorInterface $identifiersExtractor = null, NameConverterInterface $nameConverter = null) + public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ?PropertyAccessorInterface $propertyAccessor = null, ?LoggerInterface $logger = null, ?array $properties = null, IdentifiersExtractorInterface|LegacyIdentifiersExtractorInterface|null $identifiersExtractor = null, ?NameConverterInterface $nameConverter = null) { parent::__construct($managerRegistry, $logger, $properties, $nameConverter); @@ -163,7 +163,7 @@ protected function getPropertyAccessor(): PropertyAccessorInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( null === $value diff --git a/State/CollectionProvider.php b/State/CollectionProvider.php index 7534f7e..6078b80 100644 --- a/State/CollectionProvider.php +++ b/State/CollectionProvider.php @@ -37,7 +37,7 @@ final class CollectionProvider implements ProviderInterface /** * @param QueryCollectionExtensionInterface[] $collectionExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ContainerInterface $handleLinksLocator = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ?ContainerInterface $handleLinksLocator = null) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; diff --git a/State/ItemProvider.php b/State/ItemProvider.php index eccc400..7ad57d8 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -37,7 +37,7 @@ final class ItemProvider implements ProviderInterface /** * @param QueryItemExtensionInterface[] $itemExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ContainerInterface $handleLinksLocator = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ?ContainerInterface $handleLinksLocator = null) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; diff --git a/Util/QueryBuilderHelper.php b/Util/QueryBuilderHelper.php index 38f3386..7814f98 100644 --- a/Util/QueryBuilderHelper.php +++ b/Util/QueryBuilderHelper.php @@ -31,7 +31,7 @@ private function __construct() /** * Adds a join to the QueryBuilder if none exists. */ - public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association, string $joinType = null, string $conditionType = null, string $condition = null, string $originAlias = null, string $newAlias = null): string + public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association, ?string $joinType = null, ?string $conditionType = null, ?string $condition = null, ?string $originAlias = null, ?string $newAlias = null): string { $join = self::getExistingJoin($queryBuilder, $alias, $association, $originAlias); @@ -160,7 +160,7 @@ public static function traverseJoins(string $alias, QueryBuilder $queryBuilder, /** * Gets the existing join from QueryBuilder DQL parts. */ - public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association, string $originAlias = null): ?Join + public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association, ?string $originAlias = null): ?Join { $parts = $queryBuilder->getDQLPart('join'); $rootAlias = $originAlias ?? $queryBuilder->getRootAliases()[0]; From bf7718c18861a5b38c282f82529f544ffb1f2646 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 3 Feb 2024 13:33:43 +0100 Subject: [PATCH 002/152] Merge 3.2 (#6145) * fix(hydra): move owl:maxCardinality from JsonSchema to Hydra (#6136) * docs: changelog v3.2.13 * cs: missing strict type * docs: guide sf/apip version * test: security configuration * chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --------- Co-authored-by: Vincent <407859+vincentchalamon@users.noreply.github.com> --- Extension/EagerLoadingExtension.php | 6 +++--- Extension/FilterEagerLoadingExtension.php | 2 +- Extension/FilterExtension.php | 2 +- Extension/OrderExtension.php | 2 +- Extension/PaginationExtension.php | 10 +++++----- Extension/QueryCollectionExtensionInterface.php | 2 +- Extension/QueryItemExtensionInterface.php | 2 +- Extension/QueryResultCollectionExtensionInterface.php | 4 ++-- Extension/QueryResultItemExtensionInterface.php | 4 ++-- Filter/AbstractFilter.php | 6 +++--- Filter/BooleanFilter.php | 2 +- Filter/DateFilter.php | 4 ++-- Filter/ExistsFilter.php | 6 +++--- Filter/FilterInterface.php | 2 +- Filter/NumericFilter.php | 4 ++-- Filter/OrderFilter.php | 6 +++--- Filter/RangeFilter.php | 2 +- Filter/SearchFilter.php | 4 ++-- Tests/DoctrineOrmFilterTestCase.php | 8 ++++---- Tests/Filter/ExistsFilterTest.php | 6 +++--- Tests/Filter/OrderFilterTest.php | 6 +++--- Tests/Filter/SearchFilterTest.php | 2 +- ...octrineOrmResourceCollectionMetadataFactoryTest.php | 2 +- Util/QueryBuilderHelper.php | 4 ++-- 24 files changed, 49 insertions(+), 49 deletions(-) diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index 57f5c7b..63773b8 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -48,7 +48,7 @@ public function __construct(private readonly PropertyNameCollectionFactoryInterf /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } @@ -56,7 +56,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator /** * The context may contain serialization groups which helps defining joined entities that are readable. */ - public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void { $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } @@ -107,7 +107,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ * * @throws RuntimeException when the max number of joins has been reached */ - private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, int $currentDepth = null, string $parentAssociation = null): void + private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): void { if ($joinCount > $this->maxJoins) { throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the "api_platform.eager_loading.max_joins" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the "enable_max_depth" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).'); diff --git a/Extension/FilterEagerLoadingExtension.php b/Extension/FilterEagerLoadingExtension.php index 1afd8b2..31170db 100644 --- a/Extension/FilterEagerLoadingExtension.php +++ b/Extension/FilterEagerLoadingExtension.php @@ -36,7 +36,7 @@ public function __construct(private readonly bool $forceEager = true, private re /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { if (null === $resourceClass) { throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); diff --git a/Extension/FilterExtension.php b/Extension/FilterExtension.php index 551a405..298eff2 100644 --- a/Extension/FilterExtension.php +++ b/Extension/FilterExtension.php @@ -36,7 +36,7 @@ public function __construct(private readonly ContainerInterface $filterLocator) /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { if (null === $resourceClass) { throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); diff --git a/Extension/OrderExtension.php b/Extension/OrderExtension.php index 65c7034..a383383 100644 --- a/Extension/OrderExtension.php +++ b/Extension/OrderExtension.php @@ -35,7 +35,7 @@ public function __construct(private readonly ?string $order = null) /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void { if (null === $resourceClass) { throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); diff --git a/Extension/PaginationExtension.php b/Extension/PaginationExtension.php index b23285f..80513e4 100644 --- a/Extension/PaginationExtension.php +++ b/Extension/PaginationExtension.php @@ -43,7 +43,7 @@ public function __construct(private readonly ManagerRegistry $managerRegistry, p /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (null === $pagination = $this->getPagination($queryBuilder, $operation, $context)) { return; @@ -59,7 +59,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator /** * {@inheritdoc} */ - public function supportsResult(string $resourceClass, Operation $operation = null, array $context = []): bool + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool { if ($context['graphql_operation_name'] ?? false) { return $this->pagination->isGraphQlEnabled($operation, $context); @@ -71,7 +71,7 @@ public function supportsResult(string $resourceClass, Operation $operation = nul /** * {@inheritdoc} */ - public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, Operation $operation = null, array $context = []): iterable + public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): iterable { $query = $queryBuilder->getQuery(); @@ -125,7 +125,7 @@ private function addCountToContext(QueryBuilder $queryBuilder, array $context): /** * Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator. */ - private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, Operation $operation = null, array $context = []): bool + private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, ?Operation $operation = null, array $context = []): bool { $fetchJoinCollection = $operation?->getPaginationFetchJoinCollection(); @@ -158,7 +158,7 @@ private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryB /** * Determines whether the Doctrine ORM Paginator should use output walkers. */ - private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, Operation $operation = null, array $context = []): bool + private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, ?Operation $operation = null, array $context = []): bool { $useOutputWalkers = $operation?->getPaginationUseOutputWalkers(); diff --git a/Extension/QueryCollectionExtensionInterface.php b/Extension/QueryCollectionExtensionInterface.php index cb38613..191c2ac 100644 --- a/Extension/QueryCollectionExtensionInterface.php +++ b/Extension/QueryCollectionExtensionInterface.php @@ -25,5 +25,5 @@ */ interface QueryCollectionExtensionInterface { - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void; + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/Extension/QueryItemExtensionInterface.php b/Extension/QueryItemExtensionInterface.php index f337ebc..4d7aaed 100644 --- a/Extension/QueryItemExtensionInterface.php +++ b/Extension/QueryItemExtensionInterface.php @@ -25,5 +25,5 @@ */ interface QueryItemExtensionInterface { - public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void; + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void; } diff --git a/Extension/QueryResultCollectionExtensionInterface.php b/Extension/QueryResultCollectionExtensionInterface.php index 7f74cac..fe996d2 100644 --- a/Extension/QueryResultCollectionExtensionInterface.php +++ b/Extension/QueryResultCollectionExtensionInterface.php @@ -27,7 +27,7 @@ */ interface QueryResultCollectionExtensionInterface extends QueryCollectionExtensionInterface { - public function supportsResult(string $resourceClass, Operation $operation = null, array $context = []): bool; + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool; - public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, Operation $operation = null, array $context = []): iterable; + public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): iterable; } diff --git a/Extension/QueryResultItemExtensionInterface.php b/Extension/QueryResultItemExtensionInterface.php index 7cc513e..4c23d77 100644 --- a/Extension/QueryResultItemExtensionInterface.php +++ b/Extension/QueryResultItemExtensionInterface.php @@ -26,7 +26,7 @@ */ interface QueryResultItemExtensionInterface extends QueryItemExtensionInterface { - public function supportsResult(string $resourceClass, Operation $operation = null, array $context = []): bool; + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool; - public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, Operation $operation = null, array $context = []): ?object; + public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): ?object; } diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index d2b2b5d..c1deb1c 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -29,7 +29,7 @@ abstract class AbstractFilter implements FilterInterface use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) + public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) { $this->logger = $logger ?? new NullLogger(); } @@ -37,7 +37,7 @@ public function __construct(protected ManagerRegistry $managerRegistry, LoggerIn /** * {@inheritdoc} */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($context['filters'] as $property => $value) { $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); @@ -47,7 +47,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * Passes a property through the filter. */ - abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void; + abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; protected function getManagerRegistry(): ManagerRegistry { diff --git a/Filter/BooleanFilter.php b/Filter/BooleanFilter.php index cb040c4..2f230c4 100644 --- a/Filter/BooleanFilter.php +++ b/Filter/BooleanFilter.php @@ -117,7 +117,7 @@ final class BooleanFilter extends AbstractFilter /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index dd42e8a..066606e 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -138,7 +138,7 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { // Expect $values to be an array having the period as keys and the date value as values if ( @@ -220,7 +220,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query /** * Adds the where clause according to the chosen null management. */ - protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, mixed $value, string $nullManagement = null, DBALType|string $type = null): void + protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, mixed $value, ?string $nullManagement = null, DBALType|string|null $type = null): void { $type = (string) $type; $value = $this->normalizeValue($value, $operator); diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index f7f2cba..db0db4b 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -114,7 +114,7 @@ final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface { use ExistsFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, LoggerInterface $logger = null, array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, NameConverterInterface $nameConverter = null) + public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { parent::__construct($managerRegistry, $logger, $properties, $nameConverter); @@ -124,7 +124,7 @@ public function __construct(ManagerRegistry $managerRegistry, LoggerInterface $l /** * {@inheritdoc} */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); @@ -134,7 +134,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) diff --git a/Filter/FilterInterface.php b/Filter/FilterInterface.php index 50e02b1..4cfa337 100644 --- a/Filter/FilterInterface.php +++ b/Filter/FilterInterface.php @@ -28,5 +28,5 @@ interface FilterInterface extends BaseFilterInterface /** * Applies the filter. */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void; + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index bbcd1d6..c27e3f6 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -126,7 +126,7 @@ final class NumericFilter extends AbstractFilter /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) @@ -164,7 +164,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB /** * {@inheritdoc} */ - protected function getType(string $doctrineType = null): string + protected function getType(?string $doctrineType = null): string { if (null === $doctrineType || Types::DECIMAL === $doctrineType) { return 'string'; diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index abece57..bf40b54 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -197,7 +197,7 @@ final class OrderFilter extends AbstractFilter implements OrderFilterInterface { use OrderFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) + public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) { if (null !== $properties) { $properties = array_map(static function ($propertyOptions) { @@ -220,7 +220,7 @@ public function __construct(ManagerRegistry $managerRegistry, string $orderParam /** * {@inheritdoc} */ - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (isset($context['filters']) && !isset($context['filters'][$this->orderParameterName])) { return; @@ -240,7 +240,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $direction, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $direction, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) { return; diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index c2e0aaf..70c3e68 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -112,7 +112,7 @@ final class RangeFilter extends AbstractFilter implements RangeFilterInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !\is_array($values) diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index 4133f33..1a46caf 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -141,7 +141,7 @@ final class SearchFilter extends AbstractFilter implements SearchFilterInterface public const DOCTRINE_INTEGER_TYPE = Types::INTEGER; - public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface|LegacyIriConverterInterface $iriConverter, PropertyAccessorInterface $propertyAccessor = null, LoggerInterface $logger = null, array $properties = null, IdentifiersExtractorInterface|LegacyIdentifiersExtractorInterface $identifiersExtractor = null, NameConverterInterface $nameConverter = null) + public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ?PropertyAccessorInterface $propertyAccessor = null, ?LoggerInterface $logger = null, ?array $properties = null, IdentifiersExtractorInterface|LegacyIdentifiersExtractorInterface|null $identifiersExtractor = null, ?NameConverterInterface $nameConverter = null) { parent::__construct($managerRegistry, $logger, $properties, $nameConverter); @@ -163,7 +163,7 @@ protected function getPropertyAccessor(): PropertyAccessorInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( null === $value diff --git a/Tests/DoctrineOrmFilterTestCase.php b/Tests/DoctrineOrmFilterTestCase.php index f901340..77b851e 100644 --- a/Tests/DoctrineOrmFilterTestCase.php +++ b/Tests/DoctrineOrmFilterTestCase.php @@ -48,15 +48,15 @@ protected function setUp(): void /** * @dataProvider provideApplyTestData */ - public function testApply(?array $properties, array $filterParameters, string $expectedDql, array $expectedParameters = null, callable $factory = null, string $resourceClass = null): void + public function testApply(?array $properties, array $filterParameters, string $expectedDql, ?array $expectedParameters = null, ?callable $factory = null, ?string $resourceClass = null): void { $this->doTestApply($properties, $filterParameters, $expectedDql, $expectedParameters, $factory, $resourceClass); } - protected function doTestApply(?array $properties, array $filterParameters, string $expectedDql, array $expectedParameters = null, callable $filterFactory = null, string $resourceClass = null): void + protected function doTestApply(?array $properties, array $filterParameters, string $expectedDql, ?array $expectedParameters = null, ?callable $filterFactory = null, ?string $resourceClass = null): void { if (null === $filterFactory) { - $filterFactory = fn (self $that, ManagerRegistry $managerRegistry, array $properties = null): FilterInterface => new ($this->filterClass)($managerRegistry, null, $properties); + $filterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): FilterInterface => new ($this->filterClass)($managerRegistry, null, $properties); } $repository = $this->repository; @@ -83,7 +83,7 @@ protected function doTestApply(?array $properties, array $filterParameters, stri } } - protected function buildFilter(array $properties = null) + protected function buildFilter(?array $properties = null) { return new $this->filterClass($this->managerRegistry, null, $properties); } diff --git a/Tests/Filter/ExistsFilterTest.php b/Tests/Filter/ExistsFilterTest.php index cb0782e..eb2e30a 100644 --- a/Tests/Filter/ExistsFilterTest.php +++ b/Tests/Filter/ExistsFilterTest.php @@ -94,8 +94,8 @@ public function testGetDescriptionDefaultFields(): void public static function provideApplyTestData(): array { - $existsFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'exists'); - $customExistsFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'customExists'); + $existsFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'exists'); + $customExistsFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'customExists'); return array_merge_recursive( self::provideApplyTestArguments(), @@ -223,7 +223,7 @@ public static function provideApplyTestData(): array ); } - protected function buildFilter(array $properties = null) + protected function buildFilter(?array $properties = null) { return new $this->filterClass($this->managerRegistry, null, $properties, 'exists', new CustomConverter()); } diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index 2e1fa99..c83142c 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -296,8 +296,8 @@ public function testGetDescriptionDefaultFields(): void public static function provideApplyTestData(): array { - $orderFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'order', null, $properties); - $customOrderFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'customOrder', null, $properties); + $orderFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'order', null, $properties); + $customOrderFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'customOrder', null, $properties); return array_merge_recursive( self::provideApplyTestArguments(), @@ -423,7 +423,7 @@ public static function provideApplyTestData(): array ); } - protected function buildFilter(array $properties = null) + protected function buildFilter(?array $properties = null) { return new $this->filterClass($this->managerRegistry, 'order', null, $properties, new CustomConverter()); } diff --git a/Tests/Filter/SearchFilterTest.php b/Tests/Filter/SearchFilterTest.php index aa1f8e4..7b81fdc 100644 --- a/Tests/Filter/SearchFilterTest.php +++ b/Tests/Filter/SearchFilterTest.php @@ -504,7 +504,7 @@ public static function provideApplyTestData(): array ); } - protected static function buildSearchFilter(self $that, ManagerRegistry $managerRegistry, array $properties = null): SearchFilter + protected static function buildSearchFilter(self $that, ManagerRegistry $managerRegistry, ?array $properties = null): SearchFilter { $relatedDummyProphecy = $that->prophesize(RelatedDummy::class); $iriConverterProphecy = $that->prophesize(IriConverterInterface::class); diff --git a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php index 34433a5..9904186 100644 --- a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php +++ b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php @@ -65,7 +65,7 @@ public function testWithoutManager(): void /** * @dataProvider operationProvider */ - public function testWithProvider(Operation $operation, string $expectedProvider = null, string $expectedProcessor = null): void + public function testWithProvider(Operation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void { $objectManager = $this->prophesize(EntityManagerInterface::class); $managerRegistry = $this->prophesize(ManagerRegistry::class); diff --git a/Util/QueryBuilderHelper.php b/Util/QueryBuilderHelper.php index 38f3386..7814f98 100644 --- a/Util/QueryBuilderHelper.php +++ b/Util/QueryBuilderHelper.php @@ -31,7 +31,7 @@ private function __construct() /** * Adds a join to the QueryBuilder if none exists. */ - public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association, string $joinType = null, string $conditionType = null, string $condition = null, string $originAlias = null, string $newAlias = null): string + public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association, ?string $joinType = null, ?string $conditionType = null, ?string $condition = null, ?string $originAlias = null, ?string $newAlias = null): string { $join = self::getExistingJoin($queryBuilder, $alias, $association, $originAlias); @@ -160,7 +160,7 @@ public static function traverseJoins(string $alias, QueryBuilder $queryBuilder, /** * Gets the existing join from QueryBuilder DQL parts. */ - public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association, string $originAlias = null): ?Join + public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association, ?string $originAlias = null): ?Join { $parts = $queryBuilder->getDQLPart('join'); $rootAlias = $originAlias ?? $queryBuilder->getRootAliases()[0]; From 1ac56b19b75b36cc04c9b158279410e1d4f921fc Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Sun, 4 Feb 2024 14:20:47 +0100 Subject: [PATCH 003/152] chore: fix CI (#6144) * chore: disable keep_legacy_inflector in Doctrine tests * chore: fix Doctrine ORM enable_lazy_ghost_objects deprecation in tests configuration * chore: fix null value for CS * chore: fix CS post rebase from main * chore: remove useless legacy test services * chore: fix YAML with Symfony dev dependencies --- State/CollectionProvider.php | 2 +- State/ItemProvider.php | 2 +- Tests/Fixtures/Entity/CompositeLabel.php | 2 +- Tests/Fixtures/Entity/Dummy.php | 4 ++-- Tests/Fixtures/Entity/RelatedDummy.php | 2 +- Tests/Fixtures/Entity/ThirdLevel.php | 2 +- Tests/config.yml | 2 ++ 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/State/CollectionProvider.php b/State/CollectionProvider.php index 0c0248e..ab2cb5a 100644 --- a/State/CollectionProvider.php +++ b/State/CollectionProvider.php @@ -39,7 +39,7 @@ final class CollectionProvider implements ProviderInterface /** * @param QueryCollectionExtensionInterface[] $collectionExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ContainerInterface $handleLinksLocator = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ?ContainerInterface $handleLinksLocator = null) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; diff --git a/State/ItemProvider.php b/State/ItemProvider.php index 91896e7..0a7f486 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -39,7 +39,7 @@ final class ItemProvider implements ProviderInterface /** * @param QueryItemExtensionInterface[] $itemExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ContainerInterface $handleLinksLocator = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ?ContainerInterface $handleLinksLocator = null) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; diff --git a/Tests/Fixtures/Entity/CompositeLabel.php b/Tests/Fixtures/Entity/CompositeLabel.php index a884b4e..59ec038 100644 --- a/Tests/Fixtures/Entity/CompositeLabel.php +++ b/Tests/Fixtures/Entity/CompositeLabel.php @@ -53,7 +53,7 @@ public function getValue(): ?string * * @param string|null $value the value to set */ - public function setValue(string $value = null): void + public function setValue(?string $value = null): void { $this->value = $value; } diff --git a/Tests/Fixtures/Entity/Dummy.php b/Tests/Fixtures/Entity/Dummy.php index 46d4bd4..00df229 100644 --- a/Tests/Fixtures/Entity/Dummy.php +++ b/Tests/Fixtures/Entity/Dummy.php @@ -194,12 +194,12 @@ public function getFoo(): ?array return $this->foo; } - public function setFoo(array $foo = null): void + public function setFoo(?array $foo = null): void { $this->foo = $foo; } - public function setDummyDate(\DateTime $dummyDate = null): void + public function setDummyDate(?\DateTime $dummyDate = null): void { $this->dummyDate = $dummyDate; } diff --git a/Tests/Fixtures/Entity/RelatedDummy.php b/Tests/Fixtures/Entity/RelatedDummy.php index 9c15807..9fbe053 100644 --- a/Tests/Fixtures/Entity/RelatedDummy.php +++ b/Tests/Fixtures/Entity/RelatedDummy.php @@ -169,7 +169,7 @@ public function getThirdLevel(): ?ThirdLevel return $this->thirdLevel; } - public function setThirdLevel(ThirdLevel $thirdLevel = null): void + public function setThirdLevel(?ThirdLevel $thirdLevel = null): void { $this->thirdLevel = $thirdLevel; } diff --git a/Tests/Fixtures/Entity/ThirdLevel.php b/Tests/Fixtures/Entity/ThirdLevel.php index 3203c97..e3105c7 100644 --- a/Tests/Fixtures/Entity/ThirdLevel.php +++ b/Tests/Fixtures/Entity/ThirdLevel.php @@ -97,7 +97,7 @@ public function getFourthLevel(): ?FourthLevel return $this->fourthLevel; } - public function setFourthLevel(FourthLevel $fourthLevel = null): void + public function setFourthLevel(?FourthLevel $fourthLevel = null): void { $this->fourthLevel = $fourthLevel; } diff --git a/Tests/config.yml b/Tests/config.yml index 42d86cc..120b389 100644 --- a/Tests/config.yml +++ b/Tests/config.yml @@ -8,6 +8,7 @@ doctrine: orm: auto_generate_proxy_classes: '%kernel.debug%' + enable_lazy_ghost_objects: true mappings: ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity: type: attribute @@ -20,6 +21,7 @@ api_platform: formats: json: ['application/json'] doctrine: true + keep_legacy_inflector: false mapping: paths: - '%kernel.project_dir%/Fixtures/Entity' From a3e45fd944245ba68342709cf682f1899b108198 Mon Sep 17 00:00:00 2001 From: Emmanuel Averty Date: Fri, 16 Feb 2024 09:15:32 +0100 Subject: [PATCH 004/152] fix(doctrine): test array type existence before using it (#6161) --- Filter/SearchFilter.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index 7fecabb..bdb60be 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -369,8 +369,12 @@ protected function createWrapCase(bool $caseSensitive): \Closure */ protected function getType(string $doctrineType): string { + // Remove this test when doctrine/dbal:3 support is removed + if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) { + return 'array'; + } + return match ($doctrineType) { - Types::ARRAY => 'array', Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int', Types::BOOLEAN => 'bool', Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE => \DateTimeInterface::class, From b65414fee24019fff61b37a13f9251d21c5673ed Mon Sep 17 00:00:00 2001 From: Xavier Leune Date: Tue, 20 Feb 2024 09:17:55 +0100 Subject: [PATCH 005/152] feat(graphql): partial pagination for page based pagination (#6120) Co-authored-by: Xavier Leune --- Extension/DoctrinePaginatorFactory.php | 24 +++++++++++ Paginator.php | 42 +++++++++++++++++- Tests/Fixtures/Query.php | 41 ++++++++++++++++++ Tests/PaginatorTest.php | 59 ++++++++++++++++++++++++-- 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 Extension/DoctrinePaginatorFactory.php diff --git a/Extension/DoctrinePaginatorFactory.php b/Extension/DoctrinePaginatorFactory.php new file mode 100644 index 0000000..f7daadb --- /dev/null +++ b/Extension/DoctrinePaginatorFactory.php @@ -0,0 +1,24 @@ + + * + * 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\Orm\Extension; + +use Doctrine\ORM\Tools\Pagination\Paginator; + +class DoctrinePaginatorFactory +{ + public function getPaginator($query, $fetchJoinCollection): Paginator + { + return new Paginator($query, $fetchJoinCollection); + } +} diff --git a/Paginator.php b/Paginator.php index 4e1dff6..2256a62 100644 --- a/Paginator.php +++ b/Paginator.php @@ -13,17 +13,21 @@ namespace ApiPlatform\Doctrine\Orm; +use ApiPlatform\Doctrine\Orm\Extension\DoctrinePaginatorFactory; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; use ApiPlatform\State\Pagination\PaginatorInterface; use Doctrine\ORM\Query; +use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; /** * Decorates the Doctrine ORM paginator. * * @author Kévin Dunglas */ -final class Paginator extends AbstractPaginator implements PaginatorInterface, QueryAwareInterface +final class Paginator extends AbstractPaginator implements PaginatorInterface, QueryAwareInterface, HasNextPagePaginatorInterface { private ?int $totalItems = null; + private ?DoctrinePaginatorFactory $doctrinePaginatorFactory = null; /** * {@inheritdoc} @@ -52,4 +56,40 @@ public function getQuery(): Query { return $this->paginator->getQuery(); } + + /** + * {@inheritdoc} + */ + public function hasNextPage(): bool + { + if (isset($this->totalItems)) { + return $this->totalItems > ($this->firstResult + $this->maxResults); + } + + $cloneQuery = clone $this->paginator->getQuery(); + + $cloneQuery->setParameters(clone $this->paginator->getQuery()->getParameters()); + $cloneQuery->setCacheable(false); + + foreach ($this->paginator->getQuery()->getHints() as $name => $value) { + $cloneQuery->setHint($name, $value); + } + + $cloneQuery + ->setFirstResult($this->paginator->getQuery()->getFirstResult() + $this->paginator->getQuery()->getMaxResults()) + ->setMaxResults(1); + + if (null !== $this->doctrinePaginatorFactory) { + $fakePaginator = $this->doctrinePaginatorFactory->getPaginator($cloneQuery, $this->paginator->getFetchJoinCollection()); + } else { + $fakePaginator = new DoctrinePaginator($cloneQuery, $this->paginator->getFetchJoinCollection()); + } + + return iterator_count($fakePaginator->getIterator()) > 0; + } + + public function setDoctrinePaginatorFactory(?DoctrinePaginatorFactory $doctrinePaginatorFactory = null): void + { + $this->doctrinePaginatorFactory = $doctrinePaginatorFactory; + } } diff --git a/Tests/Fixtures/Query.php b/Tests/Fixtures/Query.php index b0003a8..f6536af 100644 --- a/Tests/Fixtures/Query.php +++ b/Tests/Fixtures/Query.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures; +use Doctrine\Common\Collections\ArrayCollection; + /** * Replace Doctrine\ORM\Query in tests because it cannot be mocked. */ @@ -27,4 +29,43 @@ public function getMaxResults(): ?int { return null; } + + public function setFirstResult($firstResult): self + { + return $this; + } + + public function setMaxResults($maxResults): self + { + return $this; + } + + public function setParameters($parameters): self + { + return $this; + } + + public function getParameters() + { + return new ArrayCollection(); + } + + public function setCacheable($cacheable): self + { + return $this; + } + + public function getHints() + { + return []; + } + + public function getFetchJoinCollection() + { + return false; + } + + public function getResult(): void + { + } } diff --git a/Tests/PaginatorTest.php b/Tests/PaginatorTest.php index 87f7324..db44032 100644 --- a/Tests/PaginatorTest.php +++ b/Tests/PaginatorTest.php @@ -13,11 +13,14 @@ namespace ApiPlatform\Doctrine\Orm\Tests; +use ApiPlatform\Doctrine\Orm\Extension\DoctrinePaginatorFactory; use ApiPlatform\Doctrine\Orm\Paginator; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Query; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; class PaginatorTest extends TestCase @@ -27,13 +30,14 @@ class PaginatorTest extends TestCase /** * @dataProvider initializeProvider */ - public function testInitialize(int $firstResult, int $maxResults, int $totalItems, int $currentPage, int $lastPage): void + public function testInitialize(int $firstResult, int $maxResults, int $totalItems, int $currentPage, int $lastPage, bool $hasNextPage): void { $paginator = $this->getPaginator($firstResult, $maxResults, $totalItems); $this->assertSame((float) $currentPage, $paginator->getCurrentPage()); $this->assertSame((float) $lastPage, $paginator->getLastPage()); $this->assertSame((float) $maxResults, $paginator->getItemsPerPage()); + $this->assertSame($hasNextPage, $paginator->hasNextPage()); } public function testInitializeWithQueryFirstResultNotApplied(): void @@ -90,11 +94,60 @@ private function getPaginatorWithMalformedQuery(bool $maxResults = false): void new Paginator($doctrinePaginator->reveal()); } + public function testHasNextPageShouldNotMakeQueryIfTotalPagesHasBeenCalled(): void + { + $query = $this->prophesize(Query::class); + $query->getFirstResult()->willReturn(1)->shouldBeCalled(); + $query->getMaxResults()->willReturn(15)->shouldBeCalled(); + $query->setMaxResults(Argument::any())->shouldNotBeCalled(); + + $doctrinePaginator = $this->prophesize(DoctrinePaginator::class); + + $doctrinePaginator->getQuery()->willReturn($query->reveal())->shouldBeCalled(); + $doctrinePaginator->count()->willReturn(42); + + $doctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + + $paginator = new Paginator($doctrinePaginator->reveal()); + $paginator->getTotalItems(); + $this->assertTrue($paginator->hasNextPage()); + } + + public function testHasNextPageShouldMakeQueryIfTotalPagesHasNotBeenCalled(): void + { + $query = $this->prophesize(Query::class); + $query->getFirstResult()->willReturn(1)->shouldBeCalled(); + $query->getMaxResults()->willReturn(15)->shouldBeCalled(); + $query->getParameters()->willReturn(new ArrayCollection())->shouldBeCalled(); + $query->setParameters(Argument::any())->willReturn($query->reveal())->shouldBeCalled(); + $query->setCacheable(false)->willReturn($query->reveal())->shouldBeCalled(); + $query->setMaxResults(1)->shouldBeCalled(); + $query->getHints()->willReturn([])->shouldBeCalled(); + $query->setFirstResult(Argument::any())->willReturn($query->reveal())->shouldBeCalled(); + + $doctrinePaginator = $this->prophesize(DoctrinePaginator::class); + + $doctrinePaginator->getQuery()->willReturn($query->reveal())->shouldBeCalled(); + $doctrinePaginator->count()->willReturn(42); + $doctrinePaginator->getFetchJoinCollection()->willReturn(false); + + $doctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + + $secondDoctrinePaginator = $this->prophesize(DoctrinePaginator::class); + $secondDoctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + $doctrinePaginatorFactory = $this->prophesize(DoctrinePaginatorFactory::class); + $doctrinePaginatorFactory->getPaginator(Argument::any(), Argument::any())->willReturn($secondDoctrinePaginator->reveal()); + + $paginator = new Paginator($doctrinePaginator->reveal()); + $paginator->setDoctrinePaginatorFactory($doctrinePaginatorFactory->reveal()); + $this->assertFalse($paginator->hasNextPage()); + } + public static function initializeProvider(): array { return [ - 'First of three pages of 15 items each' => [0, 15, 42, 1, 3], - 'Second of two pages of 10 items each' => [10, 10, 20, 2, 2], + 'First of three pages of 15 items each' => [0, 15, 42, 1, 3, true], + 'Second of two pages of 10 items each' => [10, 10, 20, 2, 2, false], ]; } } From e7f098c075afd0aac94d6f785116646c0566d202 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 29 Feb 2024 18:53:32 +0100 Subject: [PATCH 006/152] fix: components split dependencies (#6186) --- Tests/AppKernel.php | 2 - Tests/Filter/BooleanFilterTest.php | 1 - Tests/Filter/BooleanFilterTestTrait.php | 170 ++++++++ Tests/Filter/DateFilterTest.php | 1 - Tests/Filter/DateFilterTestTrait.php | 229 +++++++++++ Tests/Filter/ExistsFilterTest.php | 1 - Tests/Filter/ExistsFilterTestTrait.php | 280 +++++++++++++ Tests/Filter/NumericFilterTest.php | 1 - Tests/Filter/NumericFilterTestTrait.php | 206 ++++++++++ Tests/Filter/OrderFilterTest.php | 1 - Tests/Filter/OrderFilterTestTrait.php | 338 ++++++++++++++++ Tests/Filter/RangeFilterTest.php | 1 - Tests/Filter/RangeFilterTestTrait.php | 139 +++++++ Tests/Filter/SearchFilterTest.php | 1 - Tests/Filter/SearchFilterTestTrait.php | 517 ++++++++++++++++++++++++ Tests/config.yml | 9 - composer.json | 147 +++---- 17 files changed, 1944 insertions(+), 100 deletions(-) create mode 100644 Tests/Filter/BooleanFilterTestTrait.php create mode 100644 Tests/Filter/DateFilterTestTrait.php create mode 100644 Tests/Filter/ExistsFilterTestTrait.php create mode 100644 Tests/Filter/NumericFilterTestTrait.php create mode 100644 Tests/Filter/OrderFilterTestTrait.php create mode 100644 Tests/Filter/RangeFilterTestTrait.php create mode 100644 Tests/Filter/SearchFilterTestTrait.php diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index 22409c7..29229e2 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests; -use ApiPlatform\Symfony\Bundle\ApiPlatformBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; @@ -41,7 +40,6 @@ public function __construct(string $environment, bool $debug) public function registerBundles(): array { return [ - new ApiPlatformBundle(), new FrameworkBundle(), new DoctrineBundle(), new TestBundle(), diff --git a/Tests/Filter/BooleanFilterTest.php b/Tests/Filter/BooleanFilterTest.php index fa9425b..1fdd6aa 100644 --- a/Tests/Filter/BooleanFilterTest.php +++ b/Tests/Filter/BooleanFilterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Filter; -use ApiPlatform\Doctrine\Common\Tests\Filter\BooleanFilterTestTrait; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; diff --git a/Tests/Filter/BooleanFilterTestTrait.php b/Tests/Filter/BooleanFilterTestTrait.php new file mode 100644 index 0000000..26c4ef2 --- /dev/null +++ b/Tests/Filter/BooleanFilterTestTrait.php @@ -0,0 +1,170 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Amrouche Hamza + */ +trait BooleanFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildFilter([ + 'id' => null, + 'name' => null, + 'foo' => null, + 'dummyBoolean' => null, + ]); + + $this->assertEquals([ + 'dummyBoolean' => [ + 'property' => 'dummyBoolean', + 'type' => 'bool', + 'required' => false, + ], + ], $filter->getDescription($this->resourceClass)); + } + + public function testGetDescriptionDefaultFields(): void + { + $filter = $this->buildFilter(); + + $this->assertEquals([ + 'dummyBoolean' => [ + 'property' => 'dummyBoolean', + 'type' => 'bool', + 'required' => false, + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'string ("true")' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'dummyBoolean' => 'true', + ], + ], + 'string ("false")' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'dummyBoolean' => 'false', + ], + ], + 'non-boolean' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'dummyBoolean' => 'toto', + ], + ], + 'numeric string ("0")' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'dummyBoolean' => '0', + ], + ], + 'numeric string ("1")' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'dummyBoolean' => '1', + ], + ], + 'nested properties' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.dummyBoolean' => null, + ], + [ + 'relatedDummy.dummyBoolean' => '1', + ], + ], + 'numeric string ("1") on non-boolean property' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'name' => '1', + ], + ], + 'numeric string ("0") on non-boolean property' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'name' => '0', + ], + ], + 'string ("true") on non-boolean property' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'name' => 'true', + ], + ], + 'string ("false") on non-boolean property' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'name' => 'false', + ], + ], + 'mixed boolean, non-boolean and invalid property' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBoolean' => null, + ], + [ + 'dummyBoolean' => 'false', + 'toto' => 'toto', + 'name' => 'true', + 'id' => '0', + ], + ], + ]; + } +} diff --git a/Tests/Filter/DateFilterTest.php b/Tests/Filter/DateFilterTest.php index 91e6aa4..01d5e72 100644 --- a/Tests/Filter/DateFilterTest.php +++ b/Tests/Filter/DateFilterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Filter; -use ApiPlatform\Doctrine\Common\Tests\Filter\DateFilterTestTrait; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; diff --git a/Tests/Filter/DateFilterTestTrait.php b/Tests/Filter/DateFilterTestTrait.php new file mode 100644 index 0000000..24c00e4 --- /dev/null +++ b/Tests/Filter/DateFilterTestTrait.php @@ -0,0 +1,229 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Théo FIDRY + * @author Vincent CHALAMON + */ +trait DateFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildFilter(); + + $this->assertEquals([ + 'dummyDate[before]' => [ + 'property' => 'dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + ], + 'dummyDate[strictly_before]' => [ + 'property' => 'dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + ], + 'dummyDate[after]' => [ + 'property' => 'dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + ], + 'dummyDate[strictly_after]' => [ + 'property' => 'dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'after (all properties enabled)' => [ + null, + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + ], + ], + ], + 'after but not equals (all properties enabled)' => [ + null, + [ + 'dummyDate' => [ + 'strictly_after' => '2015-04-05', + ], + ], + ], + 'after' => [ + [ + 'dummyDate' => null, + ], + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + ], + ], + ], + 'after but not equals' => [ + [ + 'dummyDate' => null, + ], + [ + 'dummyDate' => [ + 'strictly_after' => '2015-04-05', + ], + ], + ], + 'before (all properties enabled)' => [ + null, + [ + 'dummyDate' => [ + 'before' => '2015-04-05', + ], + ], + ], + 'before but not equals (all properties enabled)' => [ + null, + [ + 'dummyDate' => [ + 'strictly_before' => '2015-04-05', + ], + ], + ], + 'before' => [ + [ + 'dummyDate' => null, + ], + [ + 'dummyDate' => [ + 'before' => '2015-04-05', + ], + ], + ], + 'before but not equals' => [ + [ + 'dummyDate' => null, + ], + [ + 'dummyDate' => [ + 'strictly_before' => '2015-04-05', + ], + ], + ], + 'before + after (all properties enabled)' => [ + null, + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + 'before' => '2015-04-05', + ], + ], + ], + 'before but not equals + after but not equals (all properties enabled)' => [ + null, + [ + 'dummyDate' => [ + 'strictly_after' => '2015-04-05', + 'strictly_before' => '2015-04-05', + ], + ], + ], + 'before + after' => [ + [ + 'dummyDate' => null, + ], + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + 'before' => '2015-04-05', + ], + ], + ], + 'before but not equals + after but not equals' => [ + [ + 'dummyDate' => null, + ], + [ + 'dummyDate' => [ + 'strictly_after' => '2015-04-05', + 'strictly_before' => '2015-04-05', + ], + ], + ], + 'property not enabled' => [ + [ + 'unknown' => null, + ], + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + 'before' => '2015-04-05', + ], + ], + ], + 'nested property' => [ + [ + 'relatedDummy.dummyDate' => null, + ], + [ + 'relatedDummy.dummyDate' => [ + 'after' => '2015-04-05', + ], + ], + ], + 'after (exclude_null)' => [ + [ + 'dummyDate' => 'exclude_null', + ], + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + ], + ], + ], + 'after (include_null_after)' => [ + [ + 'dummyDate' => 'include_null_after', + ], + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + ], + ], + ], + 'include null before and after (include_null_before_and_after)' => [ + [ + 'dummyDate' => 'include_null_before_and_after', + ], + [ + 'dummyDate' => [ + 'after' => '2015-04-05', + ], + ], + ], + 'bad date format' => [ + [ + 'dummyDate' => null, + ], + [ + 'dummyDate' => [ + 'after' => '1932iur123ufqe', + 'before' => [], + ], + ], + ], + ]; + } +} diff --git a/Tests/Filter/ExistsFilterTest.php b/Tests/Filter/ExistsFilterTest.php index eb2e30a..9f3f345 100644 --- a/Tests/Filter/ExistsFilterTest.php +++ b/Tests/Filter/ExistsFilterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Filter; -use ApiPlatform\Doctrine\Common\Tests\Filter\ExistsFilterTestTrait; use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\CustomConverter; diff --git a/Tests/Filter/ExistsFilterTestTrait.php b/Tests/Filter/ExistsFilterTestTrait.php new file mode 100644 index 0000000..1fcf2d5 --- /dev/null +++ b/Tests/Filter/ExistsFilterTestTrait.php @@ -0,0 +1,280 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Antoine Bluchet + */ +trait ExistsFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildFilter(['name' => null, 'description' => null]); + + $this->assertEquals([ + 'exists[description]' => [ + 'property' => 'description', + 'type' => 'bool', + 'required' => false, + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'valid values' => [ + [ + 'description' => null, + ], + [ + 'exists' => [ + 'description' => 'true', + ], + ], + ], + + 'valid values (empty for true)' => [ + [ + 'description' => null, + ], + [ + 'exists' => [ + 'description' => '', + ], + ], + ], + + 'valid values (1 for true)' => [ + [ + 'description' => null, + ], + [ + 'exists' => [ + 'description' => '1', + ], + ], + ], + + 'invalid values' => [ + [ + 'description' => null, + ], + [ + 'exists' => [ + 'description' => 'invalid', + ], + ], + ], + + 'negative values' => [ + [ + 'description' => null, + ], + [ + 'exists' => [ + 'description' => 'false', + ], + ], + ], + + 'negative values (0)' => [ + [ + 'description' => null, + ], + [ + 'exists' => [ + 'description' => '0', + ], + ], + ], + + 'multiple values (true and true)' => [ + [ + 'alias' => null, + 'description' => null, + ], + [ + 'exists' => [ + 'alias' => 'true', + 'description' => 'true', + ], + ], + ], + + 'multiple values (1 and 0)' => [ + [ + 'alias' => null, + 'description' => null, + ], + [ + 'exists' => [ + 'alias' => '1', + 'description' => '0', + ], + ], + ], + + 'multiple values (false and 0)' => [ + [ + 'alias' => null, + 'description' => null, + ], + [ + 'exists' => [ + 'alias' => 'false', + 'description' => '0', + ], + ], + ], + + 'custom exists parameter name' => [ + [ + 'alias' => null, + 'description' => null, + ], + [ + 'exists' => [ + 'alias' => 'true', + ], + 'customExists' => [ + 'description' => 'true', + ], + ], + ], + + 'related values' => [ + [ + 'description' => null, + 'relatedDummy.name' => null, + ], + [ + 'exists' => [ + 'description' => '1', + 'relatedDummy.name' => '1', + ], + ], + ], + + 'not nullable values' => [ + [ + 'description' => null, + 'name' => null, + ], + [ + 'exists' => [ + 'description' => '1', + 'name' => '0', + ], + ], + ], + + 'related collection not empty' => [ + [ + 'description' => null, + 'relatedDummies' => null, + ], + [ + 'exists' => [ + 'description' => '1', + 'relatedDummies' => '1', + ], + ], + ], + + 'related collection empty' => [ + [ + 'description' => null, + 'relatedDummies' => null, + ], + [ + 'exists' => [ + 'description' => '1', + 'relatedDummies' => '0', + ], + ], + ], + + 'related association exists' => [ + [ + 'description' => null, + 'relatedDummy' => null, + ], + [ + 'exists' => [ + 'description' => '1', + 'relatedDummy' => '1', + ], + ], + ], + + 'related association does not exist' => [ + [ + 'description' => null, + 'relatedDummy' => null, + ], + [ + 'exists' => [ + 'description' => '1', + 'relatedDummy' => '0', + ], + ], + ], + + 'related owned association does not exist' => [ + [ + 'relatedOwnedDummy' => null, + ], + [ + 'exists' => [ + 'relatedOwnedDummy' => '0', + ], + ], + ], + + 'related owned association exists' => [ + [ + 'relatedOwnedDummy' => null, + ], + [ + 'exists' => [ + 'relatedOwnedDummy' => '1', + ], + ], + ], + + 'related owning association does not exist' => [ + [ + 'relatedOwningDummy' => null, + ], + [ + 'exists' => [ + 'relatedOwningDummy' => '0', + ], + ], + ], + + 'related owning association exists' => [ + [ + 'relatedOwningDummy' => null, + ], + [ + 'exists' => [ + 'relatedOwningDummy' => '1', + ], + ], + ], + ]; + } +} diff --git a/Tests/Filter/NumericFilterTest.php b/Tests/Filter/NumericFilterTest.php index d3322ce..d5a4eda 100644 --- a/Tests/Filter/NumericFilterTest.php +++ b/Tests/Filter/NumericFilterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Filter; -use ApiPlatform\Doctrine\Common\Tests\Filter\NumericFilterTestTrait; use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; diff --git a/Tests/Filter/NumericFilterTestTrait.php b/Tests/Filter/NumericFilterTestTrait.php new file mode 100644 index 0000000..3941852 --- /dev/null +++ b/Tests/Filter/NumericFilterTestTrait.php @@ -0,0 +1,206 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Amrouche Hamza + */ +trait NumericFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildFilter([ + 'id' => null, + 'name' => null, + 'foo' => null, + 'dummyBoolean' => null, + ]); + + $this->assertEquals([ + 'id' => [ + 'property' => 'id', + 'type' => 'int', + 'required' => false, + 'is_collection' => false, + ], + 'id[]' => [ + 'property' => 'id', + 'type' => 'int', + 'required' => false, + 'is_collection' => true, + ], + ], $filter->getDescription($this->resourceClass)); + } + + public function testGetDescriptionDefaultFields(): void + { + $filter = $this->buildFilter(); + + $this->assertEquals([ + 'id' => [ + 'property' => 'id', + 'type' => 'int', + 'required' => false, + 'is_collection' => false, + ], + 'id[]' => [ + 'property' => 'id', + 'type' => 'int', + 'required' => false, + 'is_collection' => true, + ], + 'dummyFloat' => [ + 'property' => 'dummyFloat', + 'type' => 'float', + 'required' => false, + 'is_collection' => false, + ], + 'dummyFloat[]' => [ + 'property' => 'dummyFloat', + 'type' => 'float', + 'required' => false, + 'is_collection' => true, + ], + 'dummyPrice' => [ + 'property' => 'dummyPrice', + 'type' => 'string', + 'required' => false, + 'is_collection' => false, + ], + 'dummyPrice[]' => [ + 'property' => 'dummyPrice', + 'type' => 'string', + 'required' => false, + 'is_collection' => true, + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'numeric string (positive integer)' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => '21', + ], + ], + 'multiple numeric string (positive integer)' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => ['21', '22'], + ], + ], + 'multiple numeric string with one invalid property key' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => ['invalid' => '21', '22'], + ], + ], + 'multiple numeric string with invalid value keys' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => ['invalid' => '21', 'invalid2' => '22'], + ], + ], + 'multiple non-numeric' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => ['test', 'invalid'], + ], + ], + 'numeric string (negative integer)' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => '-21', + ], + ], + 'non-numeric' => [ + [ + 'id' => null, + ], + [ + 'id' => 'toto', + ], + ], + 'numeric string ("0")' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => 0, + ], + ], + 'nested property' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.id' => null, + ], + [ + 'relatedDummy.id' => 0, + ], + ], + 'mixed numeric and non-numeric' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'dummyPrice' => 10, + 'name' => '15toto', + ], + ], + 'mixed numeric, non-numeric and invalid property' => [ + [ + 'id' => null, + 'name' => null, + 'dummyPrice' => null, + ], + [ + 'toto' => 'toto', + 'name' => 'gerard', + 'dummyPrice' => '0', + ], + ], + ]; + } +} diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index c83142c..3838290 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Filter; -use ApiPlatform\Doctrine\Common\Tests\Filter\OrderFilterTestTrait; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\CustomConverter; diff --git a/Tests/Filter/OrderFilterTestTrait.php b/Tests/Filter/OrderFilterTestTrait.php new file mode 100644 index 0000000..2409ae8 --- /dev/null +++ b/Tests/Filter/OrderFilterTestTrait.php @@ -0,0 +1,338 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Théo FIDRY + * @author Vincent CHALAMON + */ +trait OrderFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildFilter(['id' => null, 'name' => null, 'foo' => null]); + + $this->assertEquals([ + 'order[id]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => [ + 'asc', + 'desc', + ], + ], + ], + 'order[name]' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => [ + 'asc', + 'desc', + ], + ], + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'valid values' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'order' => [ + 'id' => 'asc', + 'name' => 'desc', + ], + ], + ], + 'invalid values' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'order' => [ + 'id' => 'asc', + 'name' => 'invalid', + ], + ], + ], + 'valid values (properties not enabled)' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'order' => [ + 'id' => 'asc', + 'alias' => 'asc', + ], + ], + ], + 'invalid values (properties not enabled)' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'order' => [ + 'id' => 'invalid', + 'name' => 'asc', + 'alias' => 'invalid', + ], + ], + ], + 'invalid property (property not enabled)' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'order' => [ + 'unknown' => 'asc', + ], + ], + ], + 'invalid property (property enabled)' => [ + [ + 'id' => null, + 'name' => null, + 'unknown' => null, + ], + [ + 'order' => [ + 'unknown' => 'asc', + ], + ], + ], + 'custom order parameter name' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'order' => [ + 'id' => 'asc', + 'name' => 'asc', + ], + 'customOrder' => [ + 'name' => 'desc', + ], + ], + ], + 'valid values (all properties enabled)' => [ + null, + [ + 'order' => [ + 'id' => 'asc', + 'name' => 'asc', + ], + ], + ], + 'nested property' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.symfony' => null, + ], + [ + 'order' => [ + 'id' => 'asc', + 'name' => 'desc', + 'relatedDummy.symfony' => 'desc', + ], + ], + ], + 'empty values with default sort direction' => [ + [ + 'id' => 'asc', + 'name' => 'desc', + ], + [ + 'order' => [ + 'id' => null, + 'name' => null, + ], + ], + ], + 'nulls_smallest (asc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_smallest', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'asc', + 'name' => 'desc', + ], + ], + ], + 'nulls_smallest (desc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_smallest', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'desc', + 'name' => 'desc', + ], + ], + ], + 'nulls_largest (asc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_largest', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'asc', + 'name' => 'desc', + ], + ], + ], + 'nulls_largest (desc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_largest', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'desc', + 'name' => 'desc', + ], + ], + ], + 'nulls_always_first (asc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_first', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'asc', + 'name' => 'desc', + ], + ], + ], + 'nulls_always_first (desc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_first', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'desc', + 'name' => 'desc', + ], + ], + ], + 'nulls_always_last (asc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_last', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'asc', + 'name' => 'desc', + ], + ], + ], + 'nulls_always_last (desc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_last', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'desc', + 'name' => 'desc', + ], + ], + ], + 'not having order should not throw a deprecation (select unchanged)' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'name' => 'q', + ], + ], + 'not nullable relation will be a LEFT JOIN' => [ + [ + 'relatedDummy.name' => 'ASC', + ], + [ + 'order' => ['relatedDummy.name' => 'ASC'], + ], + ], + 'embedded' => [ + [ + 'embeddedDummy.dummyName' => 'ASC', + ], + [ + 'order' => ['embeddedDummy.dummyName' => 'ASC'], + ], + ], + 'embedded with nulls_comparison' => [ + [ + 'embeddedDummy.dummyName' => [ + 'nulls_comparison' => 'nulls_largest', + ], + ], + [ + 'order' => [ + 'embeddedDummy.dummyName' => 'ASC', + ], + ], + ], + 'nullable field in relation will be a LEFT JOIN' => [ + [ + 'relatedDummy.name' => null, + ], + [ + 'order' => ['relatedDummy.name' => 'ASC'], + ], + ], + ]; + } +} diff --git a/Tests/Filter/RangeFilterTest.php b/Tests/Filter/RangeFilterTest.php index 8230580..ac31b95 100644 --- a/Tests/Filter/RangeFilterTest.php +++ b/Tests/Filter/RangeFilterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Filter; -use ApiPlatform\Doctrine\Common\Tests\Filter\RangeFilterTestTrait; use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; diff --git a/Tests/Filter/RangeFilterTestTrait.php b/Tests/Filter/RangeFilterTestTrait.php new file mode 100644 index 0000000..1d2646c --- /dev/null +++ b/Tests/Filter/RangeFilterTestTrait.php @@ -0,0 +1,139 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Lee Siong Chan + */ +trait RangeFilterTestTrait +{ + private static function provideApplyTestArguments(): array + { + return [ + 'between' => [ + null, + [ + 'dummyPrice' => [ + 'between' => '9.99..15.99', + ], + ], + ], + 'between (same values)' => [ + null, + [ + 'dummyPrice' => [ + 'between' => '9.99..9.99', + ], + ], + ], + 'between (too many operands)' => [ + null, + [ + 'dummyPrice' => [ + 'between' => '9.99..15.99..99.99', + ], + ], + ], + 'between (too few operands)' => [ + null, + [ + 'dummyPrice' => [ + 'between' => '15.99', + ], + ], + ], + 'between (non-numeric operands)' => [ + null, + [ + 'dummyPrice' => [ + 'between' => 'abc..def', + ], + ], + ], + 'lt' => [ + null, + [ + 'dummyPrice' => [ + 'lt' => '9.99', + ], + ], + ], + 'lt (non-numeric)' => [ + null, + [ + 'dummyPrice' => [ + 'lt' => '127.0.0.1', + ], + ], + ], + 'lte' => [ + null, + [ + 'dummyPrice' => [ + 'lte' => '9.99', + ], + ], + ], + 'lte (non-numeric)' => [ + null, + [ + 'dummyPrice' => [ + 'lte' => '127.0.0.1', + ], + ], + ], + 'gt' => [ + null, + [ + 'dummyPrice' => [ + 'gt' => '9.99', + ], + ], + ], + 'gt (non-numeric)' => [ + null, + [ + 'dummyPrice' => [ + 'gt' => '127.0.0.1', + ], + ], + ], + 'gte' => [ + null, + [ + 'dummyPrice' => [ + 'gte' => '9.99', + ], + ], + ], + 'gte (non-numeric)' => [ + null, + [ + 'dummyPrice' => [ + 'gte' => '127.0.0.1', + ], + ], + ], + 'lte + gte' => [ + null, + [ + 'dummyPrice' => [ + 'gte' => '9.99', + 'lte' => '19.99', + ], + ], + ], + ]; + } +} diff --git a/Tests/Filter/SearchFilterTest.php b/Tests/Filter/SearchFilterTest.php index 7b81fdc..89f2e3f 100644 --- a/Tests/Filter/SearchFilterTest.php +++ b/Tests/Filter/SearchFilterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Filter; -use ApiPlatform\Doctrine\Common\Tests\Filter\SearchFilterTestTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\CustomConverter; diff --git a/Tests/Filter/SearchFilterTestTrait.php b/Tests/Filter/SearchFilterTestTrait.php new file mode 100644 index 0000000..a538ddb --- /dev/null +++ b/Tests/Filter/SearchFilterTestTrait.php @@ -0,0 +1,517 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Julien Deniau + * @author Vincent CHALAMON + */ +trait SearchFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildSearchFilter($this, $this->managerRegistry, [ + 'id' => null, + 'name' => null, + 'alias' => null, + 'dummy' => null, + 'dummyDate' => null, + 'jsonData' => null, + 'arrayData' => null, + 'nameConverted' => null, + 'foo' => null, + 'relatedDummies.dummyDate' => null, + 'relatedDummy' => null, + ]); + + $this->assertEquals([ + 'id' => [ + 'property' => 'id', + 'type' => 'int', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'id[]' => [ + 'property' => 'id', + 'type' => 'int', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'name' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'name[]' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'alias' => [ + 'property' => 'alias', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'alias[]' => [ + 'property' => 'alias', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'dummy' => [ + 'property' => 'dummy', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'dummy[]' => [ + 'property' => 'dummy', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'dummyDate' => [ + 'property' => 'dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'dummyDate[]' => [ + 'property' => 'dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'jsonData' => [ + 'property' => 'jsonData', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'jsonData[]' => [ + 'property' => 'jsonData', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'arrayData' => [ + 'property' => 'arrayData', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'arrayData[]' => [ + 'property' => 'arrayData', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'name_converted' => [ + 'property' => 'name_converted', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'name_converted[]' => [ + 'property' => 'name_converted', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'relatedDummies.dummyDate' => [ + 'property' => 'relatedDummies.dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'relatedDummies.dummyDate[]' => [ + 'property' => 'relatedDummies.dummyDate', + 'type' => \DateTimeInterface::class, + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + 'relatedDummy' => [ + 'property' => 'relatedDummy', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'relatedDummy[]' => [ + 'property' => 'relatedDummy', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'exact' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'name' => 'exact', + ], + ], + 'exact (case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iexact', + ], + [ + 'name' => 'exact', + ], + ], + 'exact (case insensitive, with special characters)' => [ + [ + 'id' => null, + 'name' => 'iexact', + ], + [ + 'name' => 'exact (special)', + ], + ], + 'exact (multiple values)' => [ + [ + 'id' => null, + 'name' => 'exact', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'exact (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iexact', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], + 'invalid property' => [ + [ + 'id' => null, + 'name' => null, + ], + [ + 'foo' => 'exact', + ], + ], + 'invalid values for relations' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy' => null, + 'relatedDummies' => null, + ], + [ + 'name' => ['foo'], + 'relatedDummy' => ['foo'], + 'relatedDummies' => [['foo']], + ], + ], + 'partial' => [ + [ + 'id' => null, + 'name' => 'partial', + ], + [ + 'name' => 'partial', + ], + ], + 'partial (case insensitive)' => [ + [ + 'id' => null, + 'name' => 'ipartial', + ], + [ + 'name' => 'partial', + ], + ], + 'partial (multiple values)' => [ + [ + 'id' => null, + 'name' => 'partial', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'partial (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'ipartial', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], + 'partial (multiple almost same values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'ipartial', + ], + [ + 'name' => [ + 'blue car', + 'Blue Car', + ], + ], + ], + 'start' => [ + [ + 'id' => null, + 'name' => 'start', + ], + [ + 'name' => 'partial', + ], + ], + 'start (case insensitive)' => [ + [ + 'id' => null, + 'name' => 'istart', + ], + [ + 'name' => 'partial', + ], + ], + 'start (multiple values)' => [ + [ + 'id' => null, + 'name' => 'start', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'start (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'istart', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], + 'end' => [ + [ + 'id' => null, + 'name' => 'end', + ], + [ + 'name' => 'partial', + ], + ], + 'end (case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iend', + ], + [ + 'name' => 'partial', + ], + ], + 'end (multiple values)' => [ + [ + 'id' => null, + 'name' => 'end', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'end (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iend', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], + 'word_start' => [ + [ + 'id' => null, + 'name' => 'word_start', + ], + [ + 'name' => 'partial', + ], + ], + 'word_start (case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iword_start', + ], + [ + 'name' => 'partial', + ], + ], + 'word_start (multiple values)' => [ + [ + 'id' => null, + 'name' => 'word_start', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'word_start (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iword_start', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], + 'invalid value for relation' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy' => null, + ], + [ + 'relatedDummy' => 'exact', + ], + ], + 'IRI value for relation' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.id' => null, + ], + [ + 'relatedDummy.id' => '/related_dummies/1', + ], + ], + 'mixed IRI and entity ID values for relations' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy' => null, + 'relatedDummies' => null, + ], + [ + 'relatedDummy' => ['/related_dummies/1', '2'], + 'relatedDummies' => '1', + ], + ], + 'nested property' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.symfony' => null, + ], + [ + 'name' => 'exact', + 'relatedDummy.symfony' => 'exact', + ], + ], + 'empty nested property' => [ + [ + 'relatedDummy.symfony' => null, + ], + [ + 'relatedDummy.symfony' => [], + ], + ], + 'integer value' => [ + [ + 'age' => 'exact', + ], + [ + 'age' => 46, + ], + ], + 'related owned one-to-one association' => [ + [ + 'id' => null, + 'relatedOwnedDummy' => null, + ], + [ + 'relatedOwnedDummy' => 1, + ], + ], + 'related owning one-to-one association' => [ + [ + 'id' => null, + 'relatedOwningDummy' => null, + ], + [ + 'relatedOwningDummy' => 1, + ], + ], + ]; + } +} diff --git a/Tests/config.yml b/Tests/config.yml index 120b389..700332d 100644 --- a/Tests/config.yml +++ b/Tests/config.yml @@ -17,15 +17,6 @@ doctrine: prefix: ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity alias: App -api_platform: - formats: - json: ['application/json'] - doctrine: true - keep_legacy_inflector: false - mapping: - paths: - - '%kernel.project_dir%/Fixtures/Entity' - services: test.property_accessor: alias: property_accessor diff --git a/composer.json b/composer.json index 982e56f..58a490d 100644 --- a/composer.json +++ b/composer.json @@ -1,88 +1,71 @@ { - "name": "api-platform/doctrine-orm", - "description": "Doctrine ORM bridge", - "type": "library", - "keywords": [ - "Doctrine", - "ORM" - ], - "homepage": "https://api-platform.com", - "license": "MIT", - "authors": [ - { - "name": "Kévin Dunglas", - "email": "kevin@dunglas.fr", - "homepage": "https://dunglas.fr" + "name": "api-platform/doctrine-orm", + "description": "Doctrine ORM bridge", + "type": "library", + "keywords": [ + "Doctrine", + "ORM" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/doctrine-common": "*@dev || ^3.1", + "api-platform/metadata": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", + "doctrine/doctrine-bundle": "^2.11", + "doctrine/orm": "^2.17", + "symfony/property-info": "^6.4 || ^7.0" }, - { - "name": "API Platform Community", - "homepage": "https://api-platform.com/community/contributors" - } - ], - "require": { - "php": ">=8.1", - "api-platform/doctrine-common": "*@dev || ^3.1", - "api-platform/metadata": "*@dev || ^3.1", - "api-platform/state": "*@dev || ^3.1", - "doctrine/doctrine-bundle": "^2.11", - "doctrine/orm": "^2.17", - "symfony/property-info": "^6.4 || ^7.0" - }, - "require-dev": { - "api-platform/parameter-validator": "*@dev || ^3.2", - "api-platform/symfony": "*@dev || ^3.2", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^10.0", - "ramsey/uuid": "^4.7", - "ramsey/uuid-doctrine": "^2.0", - "symfony/cache": "^6.4 || ^7.0", - "symfony/framework-bundle": "^6.4 || ^7.0", - "symfony/phpunit-bridge": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0", - "symfony/yaml": "^6.4 || ^7.0" - }, - "autoload": { - "psr-4": { - "ApiPlatform\\Doctrine\\Orm\\": "" - } - }, - "config": { - "preferred-install": { - "*": "dist" + "require-dev": { + "api-platform/parameter-validator": "*@dev || ^3.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^10.0", + "ramsey/uuid": "^4.7", + "ramsey/uuid-doctrine": "^2.0", + "symfony/cache": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^6.4 || ^7.0", + "symfony/property-access": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0", + "symfony/uid": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0" }, - "sort-packages": true, - "allow-plugins": { - "php-http/discovery": false - } - }, - "extra": { - "branch-alias": { - "dev-main": "3.2.x-dev" + "autoload": { + "psr-4": { + "ApiPlatform\\Doctrine\\Orm\\": "" + } + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": false + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.3.x-dev" + }, + "symfony": { + "require": "^6.4" + } }, - "symfony": { - "require": "^6.4" + "scripts": { + "test": "./vendor/bin/phpunit" } - }, - "repositories": [ - { - "type": "path", - "url": "../../Metadata" - }, - { - "type": "path", - "url": "../../State" - }, - { - "type": "path", - "url": "../Common" - }, - { - "type": "path", - "url": "../../Symfony" - }, - { - "type": "path", - "url": "../../ParameterValidator" - } - ] } From 8ade0bcfe64deb13b2dea6e94098fb319c22d117 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Mon, 4 Mar 2024 17:46:10 +0100 Subject: [PATCH 007/152] feat: improve ApiProperty::security using property name (#5853) * feat: improve ApiProperty::security using property name * docs: update guide * fix: code-selector --- Filter/OrderFilter.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index bf40b54..1987ee2 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -110,7 +110,8 @@ * * By default, whenever the query does not specify the direction explicitly (e.g.: `/books?order[title]&order[id]`), filters will not be applied unless you configure a default order direction to use: * - * [codeSelector] + *
+ * * ```php * * * ``` - * [/codeSelector] + * + *
* * When the property used for ordering can contain `null` values, you may want to specify how `null` values are treated in the comparison: * - Use the default behavior of the DBMS: use `null` strategy From a91ae37433ca955a218c663065d56bf6291c83f4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 5 Mar 2024 09:48:15 +0100 Subject: [PATCH 008/152] fix(doctrine): isAssociationInverseSide before getMappedBy (#6197) --- Metadata/Resource/DoctrineOrmLinkFactory.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Metadata/Resource/DoctrineOrmLinkFactory.php b/Metadata/Resource/DoctrineOrmLinkFactory.php index ba3dcc3..1922ccd 100644 --- a/Metadata/Resource/DoctrineOrmLinkFactory.php +++ b/Metadata/Resource/DoctrineOrmLinkFactory.php @@ -66,8 +66,16 @@ public function createLinksFromRelations(Metadata $operation): array continue; } + if (!$doctrineMetadata->isAssociationInverseSide($property)) { + continue; + } + + if (!($mappedBy = $doctrineMetadata->getAssociationMappedByTargetField($property))) { + continue; + } + $relationClass = $doctrineMetadata->getAssociationTargetClass($property); - if (!($mappedBy = $doctrineMetadata->getAssociationMappedByTargetField($property)) || !$this->resourceClassResolver->isResourceClass($relationClass)) { + if (!$this->resourceClassResolver->isResourceClass($relationClass)) { continue; } From 3077731073638fb5b4773f9ebddc13a1f9eb7747 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 5 Mar 2024 11:16:34 +0100 Subject: [PATCH 009/152] fix(doctrine): doctrine/orm:^3.0 support (#6193) * feat: add compatibility with doctrine/orm 3.0 * attempt to fix lowest --------- Co-authored-by: David de Boer --- AbstractPaginator.php | 2 +- Extension/EagerLoadingExtension.php | 5 ++-- Extension/FilterEagerLoadingExtension.php | 6 ++-- Filter/ExistsFilter.php | 29 ++++++++++++++++--- .../DoctrineOrmPropertyMetadataFactory.php | 14 +++++---- State/LinksHandlerTrait.php | 6 ++-- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/AbstractPaginator.php b/AbstractPaginator.php index 3c3fa42..5324bf1 100644 --- a/AbstractPaginator.php +++ b/AbstractPaginator.php @@ -32,7 +32,7 @@ public function __construct(DoctrinePaginator $paginator) { $query = $paginator->getQuery(); - if (null === ($firstResult = $query->getFirstResult()) || null === $maxResults = $query->getMaxResults()) { // @phpstan-ignore-line + if (null === ($firstResult = $query->getFirstResult()) || $firstResult < 0 || null === $maxResults = $query->getMaxResults()) { // @phpstan-ignore-line throw new InvalidArgumentException(sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class)); } diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index 49f63b4..e04bf2e 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -23,7 +23,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Select; use Doctrine\ORM\QueryBuilder; @@ -137,9 +136,9 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt if ( // Always skip extra lazy associations - ClassMetadataInfo::FETCH_EXTRA_LAZY === $mapping['fetch'] + ClassMetadata::FETCH_EXTRA_LAZY === $mapping['fetch'] // We don't want to interfere with doctrine on this association - || (false === $forceEager && ClassMetadataInfo::FETCH_EAGER !== $mapping['fetch']) + || (false === $forceEager && ClassMetadata::FETCH_EAGER !== $mapping['fetch']) ) { continue; } diff --git a/Extension/FilterEagerLoadingExtension.php b/Extension/FilterEagerLoadingExtension.php index 31170db..daba5d9 100644 --- a/Extension/FilterEagerLoadingExtension.php +++ b/Extension/FilterEagerLoadingExtension.php @@ -19,7 +19,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -111,12 +111,12 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator * * @param array $checked array cache of tested metadata classes */ - private function hasFetchEagerAssociation(EntityManagerInterface $em, ClassMetadataInfo $classMetadata, array &$checked = []): bool + private function hasFetchEagerAssociation(EntityManagerInterface $em, ClassMetadata $classMetadata, array &$checked = []): bool { $checked[] = $classMetadata->name; foreach ($classMetadata->getAssociationMappings() as $mapping) { - if (ClassMetadataInfo::FETCH_EAGER === $mapping['fetch']) { + if (ClassMetadata::FETCH_EAGER === $mapping['fetch']) { return true; } diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index db0db4b..5f516c4 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -18,7 +18,10 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; -use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Mapping\AssociationMapping; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; +use Doctrine\ORM\Mapping\ToOneOwningSideMapping; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -199,7 +202,7 @@ protected function isNullableField(string $property, string $resourceClass): boo if ($metadata->hasAssociation($field)) { if ($metadata->isSingleValuedAssociation($field)) { - if (!($metadata instanceof ClassMetadataInfo)) { + if (!($metadata instanceof ClassMetadata)) { return false; } @@ -211,7 +214,7 @@ protected function isNullableField(string $property, string $resourceClass): boo return true; } - if ($metadata instanceof ClassMetadataInfo && $metadata->hasField($field)) { + if ($metadata instanceof ClassMetadata && $metadata->hasField($field)) { return $metadata->isNullable($field); } @@ -223,8 +226,26 @@ protected function isNullableField(string $property, string $resourceClass): boo * * @see https://github.com/doctrine/doctrine2/blob/v2.5.4/lib/Doctrine/ORM/Tools/EntityGenerator.php#L1221-L1246 */ - private function isAssociationNullable(array $associationMapping): bool + private function isAssociationNullable(AssociationMapping|array $associationMapping): bool { + if ($associationMapping instanceof AssociationMapping) { + if (!empty($associationMapping->id)) { + return false; + } + + if ($associationMapping instanceof ToOneOwningSideMapping || $associationMapping instanceof ManyToManyOwningSideMapping) { + foreach ($associationMapping->joinColumns as $joinColumn) { + if (false === $joinColumn->nullable) { + return false; + } + } + + return true; + } + + return true; + } + if (!empty($associationMapping['id'])) { return false; } diff --git a/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php b/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php index d3e4816..c450e85 100644 --- a/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php +++ b/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php @@ -15,7 +15,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\Persistence\ManagerRegistry; /** @@ -55,7 +56,7 @@ public function create(string $resourceClass, string $property, array $options = break; } - if ($doctrineClassMetadata instanceof ClassMetadataInfo) { + if ($doctrineClassMetadata instanceof ClassMetadata) { $writable = $doctrineClassMetadata->isIdentifierNatural(); } else { $writable = false; @@ -67,10 +68,13 @@ public function create(string $resourceClass, string $property, array $options = } } - if ($doctrineClassMetadata instanceof ClassMetadataInfo && \in_array($property, $doctrineClassMetadata->getFieldNames(), true)) { - /** @var mixed[] */ + if ($doctrineClassMetadata instanceof ClassMetadata && \in_array($property, $doctrineClassMetadata->getFieldNames(), true)) { $fieldMapping = $doctrineClassMetadata->getFieldMapping($property); - $propertyMetadata = $propertyMetadata->withDefault($fieldMapping['options']['default'] ?? $propertyMetadata->getDefault()); + if (class_exists(FieldMapping::class) && $fieldMapping instanceof FieldMapping) { + $propertyMetadata = $propertyMetadata->withDefault($fieldMapping->default ?? $propertyMetadata->getDefault()); + } else { + $propertyMetadata = $propertyMetadata->withDefault($fieldMapping['options']['default'] ?? $propertyMetadata->getDefault()); + } } return $propertyMetadata; diff --git a/State/LinksHandlerTrait.php b/State/LinksHandlerTrait.php index 585195d..9c9f268 100644 --- a/State/LinksHandlerTrait.php +++ b/State/LinksHandlerTrait.php @@ -17,7 +17,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; -use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\QueryBuilder; /** @@ -83,7 +83,7 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que $associationMapping = $fromClassMetadata->getAssociationMapping($link->getFromProperty()); // @phpstan-ignore-line $relationType = $associationMapping['type']; - if ($relationType & ClassMetadataInfo::TO_MANY) { + if ($relationType & ClassMetadata::TO_MANY) { $nextAlias = $queryNameGenerator->generateJoinAlias($alias); $whereClause = []; foreach ($identifierProperties as $identifierProperty) { @@ -100,7 +100,7 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que } // A single-valued association path expression to an inverse side is not supported in DQL queries. - if ($relationType & ClassMetadataInfo::TO_ONE && !($associationMapping['isOwningSide'] ?? true)) { + if ($relationType & ClassMetadata::TO_ONE && !($associationMapping['isOwningSide'] ?? true)) { $queryBuilder->innerJoin("$previousAlias.".$associationMapping['mappedBy'], $joinAlias); } else { $queryBuilder->join( From 8cb2d64e0d06e7a79efa8ba809442007ac223301 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 21 Mar 2024 09:55:29 +0100 Subject: [PATCH 010/152] chore: doctrine deprecations --- Tests/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/config.yml b/Tests/config.yml index 700332d..e3e948b 100644 --- a/Tests/config.yml +++ b/Tests/config.yml @@ -8,6 +8,8 @@ doctrine: orm: auto_generate_proxy_classes: '%kernel.debug%' + controller_resolver: + auto_mapping: true enable_lazy_ghost_objects: true mappings: ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity: From 2097de40be5c81bf5e26112b1266a8f5fe0b403b Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 21 Mar 2024 11:24:31 +0100 Subject: [PATCH 011/152] test: decorate name converter as symfony interface changed --- Tests/Fixtures/CustomConverter.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Tests/Fixtures/CustomConverter.php b/Tests/Fixtures/CustomConverter.php index af1617c..956eca7 100644 --- a/Tests/Fixtures/CustomConverter.php +++ b/Tests/Fixtures/CustomConverter.php @@ -14,20 +14,28 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Custom converter that will only convert a property named "nameConverted" * with the same logic as Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. */ -class CustomConverter extends CamelCaseToSnakeCaseNameConverter +class CustomConverter implements NameConverterInterface { + private NameConverterInterface $nameConverter; + + public function __construct() + { + $this->nameConverter = new CamelCaseToSnakeCaseNameConverter(); + } + public function normalize(string $propertyName): string { - return 'nameConverted' === $propertyName ? parent::normalize($propertyName) : $propertyName; + return 'nameConverted' === $propertyName ? $this->nameConverter->normalize($propertyName) : $propertyName; } public function denormalize(string $propertyName): string { - return 'name_converted' === $propertyName ? parent::denormalize($propertyName) : $propertyName; + return 'name_converted' === $propertyName ? $this->nameConverter->denormalize($propertyName) : $propertyName; } } From a12063a5ac5b8683fcdfab3e92db665739523cad Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 Mar 2024 15:02:44 +0100 Subject: [PATCH 012/152] fix(doctrine): wrong return type without name converter #6079 (#6254) --- Filter/AbstractFilter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index c1deb1c..0ad569e 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -80,7 +80,7 @@ protected function isPropertyEnabled(string $property, string $resourceClass): b protected function denormalizePropertyName(string|int $property): string { if (!$this->nameConverter instanceof NameConverterInterface) { - return $property; + return (string) $property; } return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property))); From c1a7a24ea957b5498d6edf6ab90f5e0da0d53662 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 Mar 2024 17:11:24 +0100 Subject: [PATCH 013/152] feat(doctrine): parameter filter extension (#6248) * feat(doctrine): parameter filtering * feat(graphql): parameter graphql arguments --- Extension/ParameterExtension.php | 70 ++++++++++++++++++++++++++++++++ Filter/AbstractFilter.php | 11 ++++- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 Extension/ParameterExtension.php diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php new file mode 100644 index 0000000..792f311 --- /dev/null +++ b/Extension/ParameterExtension.php @@ -0,0 +1,70 @@ + + * + * 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\Orm\Extension; + +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Psr\Container\ContainerInterface; + +/** + * Reads operation parameters and execute its filter. + * + * @author Antoine Bluchet + */ +final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface +{ + public function __construct(private readonly ContainerInterface $filterLocator) + { + } + + /** + * @param array $context + */ + private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $values = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!$values) { + continue; + } + + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values] + $context); + } + } + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + + /** + * {@inheritdoc} + */ + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void + { + $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } +} diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index 0ad569e..2e258af 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; @@ -23,7 +24,7 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements FilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; @@ -64,6 +65,14 @@ protected function getLogger(): LoggerInterface return $this->logger; } + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + /** * Determines whether the given property is enabled. */ From 7fc1f6edc5b72c32e83d4d481795f423a14e2ee9 Mon Sep 17 00:00:00 2001 From: Stephan Vierkant Date: Fri, 31 May 2024 11:04:16 +0200 Subject: [PATCH 014/152] fix(metadata): add some phpdoc annotations to ORM (#6387) --- .../QueryCollectionExtensionInterface.php | 4 +++ Extension/QueryItemExtensionInterface.php | 5 ++++ Filter/AbstractFilter.php | 3 ++ Tests/Extension/PaginationExtensionTest.php | 28 +++++++++---------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/Extension/QueryCollectionExtensionInterface.php b/Extension/QueryCollectionExtensionInterface.php index 191c2ac..d58c08d 100644 --- a/Extension/QueryCollectionExtensionInterface.php +++ b/Extension/QueryCollectionExtensionInterface.php @@ -25,5 +25,9 @@ */ interface QueryCollectionExtensionInterface { + /** + * @param class-string $resourceClass + * @param array $context + */ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/Extension/QueryItemExtensionInterface.php b/Extension/QueryItemExtensionInterface.php index 4d7aaed..ddbbb04 100644 --- a/Extension/QueryItemExtensionInterface.php +++ b/Extension/QueryItemExtensionInterface.php @@ -25,5 +25,10 @@ */ interface QueryItemExtensionInterface { + /** + * @param class-string $resourceClass + * @param array $identifiers + * @param array $context + */ public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void; } diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index 2e258af..4ec7046 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -47,6 +47,9 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * Passes a property through the filter. + * + * @param class-string $resourceClass + * @param array $context */ abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; diff --git a/Tests/Extension/PaginationExtensionTest.php b/Tests/Extension/PaginationExtensionTest.php index 5c6b3e7..64e505c 100644 --- a/Tests/Extension/PaginationExtensionTest.php +++ b/Tests/Extension/PaginationExtensionTest.php @@ -67,7 +67,7 @@ public function testApplyToCollection(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 40), ['filters' => ['pagination' => true, 'itemsPerPage' => 20, '_page' => 2]]); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 40), ['filters' => ['pagination' => true, 'itemsPerPage' => 20, '_page' => 2]]); } public function testApplyToCollectionWithItemPerPageZero(): void @@ -86,7 +86,7 @@ public function testApplyToCollectionWithItemPerPageZero(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 0), ['filters' => ['pagination' => true, 'itemsPerPage' => 0, '_page' => 1]]); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 0), ['filters' => ['pagination' => true, 'itemsPerPage' => 0, '_page' => 1]]); } public function testApplyToCollectionWithItemPerPageZeroAndPage2(): void @@ -108,7 +108,7 @@ public function testApplyToCollectionWithItemPerPageZeroAndPage2(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 0), ['filters' => ['pagination' => true, 'itemsPerPage' => 0, '_page' => 2]]); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 0), ['filters' => ['pagination' => true, 'itemsPerPage' => 0, '_page' => 2]]); } public function testApplyToCollectionWithItemPerPageLessThan0(): void @@ -130,7 +130,7 @@ public function testApplyToCollectionWithItemPerPageLessThan0(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: -20), ['filters' => ['pagination' => true, 'itemsPerPage' => -20, '_page' => 2]]); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: -20), ['filters' => ['pagination' => true, 'itemsPerPage' => -20, '_page' => 2]]); } public function testApplyToCollectionWithItemPerPageTooHigh(): void @@ -149,7 +149,7 @@ public function testApplyToCollectionWithItemPerPageTooHigh(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationClientItemsPerPage: true), ['filters' => ['pagination' => true, 'itemsPerPage' => 301, '_page' => 2]]); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationClientItemsPerPage: true), ['filters' => ['pagination' => true, 'itemsPerPage' => 301, '_page' => 2]]); } public function testApplyToCollectionWithGraphql(): void @@ -165,7 +165,7 @@ public function testApplyToCollectionWithGraphql(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 20), ['filters' => ['pagination' => true, 'first' => 5, 'after' => 'OQ=='], 'graphql_operation_name' => 'query']); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationItemsPerPage: 20), ['filters' => ['pagination' => true, 'first' => 5, 'after' => 'OQ=='], 'graphql_operation_name' => 'query']); } public function testApplyToCollectionNofilters(): void @@ -181,7 +181,7 @@ public function testApplyToCollectionNofilters(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection()); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection()); } public function testApplyToCollectionPaginationDisabled(): void @@ -199,7 +199,7 @@ public function testApplyToCollectionPaginationDisabled(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection()); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection()); } public function testApplyToCollectionGraphQlPaginationDisabled(): void @@ -217,7 +217,7 @@ public function testApplyToCollectionGraphQlPaginationDisabled(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(), ['graphql_operation_name' => 'op']); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(), ['graphql_operation_name' => 'op']); } public function testApplyToCollectionWithMaximumItemsPerPage(): void @@ -237,7 +237,7 @@ public function testApplyToCollectionWithMaximumItemsPerPage(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), 'Foo', new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationMaximumItemsPerPage: 80), ['filters' => ['pagination' => true, 'itemsPerPage' => 80, 'page' => 1]]); + $extension->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(paginationEnabled: true, paginationClientEnabled: true, paginationMaximumItemsPerPage: 80), ['filters' => ['pagination' => true, 'itemsPerPage' => 80, 'page' => 1]]); } public function testSupportsResult(): void @@ -248,7 +248,7 @@ public function testSupportsResult(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $this->assertTrue($extension->supportsResult('Foo', new GetCollection())); + $this->assertTrue($extension->supportsResult(Dummy::class, new GetCollection())); } public function testSupportsResultClientNotAllowedToPaginate(): void @@ -262,7 +262,7 @@ public function testSupportsResultClientNotAllowedToPaginate(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $this->assertFalse($extension->supportsResult('Foo', new GetCollection(), ['filters' => ['pagination' => true]])); + $this->assertFalse($extension->supportsResult(Dummy::class, new GetCollection(), ['filters' => ['pagination' => true]])); } public function testSupportsResultPaginationDisabled(): void @@ -275,7 +275,7 @@ public function testSupportsResultPaginationDisabled(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $this->assertFalse($extension->supportsResult('Foo', new GetCollection())); + $this->assertFalse($extension->supportsResult(Dummy::class, new GetCollection())); } public function testSupportsResultGraphQlPaginationDisabled(): void @@ -288,7 +288,7 @@ public function testSupportsResultGraphQlPaginationDisabled(): void $this->prophesize(ManagerRegistry::class)->reveal(), $pagination ); - $this->assertFalse($extension->supportsResult('Foo', new GetCollection(), ['graphql_operation_name' => 'op'])); + $this->assertFalse($extension->supportsResult(Dummy::class, new GetCollection(), ['graphql_operation_name' => 'op'])); } public function testGetResult(): void From cd46f611c32026a587af191ddca2696c3f2e0d8c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 1 Jun 2024 14:18:37 +0200 Subject: [PATCH 015/152] chore(symfony): deprecations (#6401) --- Tests/Fixtures/CustomConverter.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/Fixtures/CustomConverter.php b/Tests/Fixtures/CustomConverter.php index 956eca7..e0a06d8 100644 --- a/Tests/Fixtures/CustomConverter.php +++ b/Tests/Fixtures/CustomConverter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -20,7 +21,7 @@ * Custom converter that will only convert a property named "nameConverted" * with the same logic as Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. */ -class CustomConverter implements NameConverterInterface +class CustomConverter implements AdvancedNameConverterInterface { private NameConverterInterface $nameConverter; @@ -29,12 +30,12 @@ public function __construct() $this->nameConverter = new CamelCaseToSnakeCaseNameConverter(); } - public function normalize(string $propertyName): string + public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { return 'nameConverted' === $propertyName ? $this->nameConverter->normalize($propertyName) : $propertyName; } - public function denormalize(string $propertyName): string + public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { return 'name_converted' === $propertyName ? $this->nameConverter->denormalize($propertyName) : $propertyName; } From c9843ee9fd5cdfc4419c1b2bc26e66029817bff6 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 13 Jun 2024 08:27:02 +0200 Subject: [PATCH 016/152] fix(doctrine): use null-safe operator when retrieving parameters (#6423) Co-authored-by: Sebastiaan <45851377+Kyzegs@users.noreply.github.com> --- Extension/ParameterExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index 792f311..334c1b9 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -35,7 +35,7 @@ public function __construct(private readonly ContainerInterface $filterLocator) */ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - foreach ($operation->getParameters() ?? [] as $parameter) { + foreach ($operation?->getParameters() ?? [] as $parameter) { $values = $parameter->getExtraProperties()['_api_values'] ?? []; if (!$values) { continue; From 8e5977b957558b12e06a3e87494a144bc8646ff8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 25 Jun 2024 16:01:23 +0200 Subject: [PATCH 017/152] feat(laravel): laravel component (#5882) * feat(laravel): laravel component * try to skip laravel * feat(jsonapi): component * feat(laravel): json api support (needs review) * work on relations * relations (needs toMany) + skolem + IRI to resource * links handler * ulid * validation * slug post * remove deprecations * move classes * fix tests * fix tests metadata * phpstan * missing class * fix laravel tests * fix stan --- Extension/FilterEagerLoadingExtension.php | 2 +- Extension/FilterExtension.php | 2 +- Extension/OrderExtension.php | 2 +- Extension/PaginationExtension.php | 2 +- Filter/SearchFilter.php | 6 ++---- Metadata/Resource/DoctrineOrmLinkFactory.php | 3 +-- PropertyHelperTrait.php | 2 +- State/Options.php | 13 ------------- .../Resource/DoctrineOrmLinkFactoryTest.php | 2 +- 9 files changed, 9 insertions(+), 25 deletions(-) diff --git a/Extension/FilterEagerLoadingExtension.php b/Extension/FilterEagerLoadingExtension.php index daba5d9..8145fa6 100644 --- a/Extension/FilterEagerLoadingExtension.php +++ b/Extension/FilterEagerLoadingExtension.php @@ -15,7 +15,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Doctrine\ORM\EntityManagerInterface; diff --git a/Extension/FilterExtension.php b/Extension/FilterExtension.php index 298eff2..801e9c4 100644 --- a/Extension/FilterExtension.php +++ b/Extension/FilterExtension.php @@ -16,7 +16,7 @@ use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Psr\Container\ContainerInterface; diff --git a/Extension/OrderExtension.php b/Extension/OrderExtension.php index a383383..3989072 100644 --- a/Extension/OrderExtension.php +++ b/Extension/OrderExtension.php @@ -15,7 +15,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; diff --git a/Extension/PaginationExtension.php b/Extension/PaginationExtension.php index 80513e4..9b4ab0d 100644 --- a/Extension/PaginationExtension.php +++ b/Extension/PaginationExtension.php @@ -17,7 +17,7 @@ use ApiPlatform\Doctrine\Orm\Paginator; use ApiPlatform\Doctrine\Orm\Util\QueryChecker; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Pagination\Pagination; use Doctrine\ORM\QueryBuilder; diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index 3b09533..61b35b7 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -13,8 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Filter; -use ApiPlatform\Api\IdentifiersExtractorInterface as LegacyIdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface; use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; use ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; @@ -141,7 +139,7 @@ final class SearchFilter extends AbstractFilter implements SearchFilterInterface public const DOCTRINE_INTEGER_TYPE = Types::INTEGER; - public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ?PropertyAccessorInterface $propertyAccessor = null, ?LoggerInterface $logger = null, ?array $properties = null, IdentifiersExtractorInterface|LegacyIdentifiersExtractorInterface|null $identifiersExtractor = null, ?NameConverterInterface $nameConverter = null) + public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface $iriConverter, ?PropertyAccessorInterface $propertyAccessor = null, ?LoggerInterface $logger = null, ?array $properties = null, ?IdentifiersExtractorInterface $identifiersExtractor = null, ?NameConverterInterface $nameConverter = null) { parent::__construct($managerRegistry, $logger, $properties, $nameConverter); @@ -150,7 +148,7 @@ public function __construct(ManagerRegistry $managerRegistry, IriConverterInterf $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); } - protected function getIriConverter(): IriConverterInterface|LegacyIriConverterInterface + protected function getIriConverter(): IriConverterInterface { return $this->iriConverter; } diff --git a/Metadata/Resource/DoctrineOrmLinkFactory.php b/Metadata/Resource/DoctrineOrmLinkFactory.php index 1922ccd..bf958b8 100644 --- a/Metadata/Resource/DoctrineOrmLinkFactory.php +++ b/Metadata/Resource/DoctrineOrmLinkFactory.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Metadata\Resource; -use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Metadata; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -28,7 +27,7 @@ */ final class DoctrineOrmLinkFactory implements LinkFactoryInterface, PropertyLinkFactoryInterface { - public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, private readonly LinkFactoryInterface&PropertyLinkFactoryInterface $linkFactory) + public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly LinkFactoryInterface&PropertyLinkFactoryInterface $linkFactory) { } diff --git a/PropertyHelperTrait.php b/PropertyHelperTrait.php index e0b9a14..a8086ca 100644 --- a/PropertyHelperTrait.php +++ b/PropertyHelperTrait.php @@ -15,7 +15,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use Doctrine\ORM\Mapping\ClassMetadata as ClassMetadataInfo; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; diff --git a/State/Options.php b/State/Options.php index 397d2da..3a9a46c 100644 --- a/State/Options.php +++ b/State/Options.php @@ -42,17 +42,4 @@ public function withEntityClass(?string $entityClass): self return $self; } - - public function getHandleLinks(): mixed - { - return $this->handleLinks; - } - - public function withHandleLinks(mixed $handleLinks): self - { - $self = clone $this; - $self->handleLinks = $handleLinks; - - return $self; - } } diff --git a/Tests/Metadata/Resource/DoctrineOrmLinkFactoryTest.php b/Tests/Metadata/Resource/DoctrineOrmLinkFactoryTest.php index 3c16c0f..65b5eee 100644 --- a/Tests/Metadata/Resource/DoctrineOrmLinkFactoryTest.php +++ b/Tests/Metadata/Resource/DoctrineOrmLinkFactoryTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Metadata\Resource; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\RelatedDummy; @@ -25,6 +24,7 @@ use ApiPlatform\Metadata\Property\PropertyNameCollection; use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\PropertyLinkFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; From bd8b844d4d15e41f72e628b482bd7bc0e6da8135 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 5 Jul 2024 17:48:09 +0200 Subject: [PATCH 018/152] fix(state): query and header parameter with the same name (#6453) --- Extension/ParameterExtension.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index 334c1b9..46c8664 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; @@ -26,6 +27,8 @@ */ final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface { + use ParameterValueExtractorTrait; + public function __construct(private readonly ContainerInterface $filterLocator) { } @@ -36,7 +39,7 @@ public function __construct(private readonly ContainerInterface $filterLocator) private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($operation?->getParameters() ?? [] as $parameter) { - $values = $parameter->getExtraProperties()['_api_values'] ?? []; + $values = $this->extractParameterValue($parameter->getValue() ?? []); if (!$values) { continue; } From 15e4671cc23f8167ff175aadfb731a19e970c37b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 8 Jul 2024 09:41:59 +0200 Subject: [PATCH 019/152] fix(state): store parameter value without its key (#6456) --- Extension/ParameterExtension.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index 46c8664..dce2c62 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -17,6 +17,7 @@ use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; use Psr\Container\ContainerInterface; @@ -39,11 +40,11 @@ public function __construct(private readonly ContainerInterface $filterLocator) private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($operation?->getParameters() ?? [] as $parameter) { - $values = $this->extractParameterValue($parameter->getValue() ?? []); - if (!$values) { + if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } + $values = $this->extractParameterValue($parameter, $v); if (null === ($filterId = $parameter->getFilter())) { continue; } From 6e8f967cd07850ba7c93a3fbbd615b82b54a68ea Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 8 Jul 2024 13:06:27 +0200 Subject: [PATCH 020/152] feat: deprecate query parameter validator (#6454) --- Tests/Filter/OrderFilterTest.php | 21 +++++++++++++++++++++ Tests/Filter/OrderFilterTestTrait.php | 2 ++ 2 files changed, 23 insertions(+) diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index 3838290..8864928 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -40,6 +40,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -52,6 +53,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -64,6 +66,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -76,6 +79,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -88,6 +92,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -100,6 +105,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -112,6 +118,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -124,6 +131,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -136,6 +144,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -148,6 +157,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -160,6 +170,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -172,6 +183,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -187,6 +199,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -199,6 +212,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -211,6 +225,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -223,6 +238,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -235,6 +251,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -247,6 +264,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -259,6 +277,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -271,6 +290,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -283,6 +303,7 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', diff --git a/Tests/Filter/OrderFilterTestTrait.php b/Tests/Filter/OrderFilterTestTrait.php index 2409ae8..b125895 100644 --- a/Tests/Filter/OrderFilterTestTrait.php +++ b/Tests/Filter/OrderFilterTestTrait.php @@ -29,6 +29,7 @@ public function testGetDescription(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', @@ -41,6 +42,7 @@ public function testGetDescription(): void 'type' => 'string', 'required' => false, 'schema' => [ + 'default' => 'asc', 'type' => 'string', 'enum' => [ 'asc', From 62c4f71be5a3bf35afccecb0e520172b5e10bc54 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 15 Jul 2024 11:45:37 +0200 Subject: [PATCH 021/152] chore: symfony 7.1 dependency and branch alias (#6468) * chore: symfony 7.1 dependency and branch alias * chore: main_request --- composer.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index e02d885..40bebfc 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "api-platform/state": "*@dev || ^3.1", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^6.4 || ^7.0" + "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { "api-platform/parameter-validator": "*@dev || ^3.2", @@ -59,13 +59,14 @@ }, "extra": { "branch-alias": { - "dev-main": "3.3.x-dev" + "dev-main": "4.0.x-dev", + "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4" + "require": "^6.4 || ^7.1" } }, "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From cd35e81df468bba602a94dd7202a71e121af2a5c Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 25 Jun 2024 22:24:35 +0200 Subject: [PATCH 022/152] chore: remove deprecations --- Tests/AppKernel.php | 7 +++++++ Tests/DoctrineOrmFilterTestCase.php | 4 +--- Tests/Extension/EagerLoadingExtensionTest.php | 4 +--- Tests/Extension/PaginationExtensionTest.php | 8 ++------ Tests/Fixtures/Entity/Dummy.php | 2 +- Tests/Fixtures/Entity/RelatedToDummyFriend.php | 2 +- .../DoctrineOrmResourceCollectionMetadataFactoryTest.php | 4 +--- Tests/PaginatorTest.php | 4 +--- Tests/Util/QueryBuilderHelperTest.php | 8 ++------ composer.json | 4 ++-- 10 files changed, 19 insertions(+), 28 deletions(-) diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index 29229e2..3f1efec 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -18,6 +18,7 @@ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; /** @@ -43,6 +44,12 @@ public function registerBundles(): array new FrameworkBundle(), new DoctrineBundle(), new TestBundle(), + new class() extends Bundle { + public function shutdown(): void + { + restore_exception_handler(); + } + }, ]; } diff --git a/Tests/DoctrineOrmFilterTestCase.php b/Tests/DoctrineOrmFilterTestCase.php index 77b851e..9a2c0e9 100644 --- a/Tests/DoctrineOrmFilterTestCase.php +++ b/Tests/DoctrineOrmFilterTestCase.php @@ -45,9 +45,7 @@ protected function setUp(): void $this->repository = $this->managerRegistry->getManagerForClass(Dummy::class)->getRepository(Dummy::class); } - /** - * @dataProvider provideApplyTestData - */ + #[\PHPUnit\Framework\Attributes\DataProvider('provideApplyTestData')] public function testApply(?array $properties, array $filterParameters, string $expectedDql, ?array $expectedParameters = null, ?callable $factory = null, ?string $resourceClass = null): void { $this->doTestApply($properties, $filterParameters, $expectedDql, $expectedParameters, $factory, $resourceClass); diff --git a/Tests/Extension/EagerLoadingExtensionTest.php b/Tests/Extension/EagerLoadingExtensionTest.php index 2bd173d..5dec2ce 100644 --- a/Tests/Extension/EagerLoadingExtensionTest.php +++ b/Tests/Extension/EagerLoadingExtensionTest.php @@ -821,9 +821,7 @@ public function testApplyToCollectionWithANonReadableButFetchEagerProperty(): vo $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); } - /** - * @dataProvider provideExistingJoinCases - */ + #[\PHPUnit\Framework\Attributes\DataProvider('provideExistingJoinCases')] public function testApplyToCollectionWithExistingJoin(string $joinType): void { $context = ['groups' => ['foo']]; diff --git a/Tests/Extension/PaginationExtensionTest.php b/Tests/Extension/PaginationExtensionTest.php index 64e505c..2b8bcc5 100644 --- a/Tests/Extension/PaginationExtensionTest.php +++ b/Tests/Extension/PaginationExtensionTest.php @@ -355,9 +355,7 @@ public function testGetResultWithoutDistinct(): void $this->assertFalse($query->getHint(CountWalker::HINT_DISTINCT)); } - /** - * @dataProvider fetchJoinCollectionProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('fetchJoinCollectionProvider')] public function testGetResultWithFetchJoinCollection(bool $paginationFetchJoinCollection, array $context, bool $expected): void { $dummyMetadata = new ClassMetadata(Dummy::class); @@ -405,9 +403,7 @@ public static function fetchJoinCollectionProvider(): array ]; } - /** - * @dataProvider fetchUseOutputWalkersProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('fetchUseOutputWalkersProvider')] public function testGetResultWithUseOutputWalkers(bool $paginationUseOutputWalkers, array $context, bool $expected): void { $dummyMetadata = new ClassMetadata(Dummy::class); diff --git a/Tests/Fixtures/Entity/Dummy.php b/Tests/Fixtures/Entity/Dummy.php index 00df229..544d01c 100644 --- a/Tests/Fixtures/Entity/Dummy.php +++ b/Tests/Fixtures/Entity/Dummy.php @@ -27,7 +27,7 @@ * * @author Kévin Dunglas */ -#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false])] +#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ORM\Entity] diff --git a/Tests/Fixtures/Entity/RelatedToDummyFriend.php b/Tests/Fixtures/Entity/RelatedToDummyFriend.php index 3e95aa9..e0482b1 100644 --- a/Tests/Fixtures/Entity/RelatedToDummyFriend.php +++ b/Tests/Fixtures/Entity/RelatedToDummyFriend.php @@ -24,7 +24,7 @@ /** * Related To Dummy Friend represent an association table for a manytomany relation. */ -#[ApiResource(normalizationContext: ['groups' => ['fakemanytomany']], filters: ['related_to_dummy_friend.name'], extraProperties: ['rfc_7807_compliant_errors' => false])] +#[ApiResource(normalizationContext: ['groups' => ['fakemanytomany']], filters: ['related_to_dummy_friend.name'])] #[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] #[ApiResource(uriTemplate: '/related_dummies/{id}/id/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] #[ApiResource(uriTemplate: '/related_dummies/{id}/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] diff --git a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php index 9904186..3e371af 100644 --- a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php +++ b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php @@ -62,9 +62,7 @@ public function testWithoutManager(): void $this->assertNull($resourceMetadataCollection->getOperation('graphql_get')->getProvider()); } - /** - * @dataProvider operationProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('operationProvider')] public function testWithProvider(Operation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void { $objectManager = $this->prophesize(EntityManagerInterface::class); diff --git a/Tests/PaginatorTest.php b/Tests/PaginatorTest.php index b5a347a..6201003 100644 --- a/Tests/PaginatorTest.php +++ b/Tests/PaginatorTest.php @@ -30,9 +30,7 @@ class PaginatorTest extends TestCase { use ProphecyTrait; - /** - * @dataProvider initializeProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('initializeProvider')] public function testInitialize(int $firstResult, int $maxResults, int $totalItems, int $currentPage, int $lastPage, bool $hasNextPage): void { $paginator = $this->getPaginator($firstResult, $maxResults, $totalItems); diff --git a/Tests/Util/QueryBuilderHelperTest.php b/Tests/Util/QueryBuilderHelperTest.php index 7ce2b44..488d217 100644 --- a/Tests/Util/QueryBuilderHelperTest.php +++ b/Tests/Util/QueryBuilderHelperTest.php @@ -29,9 +29,7 @@ class QueryBuilderHelperTest extends TestCase { use ProphecyTrait; - /** - * @dataProvider provideAddJoinOnce - */ + #[\PHPUnit\Framework\Attributes\DataProvider('provideAddJoinOnce')] public function testAddJoinOnce(?string $originAliasForJoinOnce, string $expectedAlias): void { $queryBuilder = new QueryBuilder($this->prophesize(EntityManagerInterface::class)->reveal()); @@ -57,9 +55,7 @@ public function testAddJoinOnce(?string $originAliasForJoinOnce, string $expecte $queryBuilder->getDQLPart('join')[$originAliasForJoinOnce ?? 'f'][0]->getAlias()); } - /** - * @dataProvider provideAddJoinOnce - */ + #[\PHPUnit\Framework\Attributes\DataProvider('provideAddJoinOnce')] public function testAddJoinOnceWithSpecifiedNewAlias(): void { $queryBuilder = new QueryBuilder($this->prophesize(EntityManagerInterface::class)->reveal()); diff --git a/composer.json b/composer.json index 40bebfc..4cfb545 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "require-dev": { "api-platform/parameter-validator": "*@dev || ^3.2", "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^11.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0", @@ -69,4 +69,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From 7209acf2e5f468bef1a1f4c94777e56385688633 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 9 Aug 2024 09:21:07 +0200 Subject: [PATCH 023/152] style: various cs fixes (#6504) * cs: fixes * chore: phpstan fixes --- AbstractPaginator.php | 2 +- Extension/EagerLoadingExtension.php | 4 +- Extension/OrderExtension.php | 2 +- Filter/BooleanFilter.php | 2 +- Filter/DateFilter.php | 10 ++-- Filter/ExistsFilter.php | 8 +-- Filter/NumericFilter.php | 4 +- Filter/OrderFilter.php | 6 +-- Filter/RangeFilter.php | 16 +++--- Filter/SearchFilter.php | 14 ++--- PropertyHelperTrait.php | 2 +- Tests/DoctrineOrmFilterTestCase.php | 4 +- Tests/Filter/BooleanFilterTest.php | 22 ++++---- Tests/Filter/DateFilterTest.php | 36 ++++++------- Tests/Filter/ExistsFilterTest.php | 40 +++++++-------- Tests/Filter/NumericFilterTest.php | 22 ++++---- Tests/Filter/OrderFilterTest.php | 46 ++++++++--------- Tests/Filter/RangeFilterTest.php | 28 +++++----- Tests/Filter/SearchFilterTest.php | 80 ++++++++++++++--------------- Util/QueryBuilderHelper.php | 10 ++-- Util/QueryNameGenerator.php | 4 +- 21 files changed, 181 insertions(+), 181 deletions(-) diff --git a/AbstractPaginator.php b/AbstractPaginator.php index a3c2a08..59595c7 100644 --- a/AbstractPaginator.php +++ b/AbstractPaginator.php @@ -33,7 +33,7 @@ public function __construct(DoctrinePaginator $paginator) $query = $paginator->getQuery(); if (null === ($firstResult = $query->getFirstResult()) || $firstResult < 0 || null === $maxResults = $query->getMaxResults()) { // @phpstan-ignore-line - throw new InvalidArgumentException(sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class)); + throw new InvalidArgumentException(\sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class)); } $this->paginator = $paginator; diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index bd0935d..aa1e08a 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -186,7 +186,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt $method = $isLeftJoin ? 'leftJoin' : 'innerJoin'; $associationAlias = $queryNameGenerator->generateJoinAlias($association); - $queryBuilder->{$method}(sprintf('%s.%s', $parentAlias, $association), $associationAlias); + $queryBuilder->{$method}(\sprintf('%s.%s', $parentAlias, $association), $associationAlias); ++$joinCount; } @@ -264,7 +264,7 @@ private function addSelect(QueryBuilder $queryBuilder, string $entity, string $a } } - $queryBuilder->addSelect(sprintf('partial %s.{%s}', $associationAlias, implode(',', $select))); + $queryBuilder->addSelect(\sprintf('partial %s.{%s}', $associationAlias, implode(',', $select))); } private function addSelectOnce(QueryBuilder $queryBuilder, string $alias): void diff --git a/Extension/OrderExtension.php b/Extension/OrderExtension.php index a383383..00140fd 100644 --- a/Extension/OrderExtension.php +++ b/Extension/OrderExtension.php @@ -67,7 +67,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $field = "{$rootAlias}.{$field}"; } else { $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $rootAlias, substr($field, 0, $pos)); - $field = sprintf('%s.%s', $alias, substr($field, $pos + 1)); + $field = \sprintf('%s.%s', $alias, substr($field, $pos + 1)); } $queryBuilder->addOrderBy($field, $order); } diff --git a/Filter/BooleanFilter.php b/Filter/BooleanFilter.php index 2f230c4..e9f0a83 100644 --- a/Filter/BooleanFilter.php +++ b/Filter/BooleanFilter.php @@ -142,7 +142,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $valueParameter = $queryNameGenerator->generateParameterName($field); $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); } } diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index 066606e..8533ee3 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -161,7 +161,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $type = (string) $this->getDoctrineFieldType($property, $resourceClass); if (self::EXCLUDE_NULL === $nullManagement) { - $queryBuilder->andWhere($queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $field))); + $queryBuilder->andWhere($queryBuilder->expr()->isNotNull(\sprintf('%s.%s', $alias, $field))); } if (isset($values[self::PARAMETER_BEFORE])) { @@ -234,7 +234,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } catch (\Exception) { // Silently ignore this filter if it can not be transformed to a \DateTime $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('The field "%s" has a wrong date format. Use one accepted by the \DateTime constructor', $field)), + 'exception' => new InvalidArgumentException(\sprintf('The field "%s" has a wrong date format. Use one accepted by the \DateTime constructor', $field)), ]); return; @@ -247,7 +247,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf self::PARAMETER_AFTER => '>=', self::PARAMETER_STRICTLY_AFTER => '>', ]; - $baseWhere = sprintf('%s.%s %s :%s', $alias, $field, $operatorValue[$operator], $valueParameter); + $baseWhere = \sprintf('%s.%s %s :%s', $alias, $field, $operatorValue[$operator], $valueParameter); if (null === $nullManagement || self::EXCLUDE_NULL === $nullManagement) { $queryBuilder->andWhere($baseWhere); @@ -258,12 +258,12 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf ) { $queryBuilder->andWhere($queryBuilder->expr()->orX( $baseWhere, - $queryBuilder->expr()->isNull(sprintf('%s.%s', $alias, $field)) + $queryBuilder->expr()->isNull(\sprintf('%s.%s', $alias, $field)) )); } else { $queryBuilder->andWhere($queryBuilder->expr()->andX( $baseWhere, - $queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $field)) + $queryBuilder->expr()->isNotNull(\sprintf('%s.%s', $alias, $field)) )); } diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 5f516c4..0dde78e 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -164,7 +164,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if ($metadata->hasAssociation($field)) { if ($metadata->isCollectionValuedAssociation($field)) { $queryBuilder - ->andWhere(sprintf('%s.%s %s EMPTY', $alias, $field, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s.%s %s EMPTY', $alias, $field, $value ? 'IS NOT' : 'IS')); return; } @@ -173,20 +173,20 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $field, Join::LEFT_JOIN); $queryBuilder - ->andWhere(sprintf('%s %s NULL', $alias, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s %s NULL', $alias, $value ? 'IS NOT' : 'IS')); return; } $queryBuilder - ->andWhere(sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); return; } if ($metadata->hasField($field)) { $queryBuilder - ->andWhere(sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); } } diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index c27e3f6..545b552 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -152,11 +152,11 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if (1 === \count($values)) { $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $values[0], (string) $this->getDoctrineFieldType($property, $resourceClass)); } else { $queryBuilder - ->andWhere(sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $values); } } diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index 1987ee2..b2abdab 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -263,12 +263,12 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu if (null !== $nullsComparison = $this->properties[$property]['nulls_comparison'] ?? $this->orderNullsComparison) { $nullsDirection = self::NULLS_DIRECTION_MAP[$nullsComparison][$direction]; - $nullRankHiddenField = sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); + $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); - $queryBuilder->addSelect(sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); + $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); $queryBuilder->addOrderBy($nullRankHiddenField, $nullsDirection); } - $queryBuilder->addOrderBy(sprintf('%s.%s', $alias, $field), $direction); + $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); } } diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index 70c3e68..85de711 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -164,16 +164,16 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf if ($rangeValue[0] === $rangeValue[1]) { $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $rangeValue[0]); return; } $queryBuilder - ->andWhere(sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $field, $valueParameter)) - ->setParameter(sprintf('%s_1', $valueParameter), $rangeValue[0]) - ->setParameter(sprintf('%s_2', $valueParameter), $rangeValue[1]); + ->andWhere(\sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $field, $valueParameter)) + ->setParameter(\sprintf('%s_1', $valueParameter), $rangeValue[0]) + ->setParameter(\sprintf('%s_2', $valueParameter), $rangeValue[1]); break; case self::PARAMETER_GREATER_THAN: @@ -183,7 +183,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s > :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s > :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; @@ -194,7 +194,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s >= :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s >= :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; @@ -205,7 +205,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s < :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s < :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; @@ -216,7 +216,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s <= :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s <= :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index 3b09533..a94c862 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -205,7 +205,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) { $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), + 'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), ]); return; @@ -243,7 +243,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB */ if (!$this->hasValidValues([$value], $doctrineTypeField)) { $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $associationFieldIdentifier)), + 'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $associationFieldIdentifier)), ]); return null; @@ -260,7 +260,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB * Shouldn't this actually fail harder? */ $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), + 'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), ]); return; @@ -289,7 +289,7 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild $wrapCase = $this->createWrapCase($caseSensitive); $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); - $aliasedField = sprintf('%s.%s', $alias, $field); + $aliasedField = \sprintf('%s.%s', $alias, $field); if (!$strategy || self::STRATEGY_EXACT === $strategy) { if (1 === \count($values)) { @@ -310,7 +310,7 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild $ors = []; $parameters = []; foreach ($values as $key => $value) { - $keyValueParameter = sprintf('%s_%s', $valueParameter, $key); + $keyValueParameter = \sprintf('%s_%s', $valueParameter, $key); $parameters[] = [$caseSensitive ? $value : strtolower($value), $keyValueParameter]; $ors[] = match ($strategy) { @@ -336,7 +336,7 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild $wrapCase((string) $queryBuilder->expr()->concat("'% '", $keyValueParameter, "'%'")) ) ), - default => throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)), + default => throw new InvalidArgumentException(\sprintf('strategy %s does not exist.', $strategy)), }; } @@ -360,7 +360,7 @@ protected function createWrapCase(bool $caseSensitive): \Closure return $expr; } - return sprintf('LOWER(%s)', $expr); + return \sprintf('LOWER(%s)', $expr); }; } diff --git a/PropertyHelperTrait.php b/PropertyHelperTrait.php index e0b9a14..8ed61ca 100644 --- a/PropertyHelperTrait.php +++ b/PropertyHelperTrait.php @@ -73,7 +73,7 @@ protected function addJoinsForNestedProperty(string $property, string $rootAlias } if (null === $alias) { - throw new InvalidArgumentException(sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); } return [$alias, $propertyParts['field'], $propertyParts['associations']]; diff --git a/Tests/DoctrineOrmFilterTestCase.php b/Tests/DoctrineOrmFilterTestCase.php index 77b851e..f091da0 100644 --- a/Tests/DoctrineOrmFilterTestCase.php +++ b/Tests/DoctrineOrmFilterTestCase.php @@ -78,8 +78,8 @@ protected function doTestApply(?array $properties, array $filterParameters, stri foreach ($expectedParameters as $parameterName => $expectedParameterValue) { $queryParameter = $queryBuilder->getQuery()->getParameter($parameterName); - $this->assertNotNull($queryParameter, sprintf('Expected query parameter "%s" to be set', $parameterName)); - $this->assertEquals($expectedParameterValue, $queryParameter->getValue(), sprintf('Expected query parameter "%s" to be "%s"', $parameterName, var_export($expectedParameterValue, true))); + $this->assertNotNull($queryParameter, \sprintf('Expected query parameter "%s" to be set', $parameterName)); + $this->assertEquals($expectedParameterValue, $queryParameter->getValue(), \sprintf('Expected query parameter "%s" to be "%s"', $parameterName, var_export($expectedParameterValue, true))); } } diff --git a/Tests/Filter/BooleanFilterTest.php b/Tests/Filter/BooleanFilterTest.php index 1fdd6aa..0be3965 100644 --- a/Tests/Filter/BooleanFilterTest.php +++ b/Tests/Filter/BooleanFilterTest.php @@ -32,37 +32,37 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'string ("true")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'string ("false")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'non-boolean' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string ("0")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'numeric string ("1")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'nested properties' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'numeric string ("1") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string ("0") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'string ("true") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'string ("false") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'mixed boolean, non-boolean and invalid property' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], ] ); diff --git a/Tests/Filter/DateFilterTest.php b/Tests/Filter/DateFilterTest.php index 01d5e72..cd66dd1 100644 --- a/Tests/Filter/DateFilterTest.php +++ b/Tests/Filter/DateFilterTest.php @@ -62,58 +62,58 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'after (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after but not equals (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), ], 'after' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after but not equals' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), ], 'before (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), ], 'before but not equals (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), ], 'before' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), ], 'before but not equals' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), ], 'before + after (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), ], 'before but not equals + after but not equals (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), ], 'before + after' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), ], 'before but not equals + after but not equals' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), ], 'property not enabled' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'nested property' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after (exclude_null)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate IS NOT NULL AND o.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate IS NOT NULL AND o.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after (include_null_after)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), ], 'include null before and after (include_null_before_and_after)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), ], 'bad date format' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], ] ); diff --git a/Tests/Filter/ExistsFilterTest.php b/Tests/Filter/ExistsFilterTest.php index 9f3f345..fa9f0a7 100644 --- a/Tests/Filter/ExistsFilterTest.php +++ b/Tests/Filter/ExistsFilterTest.php @@ -100,121 +100,121 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'valid values' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'valid values (empty for true)' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'valid values (1 for true)' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'invalid values' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $existsFilterFactory, ], 'negative values' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'negative values (0)' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'multiple values (true and true)' => [ - sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'multiple values (1 and 0)' => [ - sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'multiple values (false and 0)' => [ - sprintf('SELECT o FROM %s o WHERE o.alias IS NULL AND o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.alias IS NULL AND o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'custom exists parameter name' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $customExistsFilterFactory, ], 'related values' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE o.description IS NOT NULL AND relatedDummy_a1.name IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE o.description IS NOT NULL AND relatedDummy_a1.name IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'not nullable values' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'related collection not empty' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS NOT EMPTY', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS NOT EMPTY', Dummy::class), null, $existsFilterFactory, ], 'related collection empty' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS EMPTY', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS EMPTY', Dummy::class), null, $existsFilterFactory, ], 'related association exists' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'related association does not exist' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NULL', Dummy::class), null, $existsFilterFactory, ], 'related owned association does not exist' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NULL', Dummy::class), null, $existsFilterFactory, ], 'related owned association exists' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'related owning association does not exist' => [ - sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NULL', Dummy::class), null, $existsFilterFactory, ], 'related owning association exists' => [ - sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], diff --git a/Tests/Filter/NumericFilterTest.php b/Tests/Filter/NumericFilterTest.php index d5a4eda..2ef8494 100644 --- a/Tests/Filter/NumericFilterTest.php +++ b/Tests/Filter/NumericFilterTest.php @@ -76,37 +76,37 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'numeric string (positive integer)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'multiple numeric string (positive integer)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice IN (:dummyPrice_p1)', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice IN (:dummyPrice_p1)', Dummy::class), ], 'multiple numeric string with one invalid property key' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'multiple numeric string with invalid value keys' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'multiple non-numeric' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string (negative integer)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'non-numeric' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string ("0")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'nested property' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', Dummy::class), ], 'mixed numeric and non-numeric' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'mixed numeric, non-numeric and invalid property' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], ] ); diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index 3838290..d3e4c77 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -302,119 +302,119 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'valid values' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'invalid values' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), null, $orderFilterFactory, ], 'valid values (properties not enabled)' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), null, $orderFilterFactory, ], 'invalid values (properties not enabled)' => [ - sprintf('SELECT o FROM %s o ORDER BY o.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.name ASC', Dummy::class), null, $orderFilterFactory, ], 'invalid property (property not enabled)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $orderFilterFactory, ], 'invalid property (property enabled)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $orderFilterFactory, ], 'custom order parameter name' => [ - sprintf('SELECT o FROM %s o ORDER BY o.name DESC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.name DESC', Dummy::class), null, $customOrderFilterFactory, ], 'valid values (all properties enabled)' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name ASC', Dummy::class), null, $orderFilterFactory, ], 'nested property' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY o.id ASC, o.name DESC, relatedDummy_a1.symfony DESC', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY o.id ASC, o.name DESC, relatedDummy_a1.symfony DESC', Dummy::class), null, $orderFilterFactory, ], 'empty values with default sort direction' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_smallest (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_smallest (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_largest (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_largest (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_first (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_first (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_last (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_last (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'not having order should not throw a deprecation (select unchanged)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $orderFilterFactory, ], 'not nullable relation will be a LEFT JOIN' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), null, $orderFilterFactory, ], 'embedded' => [ - sprintf('SELECT o FROM %s o ORDER BY o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), null, $orderFilterFactory, EmbeddedDummy::class, ], 'embedded with nulls_comparison' => [ - sprintf('SELECT o, CASE WHEN o.embeddedDummy.dummyName IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_embeddedDummy_dummyName_null_rank FROM %s o ORDER BY _o_embeddedDummy_dummyName_null_rank DESC, o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), + \sprintf('SELECT o, CASE WHEN o.embeddedDummy.dummyName IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_embeddedDummy_dummyName_null_rank FROM %s o ORDER BY _o_embeddedDummy_dummyName_null_rank DESC, o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), null, $orderFilterFactory, EmbeddedDummy::class, ], 'nullable field in relation will be a LEFT JOIN' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), null, $orderFilterFactory, ], diff --git a/Tests/Filter/RangeFilterTest.php b/Tests/Filter/RangeFilterTest.php index ac31b95..3fbdb9f 100644 --- a/Tests/Filter/RangeFilterTest.php +++ b/Tests/Filter/RangeFilterTest.php @@ -340,46 +340,46 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'between' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice BETWEEN :dummyPrice_p1_1 AND :dummyPrice_p1_2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice BETWEEN :dummyPrice_p1_1 AND :dummyPrice_p1_2', Dummy::class), ], 'between (same values)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'between (too many operands)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'between (too few operands)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'between (non-numeric operands)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'lt' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice < :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice < :dummyPrice_p1', Dummy::class), ], 'lt (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'lte' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice <= :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice <= :dummyPrice_p1', Dummy::class), ], 'lte (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'gt' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice > :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice > :dummyPrice_p1', Dummy::class), ], 'gt (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'gte' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1', Dummy::class), ], 'gte (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'lte + gte' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1 AND o.dummyPrice <= :dummyPrice_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1 AND o.dummyPrice <= :dummyPrice_p2', Dummy::class), ], ] ); diff --git a/Tests/Filter/SearchFilterTest.php b/Tests/Filter/SearchFilterTest.php index 89f2e3f..0b11fb1 100644 --- a/Tests/Filter/SearchFilterTest.php +++ b/Tests/Filter/SearchFilterTest.php @@ -221,11 +221,11 @@ public function testDoubleJoin(): void $queryBuilder = $this->repository->createQueryBuilder(static::ALIAS); $filter = self::buildSearchFilter($this, $this->managerRegistry, ['relatedDummy.symfony' => null]); - $queryBuilder->innerJoin(sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); + $queryBuilder->innerJoin(\sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); $actual = strtolower($queryBuilder->getQuery()->getDQL()); - $expected = strtolower(sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 WHERE relateddummy_a1.symfony = :symfony_p1', static::ALIAS, Dummy::class)); + $expected = strtolower(\sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 WHERE relateddummy_a1.symfony = :symfony_p1', static::ALIAS, Dummy::class)); $this->assertSame($actual, $expected); } @@ -236,12 +236,12 @@ public function testTripleJoin(): void $queryBuilder = $this->repository->createQueryBuilder(static::ALIAS); $filter = self::buildSearchFilter($this, $this->managerRegistry, ['relatedDummy.symfony' => null, 'relatedDummy.thirdLevel.level' => null]); - $queryBuilder->innerJoin(sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); + $queryBuilder->innerJoin(\sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); $queryBuilder->innerJoin('relateddummy_a1.thirdLevel', 'thirdLevel_a1'); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); $actual = strtolower($queryBuilder->getQuery()->getDQL()); - $expected = strtolower(sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 inner join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); + $expected = strtolower(\sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 inner join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); $this->assertSame($actual, $expected); } @@ -250,13 +250,13 @@ public function testJoinLeft(): void $filters = ['relatedDummy.symfony' => 'foo', 'relatedDummy.thirdLevel.level' => '3']; $queryBuilder = $this->repository->createQueryBuilder(static::ALIAS); - $queryBuilder->leftJoin(sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); + $queryBuilder->leftJoin(\sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); $filter = self::buildSearchFilter($this, $this->managerRegistry, ['relatedDummy.symfony' => null, 'relatedDummy.thirdLevel.level' => null]); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); $actual = strtolower($queryBuilder->getQuery()->getDQL()); - $expected = strtolower(sprintf('SELECT %s FROM %s %1$s left join %1$s.relatedDummy relateddummy_a1 left join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); + $expected = strtolower(\sprintf('SELECT %s FROM %s %1$s left join %1$s.relatedDummy relateddummy_a1 left join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); $this->assertSame($actual, $expected); } @@ -269,7 +269,7 @@ public function testApplyWithAnotherAlias(): void $filter = self::buildSearchFilter($this, $this->managerRegistry, ['id' => null, 'name' => null]); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); - $expectedDql = sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', 'somealias', Dummy::class); + $expectedDql = \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', 'somealias', Dummy::class); $this->assertSame($expectedDql, $queryBuilder->getQuery()->getDQL()); } @@ -281,22 +281,22 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'exact' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), ['name_p1' => 'exact'], $filterFactory, ], 'exact (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), ['name_p1' => 'exact'], $filterFactory, ], 'exact (case insensitive, with special characters)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), ['name_p1' => 'exact (special)'], $filterFactory, ], 'exact (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN(:name_p1)', static::ALIAS, Dummy::class), [ 'name_p1' => [ 'CaSE', @@ -306,7 +306,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'exact (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN(:name_p1)', static::ALIAS, Dummy::class), [ 'name_p1' => [ 'case', @@ -316,27 +316,27 @@ public static function provideApplyTestData(): array $filterFactory, ], 'invalid property' => [ - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], 'invalid values for relations' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), [], $filterFactory, ], 'partial' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'partial (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'partial (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1, \'%%\')', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -344,7 +344,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'partial (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -352,7 +352,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'partial (multiple almost same values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'blue car', 'name_p1_1' => 'blue car', @@ -360,17 +360,17 @@ public static function provideApplyTestData(): array $filterFactory, ], 'start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\')', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'start (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(:name_p1_1, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(:name_p1_1, \'%%\')', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -378,7 +378,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'start (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -386,17 +386,17 @@ public static function provideApplyTestData(): array $filterFactory, ], 'end' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0)', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'end (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'end (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0) OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0) OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1)', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -404,7 +404,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'end (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0)) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0)) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -412,17 +412,17 @@ public static function provideApplyTestData(): array $filterFactory, ], 'word_start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'word_start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'word_start (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE (%1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')) OR (%1$s.name LIKE CONCAT(:name_p1_1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE (%1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')) OR (%1$s.name LIKE CONCAT(:name_p1_1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -430,7 +430,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'word_start (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))) OR (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_1, \'%%\')))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))) OR (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_1, \'%%\')))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -438,7 +438,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'invalid value for relation' => [ - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], @@ -451,17 +451,17 @@ public static function provideApplyTestData(): array [ 'relatedDummy' => '/related_dummie/1', ], - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], 'IRI value for relation' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), ['id_p1' => 1], $filterFactory, ], 'mixed IRI and entity ID values for relations' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN(:relatedDummy_p1) AND relatedDummies_a1.id = :id_p2', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN(:relatedDummy_p1) AND relatedDummies_a1.id = :id_p2', static::ALIAS, Dummy::class), [ 'relatedDummy_p1' => [1, 2], 'id_p2' => 1, @@ -469,7 +469,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'nested property' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE %1$s.name = :name_p1 AND relatedDummy_a1.symfony = :symfony_p2', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE %1$s.name = :name_p1 AND relatedDummy_a1.symfony = :symfony_p2', static::ALIAS, Dummy::class), [ 'name_p1' => 'exact', 'symfony_p2' => 'exact', @@ -477,24 +477,24 @@ public static function provideApplyTestData(): array $filterFactory, ], 'empty nested property' => [ - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], 'integer value' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.age = :age_p1', static::ALIAS, RelatedDummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.age = :age_p1', static::ALIAS, RelatedDummy::class), ['age_p1' => 46], $filterFactory, RelatedDummy::class, ], 'related owned one-to-one association' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), ['id_p1' => 1], $filterFactory, Dummy::class, ], 'related owning one-to-one association' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.relatedOwningDummy = :relatedOwningDummy_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.relatedOwningDummy = :relatedOwningDummy_p1', static::ALIAS, Dummy::class), ['relatedOwningDummy_p1' => 1], $filterFactory, Dummy::class, diff --git a/Util/QueryBuilderHelper.php b/Util/QueryBuilderHelper.php index 7814f98..54956fc 100644 --- a/Util/QueryBuilderHelper.php +++ b/Util/QueryBuilderHelper.php @@ -57,7 +57,7 @@ public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGenerato public static function getEntityClassByAlias(string $alias, QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): string { if (!\in_array($alias, $queryBuilder->getAllAliases(), true)) { - throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); + throw new \LogicException(\sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); } $rootAliasMap = self::mapRootAliases($queryBuilder->getRootAliases(), $queryBuilder->getRootEntities()); @@ -73,7 +73,7 @@ public static function getEntityClassByAlias(string $alias, QueryBuilder $queryB } if (null === $metadata) { - throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); + throw new \LogicException(\sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); } return $metadata->getName(); @@ -96,7 +96,7 @@ public static function findRootAlias(string $alias, QueryBuilder $queryBuilder): } } - throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); + throw new \LogicException(\sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); } /** @@ -122,7 +122,7 @@ public static function traverseJoins(string $alias, QueryBuilder $queryBuilder, while (null === $apexEntityClass) { if (!isset($aliasMap[$currentAlias])) { - throw new \LogicException(sprintf('Unknown alias "%s".', $currentAlias)); + throw new \LogicException(\sprintf('Unknown alias "%s".', $currentAlias)); } if (\is_string($aliasMap[$currentAlias])) { @@ -171,7 +171,7 @@ public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias foreach ($parts[$rootAlias] as $join) { /** @var Join $join */ - if (sprintf('%s.%s', $alias, $association) === $join->getJoin()) { + if (\sprintf('%s.%s', $alias, $association) === $join->getJoin()) { return $join; } } diff --git a/Util/QueryNameGenerator.php b/Util/QueryNameGenerator.php index 810a1e0..f6a47d2 100644 --- a/Util/QueryNameGenerator.php +++ b/Util/QueryNameGenerator.php @@ -30,7 +30,7 @@ final class QueryNameGenerator implements QueryNameGeneratorInterface */ public function generateJoinAlias(string $association): string { - return sprintf('%s_a%d', $association, $this->incrementedAssociation++); + return \sprintf('%s_a%d', $association, $this->incrementedAssociation++); } /** @@ -38,6 +38,6 @@ public function generateJoinAlias(string $association): string */ public function generateParameterName(string $name): string { - return sprintf('%s_p%d', str_replace('.', '_', $name), $this->incrementedName++); + return \sprintf('%s_p%d', str_replace('.', '_', $name), $this->incrementedName++); } } From e4212ba427fda19a7dc3dc027a1e6b74806a9bd7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 9 Aug 2024 09:49:50 +0200 Subject: [PATCH 024/152] style: cs fixes --- AbstractPaginator.php | 2 +- Extension/EagerLoadingExtension.php | 4 +- Extension/OrderExtension.php | 2 +- Filter/BooleanFilter.php | 2 +- Filter/DateFilter.php | 10 ++-- Filter/ExistsFilter.php | 8 +-- Filter/NumericFilter.php | 4 +- Filter/OrderFilter.php | 6 +-- Filter/RangeFilter.php | 16 +++--- Filter/SearchFilter.php | 14 ++--- PropertyHelperTrait.php | 2 +- Tests/AppKernel.php | 2 +- Tests/DoctrineOrmFilterTestCase.php | 4 +- Tests/Filter/BooleanFilterTest.php | 22 ++++---- Tests/Filter/DateFilterTest.php | 36 ++++++------- Tests/Filter/ExistsFilterTest.php | 40 +++++++-------- Tests/Filter/NumericFilterTest.php | 22 ++++---- Tests/Filter/OrderFilterTest.php | 46 ++++++++--------- Tests/Filter/RangeFilterTest.php | 28 +++++----- Tests/Filter/SearchFilterTest.php | 80 ++++++++++++++--------------- Util/QueryBuilderHelper.php | 10 ++-- Util/QueryNameGenerator.php | 4 +- 22 files changed, 182 insertions(+), 182 deletions(-) diff --git a/AbstractPaginator.php b/AbstractPaginator.php index a3c2a08..59595c7 100644 --- a/AbstractPaginator.php +++ b/AbstractPaginator.php @@ -33,7 +33,7 @@ public function __construct(DoctrinePaginator $paginator) $query = $paginator->getQuery(); if (null === ($firstResult = $query->getFirstResult()) || $firstResult < 0 || null === $maxResults = $query->getMaxResults()) { // @phpstan-ignore-line - throw new InvalidArgumentException(sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class)); + throw new InvalidArgumentException(\sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class)); } $this->paginator = $paginator; diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index bd0935d..aa1e08a 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -186,7 +186,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt $method = $isLeftJoin ? 'leftJoin' : 'innerJoin'; $associationAlias = $queryNameGenerator->generateJoinAlias($association); - $queryBuilder->{$method}(sprintf('%s.%s', $parentAlias, $association), $associationAlias); + $queryBuilder->{$method}(\sprintf('%s.%s', $parentAlias, $association), $associationAlias); ++$joinCount; } @@ -264,7 +264,7 @@ private function addSelect(QueryBuilder $queryBuilder, string $entity, string $a } } - $queryBuilder->addSelect(sprintf('partial %s.{%s}', $associationAlias, implode(',', $select))); + $queryBuilder->addSelect(\sprintf('partial %s.{%s}', $associationAlias, implode(',', $select))); } private function addSelectOnce(QueryBuilder $queryBuilder, string $alias): void diff --git a/Extension/OrderExtension.php b/Extension/OrderExtension.php index 3989072..1d3deaf 100644 --- a/Extension/OrderExtension.php +++ b/Extension/OrderExtension.php @@ -67,7 +67,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $field = "{$rootAlias}.{$field}"; } else { $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $rootAlias, substr($field, 0, $pos)); - $field = sprintf('%s.%s', $alias, substr($field, $pos + 1)); + $field = \sprintf('%s.%s', $alias, substr($field, $pos + 1)); } $queryBuilder->addOrderBy($field, $order); } diff --git a/Filter/BooleanFilter.php b/Filter/BooleanFilter.php index 2f230c4..e9f0a83 100644 --- a/Filter/BooleanFilter.php +++ b/Filter/BooleanFilter.php @@ -142,7 +142,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $valueParameter = $queryNameGenerator->generateParameterName($field); $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); } } diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index 066606e..8533ee3 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -161,7 +161,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $type = (string) $this->getDoctrineFieldType($property, $resourceClass); if (self::EXCLUDE_NULL === $nullManagement) { - $queryBuilder->andWhere($queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $field))); + $queryBuilder->andWhere($queryBuilder->expr()->isNotNull(\sprintf('%s.%s', $alias, $field))); } if (isset($values[self::PARAMETER_BEFORE])) { @@ -234,7 +234,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } catch (\Exception) { // Silently ignore this filter if it can not be transformed to a \DateTime $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('The field "%s" has a wrong date format. Use one accepted by the \DateTime constructor', $field)), + 'exception' => new InvalidArgumentException(\sprintf('The field "%s" has a wrong date format. Use one accepted by the \DateTime constructor', $field)), ]); return; @@ -247,7 +247,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf self::PARAMETER_AFTER => '>=', self::PARAMETER_STRICTLY_AFTER => '>', ]; - $baseWhere = sprintf('%s.%s %s :%s', $alias, $field, $operatorValue[$operator], $valueParameter); + $baseWhere = \sprintf('%s.%s %s :%s', $alias, $field, $operatorValue[$operator], $valueParameter); if (null === $nullManagement || self::EXCLUDE_NULL === $nullManagement) { $queryBuilder->andWhere($baseWhere); @@ -258,12 +258,12 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf ) { $queryBuilder->andWhere($queryBuilder->expr()->orX( $baseWhere, - $queryBuilder->expr()->isNull(sprintf('%s.%s', $alias, $field)) + $queryBuilder->expr()->isNull(\sprintf('%s.%s', $alias, $field)) )); } else { $queryBuilder->andWhere($queryBuilder->expr()->andX( $baseWhere, - $queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $field)) + $queryBuilder->expr()->isNotNull(\sprintf('%s.%s', $alias, $field)) )); } diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 5f516c4..0dde78e 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -164,7 +164,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if ($metadata->hasAssociation($field)) { if ($metadata->isCollectionValuedAssociation($field)) { $queryBuilder - ->andWhere(sprintf('%s.%s %s EMPTY', $alias, $field, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s.%s %s EMPTY', $alias, $field, $value ? 'IS NOT' : 'IS')); return; } @@ -173,20 +173,20 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $field, Join::LEFT_JOIN); $queryBuilder - ->andWhere(sprintf('%s %s NULL', $alias, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s %s NULL', $alias, $value ? 'IS NOT' : 'IS')); return; } $queryBuilder - ->andWhere(sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); return; } if ($metadata->hasField($field)) { $queryBuilder - ->andWhere(sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); + ->andWhere(\sprintf('%s.%s %s NULL', $alias, $field, $value ? 'IS NOT' : 'IS')); } } diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index c27e3f6..545b552 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -152,11 +152,11 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if (1 === \count($values)) { $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $values[0], (string) $this->getDoctrineFieldType($property, $resourceClass)); } else { $queryBuilder - ->andWhere(sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $values); } } diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index 1987ee2..b2abdab 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -263,12 +263,12 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu if (null !== $nullsComparison = $this->properties[$property]['nulls_comparison'] ?? $this->orderNullsComparison) { $nullsDirection = self::NULLS_DIRECTION_MAP[$nullsComparison][$direction]; - $nullRankHiddenField = sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); + $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); - $queryBuilder->addSelect(sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); + $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); $queryBuilder->addOrderBy($nullRankHiddenField, $nullsDirection); } - $queryBuilder->addOrderBy(sprintf('%s.%s', $alias, $field), $direction); + $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); } } diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index 70c3e68..85de711 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -164,16 +164,16 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf if ($rangeValue[0] === $rangeValue[1]) { $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $rangeValue[0]); return; } $queryBuilder - ->andWhere(sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $field, $valueParameter)) - ->setParameter(sprintf('%s_1', $valueParameter), $rangeValue[0]) - ->setParameter(sprintf('%s_2', $valueParameter), $rangeValue[1]); + ->andWhere(\sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $field, $valueParameter)) + ->setParameter(\sprintf('%s_1', $valueParameter), $rangeValue[0]) + ->setParameter(\sprintf('%s_2', $valueParameter), $rangeValue[1]); break; case self::PARAMETER_GREATER_THAN: @@ -183,7 +183,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s > :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s > :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; @@ -194,7 +194,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s >= :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s >= :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; @@ -205,7 +205,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s < :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s < :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; @@ -216,7 +216,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } $queryBuilder - ->andWhere(sprintf('%s.%s <= :%s', $alias, $field, $valueParameter)) + ->andWhere(\sprintf('%s.%s <= :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); break; diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index 61b35b7..d0caccb 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -203,7 +203,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) { $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), + 'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), ]); return; @@ -241,7 +241,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB */ if (!$this->hasValidValues([$value], $doctrineTypeField)) { $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $associationFieldIdentifier)), + 'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $associationFieldIdentifier)), ]); return null; @@ -258,7 +258,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB * Shouldn't this actually fail harder? */ $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), + 'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), ]); return; @@ -287,7 +287,7 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild $wrapCase = $this->createWrapCase($caseSensitive); $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); - $aliasedField = sprintf('%s.%s', $alias, $field); + $aliasedField = \sprintf('%s.%s', $alias, $field); if (!$strategy || self::STRATEGY_EXACT === $strategy) { if (1 === \count($values)) { @@ -308,7 +308,7 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild $ors = []; $parameters = []; foreach ($values as $key => $value) { - $keyValueParameter = sprintf('%s_%s', $valueParameter, $key); + $keyValueParameter = \sprintf('%s_%s', $valueParameter, $key); $parameters[] = [$caseSensitive ? $value : strtolower($value), $keyValueParameter]; $ors[] = match ($strategy) { @@ -334,7 +334,7 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild $wrapCase((string) $queryBuilder->expr()->concat("'% '", $keyValueParameter, "'%'")) ) ), - default => throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)), + default => throw new InvalidArgumentException(\sprintf('strategy %s does not exist.', $strategy)), }; } @@ -358,7 +358,7 @@ protected function createWrapCase(bool $caseSensitive): \Closure return $expr; } - return sprintf('LOWER(%s)', $expr); + return \sprintf('LOWER(%s)', $expr); }; } diff --git a/PropertyHelperTrait.php b/PropertyHelperTrait.php index a8086ca..8431e3e 100644 --- a/PropertyHelperTrait.php +++ b/PropertyHelperTrait.php @@ -73,7 +73,7 @@ protected function addJoinsForNestedProperty(string $property, string $rootAlias } if (null === $alias) { - throw new InvalidArgumentException(sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); } return [$alias, $propertyParts['field'], $propertyParts['associations']]; diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index 3f1efec..94b796b 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -44,7 +44,7 @@ public function registerBundles(): array new FrameworkBundle(), new DoctrineBundle(), new TestBundle(), - new class() extends Bundle { + new class extends Bundle { public function shutdown(): void { restore_exception_handler(); diff --git a/Tests/DoctrineOrmFilterTestCase.php b/Tests/DoctrineOrmFilterTestCase.php index 9a2c0e9..1b8b25c 100644 --- a/Tests/DoctrineOrmFilterTestCase.php +++ b/Tests/DoctrineOrmFilterTestCase.php @@ -76,8 +76,8 @@ protected function doTestApply(?array $properties, array $filterParameters, stri foreach ($expectedParameters as $parameterName => $expectedParameterValue) { $queryParameter = $queryBuilder->getQuery()->getParameter($parameterName); - $this->assertNotNull($queryParameter, sprintf('Expected query parameter "%s" to be set', $parameterName)); - $this->assertEquals($expectedParameterValue, $queryParameter->getValue(), sprintf('Expected query parameter "%s" to be "%s"', $parameterName, var_export($expectedParameterValue, true))); + $this->assertNotNull($queryParameter, \sprintf('Expected query parameter "%s" to be set', $parameterName)); + $this->assertEquals($expectedParameterValue, $queryParameter->getValue(), \sprintf('Expected query parameter "%s" to be "%s"', $parameterName, var_export($expectedParameterValue, true))); } } diff --git a/Tests/Filter/BooleanFilterTest.php b/Tests/Filter/BooleanFilterTest.php index 1fdd6aa..0be3965 100644 --- a/Tests/Filter/BooleanFilterTest.php +++ b/Tests/Filter/BooleanFilterTest.php @@ -32,37 +32,37 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'string ("true")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'string ("false")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'non-boolean' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string ("0")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'numeric string ("1")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'nested properties' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], 'numeric string ("1") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string ("0") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'string ("true") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'string ("false") on non-boolean property' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'mixed boolean, non-boolean and invalid property' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyBoolean = :dummyBoolean_p1', Dummy::class), ], ] ); diff --git a/Tests/Filter/DateFilterTest.php b/Tests/Filter/DateFilterTest.php index 01d5e72..cd66dd1 100644 --- a/Tests/Filter/DateFilterTest.php +++ b/Tests/Filter/DateFilterTest.php @@ -62,58 +62,58 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'after (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after but not equals (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), ], 'after' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after but not equals' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate > :dummyDate_p1', Dummy::class), ], 'before (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), ], 'before but not equals (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), ], 'before' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1', Dummy::class), ], 'before but not equals' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1', Dummy::class), ], 'before + after (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), ], 'before but not equals + after but not equals (all properties enabled)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), ], 'before + after' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate <= :dummyDate_p1 AND o.dummyDate >= :dummyDate_p2', Dummy::class), ], 'before but not equals + after but not equals' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate < :dummyDate_p1 AND o.dummyDate > :dummyDate_p2', Dummy::class), ], 'property not enabled' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'nested property' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after (exclude_null)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate IS NOT NULL AND o.dummyDate >= :dummyDate_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate IS NOT NULL AND o.dummyDate >= :dummyDate_p1', Dummy::class), ], 'after (include_null_after)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), ], 'include null before and after (include_null_before_and_after)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyDate >= :dummyDate_p1 OR o.dummyDate IS NULL', Dummy::class), ], 'bad date format' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], ] ); diff --git a/Tests/Filter/ExistsFilterTest.php b/Tests/Filter/ExistsFilterTest.php index 9f3f345..fa9f0a7 100644 --- a/Tests/Filter/ExistsFilterTest.php +++ b/Tests/Filter/ExistsFilterTest.php @@ -100,121 +100,121 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'valid values' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'valid values (empty for true)' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'valid values (1 for true)' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'invalid values' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $existsFilterFactory, ], 'negative values' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'negative values (0)' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'multiple values (true and true)' => [ - sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'multiple values (1 and 0)' => [ - sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.alias IS NOT NULL AND o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'multiple values (false and 0)' => [ - sprintf('SELECT o FROM %s o WHERE o.alias IS NULL AND o.description IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.alias IS NULL AND o.description IS NULL', Dummy::class), null, $existsFilterFactory, ], 'custom exists parameter name' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $customExistsFilterFactory, ], 'related values' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE o.description IS NOT NULL AND relatedDummy_a1.name IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE o.description IS NOT NULL AND relatedDummy_a1.name IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'not nullable values' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'related collection not empty' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS NOT EMPTY', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS NOT EMPTY', Dummy::class), null, $existsFilterFactory, ], 'related collection empty' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS EMPTY', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummies IS EMPTY', Dummy::class), null, $existsFilterFactory, ], 'related association exists' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'related association does not exist' => [ - sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.description IS NOT NULL AND o.relatedDummy IS NULL', Dummy::class), null, $existsFilterFactory, ], 'related owned association does not exist' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NULL', Dummy::class), null, $existsFilterFactory, ], 'related owned association exists' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1 IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], 'related owning association does not exist' => [ - sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NULL', Dummy::class), null, $existsFilterFactory, ], 'related owning association exists' => [ - sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NOT NULL', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.relatedOwningDummy IS NOT NULL', Dummy::class), null, $existsFilterFactory, ], diff --git a/Tests/Filter/NumericFilterTest.php b/Tests/Filter/NumericFilterTest.php index d5a4eda..2ef8494 100644 --- a/Tests/Filter/NumericFilterTest.php +++ b/Tests/Filter/NumericFilterTest.php @@ -76,37 +76,37 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'numeric string (positive integer)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'multiple numeric string (positive integer)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice IN (:dummyPrice_p1)', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice IN (:dummyPrice_p1)', Dummy::class), ], 'multiple numeric string with one invalid property key' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'multiple numeric string with invalid value keys' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'multiple non-numeric' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string (negative integer)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'non-numeric' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'numeric string ("0")' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'nested property' => [ - sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', Dummy::class), + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', Dummy::class), ], 'mixed numeric and non-numeric' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'mixed numeric, non-numeric and invalid property' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], ] ); diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index 8864928..f75fb5a 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -323,119 +323,119 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'valid values' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'invalid values' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), null, $orderFilterFactory, ], 'valid values (properties not enabled)' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC', Dummy::class), null, $orderFilterFactory, ], 'invalid values (properties not enabled)' => [ - sprintf('SELECT o FROM %s o ORDER BY o.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.name ASC', Dummy::class), null, $orderFilterFactory, ], 'invalid property (property not enabled)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $orderFilterFactory, ], 'invalid property (property enabled)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $orderFilterFactory, ], 'custom order parameter name' => [ - sprintf('SELECT o FROM %s o ORDER BY o.name DESC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.name DESC', Dummy::class), null, $customOrderFilterFactory, ], 'valid values (all properties enabled)' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name ASC', Dummy::class), null, $orderFilterFactory, ], 'nested property' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY o.id ASC, o.name DESC, relatedDummy_a1.symfony DESC', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY o.id ASC, o.name DESC, relatedDummy_a1.symfony DESC', Dummy::class), null, $orderFilterFactory, ], 'empty values with default sort direction' => [ - sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.id ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_smallest (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_smallest (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_largest (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_largest (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_first (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_first (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_last (asc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'nulls_always_last (desc)' => [ - sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), + \sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), null, $orderFilterFactory, ], 'not having order should not throw a deprecation (select unchanged)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), null, $orderFilterFactory, ], 'not nullable relation will be a LEFT JOIN' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), null, $orderFilterFactory, ], 'embedded' => [ - sprintf('SELECT o FROM %s o ORDER BY o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), + \sprintf('SELECT o FROM %s o ORDER BY o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), null, $orderFilterFactory, EmbeddedDummy::class, ], 'embedded with nulls_comparison' => [ - sprintf('SELECT o, CASE WHEN o.embeddedDummy.dummyName IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_embeddedDummy_dummyName_null_rank FROM %s o ORDER BY _o_embeddedDummy_dummyName_null_rank DESC, o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), + \sprintf('SELECT o, CASE WHEN o.embeddedDummy.dummyName IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_embeddedDummy_dummyName_null_rank FROM %s o ORDER BY _o_embeddedDummy_dummyName_null_rank DESC, o.embeddedDummy.dummyName ASC', EmbeddedDummy::class), null, $orderFilterFactory, EmbeddedDummy::class, ], 'nullable field in relation will be a LEFT JOIN' => [ - sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), + \sprintf('SELECT o FROM %s o LEFT JOIN o.relatedDummy relatedDummy_a1 ORDER BY relatedDummy_a1.name ASC', Dummy::class), null, $orderFilterFactory, ], diff --git a/Tests/Filter/RangeFilterTest.php b/Tests/Filter/RangeFilterTest.php index ac31b95..3fbdb9f 100644 --- a/Tests/Filter/RangeFilterTest.php +++ b/Tests/Filter/RangeFilterTest.php @@ -340,46 +340,46 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'between' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice BETWEEN :dummyPrice_p1_1 AND :dummyPrice_p1_2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice BETWEEN :dummyPrice_p1_1 AND :dummyPrice_p1_2', Dummy::class), ], 'between (same values)' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice = :dummyPrice_p1', Dummy::class), ], 'between (too many operands)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'between (too few operands)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'between (non-numeric operands)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'lt' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice < :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice < :dummyPrice_p1', Dummy::class), ], 'lt (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'lte' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice <= :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice <= :dummyPrice_p1', Dummy::class), ], 'lte (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'gt' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice > :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice > :dummyPrice_p1', Dummy::class), ], 'gt (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'gte' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1', Dummy::class), ], 'gte (non-numeric)' => [ - sprintf('SELECT o FROM %s o', Dummy::class), + \sprintf('SELECT o FROM %s o', Dummy::class), ], 'lte + gte' => [ - sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1 AND o.dummyPrice <= :dummyPrice_p2', Dummy::class), + \sprintf('SELECT o FROM %s o WHERE o.dummyPrice >= :dummyPrice_p1 AND o.dummyPrice <= :dummyPrice_p2', Dummy::class), ], ] ); diff --git a/Tests/Filter/SearchFilterTest.php b/Tests/Filter/SearchFilterTest.php index 89f2e3f..0b11fb1 100644 --- a/Tests/Filter/SearchFilterTest.php +++ b/Tests/Filter/SearchFilterTest.php @@ -221,11 +221,11 @@ public function testDoubleJoin(): void $queryBuilder = $this->repository->createQueryBuilder(static::ALIAS); $filter = self::buildSearchFilter($this, $this->managerRegistry, ['relatedDummy.symfony' => null]); - $queryBuilder->innerJoin(sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); + $queryBuilder->innerJoin(\sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); $actual = strtolower($queryBuilder->getQuery()->getDQL()); - $expected = strtolower(sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 WHERE relateddummy_a1.symfony = :symfony_p1', static::ALIAS, Dummy::class)); + $expected = strtolower(\sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 WHERE relateddummy_a1.symfony = :symfony_p1', static::ALIAS, Dummy::class)); $this->assertSame($actual, $expected); } @@ -236,12 +236,12 @@ public function testTripleJoin(): void $queryBuilder = $this->repository->createQueryBuilder(static::ALIAS); $filter = self::buildSearchFilter($this, $this->managerRegistry, ['relatedDummy.symfony' => null, 'relatedDummy.thirdLevel.level' => null]); - $queryBuilder->innerJoin(sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); + $queryBuilder->innerJoin(\sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); $queryBuilder->innerJoin('relateddummy_a1.thirdLevel', 'thirdLevel_a1'); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); $actual = strtolower($queryBuilder->getQuery()->getDQL()); - $expected = strtolower(sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 inner join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); + $expected = strtolower(\sprintf('SELECT %s FROM %s %1$s inner join %1$s.relatedDummy relateddummy_a1 inner join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); $this->assertSame($actual, $expected); } @@ -250,13 +250,13 @@ public function testJoinLeft(): void $filters = ['relatedDummy.symfony' => 'foo', 'relatedDummy.thirdLevel.level' => '3']; $queryBuilder = $this->repository->createQueryBuilder(static::ALIAS); - $queryBuilder->leftJoin(sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); + $queryBuilder->leftJoin(\sprintf('%s.relatedDummy', static::ALIAS), 'relateddummy_a1'); $filter = self::buildSearchFilter($this, $this->managerRegistry, ['relatedDummy.symfony' => null, 'relatedDummy.thirdLevel.level' => null]); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); $actual = strtolower($queryBuilder->getQuery()->getDQL()); - $expected = strtolower(sprintf('SELECT %s FROM %s %1$s left join %1$s.relatedDummy relateddummy_a1 left join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); + $expected = strtolower(\sprintf('SELECT %s FROM %s %1$s left join %1$s.relatedDummy relateddummy_a1 left join relateddummy_a1.thirdLevel thirdLevel_a1 WHERE relateddummy_a1.symfony = :symfony_p1 and thirdLevel_a1.level = :level_p2', static::ALIAS, Dummy::class)); $this->assertSame($actual, $expected); } @@ -269,7 +269,7 @@ public function testApplyWithAnotherAlias(): void $filter = self::buildSearchFilter($this, $this->managerRegistry, ['id' => null, 'name' => null]); $filter->apply($queryBuilder, new QueryNameGenerator(), $this->resourceClass, new Get(), ['filters' => $filters]); - $expectedDql = sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', 'somealias', Dummy::class); + $expectedDql = \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', 'somealias', Dummy::class); $this->assertSame($expectedDql, $queryBuilder->getQuery()->getDQL()); } @@ -281,22 +281,22 @@ public static function provideApplyTestData(): array self::provideApplyTestArguments(), [ 'exact' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), ['name_p1' => 'exact'], $filterFactory, ], 'exact (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), ['name_p1' => 'exact'], $filterFactory, ], 'exact (case insensitive, with special characters)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) = LOWER(:name_p1)', static::ALIAS, Dummy::class), ['name_p1' => 'exact (special)'], $filterFactory, ], 'exact (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN(:name_p1)', static::ALIAS, Dummy::class), [ 'name_p1' => [ 'CaSE', @@ -306,7 +306,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'exact (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN(:name_p1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN(:name_p1)', static::ALIAS, Dummy::class), [ 'name_p1' => [ 'case', @@ -316,27 +316,27 @@ public static function provideApplyTestData(): array $filterFactory, ], 'invalid property' => [ - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], 'invalid values for relations' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', static::ALIAS, Dummy::class), [], $filterFactory, ], 'partial' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'partial (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'partial (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1, \'%%\')', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -344,7 +344,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'partial (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -352,7 +352,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'partial (multiple almost same values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'blue car', 'name_p1_1' => 'blue car', @@ -360,17 +360,17 @@ public static function provideApplyTestData(): array $filterFactory, ], 'start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\')', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'start (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(:name_p1_1, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(:name_p1_1, \'%%\')', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -378,7 +378,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'start (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -386,17 +386,17 @@ public static function provideApplyTestData(): array $filterFactory, ], 'end' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0)', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'end (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'end (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0) OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1)', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0) OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1)', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -404,7 +404,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'end (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0)) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0)) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -412,17 +412,17 @@ public static function provideApplyTestData(): array $filterFactory, ], 'word_start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'word_start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))', static::ALIAS, Dummy::class), ['name_p1_0' => 'partial'], $filterFactory, ], 'word_start (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE (%1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')) OR (%1$s.name LIKE CONCAT(:name_p1_1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE (%1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')) OR (%1$s.name LIKE CONCAT(:name_p1_1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_1, \'%%\'))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'CaSE', 'name_p1_1' => 'SENSitive', @@ -430,7 +430,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'word_start (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))) OR (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_1, \'%%\')))', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))) OR (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_1, \'%%\')))', static::ALIAS, Dummy::class), [ 'name_p1_0' => 'case', 'name_p1_1' => 'insensitive', @@ -438,7 +438,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'invalid value for relation' => [ - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], @@ -451,17 +451,17 @@ public static function provideApplyTestData(): array [ 'relatedDummy' => '/related_dummie/1', ], - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], 'IRI value for relation' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), ['id_p1' => 1], $filterFactory, ], 'mixed IRI and entity ID values for relations' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN(:relatedDummy_p1) AND relatedDummies_a1.id = :id_p2', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN(:relatedDummy_p1) AND relatedDummies_a1.id = :id_p2', static::ALIAS, Dummy::class), [ 'relatedDummy_p1' => [1, 2], 'id_p2' => 1, @@ -469,7 +469,7 @@ public static function provideApplyTestData(): array $filterFactory, ], 'nested property' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE %1$s.name = :name_p1 AND relatedDummy_a1.symfony = :symfony_p2', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummy relatedDummy_a1 WHERE %1$s.name = :name_p1 AND relatedDummy_a1.symfony = :symfony_p2', static::ALIAS, Dummy::class), [ 'name_p1' => 'exact', 'symfony_p2' => 'exact', @@ -477,24 +477,24 @@ public static function provideApplyTestData(): array $filterFactory, ], 'empty nested property' => [ - sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s', static::ALIAS, Dummy::class), [], $filterFactory, ], 'integer value' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.age = :age_p1', static::ALIAS, RelatedDummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.age = :age_p1', static::ALIAS, RelatedDummy::class), ['age_p1' => 46], $filterFactory, RelatedDummy::class, ], 'related owned one-to-one association' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedOwnedDummy relatedOwnedDummy_a1 WHERE relatedOwnedDummy_a1.id = :id_p1', static::ALIAS, Dummy::class), ['id_p1' => 1], $filterFactory, Dummy::class, ], 'related owning one-to-one association' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.relatedOwningDummy = :relatedOwningDummy_p1', static::ALIAS, Dummy::class), + \sprintf('SELECT %s FROM %s %1$s WHERE %1$s.relatedOwningDummy = :relatedOwningDummy_p1', static::ALIAS, Dummy::class), ['relatedOwningDummy_p1' => 1], $filterFactory, Dummy::class, diff --git a/Util/QueryBuilderHelper.php b/Util/QueryBuilderHelper.php index 7814f98..54956fc 100644 --- a/Util/QueryBuilderHelper.php +++ b/Util/QueryBuilderHelper.php @@ -57,7 +57,7 @@ public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGenerato public static function getEntityClassByAlias(string $alias, QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): string { if (!\in_array($alias, $queryBuilder->getAllAliases(), true)) { - throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); + throw new \LogicException(\sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); } $rootAliasMap = self::mapRootAliases($queryBuilder->getRootAliases(), $queryBuilder->getRootEntities()); @@ -73,7 +73,7 @@ public static function getEntityClassByAlias(string $alias, QueryBuilder $queryB } if (null === $metadata) { - throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); + throw new \LogicException(\sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); } return $metadata->getName(); @@ -96,7 +96,7 @@ public static function findRootAlias(string $alias, QueryBuilder $queryBuilder): } } - throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); + throw new \LogicException(\sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias)); } /** @@ -122,7 +122,7 @@ public static function traverseJoins(string $alias, QueryBuilder $queryBuilder, while (null === $apexEntityClass) { if (!isset($aliasMap[$currentAlias])) { - throw new \LogicException(sprintf('Unknown alias "%s".', $currentAlias)); + throw new \LogicException(\sprintf('Unknown alias "%s".', $currentAlias)); } if (\is_string($aliasMap[$currentAlias])) { @@ -171,7 +171,7 @@ public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias foreach ($parts[$rootAlias] as $join) { /** @var Join $join */ - if (sprintf('%s.%s', $alias, $association) === $join->getJoin()) { + if (\sprintf('%s.%s', $alias, $association) === $join->getJoin()) { return $join; } } diff --git a/Util/QueryNameGenerator.php b/Util/QueryNameGenerator.php index 810a1e0..f6a47d2 100644 --- a/Util/QueryNameGenerator.php +++ b/Util/QueryNameGenerator.php @@ -30,7 +30,7 @@ final class QueryNameGenerator implements QueryNameGeneratorInterface */ public function generateJoinAlias(string $association): string { - return sprintf('%s_a%d', $association, $this->incrementedAssociation++); + return \sprintf('%s_a%d', $association, $this->incrementedAssociation++); } /** @@ -38,6 +38,6 @@ public function generateJoinAlias(string $association): string */ public function generateParameterName(string $name): string { - return sprintf('%s_p%d', str_replace('.', '_', $name), $this->incrementedName++); + return \sprintf('%s_p%d', str_replace('.', '_', $name), $this->incrementedName++); } } From a48e476ec3f023e68f03eac63ffb12a5dbf5dae8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 09:26:44 +0200 Subject: [PATCH 025/152] chore: bump api-platform dependencies --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 4cfb545..4528fc5 100644 --- a/composer.json +++ b/composer.json @@ -21,9 +21,9 @@ ], "require": { "php": ">=8.1", - "api-platform/doctrine-common": "*@dev || ^3.1", - "api-platform/metadata": "*@dev || ^3.1", - "api-platform/state": "*@dev || ^3.1", + "api-platform/doctrine-common": "*@dev || ^3.2 || ^4.0", + "api-platform/metadata": "*@dev || ^3.2 || ^4.0", + "api-platform/state": "*@dev || ^3.2 || ^4.0", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" From 10b842076f19c7b108894f2d290c42a4c94fb1d6 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 12 Aug 2024 12:17:47 +0200 Subject: [PATCH 026/152] chore: update branch aliases with 3.4 and 4.0 (#6509) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4528fc5..e737937 100644 --- a/composer.json +++ b/composer.json @@ -69,4 +69,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From 5ca9343c18ad2de2333d7b42dd84d49704a3d170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 12 Aug 2024 12:19:38 +0200 Subject: [PATCH 027/152] fix: add missing v4 version constraints (#6510) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e737937..accf60e 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { - "api-platform/parameter-validator": "*@dev || ^3.2", + "api-platform/parameter-validator": "*@dev || ^3.2 || ^4.0", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^11.2", "ramsey/uuid": "^4.7", From c123c1b4056c1d52855611922b0103f9e8765f99 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:36:55 +0200 Subject: [PATCH 028/152] cs: newline ending --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index accf60e..fbe9ee4 100644 --- a/composer.json +++ b/composer.json @@ -69,4 +69,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From eba49930890b12d60804b351d3fd99026ab842f2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:44:43 +0200 Subject: [PATCH 029/152] chore: align dependencies across components --- composer.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index fbe9ee4..0f0f4f2 100644 --- a/composer.json +++ b/composer.json @@ -21,27 +21,27 @@ ], "require": { "php": ">=8.1", - "api-platform/doctrine-common": "*@dev || ^3.2 || ^4.0", - "api-platform/metadata": "*@dev || ^3.2 || ^4.0", - "api-platform/state": "*@dev || ^3.2 || ^4.0", + "api-platform/doctrine-common": "@dev || ^3.2 || ^4.0", + "api-platform/metadata": "@dev || ^3.2 || ^4.0", + "api-platform/state": "@dev || ^3.2 || ^4.0", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { - "api-platform/parameter-validator": "*@dev || ^3.2 || ^4.0", - "phpspec/prophecy-phpunit": "^2.0", + "api-platform/parameter-validator": "@dev || ^3.2 || ^4.0", + "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", - "ramsey/uuid": "^4.7", + "ramsey/uuid": "^4.0", "ramsey/uuid-doctrine": "^2.0", - "symfony/cache": "^6.4 || ^7.0", - "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/cache": "^6.4 || ^7.1", + "symfony/framework-bundle": "^6.4 || ^7.1", "symfony/phpunit-bridge": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/uid": "^6.4 || ^7.0", - "symfony/validator": "^6.4 || ^7.0", - "symfony/yaml": "^6.4 || ^7.0" + "symfony/uid": "^6.4 || ^7.1", + "symfony/validator": "^6.4 || ^7.1", + "symfony/yaml": "^6.4 || ^7.1" }, "autoload": { "psr-4": { From 25589a934effeeb3899ee5d3dd21d6d44a3cb91f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 19 Aug 2024 14:51:02 +0200 Subject: [PATCH 030/152] chore: remove @dev constraint (#6513) --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 0f0f4f2..7df105b 100644 --- a/composer.json +++ b/composer.json @@ -21,15 +21,15 @@ ], "require": { "php": ">=8.1", - "api-platform/doctrine-common": "@dev || ^3.2 || ^4.0", - "api-platform/metadata": "@dev || ^3.2 || ^4.0", - "api-platform/state": "@dev || ^3.2 || ^4.0", + "api-platform/doctrine-common": "^3.2 || ^4.0", + "api-platform/metadata": "^3.2 || ^4.0", + "api-platform/state": "^3.2 || ^4.0", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { - "api-platform/parameter-validator": "@dev || ^3.2 || ^4.0", + "api-platform/parameter-validator": "^3.2 || ^4.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", "ramsey/uuid": "^4.0", From c48b76e96fd1cb9fed7fce5e0ba24dbdc31d58bd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 22 Aug 2024 14:54:29 +0200 Subject: [PATCH 031/152] fix: parameter context for filters (#6535) --- Extension/ParameterExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index dce2c62..aa65efb 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -51,7 +51,7 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; if ($filter instanceof FilterInterface) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values] + $context); + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); } } } From 30619c597ddaca6d87018ed75f25ebbf58879f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Marseille?= Date: Fri, 30 Aug 2024 10:38:25 +0200 Subject: [PATCH 032/152] feat(doctrine): add new filter for filtering an entity using PHP backed enum, resolves #6506 (#6547) --- Filter/BackedEnumFilter.php | 178 +++++++++++++++++++++ Tests/Filter/BackedEnumFilterTest.php | 49 ++++++ Tests/Filter/BackedEnumFilterTestTrait.php | 105 ++++++++++++ Tests/Filter/ExistsFilterTest.php | 5 + Tests/Filter/OrderFilterTest.php | 13 ++ Tests/Filter/RangeFilterTest.php | 25 +++ Tests/Filter/SearchFilterTest.php | 14 ++ Tests/Fixtures/Entity/Dummy.php | 5 +- Tests/Fixtures/Entity/DummyBackedEnum.php | 20 +++ Tests/Fixtures/Entity/RelatedDummy.php | 3 + 10 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 Filter/BackedEnumFilter.php create mode 100644 Tests/Filter/BackedEnumFilterTest.php create mode 100644 Tests/Filter/BackedEnumFilterTestTrait.php create mode 100644 Tests/Fixtures/Entity/DummyBackedEnum.php diff --git a/Filter/BackedEnumFilter.php b/Filter/BackedEnumFilter.php new file mode 100644 index 0000000..fffcb13 --- /dev/null +++ b/Filter/BackedEnumFilter.php @@ -0,0 +1,178 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\BackedEnumFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +/** + * The backed enum filter allows you to search on backed enum fields and values. + * + * Note: it is possible to filter on properties and relations too. + * + * Syntax: `?property=foo`. + * + *
+ * + * ```php + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * book.backed_enum_filter + * + * + * + * + * + * ``` + * + *
+ * + * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?status=published`. + * + * @author Rémi Marseille + */ +final class BackedEnumFilter extends AbstractFilter +{ + use BackedEnumFilterTrait; + + /** + * {@inheritdoc} + */ + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ( + !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass) + || !$this->isBackedEnumField($property, $resourceClass) + ) { + return; + } + + $value = $this->normalizeValue($value, $property); + if (null === $value) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); + } + + $valueParameter = $queryNameGenerator->generateParameterName($field); + + $queryBuilder + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + } + + /** + * {@inheritdoc} + */ + protected function isBackedEnumField(string $property, string $resourceClass): bool + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); + + if (!$metadata instanceof ClassMetadata) { + return false; + } + + $fieldMapping = $metadata->fieldMappings[$propertyParts['field']]; + + // Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object + if ($fieldMapping instanceof FieldMapping) { + $fieldMapping = (array) $fieldMapping; + } + + if (!$enumType = $fieldMapping['enumType']) { + return false; + } + + if (!($enumType::cases()[0] ?? null) instanceof \BackedEnum) { + return false; + } + + $this->enumTypes[$property] = $enumType; + + return true; + } +} diff --git a/Tests/Filter/BackedEnumFilterTest.php b/Tests/Filter/BackedEnumFilterTest.php new file mode 100644 index 0000000..4522e0b --- /dev/null +++ b/Tests/Filter/BackedEnumFilterTest.php @@ -0,0 +1,49 @@ + + * + * 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\Orm\Tests\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter; +use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; + +/** + * @author Rémi Marseille + */ +final class BackedEnumFilterTest extends DoctrineOrmFilterTestCase +{ + use BackedEnumFilterTestTrait; + + protected string $filterClass = BackedEnumFilter::class; + + public static function provideApplyTestData(): array + { + return array_merge_recursive( + self::provideApplyTestArguments(), + [ + 'valid case' => [ + \sprintf('SELECT o FROM %s o WHERE o.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class), + ], + 'invalid case' => [ + \sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'valid case for nested property' => [ + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class), + ], + 'invalid case for nested property' => [ + \sprintf('SELECT o FROM %s o', Dummy::class), + ], + ] + ); + } +} diff --git a/Tests/Filter/BackedEnumFilterTestTrait.php b/Tests/Filter/BackedEnumFilterTestTrait.php new file mode 100644 index 0000000..ebfb2a7 --- /dev/null +++ b/Tests/Filter/BackedEnumFilterTestTrait.php @@ -0,0 +1,105 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Rémi Marseille + */ +trait BackedEnumFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildFilter([ + 'id' => null, + 'name' => null, + 'foo' => null, + 'dummyBackedEnum' => null, + ]); + + $this->assertEquals([ + 'dummyBackedEnum' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['one', 'two'], + ], + ], + ], $filter->getDescription($this->resourceClass)); + } + + public function testGetDescriptionDefaultFields(): void + { + $filter = $this->buildFilter(); + + $this->assertEquals([ + 'dummyBackedEnum' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['one', 'two'], + ], + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'valid case' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBackedEnum' => null, + ], + [ + 'dummyBackedEnum' => 'one', + ], + ], + 'invalid case' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBackedEnum' => null, + ], + [ + 'dummyBackedEnum' => 'zero', + ], + ], + 'valid case for nested property' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.dummyBackedEnum' => null, + ], + [ + 'relatedDummy.dummyBackedEnum' => 'two', + ], + ], + 'invalid case for nested property' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.dummyBackedEnum' => null, + ], + [ + 'relatedDummy.dummyBackedEnum' => 'foo', + ], + ], + ]; + } +} diff --git a/Tests/Filter/ExistsFilterTest.php b/Tests/Filter/ExistsFilterTest.php index fa9f0a7..168714e 100644 --- a/Tests/Filter/ExistsFilterTest.php +++ b/Tests/Filter/ExistsFilterTest.php @@ -88,6 +88,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'bool', 'required' => false, ], + 'exists[dummyBackedEnum]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'bool', + 'required' => false, + ], ], $filter->getDescription($this->resourceClass)); } diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index f75fb5a..20e3293 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -191,6 +191,19 @@ public function testGetDescriptionDefaultFields(): void ], ], ], + 'order[dummyBackedEnum]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'default' => 'asc', + 'type' => 'string', + 'enum' => [ + 'asc', + 'desc', + ], + ], + ], ], $filter->getDescription($this->resourceClass)); $this->assertEquals([ diff --git a/Tests/Filter/RangeFilterTest.php b/Tests/Filter/RangeFilterTest.php index 3fbdb9f..4c0370b 100644 --- a/Tests/Filter/RangeFilterTest.php +++ b/Tests/Filter/RangeFilterTest.php @@ -331,6 +331,31 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyBackedEnum[between]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[gt]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[gte]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[lt]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[lte]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], ], $filter->getDescription($this->resourceClass)); } diff --git a/Tests/Filter/SearchFilterTest.php b/Tests/Filter/SearchFilterTest.php index 0b11fb1..5bc48cc 100644 --- a/Tests/Filter/SearchFilterTest.php +++ b/Tests/Filter/SearchFilterTest.php @@ -211,6 +211,20 @@ public function testGetDescriptionDefaultFields(): void 'strategy' => 'exact', 'is_collection' => true, ], + 'dummyBackedEnum' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'dummyBackedEnum[]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], ], $filter->getDescription($this->resourceClass)); } diff --git a/Tests/Fixtures/Entity/Dummy.php b/Tests/Fixtures/Entity/Dummy.php index 544d01c..f5a29cc 100644 --- a/Tests/Fixtures/Entity/Dummy.php +++ b/Tests/Fixtures/Entity/Dummy.php @@ -27,7 +27,7 @@ * * @author Kévin Dunglas */ -#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] +#[ApiResource(filters: ['my_dummy.backed_enum', 'my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ORM\Entity] @@ -74,6 +74,9 @@ class Dummy #[ORM\Column(nullable: true)] public $dummy; + #[ORM\Column(enumType: DummyBackedEnum::class, nullable: true)] + public DummyBackedEnum $dummyBackedEnum; + /** * @var bool|null A dummy boolean */ diff --git a/Tests/Fixtures/Entity/DummyBackedEnum.php b/Tests/Fixtures/Entity/DummyBackedEnum.php new file mode 100644 index 0000000..b5d76c0 --- /dev/null +++ b/Tests/Fixtures/Entity/DummyBackedEnum.php @@ -0,0 +1,20 @@ + + * + * 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\Orm\Tests\Fixtures\Entity; + +enum DummyBackedEnum: string +{ + case One = 'one'; + case Two = 'two'; +} diff --git a/Tests/Fixtures/Entity/RelatedDummy.php b/Tests/Fixtures/Entity/RelatedDummy.php index 9fbe053..4cf6f92 100644 --- a/Tests/Fixtures/Entity/RelatedDummy.php +++ b/Tests/Fixtures/Entity/RelatedDummy.php @@ -94,6 +94,9 @@ class RelatedDummy extends ParentDummy implements \Stringable #[Groups(['fakemanytomany', 'friends'])] public Collection|iterable $relatedToDummyFriend; + #[ORM\Column(enumType: DummyBackedEnum::class, nullable: true)] + public DummyBackedEnum $dummyBackedEnum; + /** * @var bool|null A dummy bool */ From 858b5e9918b85b1c73e25772b8e3c68362d56346 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 30 Aug 2024 11:29:28 +0200 Subject: [PATCH 033/152] feat(doctrine): add new filter for filtering an entity using PHP backed enum, resolves #6506 (#6547) (#6560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rémi Marseille --- Filter/BackedEnumFilter.php | 178 +++++++++++++++++++++ Tests/Filter/BackedEnumFilterTest.php | 49 ++++++ Tests/Filter/BackedEnumFilterTestTrait.php | 105 ++++++++++++ Tests/Filter/ExistsFilterTest.php | 5 + Tests/Filter/OrderFilterTest.php | 13 ++ Tests/Filter/RangeFilterTest.php | 25 +++ Tests/Filter/SearchFilterTest.php | 14 ++ Tests/Fixtures/Entity/Dummy.php | 5 +- Tests/Fixtures/Entity/DummyBackedEnum.php | 20 +++ Tests/Fixtures/Entity/RelatedDummy.php | 3 + 10 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 Filter/BackedEnumFilter.php create mode 100644 Tests/Filter/BackedEnumFilterTest.php create mode 100644 Tests/Filter/BackedEnumFilterTestTrait.php create mode 100644 Tests/Fixtures/Entity/DummyBackedEnum.php diff --git a/Filter/BackedEnumFilter.php b/Filter/BackedEnumFilter.php new file mode 100644 index 0000000..fffcb13 --- /dev/null +++ b/Filter/BackedEnumFilter.php @@ -0,0 +1,178 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\BackedEnumFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +/** + * The backed enum filter allows you to search on backed enum fields and values. + * + * Note: it is possible to filter on properties and relations too. + * + * Syntax: `?property=foo`. + * + *
+ * + * ```php + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * book.backed_enum_filter + * + * + * + * + * + * ``` + * + *
+ * + * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?status=published`. + * + * @author Rémi Marseille + */ +final class BackedEnumFilter extends AbstractFilter +{ + use BackedEnumFilterTrait; + + /** + * {@inheritdoc} + */ + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ( + !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass) + || !$this->isBackedEnumField($property, $resourceClass) + ) { + return; + } + + $value = $this->normalizeValue($value, $property); + if (null === $value) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); + } + + $valueParameter = $queryNameGenerator->generateParameterName($field); + + $queryBuilder + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + } + + /** + * {@inheritdoc} + */ + protected function isBackedEnumField(string $property, string $resourceClass): bool + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); + + if (!$metadata instanceof ClassMetadata) { + return false; + } + + $fieldMapping = $metadata->fieldMappings[$propertyParts['field']]; + + // Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object + if ($fieldMapping instanceof FieldMapping) { + $fieldMapping = (array) $fieldMapping; + } + + if (!$enumType = $fieldMapping['enumType']) { + return false; + } + + if (!($enumType::cases()[0] ?? null) instanceof \BackedEnum) { + return false; + } + + $this->enumTypes[$property] = $enumType; + + return true; + } +} diff --git a/Tests/Filter/BackedEnumFilterTest.php b/Tests/Filter/BackedEnumFilterTest.php new file mode 100644 index 0000000..4522e0b --- /dev/null +++ b/Tests/Filter/BackedEnumFilterTest.php @@ -0,0 +1,49 @@ + + * + * 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\Orm\Tests\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter; +use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; + +/** + * @author Rémi Marseille + */ +final class BackedEnumFilterTest extends DoctrineOrmFilterTestCase +{ + use BackedEnumFilterTestTrait; + + protected string $filterClass = BackedEnumFilter::class; + + public static function provideApplyTestData(): array + { + return array_merge_recursive( + self::provideApplyTestArguments(), + [ + 'valid case' => [ + \sprintf('SELECT o FROM %s o WHERE o.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class), + ], + 'invalid case' => [ + \sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'valid case for nested property' => [ + \sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class), + ], + 'invalid case for nested property' => [ + \sprintf('SELECT o FROM %s o', Dummy::class), + ], + ] + ); + } +} diff --git a/Tests/Filter/BackedEnumFilterTestTrait.php b/Tests/Filter/BackedEnumFilterTestTrait.php new file mode 100644 index 0000000..ebfb2a7 --- /dev/null +++ b/Tests/Filter/BackedEnumFilterTestTrait.php @@ -0,0 +1,105 @@ + + * + * 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\Orm\Tests\Filter; + +/** + * @author Rémi Marseille + */ +trait BackedEnumFilterTestTrait +{ + public function testGetDescription(): void + { + $filter = $this->buildFilter([ + 'id' => null, + 'name' => null, + 'foo' => null, + 'dummyBackedEnum' => null, + ]); + + $this->assertEquals([ + 'dummyBackedEnum' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['one', 'two'], + ], + ], + ], $filter->getDescription($this->resourceClass)); + } + + public function testGetDescriptionDefaultFields(): void + { + $filter = $this->buildFilter(); + + $this->assertEquals([ + 'dummyBackedEnum' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['one', 'two'], + ], + ], + ], $filter->getDescription($this->resourceClass)); + } + + private static function provideApplyTestArguments(): array + { + return [ + 'valid case' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBackedEnum' => null, + ], + [ + 'dummyBackedEnum' => 'one', + ], + ], + 'invalid case' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBackedEnum' => null, + ], + [ + 'dummyBackedEnum' => 'zero', + ], + ], + 'valid case for nested property' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.dummyBackedEnum' => null, + ], + [ + 'relatedDummy.dummyBackedEnum' => 'two', + ], + ], + 'invalid case for nested property' => [ + [ + 'id' => null, + 'name' => null, + 'relatedDummy.dummyBackedEnum' => null, + ], + [ + 'relatedDummy.dummyBackedEnum' => 'foo', + ], + ], + ]; + } +} diff --git a/Tests/Filter/ExistsFilterTest.php b/Tests/Filter/ExistsFilterTest.php index fa9f0a7..168714e 100644 --- a/Tests/Filter/ExistsFilterTest.php +++ b/Tests/Filter/ExistsFilterTest.php @@ -88,6 +88,11 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'bool', 'required' => false, ], + 'exists[dummyBackedEnum]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'bool', + 'required' => false, + ], ], $filter->getDescription($this->resourceClass)); } diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index f75fb5a..20e3293 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -191,6 +191,19 @@ public function testGetDescriptionDefaultFields(): void ], ], ], + 'order[dummyBackedEnum]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'default' => 'asc', + 'type' => 'string', + 'enum' => [ + 'asc', + 'desc', + ], + ], + ], ], $filter->getDescription($this->resourceClass)); $this->assertEquals([ diff --git a/Tests/Filter/RangeFilterTest.php b/Tests/Filter/RangeFilterTest.php index 3fbdb9f..4c0370b 100644 --- a/Tests/Filter/RangeFilterTest.php +++ b/Tests/Filter/RangeFilterTest.php @@ -331,6 +331,31 @@ public function testGetDescriptionDefaultFields(): void 'type' => 'string', 'required' => false, ], + 'dummyBackedEnum[between]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[gt]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[gte]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[lt]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], + 'dummyBackedEnum[lte]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + ], ], $filter->getDescription($this->resourceClass)); } diff --git a/Tests/Filter/SearchFilterTest.php b/Tests/Filter/SearchFilterTest.php index 0b11fb1..5bc48cc 100644 --- a/Tests/Filter/SearchFilterTest.php +++ b/Tests/Filter/SearchFilterTest.php @@ -211,6 +211,20 @@ public function testGetDescriptionDefaultFields(): void 'strategy' => 'exact', 'is_collection' => true, ], + 'dummyBackedEnum' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => false, + ], + 'dummyBackedEnum[]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'strategy' => 'exact', + 'is_collection' => true, + ], ], $filter->getDescription($this->resourceClass)); } diff --git a/Tests/Fixtures/Entity/Dummy.php b/Tests/Fixtures/Entity/Dummy.php index 00df229..9c1530c 100644 --- a/Tests/Fixtures/Entity/Dummy.php +++ b/Tests/Fixtures/Entity/Dummy.php @@ -27,7 +27,7 @@ * * @author Kévin Dunglas */ -#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false])] +#[ApiResource(filters: ['my_dummy.backed_enum', 'my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false])] #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ORM\Entity] @@ -74,6 +74,9 @@ class Dummy #[ORM\Column(nullable: true)] public $dummy; + #[ORM\Column(enumType: DummyBackedEnum::class, nullable: true)] + public DummyBackedEnum $dummyBackedEnum; + /** * @var bool|null A dummy boolean */ diff --git a/Tests/Fixtures/Entity/DummyBackedEnum.php b/Tests/Fixtures/Entity/DummyBackedEnum.php new file mode 100644 index 0000000..b5d76c0 --- /dev/null +++ b/Tests/Fixtures/Entity/DummyBackedEnum.php @@ -0,0 +1,20 @@ + + * + * 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\Orm\Tests\Fixtures\Entity; + +enum DummyBackedEnum: string +{ + case One = 'one'; + case Two = 'two'; +} diff --git a/Tests/Fixtures/Entity/RelatedDummy.php b/Tests/Fixtures/Entity/RelatedDummy.php index 9fbe053..4cf6f92 100644 --- a/Tests/Fixtures/Entity/RelatedDummy.php +++ b/Tests/Fixtures/Entity/RelatedDummy.php @@ -94,6 +94,9 @@ class RelatedDummy extends ParentDummy implements \Stringable #[Groups(['fakemanytomany', 'friends'])] public Collection|iterable $relatedToDummyFriend; + #[ORM\Column(enumType: DummyBackedEnum::class, nullable: true)] + public DummyBackedEnum $dummyBackedEnum; + /** * @var bool|null A dummy bool */ From 1517e0a8d227b385df1b6b2a7f83f8a90f6638f4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 30 Aug 2024 22:19:12 +0200 Subject: [PATCH 034/152] chore: deprecations (#6563) * chore: deprecations * chore: deprecations --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 40bebfc..0928b49 100644 --- a/composer.json +++ b/composer.json @@ -21,15 +21,15 @@ ], "require": { "php": ">=8.1", - "api-platform/doctrine-common": "*@dev || ^3.1", - "api-platform/metadata": "*@dev || ^3.1", - "api-platform/state": "*@dev || ^3.1", + "api-platform/doctrine-common": "^3.1", + "api-platform/metadata": "^3.1", + "api-platform/state": "^3.1", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { - "api-platform/parameter-validator": "*@dev || ^3.2", + "api-platform/parameter-validator": "^3.2", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^10.0", "ramsey/uuid": "^4.7", @@ -69,4 +69,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From b2be6f342c971aa8e3d419d5d551d44c77687fa8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 2 Sep 2024 18:34:47 +0200 Subject: [PATCH 035/152] test: doctrine auto mapping controller is deprecated --- Tests/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/config.yml b/Tests/config.yml index e3e948b..f89f33e 100644 --- a/Tests/config.yml +++ b/Tests/config.yml @@ -9,7 +9,8 @@ doctrine: orm: auto_generate_proxy_classes: '%kernel.debug%' controller_resolver: - auto_mapping: true + enabled: false + auto_mapping: false enable_lazy_ghost_objects: true mappings: ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity: From 5bf3775059c1ce7717490da2b32f4b9589178324 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 3 Sep 2024 09:00:23 +0200 Subject: [PATCH 036/152] chore: bump dependencies to ^3.4 || ^4.0 (#6576) * chore: bump dependencies to ^3.4 || ^4.0 * fix: use baseline for serializer context builder * ci: use pmu 0.12 --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 0928b49..85a0ed3 100644 --- a/composer.json +++ b/composer.json @@ -21,9 +21,9 @@ ], "require": { "php": ">=8.1", - "api-platform/doctrine-common": "^3.1", - "api-platform/metadata": "^3.1", - "api-platform/state": "^3.1", + "api-platform/doctrine-common": "^3.4 || ^4.0", + "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/state": "^3.4 || ^4.0", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" From e21f9b5cac18c86fbd6f2fa7fd8bb9a2ab8f6da6 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Sep 2024 08:35:15 +0200 Subject: [PATCH 037/152] chore: remove phpunit-bridge (#6589) --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index cb7d3cd..f9b9622 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,6 @@ "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.1", "symfony/framework-bundle": "^6.4 || ^7.1", - "symfony/phpunit-bridge": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.1", From 464b06dd919408e6a5a88914965c6d4aafbc2fbe Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Sat, 7 Sep 2024 08:31:56 +0200 Subject: [PATCH 038/152] fix: replace ApiPlatform\Exception use by ApiPlatform\Metadata\Exception (#6597) --- Extension/FilterEagerLoadingExtension.php | 2 +- Extension/FilterExtension.php | 2 +- Extension/OrderExtension.php | 2 +- Extension/PaginationExtension.php | 2 +- PropertyHelperTrait.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Extension/FilterEagerLoadingExtension.php b/Extension/FilterEagerLoadingExtension.php index daba5d9..8145fa6 100644 --- a/Extension/FilterEagerLoadingExtension.php +++ b/Extension/FilterEagerLoadingExtension.php @@ -15,7 +15,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Doctrine\ORM\EntityManagerInterface; diff --git a/Extension/FilterExtension.php b/Extension/FilterExtension.php index 298eff2..801e9c4 100644 --- a/Extension/FilterExtension.php +++ b/Extension/FilterExtension.php @@ -16,7 +16,7 @@ use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Psr\Container\ContainerInterface; diff --git a/Extension/OrderExtension.php b/Extension/OrderExtension.php index 00140fd..1d3deaf 100644 --- a/Extension/OrderExtension.php +++ b/Extension/OrderExtension.php @@ -15,7 +15,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; diff --git a/Extension/PaginationExtension.php b/Extension/PaginationExtension.php index 80513e4..9b4ab0d 100644 --- a/Extension/PaginationExtension.php +++ b/Extension/PaginationExtension.php @@ -17,7 +17,7 @@ use ApiPlatform\Doctrine\Orm\Paginator; use ApiPlatform\Doctrine\Orm\Util\QueryChecker; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Pagination\Pagination; use Doctrine\ORM\QueryBuilder; diff --git a/PropertyHelperTrait.php b/PropertyHelperTrait.php index 8ed61ca..8431e3e 100644 --- a/PropertyHelperTrait.php +++ b/PropertyHelperTrait.php @@ -15,7 +15,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use Doctrine\ORM\Mapping\ClassMetadata as ClassMetadataInfo; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; From a892e528b3e715a42fa858a5521e57c4177bf7f8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 2 Sep 2024 18:34:47 +0200 Subject: [PATCH 039/152] test: doctrine auto mapping controller is deprecated --- Tests/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/config.yml b/Tests/config.yml index e3e948b..f89f33e 100644 --- a/Tests/config.yml +++ b/Tests/config.yml @@ -9,7 +9,8 @@ doctrine: orm: auto_generate_proxy_classes: '%kernel.debug%' controller_resolver: - auto_mapping: true + enabled: false + auto_mapping: false enable_lazy_ghost_objects: true mappings: ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity: From 7ee600c1b46f32eaded7a885169bdcc2c105c999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Thu, 19 Sep 2024 17:56:07 +0200 Subject: [PATCH 040/152] chore: setup star forwarding (#6630) --- composer.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 85a0ed3..5fda971 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,10 @@ "type": "library", "keywords": [ "Doctrine", - "ORM" + "ORM", + "API", + "REST", + "GraphQL" ], "homepage": "https://api-platform.com", "license": "MIT", @@ -64,6 +67,10 @@ }, "symfony": { "require": "^6.4 || ^7.1" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "scripts": { From 9195c38abb3a36b9d526d51c0959235b5604adaf Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 19 Sep 2024 17:56:20 +0200 Subject: [PATCH 041/152] chore: php version >= 8.2 (#6628) * chore: php version >= 8.2 * down to sf 7.0 * symfony extra require * missing hal constraint --- composer.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index f9b9622..e734865 100644 --- a/composer.json +++ b/composer.json @@ -20,26 +20,26 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "api-platform/doctrine-common": "^3.4 || ^4.0", "api-platform/metadata": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^6.4 || ^7.1" + "symfony/property-info": "^6.4 || ^7.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", "ramsey/uuid": "^4.0", "ramsey/uuid-doctrine": "^2.0", - "symfony/cache": "^6.4 || ^7.1", - "symfony/framework-bundle": "^6.4 || ^7.1", + "symfony/cache": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/uid": "^6.4 || ^7.1", - "symfony/validator": "^6.4 || ^7.1", - "symfony/yaml": "^6.4 || ^7.1" + "symfony/uid": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0" }, "autoload": { "psr-4": { @@ -61,7 +61,7 @@ "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.1" + "require": "^6.4 || ^7.0" } }, "scripts": { From e34c197af383e7692fd30c45f276745b1b3bde9f Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Sep 2024 17:58:51 +0200 Subject: [PATCH 042/152] chore: fix thanks url --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index c16aae7..ac24c64 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,10 @@ }, "symfony": { "require": "^6.4 || ^7.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "scripts": { From fd6fb1c11d55bf69930762c08941acbb5726bb60 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 20 Sep 2024 11:32:54 +0200 Subject: [PATCH 043/152] chore: move dependency to dev requirements (#6634) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ac24c64..9bc1aa9 100644 --- a/composer.json +++ b/composer.json @@ -27,11 +27,11 @@ "api-platform/doctrine-common": "^3.4 || ^4.0", "api-platform/metadata": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", - "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.0" }, "require-dev": { + "doctrine/doctrine-bundle": "^2.11", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", "ramsey/uuid": "^4.0", From 74aec72e14c0f1971078ede17bb710117fb50436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 20 Sep 2024 11:46:41 +0200 Subject: [PATCH 044/152] fix: composer.json config --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5fda971..2ec46bc 100644 --- a/composer.json +++ b/composer.json @@ -27,12 +27,12 @@ "api-platform/doctrine-common": "^3.4 || ^4.0", "api-platform/metadata": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", - "doctrine/doctrine-bundle": "^2.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { "api-platform/parameter-validator": "^3.2", + "doctrine/doctrine-bundle": "^2.11", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^10.0", "ramsey/uuid": "^4.7", From b6b9eabf268a3e8b8f707db7e558f86d23022616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 20 Sep 2024 15:47:23 +0200 Subject: [PATCH 045/152] chore: add GitHub Action to automatically close PRs on subtree splits (#6648) --- .gitattributes | 3 +++ .github/workflows/close_pr.yml | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .github/workflows/close_pr.yml diff --git a/.gitattributes b/.gitattributes index ae3c2e1..801f208 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +/.github export-ignore +/.gitattributes export-ignore /.gitignore export-ignore /Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/close_pr.yml b/.github/workflows/close_pr.yml new file mode 100644 index 0000000..72a8ab4 --- /dev/null +++ b/.github/workflows/close_pr.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Thank you for your pull request. However, you have submitted this PR on a read-only sub split of `api-platform/core`. Please submit your PR on the https://github.com/api-platform/core repository.

Thanks!" From 2375ab718eee1bfef00301e5dcd50cf712f11f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 21 Sep 2024 12:54:56 +0200 Subject: [PATCH 046/152] doc: add README files for components (#6653) * docs: add README for components * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * remove trailing spaces * typo * better title --------- Co-authored-by: Antoine Bluchet --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca9c9ab --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# API Platform - Doctrine ORM Support + +Integration for [Doctrine ORM](https://www.doctrine-project.org/projects/doctrine-orm/en/current/index.html) with the [API Platform](https://api-platform.com) framework. + +[Documentation](https://api-platform.com/docs/core/getting-started/) + +> [!CAUTION] +> +> This is a read-only sub split of `api-platform/core`, please +> [report issues](https://github.com/api-platform/core/issues) and +> [send Pull Requests](https://github.com/api-platform/core/pulls) +> in the [core API Platform repository](https://github.com/api-platform/core). From 46dd020708dd128c564688a4fbfc877903747300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Mon, 4 Nov 2024 15:19:45 +0100 Subject: [PATCH 047/152] fix(doctrine): throw an exception when a filter is not found in a parameter (#6767) --- Extension/ParameterExtension.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index aa65efb..6ae3a00 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; @@ -50,9 +51,11 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter } $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; - if ($filter instanceof FilterInterface) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); + if (!$filter instanceof FilterInterface) { + throw new InvalidArgumentException(\sprintf('Could not find filter "%s" for parameter "%s" in operation "%s" for resource "%s".', $filterId, $parameter->getKey(), $operation?->getShortName(), $resourceClass)); } + + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); } } From 66da16453f6236f455fcc4d10380a717d277b9db Mon Sep 17 00:00:00 2001 From: Arvids Godjuks Date: Fri, 6 Dec 2024 12:53:33 +0200 Subject: [PATCH 048/152] fix(doctrine): fixed orm datefilter applying inner join when no filtering values have been provided (#6849) --- Filter/DateFilter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index 8533ee3..268b115 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -153,7 +153,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $alias = $queryBuilder->getRootAliases()[0]; $field = $property; - if ($this->isPropertyNested($property, $resourceClass)) { + if ($this->isPropertyNested($property, $resourceClass) && \count($values) > 0) { [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); } From 01d49d6f89060eaf4804b02bc0f8d04f59107999 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 9 Dec 2024 11:30:13 +0100 Subject: [PATCH 049/152] fix: test empty parameter against null (#6852) fixes #6844 --- Extension/ParameterExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index 6ae3a00..acd6aac 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -41,7 +41,7 @@ public function __construct(private readonly ContainerInterface $filterLocator) private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($operation?->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } From b4f6e500a8e5d8daae322e82acc35b643060ac88 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Tue, 10 Dec 2024 13:46:08 +0100 Subject: [PATCH 050/152] feat(doctrine): doctrine filters like laravel eloquent filters (#6775) * feat(doctrine): doctrine filters like laravel eloquent filters * fix: allow multiple validation with :property placeholder * fix: correct escape filter condition * fix: remove duplicated block --------- Co-authored-by: soyuka --- Extension/ParameterExtension.php | 42 +++++++++++++++++++++++--- Filter/AbstractFilter.php | 38 +++++++++++++++++------ Filter/BooleanFilter.php | 12 +++++++- Filter/DateFilter.php | 52 ++++++++++++++++++++++++-------- Filter/ExistsFilter.php | 25 +++++++++++++-- Filter/NumericFilter.php | 9 +++++- Filter/OrderFilter.php | 32 +++++++++++++++----- Filter/RangeFilter.php | 19 +++++++++++- PropertyHelperTrait.php | 2 +- 9 files changed, 191 insertions(+), 40 deletions(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index 6ae3a00..893305e 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -21,6 +23,7 @@ use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; use Psr\Container\ContainerInterface; +use Symfony\Bridge\Doctrine\ManagerRegistry; /** * Reads operation parameters and execute its filter. @@ -31,8 +34,10 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que { use ParameterValueExtractorTrait; - public function __construct(private readonly ContainerInterface $filterLocator) - { + public function __construct( + private readonly ContainerInterface $filterLocator, + private readonly ?ManagerRegistry $managerRegistry = null, + ) { } /** @@ -41,7 +46,8 @@ public function __construct(private readonly ContainerInterface $filterLocator) private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($operation?->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + // TODO: remove the null equality as a parameter can have a null value + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } @@ -50,12 +56,38 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + $filter = match (true) { + $filterId instanceof FilterInterface => $filterId, + \is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId), + default => null, + }; + if (!$filter instanceof FilterInterface) { throw new InvalidArgumentException(\sprintf('Could not find filter "%s" for parameter "%s" in operation "%s" for resource "%s".', $filterId, $parameter->getKey(), $operation?->getShortName(), $resourceClass)); } - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); + if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $filter->setManagerRegistry($this->managerRegistry); + } + + 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 ?? []); + } + + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, + ['filters' => $values, 'parameter' => $parameter] + $context + ); } } diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index 4ec7046..18b2928 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -13,10 +13,12 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -24,14 +26,18 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) - { + public function __construct( + protected ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, + protected ?array $properties = null, + protected ?NameConverterInterface $nameConverter = null, + ) { $this->logger = $logger ?? new NullLogger(); } @@ -53,29 +59,43 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q */ abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; - protected function getManagerRegistry(): ManagerRegistry + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry { + if (!$this->hasManagerRegistry()) { + throw new RuntimeException('ManagerRegistry must be initialized before accessing it.'); + } + return $this->managerRegistry; } - protected function getProperties(): ?array + public function setManagerRegistry(ManagerRegistry $managerRegistry): void { - return $this->properties; + $this->managerRegistry = $managerRegistry; } - protected function getLogger(): LoggerInterface + public function getProperties(): ?array { - return $this->logger; + return $this->properties; } /** - * @param string[] $properties + * @param array $properties */ public function setProperties(array $properties): void { $this->properties = $properties; } + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + /** * Determines whether the given property is enabled. */ diff --git a/Filter/BooleanFilter.php b/Filter/BooleanFilter.php index e9f0a83..6cc7dfd 100644 --- a/Filter/BooleanFilter.php +++ b/Filter/BooleanFilter.php @@ -15,7 +15,9 @@ use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -106,7 +108,7 @@ * @author Amrouche Hamza * @author Teoh Han Hui */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -145,4 +147,12 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'boolean']; + } } diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index 268b115..b1eb525 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -17,7 +17,12 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\DBAL\Types\Type as DBALType; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; @@ -120,7 +125,7 @@ * @author Kévin Dunglas * @author Théo FIDRY */ -final class DateFilter extends AbstractFilter implements DateFilterInterface +final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use DateFilterTrait; @@ -138,11 +143,11 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - // Expect $values to be an array having the period as keys and the date value as values + // Expect $value to be an array having the period as keys and the date value as values if ( - !\is_array($values) + !\is_array($value) || !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) || !$this->isDateField($property, $resourceClass) @@ -153,7 +158,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $alias = $queryBuilder->getRootAliases()[0]; $field = $property; - if ($this->isPropertyNested($property, $resourceClass) && \count($values) > 0) { + if ($this->isPropertyNested($property, $resourceClass) && \count($value) > 0) { [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); } @@ -164,53 +169,53 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $queryBuilder->andWhere($queryBuilder->expr()->isNotNull(\sprintf('%s.%s', $alias, $field))); } - if (isset($values[self::PARAMETER_BEFORE])) { + if (isset($value[self::PARAMETER_BEFORE])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_BEFORE, - $values[self::PARAMETER_BEFORE], + $value[self::PARAMETER_BEFORE], $nullManagement, $type ); } - if (isset($values[self::PARAMETER_STRICTLY_BEFORE])) { + if (isset($value[self::PARAMETER_STRICTLY_BEFORE])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_STRICTLY_BEFORE, - $values[self::PARAMETER_STRICTLY_BEFORE], + $value[self::PARAMETER_STRICTLY_BEFORE], $nullManagement, $type ); } - if (isset($values[self::PARAMETER_AFTER])) { + if (isset($value[self::PARAMETER_AFTER])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_AFTER, - $values[self::PARAMETER_AFTER], + $value[self::PARAMETER_AFTER], $nullManagement, $type ); } - if (isset($values[self::PARAMETER_STRICTLY_AFTER])) { + if (isset($value[self::PARAMETER_STRICTLY_AFTER])) { $this->addWhere( $queryBuilder, $queryNameGenerator, $alias, $field, self::PARAMETER_STRICTLY_AFTER, - $values[self::PARAMETER_STRICTLY_AFTER], + $value[self::PARAMETER_STRICTLY_AFTER], $nullManagement, $type ); @@ -269,4 +274,25 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf $queryBuilder->setParameter($valueParameter, $value, $type); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'date']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[after]', in: $in), + new OpenApiParameter(name: $key.'[before]', in: $in), + new OpenApiParameter(name: $key.'[strictly_after]', in: $in), + new OpenApiParameter(name: $key.'[strictly_before]', in: $in), + ]; + } } diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 0dde78e..4824057 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -15,9 +15,13 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface; use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; @@ -113,11 +117,12 @@ * * @author Teoh Han Hui */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface +final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use ExistsFilterTrait; + use PropertyPlaceholderOpenApiParameterTrait; - public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { parent::__construct($managerRegistry, $logger, $properties, $nameConverter); @@ -129,6 +134,14 @@ public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $ */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + $parameter = $context['parameter'] ?? null; + + if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + + return; + } + foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } @@ -263,4 +276,12 @@ private function isAssociationNullable(AssociationMapping|array $associationMapp return true; } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'boolean']; + } } diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index 545b552..c81a118 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -15,7 +15,9 @@ use ApiPlatform\Doctrine\Common\Filter\NumericFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -106,7 +108,7 @@ * @author Amrouche Hamza * @author Teoh Han Hui */ -final class NumericFilter extends AbstractFilter +final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { use NumericFilterTrait; @@ -176,4 +178,9 @@ protected function getType(?string $doctrineType = null): string return 'int'; } + + public function getSchema(Parameter $parameter): array + { + return ['type' => 'numeric']; + } } diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index b2abdab..61b3a65 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -15,8 +15,12 @@ use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; use ApiPlatform\Doctrine\Common\Filter\OrderFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -195,11 +199,12 @@ * @author Kévin Dunglas * @author Théo FIDRY */ -final class OrderFilter extends AbstractFilter implements OrderFilterInterface +final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use OrderFilterTrait; + use PropertyPlaceholderOpenApiParameterTrait; - public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) + public function __construct(?ManagerRegistry $managerRegistry = null, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null, private readonly ?string $orderNullsComparison = null) { if (null !== $properties) { $properties = array_map(static function ($propertyOptions) { @@ -224,12 +229,17 @@ public function __construct(ManagerRegistry $managerRegistry, string $orderParam */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if (isset($context['filters']) && !isset($context['filters'][$this->orderParameterName])) { + if ( + isset($context['filters']) + && (!isset($context['filters'][$this->orderParameterName]) || !\is_array($context['filters'][$this->orderParameterName])) + && !isset($context['parameter']) + ) { return; } - if (!isset($context['filters'][$this->orderParameterName]) || !\is_array($context['filters'][$this->orderParameterName])) { - parent::apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + $parameter = $context['parameter'] ?? null; + if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); return; } @@ -242,13 +252,13 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $direction, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) { return; } - $direction = $this->normalizeValue($direction, $property); + $direction = $this->normalizeValue($value, $property); if (null === $direction) { return; } @@ -271,4 +281,12 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc']]; + } } diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index 85de711..ae052fa 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -16,7 +16,11 @@ use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface; use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -105,7 +109,7 @@ * * @author Lee Siong Chan */ -final class RangeFilter extends AbstractFilter implements RangeFilterInterface +final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { use RangeFilterTrait; @@ -222,4 +226,17 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf break; } } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[gt]', in: $in), + new OpenApiParameter(name: $key.'[lt]', in: $in), + new OpenApiParameter(name: $key.'[gte]', in: $in), + new OpenApiParameter(name: $key.'[lte]', in: $in), + ]; + } } diff --git a/PropertyHelperTrait.php b/PropertyHelperTrait.php index 8431e3e..d9376bc 100644 --- a/PropertyHelperTrait.php +++ b/PropertyHelperTrait.php @@ -29,7 +29,7 @@ */ trait PropertyHelperTrait { - abstract protected function getManagerRegistry(): ManagerRegistry; + abstract protected function getManagerRegistry(): ?ManagerRegistry; /** * Splits the given property into parts. From 70f7c7bbcfe8728d4a548b7ba97aff1cb885a355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 31 Jan 2025 14:52:20 +0100 Subject: [PATCH 051/152] chore: cross-package version dependencies (#6935) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ff2b513..8757f18 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^3.4 || ^4.0", + "api-platform/doctrine-common": "^4.1", "api-platform/metadata": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", "doctrine/orm": "^2.17 || ^3.0", From 5f0f749ac53019a730debd7ac95549c473f557fd Mon Sep 17 00:00:00 2001 From: Tac Tacelosky Date: Mon, 10 Feb 2025 04:23:32 -0500 Subject: [PATCH 052/152] chore: remove AdvancedNameConverterInterface usage (#6956) --- Tests/Fixtures/CustomConverter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/Fixtures/CustomConverter.php b/Tests/Fixtures/CustomConverter.php index e0a06d8..da479c7 100644 --- a/Tests/Fixtures/CustomConverter.php +++ b/Tests/Fixtures/CustomConverter.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures; -use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -21,7 +20,7 @@ * Custom converter that will only convert a property named "nameConverted" * with the same logic as Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. */ -class CustomConverter implements AdvancedNameConverterInterface +class CustomConverter implements NameConverterInterface { private NameConverterInterface $nameConverter; From 5aa5047c514de3b147b4909bcabf0848a8ddd0c8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:08:08 +0100 Subject: [PATCH 053/152] chore: dependency constraints (#6988) --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 8757f18..1470080 100644 --- a/composer.json +++ b/composer.json @@ -25,15 +25,15 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^4.1", - "api-platform/metadata": "^3.4 || ^4.0", - "api-platform/state": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", + "api-platform/state": "^4.1", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^6.4 || ^7.0" + "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^10.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0", @@ -60,7 +60,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.0.x-dev", + "dev-main": "4.2.x-dev", "dev-3.4": "3.4.x-dev" }, "symfony": { From ffae2b442a13436d9048359ccd3d8d9024a67850 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:39:00 +0100 Subject: [PATCH 054/152] chore: dependency constraints for laravel 12 (#6989) --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index ff2b513..752ee62 100644 --- a/composer.json +++ b/composer.json @@ -28,12 +28,12 @@ "api-platform/metadata": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^6.4 || ^7.0" + "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^10.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0", From fbaff08933ef9db56470e07ef68b73fb62235203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Fri, 7 Mar 2025 11:15:36 +0100 Subject: [PATCH 055/152] fix(doctrine): Add a proper exception when a doctrine manager could not be found for a resource class (#6995) * fix(doctrine): Add a proper exception when a doctrine manager could not be found for a resource class * Update ItemProvider.php --------- Co-authored-by: Antoine Bluchet --- State/ItemProvider.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/State/ItemProvider.php b/State/ItemProvider.php index 0a7f486..8f32acb 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -53,8 +53,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $entityClass = $options->getEntityClass(); } - /** @var EntityManagerInterface $manager */ + /** @var EntityManagerInterface|null $manager */ $manager = $this->managerRegistry->getManagerForClass($entityClass); + if (null === $manager) { + throw new RuntimeException(\sprintf('No manager found for class "%s". Are you sure it\'s an entity?', $entityClass)); + } $fetchData = $context['fetch_data'] ?? true; if (!$fetchData && \array_key_exists('id', $uriVariables)) { From 59a5cb85571dc75867854b0ec01369a9d805d822 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 7 Mar 2025 17:11:19 +0100 Subject: [PATCH 056/152] fix(doctrine): correct the use statement for ManagerRegistry (#7004) --- Extension/ParameterExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index cc99611..838ee7b 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -22,8 +22,8 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; -use Symfony\Bridge\Doctrine\ManagerRegistry; /** * Reads operation parameters and execute its filter. From 89a4bac54df434f39d796ec9cefb03ba74db0a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 8 Mar 2025 08:53:11 +0100 Subject: [PATCH 057/152] fix(doctrine): mapping ArrayAccess deprecation (#6982) * fix: Doctrine mapping ArrayAccess deprecation * fix tests --------- Co-authored-by: soyuka --- Extension/EagerLoadingExtension.php | 2 +- Tests/Extension/EagerLoadingExtensionTest.php | 33 ++++++++++--------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index aa1e08a..240c1ff 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -181,7 +181,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt $associationAlias = $existingJoin->getAlias(); $isLeftJoin = Join::LEFT_JOIN === $existingJoin->getJoinType(); } else { - $isNullable = $mapping['joinColumns'][0]['nullable'] ?? true; + $isNullable = $mapping['joinColumns'][0]?->nullable ?? true; $isLeftJoin = false !== $wasLeftJoin || true === $isNullable; $method = $isLeftJoin ? 'leftJoin' : 'innerJoin'; diff --git a/Tests/Extension/EagerLoadingExtensionTest.php b/Tests/Extension/EagerLoadingExtensionTest.php index 5dec2ce..2ad7a74 100644 --- a/Tests/Extension/EagerLoadingExtensionTest.php +++ b/Tests/Extension/EagerLoadingExtensionTest.php @@ -35,6 +35,7 @@ use ApiPlatform\Metadata\Property\PropertyNameCollection; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use PHPUnit\Framework\TestCase; @@ -94,8 +95,8 @@ public function testApplyToCollection(): void $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], - 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: true)], 'targetEntity' => RelatedDummy::class], + 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => RelatedDummy::class], ]; $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); @@ -181,9 +182,9 @@ public function testApplyToItem(): void $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], - 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => UnknownDummy::class], - 'relatedDummy3' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinTable' => ['joinColumns' => [['nullable' => false]]], 'targetEntity' => UnknownDummy::class], + 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: true)], 'targetEntity' => RelatedDummy::class], + 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => UnknownDummy::class], + 'relatedDummy3' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinTable' => ['joinColumns' => [new JoinColumn(nullable: false)]], 'targetEntity' => UnknownDummy::class], 'relatedDummy4' => ['fetch' => ClassMetadata::FETCH_EAGER, 'targetEntity' => UnknownDummy::class], 'relatedDummy5' => ['fetch' => ClassMetadata::FETCH_LAZY, 'targetEntity' => UnknownDummy::class], 'singleInheritanceRelation' => ['fetch' => ClassMetadata::FETCH_EAGER, 'targetEntity' => AbstractDummy::class], @@ -200,7 +201,7 @@ public function testApplyToItem(): void $relatedClassMetadataProphecy->hasField('embeddedDummy.name')->willReturn(true)->shouldBeCalled(); $relatedClassMetadataProphecy->associationMappings = [ - 'relation' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => UnknownDummy::class], + 'relation' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => UnknownDummy::class], 'thirdLevel' => ['fetch' => ClassMetadata::FETCH_EAGER, 'targetEntity' => ThirdLevel::class, 'sourceEntity' => RelatedDummy::class, 'inversedBy' => 'relatedDummies', 'type' => ClassMetadata::TO_ONE], ]; @@ -361,13 +362,13 @@ public function testMaxJoinsReached(): void $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => RelatedDummy::class], ]; $classMetadataProphecy->hasField('relatedDummy')->willReturn(true); $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); $relatedClassMetadataProphecy->associationMappings = [ - 'dummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => Dummy::class], + 'dummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => Dummy::class], ]; $relatedClassMetadataProphecy->hasField('dummy')->willReturn(true); @@ -410,13 +411,13 @@ public function testMaxDepth(): void $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => RelatedDummy::class], ]; $classMetadataProphecy->hasField('relatedDummy')->willReturn(true); $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); $relatedClassMetadataProphecy->associationMappings = [ - 'dummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => Dummy::class], + 'dummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => Dummy::class], ]; $relatedClassMetadataProphecy->hasField('dummy')->willReturn(true); @@ -471,7 +472,7 @@ public function testForceEager(): void $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relation' => ['fetch' => ClassMetadata::FETCH_LAZY, 'targetEntity' => UnknownDummy::class, 'joinColumns' => [['nullable' => false]]], + 'relation' => ['fetch' => ClassMetadata::FETCH_LAZY, 'targetEntity' => UnknownDummy::class, 'joinColumns' => [new JoinColumn(nullable: false)]], ]; $unknownClassMetadataProphecy = $this->prophesize(ClassMetadata::class); @@ -577,7 +578,7 @@ public function testResourceClassNotFoundExceptionPropertyNameCollection(): void $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relation' => ['fetch' => ClassMetadata::FETCH_LAZY, 'targetEntity' => UnknownDummy::class, 'joinColumns' => [['nullable' => false]]], + 'relation' => ['fetch' => ClassMetadata::FETCH_LAZY, 'targetEntity' => UnknownDummy::class, 'joinColumns' => [new JoinColumn(nullable: false)]], ]; $emProphecy = $this->prophesize(EntityManager::class); $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); @@ -751,8 +752,8 @@ public function testApplyToCollectionNoPartial(): void $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], - 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: true)], 'targetEntity' => RelatedDummy::class], + 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => RelatedDummy::class], ]; $emProphecy = $this->prophesize(EntityManager::class); @@ -796,8 +797,8 @@ public function testApplyToCollectionWithANonReadableButFetchEagerProperty(): vo $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ - 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], - 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: true)], 'targetEntity' => RelatedDummy::class], + 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [new JoinColumn(nullable: false)], 'targetEntity' => RelatedDummy::class], ]; $emProphecy = $this->prophesize(EntityManager::class); From 68430e6fac333e0350bf64f4e07446a2f3bbcd10 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 24 Mar 2025 14:07:46 +0100 Subject: [PATCH 058/152] =?UTF-8?q?Revert=20"fix(doctrine):=20throw=20an?= =?UTF-8?q?=20exception=20when=20a=20filter=20is=20not=20found=20in=20a=20?= =?UTF-8?q?par=E2=80=A6"=20(#7046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2f967d9345004779f409b9ce1b5d0cbba84c7132. --- Extension/ParameterExtension.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index acd6aac..36ebc2b 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -16,7 +16,6 @@ use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; @@ -51,11 +50,9 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter } $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; - if (!$filter instanceof FilterInterface) { - throw new InvalidArgumentException(\sprintf('Could not find filter "%s" for parameter "%s" in operation "%s" for resource "%s".', $filterId, $parameter->getKey(), $operation?->getShortName(), $resourceClass)); + if ($filter instanceof FilterInterface) { + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); } - - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); } } From b2b5f4bb1498aed276cfe28f434dff674bb9b26d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Mar 2025 16:01:40 +0100 Subject: [PATCH 059/152] fix(doctrine): joinColumn might be an array (#7060) fixes #7052 --- Extension/EagerLoadingExtension.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index 240c1ff..bcff245 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -181,7 +181,13 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt $associationAlias = $existingJoin->getAlias(); $isLeftJoin = Join::LEFT_JOIN === $existingJoin->getJoinType(); } else { - $isNullable = $mapping['joinColumns'][0]?->nullable ?? true; + $joinColumn = $mapping['joinColumns'][0] ?? ['nullable' => true]; + if (\is_array($joinColumn)) { + $isNullable = $joinColumn['nullable'] ?? true; + } else { + $isNullable = $joinColumn->nullable ?? true; + } + $isLeftJoin = false !== $wasLeftJoin || true === $isNullable; $method = $isLeftJoin ? 'leftJoin' : 'innerJoin'; From 4cfd9b739e82c6fb2c6f9e9c7ac2c42a1bc589d2 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 11 Apr 2025 11:32:56 +0200 Subject: [PATCH 060/152] chore: phpunit missing deprecation triggers (#7059) --- phpunit.xml.dist | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 913b48c..12b5bda 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,11 @@ + + trigger_deprecation + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + ./ From 264e0b841a00c18bc7a3a58011c6a7f42f8378c2 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Wed, 16 Apr 2025 21:29:35 +0200 Subject: [PATCH 061/152] feat: Use `Type` of `TypeInfo` instead of `PropertyInfo` (#6979) Co-authored-by: soyuka scopes: metadata, doctrine, json-schema --- composer.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1470080..05d7b99 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev", "symfony/uid": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" @@ -73,5 +74,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } From 7f0c61ec30546593ebe0aa69652d471ac0e82898 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 18 Apr 2025 10:39:51 +0200 Subject: [PATCH 062/152] ci: patch phpunit deprecations inside component (#7103) --- composer.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 1470080..6b7c9bd 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "require-dev": { "doctrine/doctrine-bundle": "^2.11", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2", + "phpunit/phpunit": "11.5.x-dev", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0", @@ -73,5 +73,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/soyuka/phpunit" + } + ] } From 9f6640daf280469b88d8afb4507b20a8dd460e6e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 23 Apr 2025 10:40:08 +0200 Subject: [PATCH 063/152] refactor(state): state options code duplication (#7109) --- ...neOrmResourceCollectionMetadataFactory.php | 13 +++++-------- State/CollectionProvider.php | 7 +++---- State/ItemProvider.php | 7 +++---- State/LinksHandlerTrait.php | 19 ++++--------------- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php index c102182..77b7737 100644 --- a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php +++ b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php @@ -22,11 +22,14 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; final class DoctrineOrmResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface { + use StateOptionsTrait; + public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly ResourceMetadataCollectionFactoryInterface $decorated) { } @@ -45,10 +48,7 @@ public function create(string $resourceClass): ResourceMetadataCollection if ($operations) { /** @var Operation $operation */ foreach ($resourceMetadata->getOperations() as $operationName => $operation) { - $entityClass = $operation->getClass(); - if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface) { continue; @@ -64,10 +64,7 @@ public function create(string $resourceClass): ResourceMetadataCollection if ($graphQlOperations) { foreach ($graphQlOperations as $operationName => $graphQlOperation) { - $entityClass = $graphQlOperation->getClass(); - if (($options = $graphQlOperation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } + $entityClass = $this->getStateOptionsClass($graphQlOperation, $graphQlOperation->getClass(), Options::class); if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface) { continue; diff --git a/State/CollectionProvider.php b/State/CollectionProvider.php index ab2cb5a..3815447 100644 --- a/State/CollectionProvider.php +++ b/State/CollectionProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -35,6 +36,7 @@ final class CollectionProvider implements ProviderInterface { use LinksHandlerLocatorTrait; use LinksHandlerTrait; + use StateOptionsTrait; /** * @param QueryCollectionExtensionInterface[] $collectionExtensions @@ -48,10 +50,7 @@ public function __construct(ResourceMetadataCollectionFactoryInterface $resource public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable { - $entityClass = $operation->getClass(); - if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); /** @var EntityManagerInterface $manager */ $manager = $this->managerRegistry->getManagerForClass($entityClass); diff --git a/State/ItemProvider.php b/State/ItemProvider.php index 8f32acb..b201d03 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -35,6 +36,7 @@ final class ItemProvider implements ProviderInterface { use LinksHandlerLocatorTrait; use LinksHandlerTrait; + use StateOptionsTrait; /** * @param QueryItemExtensionInterface[] $itemExtensions @@ -48,10 +50,7 @@ public function __construct(ResourceMetadataCollectionFactoryInterface $resource public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object { - $entityClass = $operation->getClass(); - if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); /** @var EntityManagerInterface|null $manager */ $manager = $this->managerRegistry->getManagerForClass($entityClass); diff --git a/State/LinksHandlerTrait.php b/State/LinksHandlerTrait.php index 9a088a4..d44805a 100644 --- a/State/LinksHandlerTrait.php +++ b/State/LinksHandlerTrait.php @@ -17,6 +17,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -27,6 +28,7 @@ trait LinksHandlerTrait { use CommonLinksHandlerTrait; + use StateOptionsTrait; private ManagerRegistry $managerRegistry; @@ -159,25 +161,12 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que private function getLinkFromClass(Link $link, Operation $operation): string { $fromClass = $link->getFromClass(); - if ($fromClass === $operation->getClass() && $entityClass = $this->getStateOptionsEntityClass($operation)) { + if ($fromClass === $operation->getClass() && $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class)) { return $entityClass; } $operation = $this->resourceMetadataCollectionFactory->create($fromClass)->getOperation(); - if ($entityClass = $this->getStateOptionsEntityClass($operation)) { - return $entityClass; - } - - throw new \Exception('Can not found a doctrine class for this link.'); - } - - private function getStateOptionsEntityClass(Operation $operation): ?string - { - if (($options = $operation->getStateOptions()) && $options instanceof Options && $entityClass = $options->getEntityClass()) { - return $entityClass; - } - - return null; + return $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); } } From 493b61c9f7ee93d86878227e210560fcb1a4a4b9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:26:52 +0200 Subject: [PATCH 064/152] chore: symfony/type-info 7.3.0-BETA1 --- composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composer.json b/composer.json index a2d49f2..d590c98 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "^7.3-dev", + "symfony/type-info": "v7.3.0-BETA1", "symfony/uid": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" @@ -76,10 +76,6 @@ "test": "./vendor/bin/phpunit" }, "repositories": [ - { - "type": "vcs", - "url": "https://github.com/symfony/type-info" - }, { "type": "vcs", "url": "https://github.com/soyuka/phpunit" From 2f64d82c068e545539649995538593a70656d7c4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 7 May 2025 12:11:37 +0200 Subject: [PATCH 065/152] fix(doctrine): filters schema for dates and numbers (#7131) --- Filter/DateFilter.php | 2 +- Filter/NumericFilter.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index b1eb525..6559aba 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -280,7 +280,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf */ public function getSchema(Parameter $parameter): array { - return ['type' => 'date']; + return ['type' => 'string', 'format' => 'date']; } public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index c81a118..71e5841 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -181,6 +181,6 @@ protected function getType(?string $doctrineType = null): string public function getSchema(Parameter $parameter): array { - return ['type' => 'numeric']; + return ['type' => 'number']; } } From 6380a9518e64eb495fb5e8972902fb78e3be752e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 14 May 2025 06:54:42 +0200 Subject: [PATCH 066/152] chore(doctrine): constraint api-platform/state v4.1.8 (#7142) fixes #7138 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6b7c9bd..6972438 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "php": ">=8.2", "api-platform/doctrine-common": "^4.1", "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1", + "api-platform/state": "^4.1.8", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" }, From 5e0cd55b2bf0dbdaf512fbdbbae71f1d79827980 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:13:30 +0200 Subject: [PATCH 067/152] chore: bump patch dependencies --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 6972438..7ab5506 100644 --- a/composer.json +++ b/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1.8", + "api-platform/doctrine-common": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" }, From 3194e444de6cb281f6e91eac5d371bfe706e3781 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 27 May 2025 15:41:14 +0200 Subject: [PATCH 068/152] chore: type-info v7.3.0-RC1 #7177 (#7178) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b40ec5f..8f33594 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "v7.3.0-BETA1", + "symfony/type-info": "v7.3.0-RC1", "symfony/uid": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" From b85107d6c7f287bdb265e626550449a399b4c031 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 May 2025 10:03:08 +0200 Subject: [PATCH 069/152] ci: prefer-lowest to avoid bumping inter components dependencies (#7169) --- Filter/BackedEnumFilter.php | 2 +- composer.json | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Filter/BackedEnumFilter.php b/Filter/BackedEnumFilter.php index fffcb13..de0d15b 100644 --- a/Filter/BackedEnumFilter.php +++ b/Filter/BackedEnumFilter.php @@ -163,7 +163,7 @@ protected function isBackedEnumField(string $property, string $resourceClass): b $fieldMapping = (array) $fieldMapping; } - if (!$enumType = $fieldMapping['enumType']) { + if (!($enumType = $fieldMapping['enumType'] ?? null)) { return false; } diff --git a/composer.json b/composer.json index 7ab5506..bccdaef 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,8 @@ "extra": { "branch-alias": { "dev-main": "4.2.x-dev", - "dev-3.4": "3.4.x-dev" + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { "require": "^6.4 || ^7.0" @@ -79,5 +80,6 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ] + ], + "version": "4.1.12" } From dc3a22f3d8be40803949667d18c0eb2ea23ac967 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 2 Jun 2025 16:22:51 +0200 Subject: [PATCH 070/152] chore: use type-info:^7.3 (#7185) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8f33594..be14dca 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "v7.3.0-RC1", + "symfony/type-info": "^7.3", "symfony/uid": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" From 31a689120418309db34d882a2ac0c5584c419eb5 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:02:15 +0200 Subject: [PATCH 071/152] fix: bump composer.json version nodes --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bccdaef..d6c844c 100644 --- a/composer.json +++ b/composer.json @@ -81,5 +81,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.12" + "version": "4.1.14" } From 5f497f6b4c217cd03cc804f860d63f623d9db1ee Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:18:03 +0200 Subject: [PATCH 072/152] chore: missing "v" prefix in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d6c844c..7b99c4c 100644 --- a/composer.json +++ b/composer.json @@ -81,5 +81,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.14" + "version": "v4.1.15" } From 61a199da6f6014dba2da43ea1a66b2c9dda27263 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Jun 2025 16:56:47 +0200 Subject: [PATCH 073/152] ci: remove version from composer to avoid release side effects (#7196) --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 7b99c4c..a32498d 100644 --- a/composer.json +++ b/composer.json @@ -80,6 +80,5 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ], - "version": "v4.1.15" + ] } From e0595a553ee3eb20a2afaa06012c7540620fbeaf Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 27 Jun 2025 15:30:43 +0200 Subject: [PATCH 074/152] refactor(metadata): cascade resource to operation (#7246) --- .../DoctrineOrmResourceCollectionMetadataFactory.php | 3 --- .../DoctrineOrmResourceCollectionMetadataFactoryTest.php | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php index 77b7737..7715572 100644 --- a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php +++ b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php @@ -16,7 +16,6 @@ use ApiPlatform\Doctrine\Orm\State\CollectionProvider; use ApiPlatform\Doctrine\Orm\State\ItemProvider; use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; @@ -41,12 +40,10 @@ public function create(string $resourceClass): ResourceMetadataCollection { $resourceMetadataCollection = $this->decorated->create($resourceClass); - /** @var ApiResource $resourceMetadata */ foreach ($resourceMetadataCollection as $i => $resourceMetadata) { $operations = $resourceMetadata->getOperations(); if ($operations) { - /** @var Operation $operation */ foreach ($resourceMetadata->getOperations() as $operationName => $operation) { $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); diff --git a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php index 3e371af..966b898 100644 --- a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php +++ b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php @@ -21,7 +21,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -34,7 +34,7 @@ class DoctrineOrmResourceCollectionMetadataFactoryTest extends TestCase { use ProphecyTrait; - private function getResourceMetadataCollectionFactory(Operation $operation) + private function getResourceMetadataCollectionFactory(HttpOperation $operation) { $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataCollectionFactory->create($operation->getClass())->willReturn(new ResourceMetadataCollection($operation->getClass(), [ @@ -63,7 +63,7 @@ public function testWithoutManager(): void } #[\PHPUnit\Framework\Attributes\DataProvider('operationProvider')] - public function testWithProvider(Operation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void + public function testWithProvider(HttpOperation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void { $objectManager = $this->prophesize(EntityManagerInterface::class); $managerRegistry = $this->prophesize(ManagerRegistry::class); From 41fac6b891081069a29851335e248614cc6d96a1 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Jun 2025 15:58:28 +0200 Subject: [PATCH 075/152] chore: bump phpstan version (#7239) --- State/CollectionProvider.php | 2 +- State/ItemProvider.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/State/CollectionProvider.php b/State/CollectionProvider.php index 3815447..d67f7fe 100644 --- a/State/CollectionProvider.php +++ b/State/CollectionProvider.php @@ -56,7 +56,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $manager = $this->managerRegistry->getManagerForClass($entityClass); $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { + if (!method_exists($repository, 'createQueryBuilder')) { // @phpstan-ignore-line function.alreadyNarrowedType throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); } diff --git a/State/ItemProvider.php b/State/ItemProvider.php index b201d03..f69a208 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -65,7 +65,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { + if (!method_exists($repository, 'createQueryBuilder')) { // @phpstan-ignore-line function.alreadyNarrowedType throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); } From f388929e680135ac67e25a43256a312bb9263016 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Jun 2025 14:41:47 +0200 Subject: [PATCH 076/152] chore: solve some phpstan issues (#7249) --- Filter/DateFilter.php | 2 +- Filter/RangeFilter.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index 6559aba..df038ea 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -283,7 +283,7 @@ public function getSchema(Parameter $parameter): array return ['type' => 'string', 'format' => 'date']; } - public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + public function getOpenApiParameters(Parameter $parameter): array { $in = $parameter instanceof QueryParameter ? 'query' : 'header'; $key = $parameter->getKey(); diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index ae052fa..6a1ec3a 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -227,7 +227,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf } } - public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + public function getOpenApiParameters(Parameter $parameter): array { $in = $parameter instanceof QueryParameter ? 'query' : 'header'; $key = $parameter->getKey(); From 9e64ae6e6a26b00be8713fbd1a42f6172600fef5 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Jun 2025 14:44:06 +0200 Subject: [PATCH 077/152] chore: use treat phpdoc type as certain (#7250) --- Metadata/Property/DoctrineOrmPropertyMetadataFactory.php | 2 +- State/CollectionProvider.php | 2 +- State/ItemProvider.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php b/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php index c450e85..1b6b75d 100644 --- a/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php +++ b/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php @@ -70,7 +70,7 @@ public function create(string $resourceClass, string $property, array $options = if ($doctrineClassMetadata instanceof ClassMetadata && \in_array($property, $doctrineClassMetadata->getFieldNames(), true)) { $fieldMapping = $doctrineClassMetadata->getFieldMapping($property); - if (class_exists(FieldMapping::class) && $fieldMapping instanceof FieldMapping) { + if (class_exists(FieldMapping::class)) { $propertyMetadata = $propertyMetadata->withDefault($fieldMapping->default ?? $propertyMetadata->getDefault()); } else { $propertyMetadata = $propertyMetadata->withDefault($fieldMapping['options']['default'] ?? $propertyMetadata->getDefault()); diff --git a/State/CollectionProvider.php b/State/CollectionProvider.php index d67f7fe..3815447 100644 --- a/State/CollectionProvider.php +++ b/State/CollectionProvider.php @@ -56,7 +56,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $manager = $this->managerRegistry->getManagerForClass($entityClass); $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { // @phpstan-ignore-line function.alreadyNarrowedType + if (!method_exists($repository, 'createQueryBuilder')) { throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); } diff --git a/State/ItemProvider.php b/State/ItemProvider.php index f69a208..b201d03 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -65,7 +65,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { // @phpstan-ignore-line function.alreadyNarrowedType + if (!method_exists($repository, 'createQueryBuilder')) { throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); } From 6c59d4a058eb2829c302b49733d3883cf31dd69b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 1 Jul 2025 14:36:47 +0200 Subject: [PATCH 078/152] chore: more phpstan fixes (#7265) --- Util/QueryBuilderHelper.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Util/QueryBuilderHelper.php b/Util/QueryBuilderHelper.php index 54956fc..52bccaf 100644 --- a/Util/QueryBuilderHelper.php +++ b/Util/QueryBuilderHelper.php @@ -186,13 +186,7 @@ public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias */ private static function mapRootAliases(array $rootAliases, array $rootEntities): array { - /** @var false|array $aliasMap */ - $aliasMap = array_combine($rootAliases, $rootEntities); - if (false === $aliasMap) { - throw new \LogicException('Number of root aliases and root entities do not match.'); - } - - return $aliasMap; + return array_combine($rootAliases, $rootEntities); } /** From 5995b1d67475b4b2bda00b04d635fb16afa1e4d5 Mon Sep 17 00:00:00 2001 From: MeronNagy Date: Tue, 15 Jul 2025 09:24:21 +0200 Subject: [PATCH 079/152] feat(doctrine): support integer-backed enums in BackedEnumFilter (#7129) --- Filter/BackedEnumFilter.php | 22 +++++++++--- Tests/Filter/BackedEnumFilterTest.php | 9 +++++ Tests/Filter/BackedEnumFilterTestTrait.php | 41 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Filter/BackedEnumFilter.php b/Filter/BackedEnumFilter.php index de0d15b..ab39bd0 100644 --- a/Filter/BackedEnumFilter.php +++ b/Filter/BackedEnumFilter.php @@ -125,8 +125,14 @@ protected function filterProperty(string $property, mixed $value, QueryBuilder $ return; } - $value = $this->normalizeValue($value, $property); - if (null === $value) { + $values = \is_array($value) ? $value : [$value]; + + $normalizedValues = array_filter(array_map( + fn ($v) => $this->normalizeValue($v, $property), + $values + )); + + if (empty($normalizedValues)) { return; } @@ -139,9 +145,17 @@ protected function filterProperty(string $property, mixed $value, QueryBuilder $ $valueParameter = $queryNameGenerator->generateParameterName($field); + if (1 === \count($values)) { + $queryBuilder + ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $values[0]); + + return; + } + $queryBuilder - ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); + ->andWhere(\sprintf('%s.%s IN (:%s)', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $values); } /** diff --git a/Tests/Filter/BackedEnumFilterTest.php b/Tests/Filter/BackedEnumFilterTest.php index 4522e0b..5f782da 100644 --- a/Tests/Filter/BackedEnumFilterTest.php +++ b/Tests/Filter/BackedEnumFilterTest.php @@ -43,6 +43,15 @@ public static function provideApplyTestData(): array 'invalid case for nested property' => [ \sprintf('SELECT o FROM %s o', Dummy::class), ], + 'valid case (multiple values)' => [ + \sprintf('SELECT o FROM %s o WHERE o.dummyBackedEnum IN (:dummyBackedEnum_p1)', Dummy::class), + [ + 'dummyBackedEnum_p1' => [ + 'one', + 'two', + ], + ], + ], ] ); } diff --git a/Tests/Filter/BackedEnumFilterTestTrait.php b/Tests/Filter/BackedEnumFilterTestTrait.php index ebfb2a7..bb864d1 100644 --- a/Tests/Filter/BackedEnumFilterTestTrait.php +++ b/Tests/Filter/BackedEnumFilterTestTrait.php @@ -32,11 +32,25 @@ public function testGetDescription(): void 'property' => 'dummyBackedEnum', 'type' => 'string', 'required' => false, + 'is_collection' => false, 'schema' => [ 'type' => 'string', 'enum' => ['one', 'two'], ], ], + 'dummyBackedEnum[]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'is_collection' => true, + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['one', 'two'], + ], + ], + ], ], $filter->getDescription($this->resourceClass)); } @@ -49,11 +63,25 @@ public function testGetDescriptionDefaultFields(): void 'property' => 'dummyBackedEnum', 'type' => 'string', 'required' => false, + 'is_collection' => false, 'schema' => [ 'type' => 'string', 'enum' => ['one', 'two'], ], ], + 'dummyBackedEnum[]' => [ + 'property' => 'dummyBackedEnum', + 'type' => 'string', + 'required' => false, + 'is_collection' => true, + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['one', 'two'], + ], + ], + ], ], $filter->getDescription($this->resourceClass)); } @@ -100,6 +128,19 @@ private static function provideApplyTestArguments(): array 'relatedDummy.dummyBackedEnum' => 'foo', ], ], + 'valid case (multiple values)' => [ + [ + 'id' => null, + 'name' => null, + 'dummyBackedEnum' => null, + ], + [ + 'dummyBackedEnum' => [ + 'one', + 'two', + ], + ], + ], ]; } } From dac858eedb1090e793bc767c1890502363d5a999 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 25 Jul 2025 11:37:01 +0200 Subject: [PATCH 080/152] feat(metadata): class is now class-string (#7307) --- Tests/State/CollectionProviderTest.php | 2 +- Tests/State/ItemProviderTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/State/CollectionProviderTest.php b/Tests/State/CollectionProviderTest.php index 77dbb30..9837c79 100644 --- a/Tests/State/CollectionProviderTest.php +++ b/Tests/State/CollectionProviderTest.php @@ -138,7 +138,7 @@ public function testCannotCreateQueryBuilder(): void public function testHandleLinksCallable(): void { - $class = 'foo'; + $class = \stdClass::class; $resourceMetadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); $query = $this->createStub($this->getQueryClass()); $query->method('getResult')->willReturn([]); diff --git a/Tests/State/ItemProviderTest.php b/Tests/State/ItemProviderTest.php index 3543219..ec73037 100644 --- a/Tests/State/ItemProviderTest.php +++ b/Tests/State/ItemProviderTest.php @@ -328,7 +328,7 @@ class_exists(ManyToOneAssociationMapping::class) ? public function testHandleLinksCallable(): void { - $class = 'foo'; + $class = \stdClass::class; $resourceMetadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); $query = $this->createStub($this->getQueryClass()); $query->method('getOneOrNullResult')->willReturn(null); From 4607af4ff8809cafc61ab14ed9059f69321226c8 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 18 Aug 2025 15:34:44 +0200 Subject: [PATCH 081/152] chore: add param type (#7313) --- Extension/DoctrinePaginatorFactory.php | 6 ++++++ Filter/AbstractFilter.php | 2 +- Filter/BooleanFilter.php | 2 +- Filter/DateFilter.php | 2 +- Filter/ExistsFilter.php | 2 +- Filter/NumericFilter.php | 2 +- Filter/OrderFilter.php | 2 +- Filter/RangeFilter.php | 2 +- Filter/SearchFilter.php | 2 +- 9 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Extension/DoctrinePaginatorFactory.php b/Extension/DoctrinePaginatorFactory.php index f7daadb..ed4cf36 100644 --- a/Extension/DoctrinePaginatorFactory.php +++ b/Extension/DoctrinePaginatorFactory.php @@ -13,10 +13,16 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\Paginator; class DoctrinePaginatorFactory { + /** + * @param Query|QueryBuilder $query + * @param bool $fetchJoinCollection + */ public function getPaginator($query, $fetchJoinCollection): Paginator { return new Paginator($query, $fetchJoinCollection); diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index 18b2928..0f9e741 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -57,7 +57,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q * @param class-string $resourceClass * @param array $context */ - abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; + abstract protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; public function hasManagerRegistry(): bool { diff --git a/Filter/BooleanFilter.php b/Filter/BooleanFilter.php index 6cc7dfd..9fda1f5 100644 --- a/Filter/BooleanFilter.php +++ b/Filter/BooleanFilter.php @@ -119,7 +119,7 @@ final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInte /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index df038ea..b7f7af5 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -143,7 +143,7 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface, Js /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { // Expect $value to be an array having the period as keys and the date value as values if ( diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 4824057..7dbb046 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -150,7 +150,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index 71e5841..661e96a 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -128,7 +128,7 @@ final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInte /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !$this->isPropertyEnabled($property, $resourceClass) diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index 61b3a65..4962bf2 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -252,7 +252,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) { return; diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index 6a1ec3a..2400100 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -116,7 +116,7 @@ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, /** * {@inheritdoc} */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, mixed $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( !\is_array($values) diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index d0caccb..a93a8c1 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -161,7 +161,7 @@ protected function getPropertyAccessor(): PropertyAccessorInterface /** * {@inheritdoc} */ - protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ( null === $value From c730fe3475c62d7e1367a76b05985ce4eba2c511 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 19 Aug 2025 10:04:29 +0200 Subject: [PATCH 082/152] chore: 4.2 branch alias --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9b867fc..78855ee 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,8 @@ }, "extra": { "branch-alias": { - "dev-main": "4.2.x-dev", + "dev-main": "4.3.x-dev", + "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, From 87a44d01de1d7dee24d653c7ee7fc6f0d5cec169 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Wed, 27 Aug 2025 14:34:14 +0200 Subject: [PATCH 083/152] feat(doctrine): new search filters (#7121) Co-authored-by: soyuka --- Extension/ParameterExtension.php | 7 ++++ Filter/ExactFilter.php | 49 ++++++++++++++++++++++++ Filter/FilterInterface.php | 3 ++ Filter/FreeTextQueryFilter.php | 59 +++++++++++++++++++++++++++++ Filter/IriFilter.php | 58 ++++++++++++++++++++++++++++ Filter/OrFilter.php | 61 ++++++++++++++++++++++++++++++ Filter/PartialSearchFilter.php | 65 ++++++++++++++++++++++++++++++++ 7 files changed, 302 insertions(+) create mode 100644 Filter/ExactFilter.php create mode 100644 Filter/FreeTextQueryFilter.php create mode 100644 Filter/IriFilter.php create mode 100644 Filter/OrFilter.php create mode 100644 Filter/PartialSearchFilter.php diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index 17d0235..70533ad 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; @@ -23,6 +24,7 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; /** * Reads operation parameters and execute its filter. @@ -36,6 +38,7 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que public function __construct( private readonly ContainerInterface $filterLocator, private readonly ?ManagerRegistry $managerRegistry = null, + private readonly ?LoggerInterface $logger = null, ) { } @@ -68,6 +71,10 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter $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(); diff --git a/Filter/ExactFilter.php b/Filter/ExactFilter.php new file mode 100644 index 0000000..3795615 --- /dev/null +++ b/Filter/ExactFilter.php @@ -0,0 +1,49 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + $value = $parameter->getValue(); + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + if (\is_array($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)); + } + + $queryBuilder->setParameter($parameterName, $value); + } +} diff --git a/Filter/FilterInterface.php b/Filter/FilterInterface.php index 4cfa337..7539574 100644 --- a/Filter/FilterInterface.php +++ b/Filter/FilterInterface.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ORM\QueryBuilder; /** @@ -27,6 +28,8 @@ interface FilterInterface extends BaseFilterInterface { /** * Applies the filter. + * + * @param array{filters?: array|array, parameter?: Parameter, ...} $context */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/Filter/FreeTextQueryFilter.php b/Filter/FreeTextQueryFilter.php new file mode 100644 index 0000000..c73504c --- /dev/null +++ b/Filter/FreeTextQueryFilter.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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + + /** + * @param list $properties an array of properties, defaults to `parameter->getProperties()` + */ + public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['parameter' => $parameter->withProperty($property)] + $context + ); + } + } +} diff --git a/Filter/IriFilter.php b/Filter/IriFilter.php new file mode 100644 index 0000000..32acf8f --- /dev/null +++ b/Filter/IriFilter.php @@ -0,0 +1,58 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + $value = $parameter->getValue(); + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); + + if (is_iterable($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); + $queryBuilder->setParameter($parameterName, $value); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + $queryBuilder->setParameter($parameterName, $value); + } + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } +} diff --git a/Filter/OrFilter.php b/Filter/OrFilter.php new file mode 100644 index 0000000..d8e0202 --- /dev/null +++ b/Filter/OrFilter.php @@ -0,0 +1,61 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + * + * @experimental + */ +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly FilterInterface $filter) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['whereClause' => 'orWhere'] + $context + ); + } +} diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php new file mode 100644 index 0000000..90cde75 --- /dev/null +++ b/Filter/PartialSearchFilter.php @@ -0,0 +1,65 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $field = $alias.'.'.$property; + $parameterName = $queryNameGenerator->generateParameterName($property); + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $queryBuilder->setParameter($parameterName, '%'.strtolower($values).'%'); + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + )); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + ); + $queryBuilder->setParameter($parameterName, '%'.strtolower($val).'%'); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } +} From 654756b0ac543b1c133be066f0604672d11415c5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 5 Sep 2025 10:12:26 +0200 Subject: [PATCH 084/152] chore: remove @experimental flag from parameters (#7357) --- State/LinksHandlerInterface.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/State/LinksHandlerInterface.php b/State/LinksHandlerInterface.php index 98b75c0..5372d52 100644 --- a/State/LinksHandlerInterface.php +++ b/State/LinksHandlerInterface.php @@ -17,9 +17,6 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; -/** - * @experimental - */ interface LinksHandlerInterface { /** From 9989d0fd4badfe77fa71fed9ae734d146bc0495d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 15 Sep 2025 11:57:10 +0200 Subject: [PATCH 085/152] fix(metadata): compute isWritable during updates (#7383) fixes #7382 --- Metadata/Property/DoctrineOrmPropertyMetadataFactory.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php b/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php index c450e85..7706c3f 100644 --- a/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php +++ b/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php @@ -56,6 +56,10 @@ public function create(string $resourceClass, string $property, array $options = break; } + if ($options['api_allow_update'] ?? false) { + break; + } + if ($doctrineClassMetadata instanceof ClassMetadata) { $writable = $doctrineClassMetadata->isIdentifierNatural(); } else { From 23b0de35bb7d2903854c6ee3ac300b7f5056c12b Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 16 Sep 2025 14:12:59 +0200 Subject: [PATCH 086/152] chore: lowest fixes --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 78855ee..8fb65b1 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,11 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.1.11", + "api-platform/doctrine-common": "^4.2.0-alpha.3@alpha", "api-platform/metadata": "^4.1.11", "api-platform/state": "^4.1.11", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^6.4 || ^7.1" + "symfony/type-info": "^7.3" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", @@ -40,7 +40,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "^7.3", + "symfony/property-info": "^6.4 || ^7.1", "symfony/uid": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" From d35d97423f7b399117ee033ecc886b3ed9dc2e23 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 7 Oct 2025 15:54:25 +0200 Subject: [PATCH 087/152] fix(doctrine): group or filter in an AndWhere #7441 (#7445) --- Filter/FreeTextQueryFilter.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Filter/FreeTextQueryFilter.php b/Filter/FreeTextQueryFilter.php index c73504c..b773f04 100644 --- a/Filter/FreeTextQueryFilter.php +++ b/Filter/FreeTextQueryFilter.php @@ -20,6 +20,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Operation; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\QueryBuilder; final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface @@ -46,14 +47,26 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q } $parameter = $context['parameter']; + $qb = clone $queryBuilder; + $qb->resetDQLPart('where'); + $qb->setParameters(new ArrayCollection()); foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { $this->filter->apply( - $queryBuilder, + $qb, $queryNameGenerator, $resourceClass, $operation, ['parameter' => $parameter->withProperty($property)] + $context ); } + + $queryBuilder->andWhere($qb->getDQLPart('where')); + $parameters = $queryBuilder->getParameters(); + + foreach ($qb->getParameters() as $p) { + $parameters->add($p); + } + + $queryBuilder->setParameters($parameters); } } From 11a1469170de84ddb3af27a255ea1fdc068eb77c Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Fri, 24 Oct 2025 12:01:23 +0200 Subject: [PATCH 088/152] feat(doctrine): remove PUT & PATCH for readonly entity (#7453) --- ...neOrmResourceCollectionMetadataFactory.php | 15 +++++- Tests/Fixtures/Entity/DummyReadOnly.php | 53 +++++++++++++++++++ ...mResourceCollectionMetadataFactoryTest.php | 48 +++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 Tests/Fixtures/Entity/DummyReadOnly.php diff --git a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php index 7715572..d2e54dd 100644 --- a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php +++ b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php @@ -19,6 +19,8 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Util\StateOptionsTrait; @@ -44,10 +46,19 @@ public function create(string $resourceClass): ResourceMetadataCollection $operations = $resourceMetadata->getOperations(); if ($operations) { - foreach ($resourceMetadata->getOperations() as $operationName => $operation) { + foreach ($operations as $operationName => $operation) { $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); - if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface) { + $manager = $this->managerRegistry->getManagerForClass($entityClass); + if (!$manager instanceof EntityManagerInterface) { + continue; + } + + $classMetadata = $manager->getClassMetadata($entityClass); + // @see https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/reference/improving-performance.html#read-only-entities + // Read-Only allows to persist new entities of a kind and remove existing ones, they are just not considered for updates. + if ($classMetadata->isReadOnly && ($operation instanceof Put || $operation instanceof Patch)) { + $operations->remove($operationName); continue; } diff --git a/Tests/Fixtures/Entity/DummyReadOnly.php b/Tests/Fixtures/Entity/DummyReadOnly.php new file mode 100644 index 0000000..212e421 --- /dev/null +++ b/Tests/Fixtures/Entity/DummyReadOnly.php @@ -0,0 +1,53 @@ + + * + * 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\Orm\Tests\Fixtures\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity(readOnly: true)] +class DummyReadOnly +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + #[ORM\Column(type: 'string')] + private string $name; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php index 966b898..7ec1ae7 100644 --- a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php +++ b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php @@ -17,15 +17,20 @@ use ApiPlatform\Doctrine\Orm\State\CollectionProvider; use ApiPlatform\Doctrine\Orm\State\ItemProvider; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\DummyReadOnly; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -66,6 +71,7 @@ public function testWithoutManager(): void public function testWithProvider(HttpOperation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void { $objectManager = $this->prophesize(EntityManagerInterface::class); + $objectManager->getClassMetadata($operation->getClass())->willReturn(new ClassMetadata(Dummy::class)); $managerRegistry = $this->prophesize(ManagerRegistry::class); $managerRegistry->getManagerForClass($operation->getClass())->willReturn($objectManager->reveal()); $resourceMetadataCollectionFactory = new DoctrineOrmResourceCollectionMetadataFactory($managerRegistry->reveal(), $this->getResourceMetadataCollectionFactory($operation)); @@ -85,4 +91,46 @@ public static function operationProvider(): iterable yield [(new GetCollection())->withOperation($default), CollectionProvider::class, 'api_platform.doctrine.orm.state.persist_processor']; yield [(new Delete())->withOperation($default), ItemProvider::class, 'api_platform.doctrine.orm.state.remove_processor']; } + + public function testReadOnlyEntitiesShouldNotIncludeUpdateOperations(): void + { + $objectManager = $this->createMock(EntityManagerInterface::class); + $readOnlyMetadata = new ClassMetadata(DummyReadOnly::class); + $readOnlyMetadata->markReadOnly(); + $objectManager->method('getClassMetadata')->willReturn($readOnlyMetadata); + $managerRegistry = $this->createMock(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->with(DummyReadOnly::class)->willReturn($objectManager); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory + ->method('create') + ->with(DummyReadOnly::class) + ->willReturn(new ResourceMetadataCollection(DummyReadOnly::class, [ + (new ApiResource()) + ->withOperations( + new Operations([ + 'get' => (new Get())->withClass(DummyReadOnly::class), + 'get_collection' => (new GetCollection())->withClass(DummyReadOnly::class), + 'post' => (new Post())->withClass(DummyReadOnly::class), + 'put' => (new Put())->withClass(DummyReadOnly::class), + 'patch' => (new Patch())->withClass(DummyReadOnly::class), + 'delete' => (new Delete())->withClass(DummyReadOnly::class), + ]) + ), + ])); + + $resourceMetadataCollectionFactory = new DoctrineOrmResourceCollectionMetadataFactory($managerRegistry, $resourceMetadataCollectionFactory); + + $resourceMetadataCollection = $resourceMetadataCollectionFactory->create(DummyReadOnly::class); + /** @var ApiResource $apiResource */ + $apiResource = $resourceMetadataCollection->getIterator()->current(); + $operations = $apiResource->getOperations(); + $this->assertNotNull($operations); + $this->assertTrue($operations->has('get')); + $this->assertTrue($operations->has('get_collection')); + $this->assertTrue($operations->has('post')); + $this->assertFalse($operations->has('put')); + $this->assertFalse($operations->has('path')); + $this->assertTrue($operations->has('delete')); + } } From 686d508cdda2e2a53d093d10a26de70d88c00314 Mon Sep 17 00:00:00 2001 From: Nicolas LAURENT Date: Fri, 31 Oct 2025 09:52:41 +0100 Subject: [PATCH 089/152] fix(doctrine): properly set properties according to interface (#7487) --- Extension/ParameterExtension.php | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index 17d0235..8d20754 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\Extension; 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\Orm\Filter\FilterInterface; @@ -68,19 +69,21 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter $filter->setManagerRegistry($this->managerRegistry); } - if ($filter instanceof AbstractFilter && !$filter->getProperties()) { + if ($filter instanceof PropertyAwareFilterInterface) { + $properties = []; $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); - - if (str_contains($propertyKey, ':property')) { - $extraProperties = $parameter->getExtraProperties()['_properties'] ?? []; - foreach (array_keys($extraProperties) as $property) { - $properties[$property] = $parameter->getFilterContext(); + 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(); + } } - } else { - $properties = [$propertyKey => $parameter->getFilterContext()]; } - $filter->setProperties($properties ?? []); + $filter->setProperties($properties + [$propertyKey => $parameter->getFilterContext()]); } $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, From 16dc9352bd957c4cc91951bc7ceaf4ce26973e7e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 31 Oct 2025 17:12:05 +0100 Subject: [PATCH 090/152] chore: bump deps --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 8fb65b1..09cd9b2 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^4.2.0-alpha.3@alpha", - "api-platform/metadata": "^4.1.11", - "api-platform/state": "^4.1.11", + "api-platform/metadata": "^4.2", + "api-platform/state": "^4.2", "doctrine/orm": "^2.17 || ^3.0", "symfony/type-info": "^7.3" }, From 9b0964438b1f26758740f2d5d1b6a862ed565c43 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 13 Nov 2025 15:23:57 +0100 Subject: [PATCH 091/152] chore: symfony/validator:^6.4.11 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 09cd9b2..c7169fb 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ "symfony/serializer": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/uid": "^6.4 || ^7.0", - "symfony/validator": "^6.4 || ^7.0", + "symfony/validator": "^6.4.11 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" }, "autoload": { From f81cd24d05bf69ad3aae2a7aa5fb5be07b329ca9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 13 Nov 2025 16:19:51 +0100 Subject: [PATCH 092/152] chore: bump self dependencies to avoid installing lowest --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c7169fb..af62c91 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "php": ">=8.2", "api-platform/doctrine-common": "^4.2.0-alpha.3@alpha", "api-platform/metadata": "^4.2", - "api-platform/state": "^4.2", + "api-platform/state": "^4.2.4", "doctrine/orm": "^2.17 || ^3.0", "symfony/type-info": "^7.3" }, From f796384bf4208c1a3c9132b8f79fd2475c119aec Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 13 Nov 2025 16:57:28 +0100 Subject: [PATCH 093/152] chore: symfony/validator:^6.4.11 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a32498d..d911cbb 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0", - "symfony/validator": "^6.4 || ^7.0", + "symfony/validator": "^6.4.11 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" }, "autoload": { From 74c91e76bc2e26592f80b3f872f3f97903cc3055 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 30 Nov 2025 13:55:42 +0100 Subject: [PATCH 094/152] chore: support symfony 8 (#7561) --- Tests/AppKernel.php | 6 ++++- Tests/Fixtures/Entity/Company.php | 2 +- Tests/Fixtures/Entity/CompositeItem.php | 2 +- Tests/Fixtures/Entity/CompositeLabel.php | 2 +- Tests/Fixtures/Entity/CompositeRelation.php | 2 +- Tests/Fixtures/Entity/DummyCarColor.php | 2 +- Tests/Fixtures/Entity/DummyFriend.php | 2 +- .../Entity/DummyPropertyWithDefaultValue.php | 2 +- Tests/Fixtures/Entity/EmbeddableDummy.php | 2 +- Tests/Fixtures/Entity/EmbeddedDummy.php | 2 +- Tests/Fixtures/Entity/Employee.php | 2 +- Tests/Fixtures/Entity/FourthLevel.php | 2 +- Tests/Fixtures/Entity/ParentDummy.php | 2 +- .../Entity/PropertyCollectionIriOnly.php | 2 +- .../PropertyCollectionIriOnlyRelation.php | 2 +- .../PropertyUriTemplateOneToOneRelation.php | 2 +- Tests/Fixtures/Entity/RelatedDummy.php | 2 +- .../Fixtures/Entity/RelatedToDummyFriend.php | 2 +- Tests/Fixtures/Entity/ThirdLevel.php | 2 +- Tests/config.yml | 2 -- composer.json | 24 +++++++++---------- 21 files changed, 35 insertions(+), 33 deletions(-) diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index 94b796b..d54549a 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -20,6 +20,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\PropertyInfo\Type; /** * AppKernel for tests. @@ -78,11 +79,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'php_errors' => ['log' => true], 'router' => ['utf8' => true], 'http_method_override' => false, - 'annotations' => false, 'handle_all_throwables' => true, 'uid' => ['default_uuid_version' => 7, 'time_based_uuid_version' => 7], ]; + if (!class_exists(Type::class)) { + $c->setParameter('.json_streamer.lazy_ghosts_dir', __DIR__.'/cache/json_streamer_lazy_ghost'); + } + $c->prependExtensionConfig('framework', $config); $loader->load(__DIR__.'/config.yml'); diff --git a/Tests/Fixtures/Entity/Company.php b/Tests/Fixtures/Entity/Company.php index 6fd1498..3568dcc 100644 --- a/Tests/Fixtures/Entity/Company.php +++ b/Tests/Fixtures/Entity/Company.php @@ -19,7 +19,7 @@ use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Post; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource] #[GetCollection] diff --git a/Tests/Fixtures/Entity/CompositeItem.php b/Tests/Fixtures/Entity/CompositeItem.php index bbcea4f..d9dd045 100644 --- a/Tests/Fixtures/Entity/CompositeItem.php +++ b/Tests/Fixtures/Entity/CompositeItem.php @@ -17,7 +17,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Composite Item. diff --git a/Tests/Fixtures/Entity/CompositeLabel.php b/Tests/Fixtures/Entity/CompositeLabel.php index 59ec038..e884005 100644 --- a/Tests/Fixtures/Entity/CompositeLabel.php +++ b/Tests/Fixtures/Entity/CompositeLabel.php @@ -15,7 +15,7 @@ use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Composite Label. diff --git a/Tests/Fixtures/Entity/CompositeRelation.php b/Tests/Fixtures/Entity/CompositeRelation.php index c274d16..8265ad0 100644 --- a/Tests/Fixtures/Entity/CompositeRelation.php +++ b/Tests/Fixtures/Entity/CompositeRelation.php @@ -15,7 +15,7 @@ use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Composite Relation. diff --git a/Tests/Fixtures/Entity/DummyCarColor.php b/Tests/Fixtures/Entity/DummyCarColor.php index 3d87399..816a0f8 100644 --- a/Tests/Fixtures/Entity/DummyCarColor.php +++ b/Tests/Fixtures/Entity/DummyCarColor.php @@ -17,7 +17,7 @@ use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation as Serializer; +use Symfony\Component\Serializer\Attribute as Serializer; use Symfony\Component\Validator\Constraints as Assert; #[ApiResource] diff --git a/Tests/Fixtures/Entity/DummyFriend.php b/Tests/Fixtures/Entity/DummyFriend.php index af44f58..cccf46e 100644 --- a/Tests/Fixtures/Entity/DummyFriend.php +++ b/Tests/Fixtures/Entity/DummyFriend.php @@ -16,7 +16,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; /** diff --git a/Tests/Fixtures/Entity/DummyPropertyWithDefaultValue.php b/Tests/Fixtures/Entity/DummyPropertyWithDefaultValue.php index 7bec9f9..9a1ccb3 100644 --- a/Tests/Fixtures/Entity/DummyPropertyWithDefaultValue.php +++ b/Tests/Fixtures/Entity/DummyPropertyWithDefaultValue.php @@ -15,7 +15,7 @@ use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * DummyPropertyWithDefaultValue. diff --git a/Tests/Fixtures/Entity/EmbeddableDummy.php b/Tests/Fixtures/Entity/EmbeddableDummy.php index ed37405..c47bed9 100644 --- a/Tests/Fixtures/Entity/EmbeddableDummy.php +++ b/Tests/Fixtures/Entity/EmbeddableDummy.php @@ -15,7 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; /** diff --git a/Tests/Fixtures/Entity/EmbeddedDummy.php b/Tests/Fixtures/Entity/EmbeddedDummy.php index 2341b0a..7c843ff 100644 --- a/Tests/Fixtures/Entity/EmbeddedDummy.php +++ b/Tests/Fixtures/Entity/EmbeddedDummy.php @@ -20,7 +20,7 @@ use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; /** diff --git a/Tests/Fixtures/Entity/Employee.php b/Tests/Fixtures/Entity/Employee.php index d2a7d47..e08fdb5 100644 --- a/Tests/Fixtures/Entity/Employee.php +++ b/Tests/Fixtures/Entity/Employee.php @@ -18,7 +18,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource] #[Post] diff --git a/Tests/Fixtures/Entity/FourthLevel.php b/Tests/Fixtures/Entity/FourthLevel.php index 0e193cd..ed6e67c 100644 --- a/Tests/Fixtures/Entity/FourthLevel.php +++ b/Tests/Fixtures/Entity/FourthLevel.php @@ -18,7 +18,7 @@ use ApiPlatform\Metadata\Link; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Fourth Level. diff --git a/Tests/Fixtures/Entity/ParentDummy.php b/Tests/Fixtures/Entity/ParentDummy.php index c8f1521..a229bc7 100644 --- a/Tests/Fixtures/Entity/ParentDummy.php +++ b/Tests/Fixtures/Entity/ParentDummy.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Parent Dummy. diff --git a/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php b/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php index 7aaaaa6..ac50cb9 100644 --- a/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php +++ b/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php @@ -20,7 +20,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Assert that a property being a collection set with ApiProperty::UriTemplate to true returns only the IRI of the collection. diff --git a/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php b/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php index 1bdd72c..0c5f07e 100644 --- a/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php +++ b/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php @@ -17,7 +17,7 @@ use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Post; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints\NotBlank; #[ diff --git a/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php b/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php index 7307e54..b825542 100644 --- a/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php +++ b/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php @@ -18,7 +18,7 @@ use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Post; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints\NotBlank; #[ diff --git a/Tests/Fixtures/Entity/RelatedDummy.php b/Tests/Fixtures/Entity/RelatedDummy.php index 4cf6f92..e35427b 100644 --- a/Tests/Fixtures/Entity/RelatedDummy.php +++ b/Tests/Fixtures/Entity/RelatedDummy.php @@ -27,7 +27,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; /** diff --git a/Tests/Fixtures/Entity/RelatedToDummyFriend.php b/Tests/Fixtures/Entity/RelatedToDummyFriend.php index e0482b1..fb81098 100644 --- a/Tests/Fixtures/Entity/RelatedToDummyFriend.php +++ b/Tests/Fixtures/Entity/RelatedToDummyFriend.php @@ -18,7 +18,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; /** diff --git a/Tests/Fixtures/Entity/ThirdLevel.php b/Tests/Fixtures/Entity/ThirdLevel.php index e3105c7..9cbd04a 100644 --- a/Tests/Fixtures/Entity/ThirdLevel.php +++ b/Tests/Fixtures/Entity/ThirdLevel.php @@ -19,7 +19,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Third Level. diff --git a/Tests/config.yml b/Tests/config.yml index f89f33e..a5b5288 100644 --- a/Tests/config.yml +++ b/Tests/config.yml @@ -7,11 +7,9 @@ doctrine: symfony_uuid: Symfony\Bridge\Doctrine\Types\UuidType orm: - auto_generate_proxy_classes: '%kernel.debug%' controller_resolver: enabled: false auto_mapping: false - enable_lazy_ghost_objects: true mappings: ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity: type: attribute diff --git a/composer.json b/composer.json index af62c91..5a75a39 100644 --- a/composer.json +++ b/composer.json @@ -27,23 +27,23 @@ "api-platform/doctrine-common": "^4.2.0-alpha.3@alpha", "api-platform/metadata": "^4.2", "api-platform/state": "^4.2.4", - "doctrine/orm": "^2.17 || ^3.0", - "symfony/type-info": "^7.3" + "doctrine/orm": "^2.17 || ^3.0" }, "require-dev": { - "doctrine/doctrine-bundle": "^2.11", + "doctrine/doctrine-bundle": "^2.11 || ^3.1", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "11.5.x-dev", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "symfony/cache": "^6.4 || ^7.0", - "symfony/framework-bundle": "^6.4 || ^7.0", - "symfony/property-access": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.1", - "symfony/uid": "^6.4 || ^7.0", - "symfony/validator": "^6.4.11 || ^7.0", - "symfony/yaml": "^6.4 || ^7.0" + "symfony/cache": "^6.4 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/uid": "^6.4 || ^7.0 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { "psr-4": { @@ -67,7 +67,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0" + "require": "^6.4 || ^7.0 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", From cbfbe6ce1886bec8982e183839bc0a2190570261 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 4 Dec 2025 14:06:14 +0100 Subject: [PATCH 095/152] fix(metadata): filter interface context php doc (#7560) --- Filter/FilterInterface.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Filter/FilterInterface.php b/Filter/FilterInterface.php index 7539574..131488e 100644 --- a/Filter/FilterInterface.php +++ b/Filter/FilterInterface.php @@ -29,7 +29,9 @@ interface FilterInterface extends BaseFilterInterface /** * Applies the filter. * - * @param array{filters?: array|array, parameter?: Parameter, ...} $context + * @phpstan-param array $context + * + * @psalm-param array{filters?: array, parameter?: Parameter, ...} $context */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } From 6ea550f2db2db04979aefd654c115ecd6f897039 Mon Sep 17 00:00:00 2001 From: Nathan Pesneau <129308244+NathanPesneau@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:20:26 +0100 Subject: [PATCH 096/152] refactor(state): replace :property placeholder with properties (#7547) fixes #7478 --- Extension/ParameterExtension.php | 42 +++++++------------------------- composer.json | 2 +- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index b7c8d89..c21c101 100644 --- a/Extension/ParameterExtension.php +++ b/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/composer.json b/composer.json index 5a75a39..8952b55 100644 --- a/composer.json +++ b/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" From 8899808699fe74fde3340d2bc217209dabcce169 Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Mon, 15 Dec 2025 14:07:25 +0100 Subject: [PATCH 097/152] ci: php 8.5 (#7585) --- Filter/ExistsFilter.php | 2 +- Filter/OrderFilter.php | 2 +- Tests/Extension/PaginationExtensionTest.php | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 7dbb046..9423aa7 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -136,7 +136,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q { $parameter = $context['parameter'] ?? null; - if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + if (null !== ($value = $context['filters'][$parameter?->getProperty() ?? ''] ?? null)) { $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); return; diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index 4962bf2..586060b 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -238,7 +238,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q } $parameter = $context['parameter'] ?? null; - if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + if (null !== ($value = $context['filters'][$parameter?->getProperty() ?? ''] ?? null)) { $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); return; diff --git a/Tests/Extension/PaginationExtensionTest.php b/Tests/Extension/PaginationExtensionTest.php index 2b8bcc5..73e3c82 100644 --- a/Tests/Extension/PaginationExtensionTest.php +++ b/Tests/Extension/PaginationExtensionTest.php @@ -387,7 +387,6 @@ public function testGetResultWithFetchJoinCollection(bool $paginationFetchJoinCo $this->assertInstanceOf(PaginatorInterface::class, $result); $doctrinePaginatorReflectionProperty = new \ReflectionProperty(AbstractPaginator::class, 'paginator'); - $doctrinePaginatorReflectionProperty->setAccessible(true); $doctrinePaginator = $doctrinePaginatorReflectionProperty->getValue($result); $this->assertSame($expected, $doctrinePaginator->getFetchJoinCollection()); @@ -435,7 +434,6 @@ public function testGetResultWithUseOutputWalkers(bool $paginationUseOutputWalke $this->assertInstanceOf(PaginatorInterface::class, $result); $doctrinePaginatorReflectionProperty = new \ReflectionProperty(AbstractPaginator::class, 'paginator'); - $doctrinePaginatorReflectionProperty->setAccessible(true); $doctrinePaginator = $doctrinePaginatorReflectionProperty->getValue($result); $this->assertSame($expected, $doctrinePaginator->getUseOutputWalkers()); From b97da1a9dc3f5934e3aebab79c68da5b7c7dfe60 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Dec 2025 22:56:14 +0100 Subject: [PATCH 098/152] refactor(mongodb): isolate mongodb-specific resources and fix filters Move MongoDB-specific ApiResource fixtures to separate ApiResourceOdm directory to better organize ODM-only resources. Fix PropertyHelperTrait nullable type handling and add MongoDB check in PropertyMetadataLoader. Fix ExistsFilter null key array handling and DateFilter imports. --- Filter/ExistsFilter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 7dbb046..5315473 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -135,8 +135,9 @@ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInt public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter'] ?? null; + $propertyKey = $parameter?->getProperty(); - if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + if (null !== $propertyKey && null !== ($value = $context['filters'][$propertyKey] ?? null)) { $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); return; From cae394490b61fbf2dd9343ccf6101a0d01d17e7b Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Dec 2025 22:56:46 +0100 Subject: [PATCH 099/152] ci: upgrade to phpunit 12 Remove soyuka/phpunit fork from all composer.json files and upgrade to PHPUnit 12.2. Update CI workflow to install PHPUnit before other steps and configure MongoDB conditional execution. Migrate tests from Prophecy to PHPUnit native mocking in FieldsBuilderTest and Symfony event listener tests. Remove unused dataprovider and fix warnings. --- Tests/Util/QueryBuilderHelperTest.php | 1 - composer.json | 10 ++-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Tests/Util/QueryBuilderHelperTest.php b/Tests/Util/QueryBuilderHelperTest.php index 488d217..709e936 100644 --- a/Tests/Util/QueryBuilderHelperTest.php +++ b/Tests/Util/QueryBuilderHelperTest.php @@ -55,7 +55,6 @@ public function testAddJoinOnce(?string $originAliasForJoinOnce, string $expecte $queryBuilder->getDQLPart('join')[$originAliasForJoinOnce ?? 'f'][0]->getAlias()); } - #[\PHPUnit\Framework\Attributes\DataProvider('provideAddJoinOnce')] public function testAddJoinOnceWithSpecifiedNewAlias(): void { $queryBuilder = new QueryBuilder($this->prophesize(EntityManagerInterface::class)->reveal()); diff --git a/composer.json b/composer.json index 8952b55..0fb956e 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", @@ -76,11 +76,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } From 0d36a5c64f3f1d6f895a8e556134f8180ffc762e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Tue, 6 Jan 2026 09:24:50 +0100 Subject: [PATCH 100/152] fix(doctrine): partial fetch when relation switches context (#7645) --- Extension/EagerLoadingExtension.php | 42 +++++++++- Tests/Extension/EagerLoadingExtensionTest.php | 77 ++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index bcff245..536f6a4 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -26,6 +26,7 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Select; use Doctrine\ORM\QueryBuilder; +use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; @@ -198,11 +199,13 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt if (true === $fetchPartial) { try { - $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $options); + $propertyOptions = $this->getPropertyContext($attributesMetadata[$association] ?? null, $options); + $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyOptions); } catch (ResourceClassNotFoundException) { continue; } } else { + $propertyOptions = null; $this->addSelectOnce($queryBuilder, $associationAlias); } @@ -225,10 +228,45 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt } } - $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $options, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association); + $propertyOptions ??= $this->getPropertyContext($attributesMetadata[$association] ?? null, $options); + $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyOptions, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association); } } + private function getPropertyContext(?AttributeMetadataInterface $attributeMetadata, array $options): array + { + if (null === $attributeMetadata) { + return $options; + } + + $hasNormalizationContext = (isset($options['normalization_groups']) || isset($options['serializer_groups'])) && [] !== $attributeMetadata->getNormalizationContexts(); + $hasDenormalizationContext = (isset($options['denormalization_groups']) || isset($options['serializer_groups'])) && [] !== $attributeMetadata->getDenormalizationContexts(); + + if (!$hasNormalizationContext && !$hasDenormalizationContext) { + return $options; + } + + $propertyOptions = $options; + $propertyOptions['normalization_groups'] ??= $options['serializer_groups']; + $propertyOptions['denormalization_groups'] ??= $options['serializer_groups']; + // we don't rely on 'serializer_groups' anymore because `context` changes normalization and/or denormalization + // but does not have options for both at the same time + unset($propertyOptions['serializer_groups']); + + if ($hasNormalizationContext) { + $originalGroups = $options['normalization_groups'] ?? $options['serializer_groups']; + $propertyContext = $attributeMetadata->getNormalizationContextForGroups((array) $originalGroups); + $propertyOptions['normalization_groups'] = $propertyContext[AbstractNormalizer::GROUPS] ?? $originalGroups; + } + if ($hasDenormalizationContext) { + $originalGroups = $options['denormalization_groups'] ?? $options['serializer_groups']; + $propertyContext = $attributeMetadata->getDenormalizationContextForGroups((array) $originalGroups); + $propertyOptions['denormalization_groups'] = $propertyContext[AbstractNormalizer::GROUPS] ?? $originalGroups; + } + + return $propertyOptions; + } + private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): void { $select = []; diff --git a/Tests/Extension/EagerLoadingExtensionTest.php b/Tests/Extension/EagerLoadingExtensionTest.php index 2ad7a74..73f079d 100644 --- a/Tests/Extension/EagerLoadingExtensionTest.php +++ b/Tests/Extension/EagerLoadingExtensionTest.php @@ -336,6 +336,82 @@ public function testDenormalizeItemWithExistingGroups(): void $eagerExtensionTest->applyToItem($queryBuilderProphecy->reveal(), new QueryNameGenerator(), RelatedDummy::class, ['id' => 1], new Get(name: 'item_operation', normalizationContext: ['groups' => ['foo']]), [AbstractNormalizer::GROUPS => 'some_groups']); } + public function testContextSwitch(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $relatedNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn($relatedNameCollection)->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $relationPropertyMetadata = new ApiProperty(); + $relationPropertyMetadata = $relationPropertyMetadata->withReadableLink(false); + + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($relationPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($relationPropertyMetadata)->shouldBeCalled(); + + $idPropertyMetadata = new ApiProperty(); + $idPropertyMetadata = $idPropertyMetadata->withIdentifier(true); + $namePropertyMetadata = new ApiProperty(); + $namePropertyMetadata = $namePropertyMetadata->withReadable(true); + + // When called via `relatedDummies` without context switch + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'id', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($idPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($namePropertyMetadata)->shouldBeCalled(); + + // When called via `relatedDummy` with context switch + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'id', ['normalization_groups' => ['bar'], 'denormalization_groups' => ['foo']])->willReturn($idPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['normalization_groups' => ['bar'], 'denormalization_groups' => ['foo']])->willReturn($namePropertyMetadata)->shouldBeCalled(); + + $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->associationMappings = [ + 'relatedDummies' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], + ]; + + $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); + + foreach ($relatedNameCollection as $property) { + if ('id' !== $property && 'embeddedDummy' !== $property) { + $relatedClassMetadataProphecy->hasField($property)->willReturn('notindatabase' !== $property)->shouldBeCalled(); + } + } + + $dummyClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class); + $relatedClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class); + $classMetadataFactoryProphecy = $this->prophesize(ClassMetadataFactoryInterface::class); + + $relatedDummyAttributeMetadata = new AttributeMetadata('relatedDummy'); + $relatedDummyAttributeMetadata->setNormalizationContextForGroups(['groups' => ['bar']], ['foo']); + + $dummyClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn(['relatedDummy' => $relatedDummyAttributeMetadata]); + $relatedClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn([]); + + $classMetadataFactoryProphecy->getMetadataFor(RelatedDummy::class)->willReturn($relatedClassMetadataInterfaceProphecy->reveal()); + $classMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummyClassMetadataInterfaceProphecy->reveal()); + + $relatedClassMetadataProphecy->associationMappings = []; + + $emProphecy = $this->prophesize(EntityManager::class); + $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); + + $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); + + $queryBuilderProphecy->leftJoin('o.relatedDummies', 'relatedDummies_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a2')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addSelect('partial relatedDummies_a1.{id,name}')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addSelect('partial relatedDummy_a2.{id,name}')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); + + $queryBuilder = $queryBuilderProphecy->reveal(); + $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true, $classMetadataFactoryProphecy->reveal()); + $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); + } + public function testMaxJoinsReached(): void { $this->expectException(RuntimeException::class); @@ -496,7 +572,6 @@ public function testForceEager(): void public function testExtraLazy(): void { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - // $propertyNameCollectionFactoryProphecy->create(UnknownDummy::class)->willReturn(new PropertyNameCollection(['id']))->shouldBeCalled(); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $relationPropertyMetadata = new ApiProperty(); From 7528689abb9568be1529dd9b74592e364587dc86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Tue, 6 Jan 2026 14:53:16 +0100 Subject: [PATCH 101/152] fix(doctrine): fix partial fetch with same entity included multiple time with different fields (#7647) --- Extension/EagerLoadingExtension.php | 82 ++++++++++--- Tests/Extension/EagerLoadingExtensionTest.php | 112 +++++++++++++++++- 2 files changed, 173 insertions(+), 21 deletions(-) diff --git a/Extension/EagerLoadingExtension.php b/Extension/EagerLoadingExtension.php index 536f6a4..01a54bc 100644 --- a/Extension/EagerLoadingExtension.php +++ b/Extension/EagerLoadingExtension.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query\AST\PartialObjectExpression; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Select; use Doctrine\ORM\QueryBuilder; @@ -70,7 +71,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ $options = []; $forceEager = $operation?->getForceEager() ?? $this->forceEager; - $fetchPartial = $operation?->getFetchPartial() ?? $this->fetchPartial; + $fetchPartial = class_exists(PartialObjectExpression::class) && ($operation?->getFetchPartial() ?? $this->fetchPartial); if (!isset($context['groups']) && !isset($context['attributes'])) { $contextType = isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context'; @@ -95,7 +96,61 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ $options['denormalization_groups'] = $denormalizationGroups; } - $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context); + $selects = $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context); + $selectsByClass = []; + foreach ($selects as [$entity, $alias, $fields]) { + if ($entity === $resourceClass) { + // We don't perform partial select the root entity + $fields = null; + } + + if (!isset($selectsByClass[$entity])) { + $selectsByClass[$entity] = [ + 'aliases' => [$alias => true], + 'fields' => null === $fields ? null : array_flip($fields), + ]; + } else { + $selectsByClass[$entity]['aliases'][$alias] = true; + if (null === $selectsByClass[$entity]['fields']) { + continue; + } + + if (null === $fields) { + $selectsByClass[$entity]['fields'] = null; + continue; + } + + // Merge fields + foreach ($fields as $field) { + $selectsByClass[$entity]['fields'][$field] = true; + } + } + } + + $existingSelects = []; + foreach ($queryBuilder->getDQLPart('select') ?? [] as $dqlSelect) { + if (!$dqlSelect instanceof Select) { + continue; + } + foreach ($dqlSelect->getParts() as $part) { + $existingSelects[(string) $part] = true; + } + } + + foreach ($selectsByClass as $data) { + $fields = null === $data['fields'] ? null : array_keys($data['fields']); + foreach (array_keys($data['aliases']) as $alias) { + if (isset($existingSelects[$alias])) { + continue; + } + + if (null === $fields) { + $queryBuilder->addSelect($alias); + } else { + $queryBuilder->addSelect(\sprintf('partial %s.{%s}', $alias, implode(',', $fields))); + } + } + } } /** @@ -107,7 +162,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ * * @throws RuntimeException when the max number of joins has been reached */ - private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): void + private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): iterable { if ($joinCount > $this->maxJoins) { throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the "api_platform.eager_loading.max_joins" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the "enable_max_depth" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).'); @@ -200,13 +255,13 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt if (true === $fetchPartial) { try { $propertyOptions = $this->getPropertyContext($attributesMetadata[$association] ?? null, $options); - $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyOptions); + yield from $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyOptions); } catch (ResourceClassNotFoundException) { continue; } } else { $propertyOptions = null; - $this->addSelectOnce($queryBuilder, $associationAlias); + yield [$resourceClass, $associationAlias, null]; } // Avoid recursive joins for self-referencing relations @@ -229,7 +284,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt } $propertyOptions ??= $this->getPropertyContext($attributesMetadata[$association] ?? null, $options); - $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyOptions, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association); + yield from $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyOptions, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association); } } @@ -267,13 +322,13 @@ private function getPropertyContext(?AttributeMetadataInterface $attributeMetada return $propertyOptions; } - private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): void + private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): iterable { $select = []; $entityManager = $queryBuilder->getEntityManager(); $targetClassMetadata = $entityManager->getClassMetadata($entity); if (!empty($targetClassMetadata->subClasses)) { - $this->addSelectOnce($queryBuilder, $associationAlias); + yield [$entity, $associationAlias, null]; return; } @@ -308,15 +363,6 @@ private function addSelect(QueryBuilder $queryBuilder, string $entity, string $a } } - $queryBuilder->addSelect(\sprintf('partial %s.{%s}', $associationAlias, implode(',', $select))); - } - - private function addSelectOnce(QueryBuilder $queryBuilder, string $alias): void - { - $existingSelects = array_reduce($queryBuilder->getDQLPart('select') ?? [], fn ($existing, $dqlSelect) => ($dqlSelect instanceof Select) ? array_merge($existing, $dqlSelect->getParts()) : $existing, []); - - if (!\in_array($alias, $existingSelects, true)) { - $queryBuilder->addSelect($alias); - } + yield [$entity, $associationAlias, $select]; } } diff --git a/Tests/Extension/EagerLoadingExtensionTest.php b/Tests/Extension/EagerLoadingExtensionTest.php index 73f079d..ea8fd82 100644 --- a/Tests/Extension/EagerLoadingExtensionTest.php +++ b/Tests/Extension/EagerLoadingExtensionTest.php @@ -117,6 +117,7 @@ public function testApplyToCollection(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -224,6 +225,7 @@ public function testApplyToItem(): void $emProphecy->getClassMetadata(ThirdLevel::class)->shouldBeCalled()->willReturn($thirdLevelMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); $queryBuilderProphecy->leftJoin('relatedDummy_a1.relation', 'relation_a2')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -269,6 +271,7 @@ public function testCreateItemWithOperation(): void $emProphecy = $this->prophesize(EntityManager::class); $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -290,6 +293,7 @@ public function testCreateCollectionWithOperation(): void $emProphecy = $this->prophesize(EntityManager::class); $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -310,6 +314,7 @@ public function testDenormalizeItemWithCorrectResourceClass(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -330,6 +335,7 @@ public function testDenormalizeItemWithExistingGroups(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -364,6 +370,7 @@ public function testContextSwitch(): void $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['normalization_groups' => ['bar'], 'denormalization_groups' => ['foo']])->willReturn($namePropertyMetadata)->shouldBeCalled(); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ @@ -375,7 +382,7 @@ public function testContextSwitch(): void foreach ($relatedNameCollection as $property) { if ('id' !== $property && 'embeddedDummy' !== $property) { - $relatedClassMetadataProphecy->hasField($property)->willReturn('notindatabase' !== $property)->shouldBeCalled(); + $relatedClassMetadataProphecy->hasField($property)->willReturn(true)->shouldBeCalled(); } } @@ -412,6 +419,90 @@ public function testContextSwitch(): void $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); } + public function testSameEntityWithDifferentPartialProperties(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $relatedNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn($relatedNameCollection)->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $relationPropertyMetadata = new ApiProperty(); + $relationPropertyMetadata = $relationPropertyMetadata->withReadableLink(false); + + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy1', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($relationPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy2', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($relationPropertyMetadata)->shouldBeCalled(); + + $idPropertyMetadata = (new ApiProperty())->withIdentifier(true); + $namePropertyMetadataGroupA = (new ApiProperty())->withReadable(true); + // the property Name IS NOT readable in group B + $namePropertyMetadataGroupB = (new ApiProperty())->withReadable(false); + + // When called via `relatedDummy1` + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'id', ['normalization_groups' => ['A'], 'denormalization_groups' => ['foo']])->willReturn($idPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['normalization_groups' => ['A'], 'denormalization_groups' => ['foo']])->willReturn($namePropertyMetadataGroupA)->shouldBeCalled(); + + // When called via `relatedDummy2` + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'id', ['normalization_groups' => ['B'], 'denormalization_groups' => ['foo']])->willReturn($idPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['normalization_groups' => ['B'], 'denormalization_groups' => ['foo']])->willReturn($namePropertyMetadataGroupB)->shouldBeCalled(); + + $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->associationMappings = [ + 'relatedDummy1' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], + ]; + + $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); + + foreach ($relatedNameCollection as $property) { + if ('id' !== $property && 'embeddedDummy' !== $property) { + $relatedClassMetadataProphecy->hasField($property)->willReturn(true)->shouldBeCalled(); + } + } + + $dummyClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class); + $relatedClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class); + $classMetadataFactoryProphecy = $this->prophesize(ClassMetadataFactoryInterface::class); + + $relatedDummy1AttributeMetadata = new AttributeMetadata('relatedDummy'); + $relatedDummy1AttributeMetadata->setNormalizationContextForGroups(['groups' => ['A']], ['foo']); + + $relatedDummy2AttributeMetadata = new AttributeMetadata('relatedDummy'); + $relatedDummy2AttributeMetadata->setNormalizationContextForGroups(['groups' => ['B']], ['foo']); + + $dummyClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn([ + 'relatedDummy1' => $relatedDummy1AttributeMetadata, + 'relatedDummy2' => $relatedDummy2AttributeMetadata, + ]); + $relatedClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn([]); + + $classMetadataFactoryProphecy->getMetadataFor(RelatedDummy::class)->willReturn($relatedClassMetadataInterfaceProphecy->reveal()); + $classMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummyClassMetadataInterfaceProphecy->reveal()); + + $relatedClassMetadataProphecy->associationMappings = []; + + $emProphecy = $this->prophesize(EntityManager::class); + $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); + + $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); + + $queryBuilderProphecy->leftJoin('o.relatedDummy1', 'relatedDummy1_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->leftJoin('o.relatedDummy2', 'relatedDummy2_a2')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addSelect('partial relatedDummy1_a1.{id,name}')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + // here is the purpose of this test: name is not readable in group B, BUT it is part of the partial selection because it is readable in group A + $queryBuilderProphecy->addSelect('partial relatedDummy2_a2.{id,name}')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); + + $queryBuilder = $queryBuilderProphecy->reveal(); + $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true, $classMetadataFactoryProphecy->reveal()); + $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); + } + public function testMaxJoinsReached(): void { $this->expectException(RuntimeException::class); @@ -454,10 +545,11 @@ public function testMaxJoinsReached(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); - $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->shouldBeCalled()->willReturn($queryBuilderProphecy); - $queryBuilderProphecy->addSelect(Argument::type('string'))->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addSelect(Argument::type('string'))->willReturn($queryBuilderProphecy); $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -519,6 +611,7 @@ public function testMaxDepth(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->shouldBeCalledTimes(2)->willReturn($queryBuilderProphecy); @@ -563,6 +656,7 @@ public function testForceEager(): void $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -593,6 +687,7 @@ public function testExtraLazy(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -614,6 +709,7 @@ public function testResourceClassNotFoundException(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -635,6 +731,7 @@ public function testPropertyNotFoundException(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -660,6 +757,7 @@ public function testResourceClassNotFoundExceptionPropertyNameCollection(): void $emProphecy->getClassMetadata(UnknownDummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->innerJoin('o.relation', 'relation_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); $queryBuilderProphecy->getDQLPart('join')->willReturn([]); @@ -713,6 +811,7 @@ public function testAttributes(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummies', 'relatedDummies_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -749,6 +848,7 @@ public function testNotInAttributes(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilder = $queryBuilderProphecy->reveal(); @@ -801,6 +901,7 @@ public function testOnlyOneRelationNotInAttributes(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -838,6 +939,7 @@ public function testApplyToCollectionNoPartial(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -883,6 +985,7 @@ public function testApplyToCollectionWithANonReadableButFetchEagerProperty(): vo $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -925,6 +1028,7 @@ public function testApplyToCollectionWithExistingJoin(string $joinType): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->getDQLPart('join')->willReturn([ 'o' => [ @@ -971,6 +1075,7 @@ public function testApplyToCollectionWithAReadableButNotFetchEagerProperty(): vo $emProphecy->getClassMetadata(RelatedDummy::class)->shouldNotBecalled(); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldNotBeCalled(); @@ -1008,6 +1113,7 @@ public function testAvoidFetchCollectionOnIriOnlyProperty(): void $emProphecy->getClassMetadata(PropertyCollectionIriOnlyRelation::class)->shouldNotBecalled(); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.propertyCollectionIriOnlyRelation', 'propertyCollectionIriOnlyRelation_a1')->shouldNotBeCalled(); From 6dd409d7e90b031cc9bddaa397f049da9da0799f Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 8 Jan 2026 15:27:10 +0100 Subject: [PATCH 102/152] fix(doctrine): escape values with `%` or `_` in search filter (#7653) --- Filter/PartialSearchFilter.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index 90cde75..0df3ff3 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -38,12 +38,10 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $values = $parameter->getValue(); if (!is_iterable($values)) { - $queryBuilder->setParameter($parameterName, '%'.strtolower($values).'%'); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue(strtolower($values))); - $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($queryBuilder->expr()->like( - 'LOWER('.$field.')', - ':'.$parameterName - )); + $likeExpression = 'LOWER('.$field.') LIKE :'.$parameterName.' ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); return; } @@ -51,15 +49,17 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $likeExpressions = []; foreach ($values as $val) { $parameterName = $queryNameGenerator->generateParameterName($property); - $likeExpressions[] = $queryBuilder->expr()->like( - 'LOWER('.$field.')', - ':'.$parameterName - ); - $queryBuilder->setParameter($parameterName, '%'.strtolower($val).'%'); + $likeExpressions[] = 'LOWER('.$field.') LIKE :'.$parameterName.' ESCAPE \'\\\''; + $queryBuilder->setParameter($parameterName, $this->formatLikeValue(strtolower($val))); } $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( $queryBuilder->expr()->orX(...$likeExpressions) ); } + + private function formatLikeValue(string $value): string + { + return '%'.addcslashes($value, '\\%_').'%'; + } } From f10ad1a60359a2ba7b63db2d2d99be8d67c9da50 Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Fri, 9 Jan 2026 10:22:42 +0100 Subject: [PATCH 103/152] feat(doctrine): uuid filter (#7628) Co-authored-by: soyuka --- Filter/AbstractUuidFilter.php | 233 ++++++++++++++++++++++++++++++++++ Filter/UlidFilter.php | 88 +++++++++++++ Filter/UuidBinaryFilter.php | 40 ++++++ Filter/UuidFilter.php | 18 +++ composer.json | 2 +- 5 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 Filter/AbstractUuidFilter.php create mode 100644 Filter/UlidFilter.php create mode 100644 Filter/UuidBinaryFilter.php create mode 100644 Filter/UuidFilter.php diff --git a/Filter/AbstractUuidFilter.php b/Filter/AbstractUuidFilter.php new file mode 100644 index 0000000..b96f3e9 --- /dev/null +++ b/Filter/AbstractUuidFilter.php @@ -0,0 +1,233 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Types\ConversionException; +use Doctrine\DBAL\Types\Type; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +/** + * @internal + */ +class AbstractUuidFilter implements FilterInterface, ManagerRegistryAwareInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OrmPropertyHelperTrait; + use PropertyHelperTrait; + + private const UUID_SCHEMA = [ + 'type' => 'string', + 'format' => 'uuid', + ]; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter'] ?? null; + if (!$parameter) { + return; + } + + $this->filterProperty($parameter->getProperty(), $parameter->getValue(), $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + + private function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + $associations = []; + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); + } + + $metadata = $this->getNestedMetadata($resourceClass, $associations); + + if ($metadata->hasField($field)) { + $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($property, $resourceClass), $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value); + + return; + } + + // metadata doesn't have the field, nor an association on the field + if (!$metadata->hasAssociation($field)) { + $this->logger->notice('Tried to filter on a non-existent field or association', [ + 'field' => $field, + 'resource_class' => $resourceClass, + 'exception' => new InvalidArgumentException(\sprintf('Property "%s" does not exist in resource "%s".', $field, $resourceClass)), + ]); + + return; + } + + // association, let's fetch the entity (or reference to it) if we can so we can make sure we get its orm id + $associationResourceClass = $metadata->getAssociationTargetClass($field); + $associationMetadata = $this->getClassMetadata($associationResourceClass); + $associationFieldIdentifier = $associationMetadata->getIdentifierFieldNames()[0]; + $doctrineTypeField = $this->getDoctrineFieldType($associationFieldIdentifier, $associationResourceClass); + + $associationAlias = $alias; + $associationField = $field; + + if ($metadata->isCollectionValuedAssociation($associationField) || $metadata->isAssociationInverseSide($field)) { + $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $associationField); + $associationField = $associationFieldIdentifier; + } + + $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value); + } + + /** + * Converts value to their database representation. + */ + private function convertValuesToTheDatabaseRepresentation(QueryBuilder $queryBuilder, ?string $doctrineFieldType, mixed $value): mixed + { + if (null === $doctrineFieldType || !Type::hasType($doctrineFieldType)) { + throw new InvalidArgumentException(\sprintf('The Doctrine type "%s" is not valid or not registered.', $doctrineFieldType)); + } + + $doctrineType = Type::getType($doctrineFieldType); + $platform = $queryBuilder->getEntityManager()->getConnection()->getDatabasePlatform(); + + $convertValue = static function (mixed $value) use ($doctrineType, $platform) { + try { + return $doctrineType->convertToDatabaseValue($value, $platform); + } catch (ConversionException $e) { + throw new InvalidArgumentException(\sprintf('The value "%s" could not be converted to database representation.', $value), previous: $e); + } + }; + + if (\is_array($value)) { + return array_map($convertValue, $value); + } + + return $convertValue($value); + } + + /** + * Adds where clause. + */ + private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value): void + { + $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); + $aliasedField = \sprintf('%s.%s', $alias, $field); + + if (!\is_array($value)) { + $queryBuilder + ->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + + return; + } + + $queryBuilder + ->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineArrayParameterType()); + } + + protected function getDoctrineParameterType(): ?ParameterType + { + return null; + } + + protected function getDoctrineArrayParameterType(): ?ArrayParameterType + { + return null; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $schema = $parameter->getSchema(); + $isArraySchema = 'array' === ($schema['type'] ?? null); + $hasNonArrayType = isset($schema['type']) && 'array' !== $schema['type']; + + // Get filter's base schema + $baseSchema = self::UUID_SCHEMA; + $arraySchema = ['type' => 'array', 'items' => $baseSchema]; + + if ($isArraySchema) { + return new OpenApiParameter( + name: $parameter->getKey().'[]', + in: $in, + schema: $arraySchema, + style: 'deepObject', + explode: true, + ); + } + + if ($hasNonArrayType) { + return new OpenApiParameter( + name: $parameter->getKey(), + in: $in, + schema: $baseSchema, + ); + } + + // oneOf or no specific type constraint - return both with explicit schemas + return [ + new OpenApiParameter( + name: $parameter->getKey(), + in: $in, + schema: $baseSchema, + ), + new OpenApiParameter( + name: $parameter->getKey().'[]', + in: $in, + schema: $arraySchema, + style: 'deepObject', + explode: true, + ), + ]; + } + + public function getSchema(Parameter $parameter): array + { + if (false === $parameter->getCastToArray()) { + return self::UUID_SCHEMA; + } + + return [ + 'oneOf' => [ + self::UUID_SCHEMA, + [ + 'type' => 'array', + 'items' => self::UUID_SCHEMA, + ], + ], + ]; + } +} diff --git a/Filter/UlidFilter.php b/Filter/UlidFilter.php new file mode 100644 index 0000000..4f54560 --- /dev/null +++ b/Filter/UlidFilter.php @@ -0,0 +1,88 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; + +final class UlidFilter extends AbstractUuidFilter +{ + private const ULID_SCHEMA = [ + 'type' => 'string', + 'format' => 'ulid', + ]; + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $schema = $parameter->getSchema(); + $isArraySchema = 'array' === ($schema['type'] ?? null); + $hasNonArrayType = isset($schema['type']) && 'array' !== $schema['type']; + + $baseSchema = self::ULID_SCHEMA; + $arraySchema = ['type' => 'array', 'items' => $baseSchema]; + + if ($isArraySchema) { + return new OpenApiParameter( + name: $parameter->getKey().'[]', + in: $in, + schema: $arraySchema, + style: 'deepObject', + explode: true, + ); + } + + if ($hasNonArrayType) { + return new OpenApiParameter( + name: $parameter->getKey(), + in: $in, + schema: $baseSchema, + ); + } + + // oneOf or no specific type constraint - return both with explicit schemas + return [ + new OpenApiParameter( + name: $parameter->getKey(), + in: $in, + schema: $baseSchema, + ), + new OpenApiParameter( + name: $parameter->getKey().'[]', + in: $in, + schema: $arraySchema, + style: 'deepObject', + explode: true, + ), + ]; + } + + public function getSchema(Parameter $parameter): array + { + if (false === $parameter->getCastToArray()) { + return self::ULID_SCHEMA; + } + + return [ + 'oneOf' => [ + self::ULID_SCHEMA, + [ + 'type' => 'array', + 'items' => self::ULID_SCHEMA, + ], + ], + ]; + } +} diff --git a/Filter/UuidBinaryFilter.php b/Filter/UuidBinaryFilter.php new file mode 100644 index 0000000..d2dd43c --- /dev/null +++ b/Filter/UuidBinaryFilter.php @@ -0,0 +1,40 @@ + + * + * 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\Orm\Filter; + +use Composer\InstalledVersions; +use Composer\Semver\VersionParser; +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\ParameterType; + +final class UuidBinaryFilter extends AbstractUuidFilter +{ + public function __construct() + { + if (!InstalledVersions::satisfies(new VersionParser(), 'doctrine/orm', '^3.0.1')) { + // @see https://github.com/doctrine/orm/pull/11287 + throw new \LogicException('The "doctrine/orm" package version 3.0.1 or higher is required to use the UuidBinaryFilter. Please upgrade your dependencies.'); + } + } + + protected function getDoctrineParameterType(): ParameterType + { + return ParameterType::BINARY; + } + + protected function getDoctrineArrayParameterType(): ArrayParameterType + { + return ArrayParameterType::BINARY; + } +} diff --git a/Filter/UuidFilter.php b/Filter/UuidFilter.php new file mode 100644 index 0000000..416615c --- /dev/null +++ b/Filter/UuidFilter.php @@ -0,0 +1,18 @@ + + * + * 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\Orm\Filter; + +final class UuidFilter extends AbstractUuidFilter +{ +} diff --git a/composer.json b/composer.json index 0fb956e..fd04dd4 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "api-platform/doctrine-common": "^4.2.9", "api-platform/metadata": "^4.2", "api-platform/state": "^4.2.4", - "doctrine/orm": "^2.17 || ^3.0" + "doctrine/orm": "^2.17 || ^3.0.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", From 0a16719be88909fd0a3d1a49c75691c0dcbad254 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 12 Jan 2026 14:36:15 +0100 Subject: [PATCH 104/152] chore: phpdoc for array of some interface (#7583) --- Extension/QueryResultCollectionExtensionInterface.php | 6 ++++++ Extension/QueryResultItemExtensionInterface.php | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Extension/QueryResultCollectionExtensionInterface.php b/Extension/QueryResultCollectionExtensionInterface.php index fe996d2..0d58803 100644 --- a/Extension/QueryResultCollectionExtensionInterface.php +++ b/Extension/QueryResultCollectionExtensionInterface.php @@ -27,7 +27,13 @@ */ interface QueryResultCollectionExtensionInterface extends QueryCollectionExtensionInterface { + /** + * @param array $context + */ public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool; + /** + * @param array $context + */ public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): iterable; } diff --git a/Extension/QueryResultItemExtensionInterface.php b/Extension/QueryResultItemExtensionInterface.php index 4c23d77..e46fddf 100644 --- a/Extension/QueryResultItemExtensionInterface.php +++ b/Extension/QueryResultItemExtensionInterface.php @@ -26,7 +26,13 @@ */ interface QueryResultItemExtensionInterface extends QueryItemExtensionInterface { + /** + * @param array $context + */ public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool; + /** + * @param array $context + */ public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): ?object; } From b1ca100ed3ba802777a7fe915be1bb045f5e1a1c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 19 Jan 2026 09:55:38 +0100 Subject: [PATCH 105/152] fix(doctrine): useless generateParameterName call (#7679) --- Filter/PartialSearchFilter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index 0df3ff3..dec80f4 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -34,10 +34,10 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $field = $alias.'.'.$property; - $parameterName = $queryNameGenerator->generateParameterName($property); $values = $parameter->getValue(); if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); $queryBuilder->setParameter($parameterName, $this->formatLikeValue(strtolower($values))); $likeExpression = 'LOWER('.$field.') LIKE :'.$parameterName.' ESCAPE \'\\\''; From 9f58ef03730acf2ad46bd2eed977452ffc5cf2ec Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Thu, 22 Jan 2026 10:49:57 +0100 Subject: [PATCH 106/152] fix(doctrine): throw exception if property is null for the doctrine filters (#7681) --- Filter/AbstractUuidFilter.php | 4 ++++ Filter/ExactFilter.php | 6 ++++++ Filter/IriFilter.php | 6 ++++++ Filter/PartialSearchFilter.php | 6 ++++++ 4 files changed, 22 insertions(+) diff --git a/Filter/AbstractUuidFilter.php b/Filter/AbstractUuidFilter.php index b96f3e9..5f221fd 100644 --- a/Filter/AbstractUuidFilter.php +++ b/Filter/AbstractUuidFilter.php @@ -59,6 +59,10 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Nested properties are not automatically resolved. Please provide the property explicitly.', $parameter->getKey())); + } + $this->filterProperty($parameter->getProperty(), $parameter->getValue(), $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } diff --git a/Filter/ExactFilter.php b/Filter/ExactFilter.php index 3795615..b075ea2 100644 --- a/Filter/ExactFilter.php +++ b/Filter/ExactFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -32,6 +33,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q { $parameter = $context['parameter']; $value = $parameter->getValue(); + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); diff --git a/Filter/IriFilter.php b/Filter/IriFilter.php index 32acf8f..43ec3ba 100644 --- a/Filter/IriFilter.php +++ b/Filter/IriFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ParameterProviderFilterInterface; @@ -34,6 +35,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q { $parameter = $context['parameter']; $value = $parameter->getValue(); + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index 0df3ff3..94fd0f7 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -31,6 +32,11 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilt public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $field = $alias.'.'.$property; From 65c0af0ec4a873a39a7cfd54a51b732cb182320b Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Fri, 16 Jan 2026 10:47:57 +0100 Subject: [PATCH 107/152] feat(doctrine): throw exception if parameter property is not provided --- Filter/ExactFilter.php | 6 ++++++ Filter/IriFilter.php | 6 ++++++ Filter/PartialSearchFilter.php | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/Filter/ExactFilter.php b/Filter/ExactFilter.php index 3795615..b075ea2 100644 --- a/Filter/ExactFilter.php +++ b/Filter/ExactFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -32,6 +33,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q { $parameter = $context['parameter']; $value = $parameter->getValue(); + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); diff --git a/Filter/IriFilter.php b/Filter/IriFilter.php index 32acf8f..43ec3ba 100644 --- a/Filter/IriFilter.php +++ b/Filter/IriFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ParameterProviderFilterInterface; @@ -34,6 +35,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q { $parameter = $context['parameter']; $value = $parameter->getValue(); + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index dec80f4..e25491a 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -31,6 +32,11 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilt public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $field = $alias.'.'.$property; From 2d497e49785790fc218c5c3e3a69ada764c3f01a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 23 Jan 2026 15:23:05 +0100 Subject: [PATCH 108/152] fix(doctrine): PartialSearchFilter on multibyte characters (#7688) --- Filter/PartialSearchFilter.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index e25491a..a7df955 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -44,9 +44,9 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q if (!is_iterable($values)) { $parameterName = $queryNameGenerator->generateParameterName($property); - $queryBuilder->setParameter($parameterName, $this->formatLikeValue(strtolower($values))); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); - $likeExpression = 'LOWER('.$field.') LIKE :'.$parameterName.' ESCAPE \'\\\''; + $likeExpression = 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); return; @@ -55,8 +55,8 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $likeExpressions = []; foreach ($values as $val) { $parameterName = $queryNameGenerator->generateParameterName($property); - $likeExpressions[] = 'LOWER('.$field.') LIKE :'.$parameterName.' ESCAPE \'\\\''; - $queryBuilder->setParameter($parameterName, $this->formatLikeValue(strtolower($val))); + $likeExpressions[] = 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); } $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( From 7a7c5cb7261ead50481a9a2d8ef721e21ea97945 Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Fri, 23 Jan 2026 15:24:03 +0100 Subject: [PATCH 109/152] fix(symfony): fix for symfony/json_streamer 8.0 with enable_json_streamer (#7684) --- Tests/AppKernel.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index d54549a..2037665 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -20,7 +20,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; -use Symfony\Component\PropertyInfo\Type; /** * AppKernel for tests. @@ -83,10 +82,6 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'uid' => ['default_uuid_version' => 7, 'time_based_uuid_version' => 7], ]; - if (!class_exists(Type::class)) { - $c->setParameter('.json_streamer.lazy_ghosts_dir', __DIR__.'/cache/json_streamer_lazy_ghost'); - } - $c->prependExtensionConfig('framework', $config); $loader->load(__DIR__.'/config.yml'); From cf5c99a209a7be3e508c6f5d0fa4d853d43cff84 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 26 Jan 2026 16:38:30 +0100 Subject: [PATCH 110/152] cs: static fn fixes --- Filter/ExistsFilter.php | 2 +- Tests/Filter/ExistsFilterTest.php | 4 ++-- Tests/Filter/OrderFilterTest.php | 4 ++-- Tests/Filter/SearchFilterTest.php | 2 +- Tests/Fixtures/Entity/PropertyCollectionIriOnly.php | 6 ++---- .../Fixtures/Entity/PropertyCollectionIriOnlyRelation.php | 6 ++---- .../Entity/PropertyUriTemplateOneToOneRelation.php | 6 ++---- Tests/PaginatorTest.php | 8 ++++---- Tests/State/ItemProviderTest.php | 4 ++-- 9 files changed, 18 insertions(+), 24 deletions(-) diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 5315473..b9f2385 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -216,7 +216,7 @@ protected function isNullableField(string $property, string $resourceClass): boo if ($metadata->hasAssociation($field)) { if ($metadata->isSingleValuedAssociation($field)) { - if (!($metadata instanceof ClassMetadata)) { + if (!$metadata instanceof ClassMetadata) { return false; } diff --git a/Tests/Filter/ExistsFilterTest.php b/Tests/Filter/ExistsFilterTest.php index 168714e..9e81844 100644 --- a/Tests/Filter/ExistsFilterTest.php +++ b/Tests/Filter/ExistsFilterTest.php @@ -98,8 +98,8 @@ public function testGetDescriptionDefaultFields(): void public static function provideApplyTestData(): array { - $existsFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'exists'); - $customExistsFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'customExists'); + $existsFilterFactory = static fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'exists'); + $customExistsFilterFactory = static fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): ExistsFilter => new ExistsFilter($managerRegistry, null, $properties, 'customExists'); return array_merge_recursive( self::provideApplyTestArguments(), diff --git a/Tests/Filter/OrderFilterTest.php b/Tests/Filter/OrderFilterTest.php index 20e3293..0724695 100644 --- a/Tests/Filter/OrderFilterTest.php +++ b/Tests/Filter/OrderFilterTest.php @@ -329,8 +329,8 @@ public function testGetDescriptionDefaultFields(): void public static function provideApplyTestData(): array { - $orderFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'order', null, $properties); - $customOrderFilterFactory = fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'customOrder', null, $properties); + $orderFilterFactory = static fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'order', null, $properties); + $customOrderFilterFactory = static fn (self $that, ManagerRegistry $managerRegistry, ?array $properties = null): OrderFilter => new OrderFilter($managerRegistry, 'customOrder', null, $properties); return array_merge_recursive( self::provideApplyTestArguments(), diff --git a/Tests/Filter/SearchFilterTest.php b/Tests/Filter/SearchFilterTest.php index 5bc48cc..6872766 100644 --- a/Tests/Filter/SearchFilterTest.php +++ b/Tests/Filter/SearchFilterTest.php @@ -522,7 +522,7 @@ protected static function buildSearchFilter(self $that, ManagerRegistry $manager $relatedDummyProphecy = $that->prophesize(RelatedDummy::class); $iriConverterProphecy = $that->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri(Argument::type('string'), ['fetch_data' => false])->will(function ($args) use ($relatedDummyProphecy) { + $iriConverterProphecy->getResourceFromIri(Argument::type('string'), ['fetch_data' => false])->will(static function ($args) use ($relatedDummyProphecy) { if (str_contains((string) $args[0], '/related_dummies')) { $relatedDummyProphecy->getId()->shouldBeCalled()->willReturn(1); diff --git a/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php b/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php index ac50cb9..d2b48d1 100644 --- a/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php +++ b/Tests/Fixtures/Entity/PropertyCollectionIriOnly.php @@ -25,11 +25,9 @@ /** * Assert that a property being a collection set with ApiProperty::UriTemplate to true returns only the IRI of the collection. */ -#[ - Post, +#[Post, Get(normalizationContext: ['groups' => ['read']]), - GetCollection(normalizationContext: ['groups' => ['read']]), -] + GetCollection(normalizationContext: ['groups' => ['read']]),] #[ORM\Entity] class PropertyCollectionIriOnly { diff --git a/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php b/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php index 0c5f07e..9745984 100644 --- a/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php +++ b/Tests/Fixtures/Entity/PropertyCollectionIriOnlyRelation.php @@ -20,16 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints\NotBlank; -#[ - Post, +#[Post, GetCollection(uriTemplate: '/property-collection-relations'), GetCollection( uriTemplate: '/parent/{parentId}/another-collection-operations', uriVariables: [ 'parentId' => new Link(toProperty: 'propertyCollectionIriOnly', fromClass: PropertyCollectionIriOnly::class), ] - ) -] + )] #[ORM\Entity] class PropertyCollectionIriOnlyRelation { diff --git a/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php b/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php index b825542..0d19781 100644 --- a/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php +++ b/Tests/Fixtures/Entity/PropertyUriTemplateOneToOneRelation.php @@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints\NotBlank; -#[ - Post, +#[Post, GetCollection(uriTemplate: '/property-uri-template/one-to-ones'), Get( uriTemplate: '/parent/{parentId}/property-uri-template/one-to-ones/{id}', @@ -30,8 +29,7 @@ 'parentId' => new Link(toProperty: 'propertyToOneIriOnly', fromClass: PropertyCollectionIriOnly::class), 'id' => new Link(fromClass: PropertyUriTemplateOneToOneRelation::class), ] - ) -] + )] #[ORM\Entity] class PropertyUriTemplateOneToOneRelation { diff --git a/Tests/PaginatorTest.php b/Tests/PaginatorTest.php index 6201003..3c91513 100644 --- a/Tests/PaginatorTest.php +++ b/Tests/PaginatorTest.php @@ -75,7 +75,7 @@ private function getPaginator(int $firstResult = 1, int $maxResults = 15, int $t $doctrinePaginator->getQuery()->willReturn($query->reveal())->shouldBeCalled(); $doctrinePaginator->count()->willReturn($totalItems); - $doctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + $doctrinePaginator->getIterator()->will(static fn (): \ArrayIterator => new \ArrayIterator()); return new Paginator($doctrinePaginator->reveal()); } @@ -107,7 +107,7 @@ public function testHasNextPageShouldNotMakeQueryIfTotalPagesHasBeenCalled(): vo $doctrinePaginator->getQuery()->willReturn($query->reveal())->shouldBeCalled(); $doctrinePaginator->count()->willReturn(42); - $doctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + $doctrinePaginator->getIterator()->will(static fn (): \ArrayIterator => new \ArrayIterator()); $paginator = new Paginator($doctrinePaginator->reveal()); $paginator->getTotalItems(); @@ -129,10 +129,10 @@ public function testHasNextPageShouldMakeQueryIfTotalPagesHasNotBeenCalled(): vo $doctrinePaginator->count()->willReturn(42); $doctrinePaginator->getFetchJoinCollection()->willReturn(false); - $doctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + $doctrinePaginator->getIterator()->will(static fn (): \ArrayIterator => new \ArrayIterator()); $secondDoctrinePaginator = $this->prophesize(DoctrinePaginator::class); - $secondDoctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + $secondDoctrinePaginator->getIterator()->will(static fn (): \ArrayIterator => new \ArrayIterator()); $doctrinePaginatorFactory = $this->prophesize(DoctrinePaginatorFactory::class); $doctrinePaginatorFactory->getPaginator(Argument::any(), Argument::any())->willReturn($secondDoctrinePaginator->reveal()); diff --git a/Tests/State/ItemProviderTest.php b/Tests/State/ItemProviderTest.php index ec73037..168939c 100644 --- a/Tests/State/ItemProviderTest.php +++ b/Tests/State/ItemProviderTest.php @@ -236,7 +236,7 @@ private function getManagerRegistry(string $resourceClass, array $identifierFiel $classMetadataMock->method('getTypeOfField') ->with($this->logicalOr(...array_keys($identifierFields))) - ->willReturnCallback(function ($name) use ($getTypeOfFieldExpectations) { + ->willReturnCallback(static function ($name) use ($getTypeOfFieldExpectations) { return $getTypeOfFieldExpectations[$name]; }); @@ -260,7 +260,7 @@ private function getManagerRegistry(string $resourceClass, array $identifierFiel $managerMock->method('getClassMetadata') ->with($this->logicalOr(...array_keys($classMetadataExpectations))) - ->willReturnCallback(function ($name) use ($classMetadataExpectations) { + ->willReturnCallback(static function ($name) use ($classMetadataExpectations) { return $classMetadataExpectations[$name]; }); From df1c51310c027bf58c13d7839938d2960b88f52c Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 28 Jan 2026 09:38:33 +0100 Subject: [PATCH 111/152] chore: update dependencies --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index fd04dd4..c098e6b 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "api-platform/doctrine-common": "^4.2.9", "api-platform/metadata": "^4.2", "api-platform/state": "^4.2.4", + "composer/semver": "^3.4", "doctrine/orm": "^2.17 || ^3.0.1" }, "require-dev": { From 69fc72d4686a9e5c19dd0063140dea7e7151655b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:25:50 +0100 Subject: [PATCH 112/152] feat: allow both uppercase and lowercase order direction in OrderFilter schema (#7741) feat: allow both uppercase and lowercase order direction in OrderFilter schema Co-authored-by: soyuka <1321971+soyuka@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: soyuka <1321971+soyuka@users.noreply.github.com> --- Filter/OrderFilter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index 586060b..54de602 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -287,6 +287,6 @@ protected function filterProperty(string $property, mixed $value, QueryBuilder $ */ public function getSchema(Parameter $parameter): array { - return ['type' => 'string', 'enum' => ['asc', 'desc']]; + return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']]; } } From 94d83346030ed78ae3d2f860110e27088788dbc8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 8 Feb 2026 18:29:08 +0100 Subject: [PATCH 113/152] feat(doctrine): allow both uppercase and lowercase order direction in OrderFilter schema (#7741) (#7742) feat: allow both uppercase and lowercase order direction in OrderFilter schema Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: soyuka <1321971+soyuka@users.noreply.github.com> --- Filter/OrderFilter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index 4962bf2..f924aab 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -287,6 +287,6 @@ protected function filterProperty(string $property, mixed $value, QueryBuilder $ */ public function getSchema(Parameter $parameter): array { - return ['type' => 'string', 'enum' => ['asc', 'desc']]; + return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']]; } } From 4affd88be778616ed59858cf175bd70a505fe32c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 10 Feb 2026 17:53:33 +0100 Subject: [PATCH 114/152] feat(doctrine): Add caseSensitive option to PartialSearchFilter (#7675) --- Filter/PartialSearchFilter.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index a7df955..9bfa976 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -29,6 +29,10 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilt use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; + public function __construct(private readonly bool $caseSensitive = false) + { + } + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter']; @@ -46,7 +50,9 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $parameterName = $queryNameGenerator->generateParameterName($property); $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); - $likeExpression = 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); return; @@ -55,7 +61,10 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $likeExpressions = []; foreach ($values as $val) { $parameterName = $queryNameGenerator->generateParameterName($property); - $likeExpressions[] = 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); } From 125abfcb01f0d0e71682da0bf9c35df04540bf14 Mon Sep 17 00:00:00 2001 From: Alexis Lefebvre <2071331+alexislefebvre@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:29:19 +0100 Subject: [PATCH 115/152] fix(metadata): use entity class from stateOptions for filter property resolution (#7739) Closes #7610 --- ...neOrmResourceCollectionMetadataFactory.php | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php index 7715572..10c7338 100644 --- a/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php +++ b/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php @@ -51,7 +51,9 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $operations->add($operationName, $this->addDefaults($operation)); + $operation = $this->addDefaults($operation); + $operation = $this->setParametersFilterClass($operation, $entityClass); + $operations->add($operationName, $operation); } $resourceMetadata = $resourceMetadata->withOperations($operations); @@ -67,7 +69,9 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $graphQlOperations[$operationName] = $this->addDefaults($graphQlOperation); + $graphQlOperation = $this->addDefaults($graphQlOperation); + $graphQlOperation = $this->setParametersFilterClass($graphQlOperation, $entityClass); + $graphQlOperations[$operationName] = $graphQlOperation; } $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); @@ -115,4 +119,20 @@ private function getProcessor(Operation $operation): string return 'api_platform.doctrine.orm.state.persist_processor'; } + + private function setParametersFilterClass(Operation $operation, string $entityClass): Operation + { + $parameters = $operation->getParameters(); + if (!$parameters) { + return $operation; + } + + foreach ($parameters as $key => $parameter) { + if (null === $parameter->getFilterClass()) { + $parameters->add($key, $parameter->withFilterClass($entityClass)); + } + } + + return $operation->withParameters($parameters); + } } From 6dbea77a7ceb2f2b09c62f139028a978b260099d Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:07:33 +0100 Subject: [PATCH 116/152] refactor: use imported DataProvider instead of FQCN (#7753) Co-authored-by: Antoine Bluchet --- Tests/DoctrineOrmFilterTestCase.php | 3 ++- Tests/Extension/EagerLoadingExtensionTest.php | 3 ++- Tests/Extension/PaginationExtensionTest.php | 5 +++-- .../DoctrineOrmResourceCollectionMetadataFactoryTest.php | 3 ++- Tests/PaginatorTest.php | 3 ++- Tests/Util/QueryBuilderHelperTest.php | 3 ++- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Tests/DoctrineOrmFilterTestCase.php b/Tests/DoctrineOrmFilterTestCase.php index 1b8b25c..e588c48 100644 --- a/Tests/DoctrineOrmFilterTestCase.php +++ b/Tests/DoctrineOrmFilterTestCase.php @@ -18,6 +18,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** @@ -45,7 +46,7 @@ protected function setUp(): void $this->repository = $this->managerRegistry->getManagerForClass(Dummy::class)->getRepository(Dummy::class); } - #[\PHPUnit\Framework\Attributes\DataProvider('provideApplyTestData')] + #[DataProvider('provideApplyTestData')] public function testApply(?array $properties, array $filterParameters, string $expectedDql, ?array $expectedParameters = null, ?callable $factory = null, ?string $resourceClass = null): void { $this->doTestApply($properties, $filterParameters, $expectedDql, $expectedParameters, $factory, $resourceClass); diff --git a/Tests/Extension/EagerLoadingExtensionTest.php b/Tests/Extension/EagerLoadingExtensionTest.php index ea8fd82..1eb400b 100644 --- a/Tests/Extension/EagerLoadingExtensionTest.php +++ b/Tests/Extension/EagerLoadingExtensionTest.php @@ -38,6 +38,7 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -1000,7 +1001,7 @@ public function testApplyToCollectionWithANonReadableButFetchEagerProperty(): vo $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); } - #[\PHPUnit\Framework\Attributes\DataProvider('provideExistingJoinCases')] + #[DataProvider('provideExistingJoinCases')] public function testApplyToCollectionWithExistingJoin(string $joinType): void { $context = ['groups' => ['foo']]; diff --git a/Tests/Extension/PaginationExtensionTest.php b/Tests/Extension/PaginationExtensionTest.php index 2b8bcc5..f4acdd7 100644 --- a/Tests/Extension/PaginationExtensionTest.php +++ b/Tests/Extension/PaginationExtensionTest.php @@ -30,6 +30,7 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\CountWalker; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -355,7 +356,7 @@ public function testGetResultWithoutDistinct(): void $this->assertFalse($query->getHint(CountWalker::HINT_DISTINCT)); } - #[\PHPUnit\Framework\Attributes\DataProvider('fetchJoinCollectionProvider')] + #[DataProvider('fetchJoinCollectionProvider')] public function testGetResultWithFetchJoinCollection(bool $paginationFetchJoinCollection, array $context, bool $expected): void { $dummyMetadata = new ClassMetadata(Dummy::class); @@ -403,7 +404,7 @@ public static function fetchJoinCollectionProvider(): array ]; } - #[\PHPUnit\Framework\Attributes\DataProvider('fetchUseOutputWalkersProvider')] + #[DataProvider('fetchUseOutputWalkersProvider')] public function testGetResultWithUseOutputWalkers(bool $paginationUseOutputWalkers, array $context, bool $expected): void { $dummyMetadata = new ClassMetadata(Dummy::class); diff --git a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php index 966b898..c25f7cf 100644 --- a/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php +++ b/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php @@ -27,6 +27,7 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -62,7 +63,7 @@ public function testWithoutManager(): void $this->assertNull($resourceMetadataCollection->getOperation('graphql_get')->getProvider()); } - #[\PHPUnit\Framework\Attributes\DataProvider('operationProvider')] + #[DataProvider('operationProvider')] public function testWithProvider(HttpOperation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void { $objectManager = $this->prophesize(EntityManagerInterface::class); diff --git a/Tests/PaginatorTest.php b/Tests/PaginatorTest.php index 3c91513..ce1ebb8 100644 --- a/Tests/PaginatorTest.php +++ b/Tests/PaginatorTest.php @@ -22,6 +22,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -30,7 +31,7 @@ class PaginatorTest extends TestCase { use ProphecyTrait; - #[\PHPUnit\Framework\Attributes\DataProvider('initializeProvider')] + #[DataProvider('initializeProvider')] public function testInitialize(int $firstResult, int $maxResults, int $totalItems, int $currentPage, int $lastPage, bool $hasNextPage): void { $paginator = $this->getPaginator($firstResult, $maxResults, $totalItems); diff --git a/Tests/Util/QueryBuilderHelperTest.php b/Tests/Util/QueryBuilderHelperTest.php index 709e936..1cb2409 100644 --- a/Tests/Util/QueryBuilderHelperTest.php +++ b/Tests/Util/QueryBuilderHelperTest.php @@ -21,6 +21,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -29,7 +30,7 @@ class QueryBuilderHelperTest extends TestCase { use ProphecyTrait; - #[\PHPUnit\Framework\Attributes\DataProvider('provideAddJoinOnce')] + #[DataProvider('provideAddJoinOnce')] public function testAddJoinOnce(?string $originAliasForJoinOnce, string $expectedAlias): void { $queryBuilder = new QueryBuilder($this->prophesize(EntityManagerInterface::class)->reveal()); From 5a8706751299bafb824939e6ed73a8bea57eb95e Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:07:35 +0100 Subject: [PATCH 117/152] feat: support relations on filters (#7711) Co-authored-by: soyuka --- Filter/ExactFilter.php | 4 +++ Filter/FreeTextQueryFilter.php | 34 +++++++++++++++++++- Filter/PartialSearchFilter.php | 3 ++ NestedPropertyHelperTrait.php | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 NestedPropertyHelperTrait.php diff --git a/Filter/ExactFilter.php b/Filter/ExactFilter.php index b075ea2..194c985 100644 --- a/Filter/ExactFilter.php +++ b/Filter/ExactFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -27,6 +28,7 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void @@ -42,6 +44,8 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); + [$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + if (\is_array($value)) { $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); diff --git a/Filter/FreeTextQueryFilter.php b/Filter/FreeTextQueryFilter.php index b773f04..2a17847 100644 --- a/Filter/FreeTextQueryFilter.php +++ b/Filter/FreeTextQueryFilter.php @@ -17,6 +17,7 @@ use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Operation; @@ -51,16 +52,47 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $qb->resetDQLPart('where'); $qb->setParameters(new ArrayCollection()); foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $subParameter = $parameter->withProperty($property); + + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + if (isset($nestedPropertiesInfo[$property])) { + $subParameter = $subParameter->withExtraProperties([ + ...$subParameter->getExtraProperties(), + 'nested_property_info' => $nestedPropertiesInfo[$property], + ]); + } + $this->filter->apply( $qb, $queryNameGenerator, $resourceClass, $operation, - ['parameter' => $parameter->withProperty($property)] + $context + ['parameter' => $subParameter] + $context ); } $queryBuilder->andWhere($qb->getDQLPart('where')); + + foreach ($qb->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + $joinString = $join->getJoin(); + if (str_contains($joinString, '.')) { + [$parentAlias, $association] = explode('.', $joinString, 2); + QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + $parentAlias, + $association, + $join->getJoinType(), + $join->getConditionType(), + $join->getCondition(), + null, + $join->getAlias() + ); + } + } + } + $parameters = $queryBuilder->getParameters(); foreach ($qb->getParameters() as $p) { diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index 9bfa976..30ad838 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -27,6 +28,7 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; public function __construct(private readonly bool $caseSensitive = false) @@ -43,6 +45,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); $field = $alias.'.'.$property; $values = $parameter->getValue(); diff --git a/NestedPropertyHelperTrait.php b/NestedPropertyHelperTrait.php new file mode 100644 index 0000000..c76c6cc --- /dev/null +++ b/NestedPropertyHelperTrait.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\Doctrine\Orm; + +use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Parameter; +use Doctrine\ORM\QueryBuilder; + +/** + * Helper trait for handling nested properties. + * + * @author Kévin Dunglas + */ +trait NestedPropertyHelperTrait +{ + /** + * Adds the necessary join for a nested property. + * + * @return array An array where the first element is the join $alias of the leaf entity, + * the second element is the leaf property + */ + protected function addJoinsForNestedProperty( + string $property, + string $alias, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + Parameter $parameter, + ): array { + $extraProperties = $parameter->getExtraProperties(); + $nestedInfo = $extraProperties['nested_property_info'] ?? null; + + if (!$nestedInfo) { + return [$alias, $property]; + } + + foreach ($nestedInfo['converted_relation_segments'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + $alias, + $association + ); + } + + return [$alias, $nestedInfo['leaf_property']]; + } +} From 99d8b8cdee4ca79fd3abb351991bda6b42696eee Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 13 Feb 2026 18:30:49 +0100 Subject: [PATCH 118/152] fix(serializer): prevent context leakage with service-based entity resolution (#7756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(serializer): prevent context leakage with service-based entity resolution (#7733) | Q | A | ------------- | --- | Branch? | 4.2 | Tickets | Fixes #7733 | License | MIT | Doc PR | ∅ * Create OperationResourceResolverInterface service to validate entity-to-resource mappings * Add framework-specific decorators (Doctrine, Eloquent) to handle stateOptions validation * Remove force_resource_class propagation to nested objects preventing DateTimeImmutable issues --- ...trineOrmOperationResourceClassResolver.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 Serializer/DoctrineOrmOperationResourceClassResolver.php diff --git a/Serializer/DoctrineOrmOperationResourceClassResolver.php b/Serializer/DoctrineOrmOperationResourceClassResolver.php new file mode 100644 index 0000000..b6d2535 --- /dev/null +++ b/Serializer/DoctrineOrmOperationResourceClassResolver.php @@ -0,0 +1,54 @@ + + * + * 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\Orm\Serializer; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Serializer\OperationResourceClassResolver; + +/** + * Doctrine ORM operation resource resolver. + * + * Handles entity-to-resource mappings from Doctrine ORM's stateOptions. + * + * @author Kévin Dunglas + */ +final class DoctrineOrmOperationResourceClassResolver extends OperationResourceClassResolver +{ + use ClassInfoTrait; + + public function resolve(object|string $resource, Operation $operation): string + { + if (\is_string($resource)) { + return $resource; + } + + $objectClass = $this->getObjectClass($resource); + $stateOptions = $operation->getStateOptions(); + + // Doctrine ORM: Check for entity class in stateOptions + if ($stateOptions instanceof Options) { + $entityClass = $stateOptions->getEntityClass(); + + // Validate object matches the backing entity class + if ($entityClass && is_a($objectClass, $entityClass, true)) { + return $operation->getClass(); + } + } + + // Fallback to core behavior + return parent::resolve($resource, $operation); + } +} From 0c2fe0a423b30900535cf2b42d0c196adb191f16 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 18 Feb 2026 09:02:42 +0100 Subject: [PATCH 119/152] feat(doctrine): add nested relation support to IriFilter and UuidFilter (#7759) * Migrate IriFilter to use NestedPropertyHelperTrait for nested properties * Migrate AbstractUuidFilter from OrmPropertyHelperTrait to NestedPropertyHelperTrait * Add comprehensive tests for nested filtering with both filters --- Filter/AbstractUuidFilter.php | 48 +++++++++++++------ Filter/ExactFilter.php | 2 +- Filter/IriFilter.php | 61 ++++++++++++++++++++--- Filter/PartialSearchFilter.php | 2 +- Filter/SortFilter.php | 88 ++++++++++++++++++++++++++++++++++ NestedPropertyHelperTrait.php | 6 ++- 6 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 Filter/SortFilter.php diff --git a/Filter/AbstractUuidFilter.php b/Filter/AbstractUuidFilter.php index 5f221fd..84b73db 100644 --- a/Filter/AbstractUuidFilter.php +++ b/Filter/AbstractUuidFilter.php @@ -18,7 +18,7 @@ use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; -use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; @@ -33,7 +33,7 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\Type; -use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\QueryBuilder; /** @@ -44,7 +44,7 @@ class AbstractUuidFilter implements FilterInterface, ManagerRegistryAwareInterfa use BackwardCompatibleFilterDescriptionTrait; use LoggerAwareTrait; use ManagerRegistryAwareTrait; - use OrmPropertyHelperTrait; + use NestedPropertyHelperTrait; use PropertyHelperTrait; private const UUID_SCHEMA = [ @@ -52,6 +52,22 @@ class AbstractUuidFilter implements FilterInterface, ManagerRegistryAwareInterfa 'format' => 'uuid', ]; + /** + * Gets class metadata for the given resource. + */ + protected function getClassMetadata(string $resourceClass): \Doctrine\Persistence\Mapping\ClassMetadata + { + $manager = $this + ->getManagerRegistry() + ->getManagerForClass($resourceClass); + + if ($manager) { + return $manager->getClassMetadata($resourceClass); + } + + return new ClassMetadata($resourceClass); + } + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter'] ?? null; @@ -60,26 +76,28 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q } if (null === $parameter->getProperty()) { - throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Nested properties are not automatically resolved. Please provide the property explicitly.', $parameter->getKey())); + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); } - $this->filterProperty($parameter->getProperty(), $parameter->getValue(), $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + $this->filterProperty($parameter, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } - private function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + $property = $parameter->getProperty(); + $value = $parameter->getValue(); $alias = $queryBuilder->getRootAliases()[0]; - $field = $property; - $associations = []; - if ($this->isPropertyNested($property, $resourceClass)) { - [$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); - } + [$alias, $field] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + + // Get the target resource class for nested properties + $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + $targetResourceClass = $nestedInfo['leaf_class'] ?? $resourceClass; - $metadata = $this->getNestedMetadata($resourceClass, $associations); + $metadata = $this->getClassMetadata($targetResourceClass); if ($metadata->hasField($field)) { - $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($property, $resourceClass), $value); + $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($field, $targetResourceClass), $value); $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value); return; @@ -89,8 +107,8 @@ private function filterProperty(string $property, mixed $value, QueryBuilder $qu if (!$metadata->hasAssociation($field)) { $this->logger->notice('Tried to filter on a non-existent field or association', [ 'field' => $field, - 'resource_class' => $resourceClass, - 'exception' => new InvalidArgumentException(\sprintf('Property "%s" does not exist in resource "%s".', $field, $resourceClass)), + 'resource_class' => $targetResourceClass, + 'exception' => new InvalidArgumentException(\sprintf('Property "%s" does not exist in resource "%s".', $field, $targetResourceClass)), ]); return; diff --git a/Filter/ExactFilter.php b/Filter/ExactFilter.php index 194c985..1420fcb 100644 --- a/Filter/ExactFilter.php +++ b/Filter/ExactFilter.php @@ -44,7 +44,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); - [$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); if (\is_array($value)) { $queryBuilder diff --git a/Filter/IriFilter.php b/Filter/IriFilter.php index 43ec3ba..7bd0963 100644 --- a/Filter/IriFilter.php +++ b/Filter/IriFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -29,6 +30,7 @@ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; use OpenApiFilterTrait; public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void @@ -42,19 +44,66 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; + + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $parameterName = $queryNameGenerator->generateParameterName($property); - $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); + // Resolve the metadata for the entity that owns the leaf property. + // For nested properties like "department.company", we need to walk the association chain + // to get the metadata of the entity that owns "company" (i.e. FilterDepartment). + $em = $queryBuilder->getEntityManager(); + $metadata = $em->getClassMetadata($resourceClass); + $originalProperty = $parameter->getProperty(); + $segments = explode('.', $originalProperty); + // Walk all segments except the last (which is the leaf property) + for ($i = 0, $count = \count($segments) - 1; $i < $count; ++$i) { + $associationMapping = $metadata->getAssociationMapping($segments[$i]); + $metadata = $em->getClassMetadata($associationMapping['targetEntity']); + } + + // Determine if the association is a collection (OneToMany/ManyToMany) or single-valued (ManyToOne/OneToOne). + // Collection associations require a JOIN to compare individual elements. + // Single-valued associations can be compared directly, which avoids issues with custom ID types (e.g. UUID). + $isCollectionAssociation = $metadata->isCollectionValuedAssociation($property); + + if ($isCollectionAssociation) { + $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); + + if (is_iterable($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + } - if (is_iterable($value)) { - $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); $queryBuilder->setParameter($parameterName, $value); - } else { + + return; + } + + $propertyExpr = \sprintf('%s.%s', $alias, $property); + + if (is_iterable($value)) { $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $propertyExpr, $parameterName)); $queryBuilder->setParameter($parameterName, $value); + + return; } + + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $propertyExpr, $parameterName)); + + // Extract the identifier value and its type from the target entity metadata + // to properly handle custom ID types (e.g. UUID). + $associationMapping = $metadata->getAssociationMapping($property); + $targetMetadata = $em->getClassMetadata($associationMapping['targetEntity']); + $idFieldNames = $targetMetadata->getIdentifierFieldNames(); + $idType = $targetMetadata->getTypeOfField($idFieldNames[0]); + $identifierValues = $targetMetadata->getIdentifierValues($value); + $queryBuilder->setParameter($parameterName, reset($identifierValues), $idType); } public static function getParameterProvider(): string diff --git a/Filter/PartialSearchFilter.php b/Filter/PartialSearchFilter.php index 30ad838..2b78b53 100644 --- a/Filter/PartialSearchFilter.php +++ b/Filter/PartialSearchFilter.php @@ -45,7 +45,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; - [$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); $field = $alias.'.'.$property; $values = $parameter->getValue(); diff --git a/Filter/SortFilter.php b/Filter/SortFilter.php new file mode 100644 index 0000000..9ec3b57 --- /dev/null +++ b/Filter/SortFilter.php @@ -0,0 +1,88 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +/** + * Parameter-based order filter for sorting a collection by a property. + * + * Unlike {@see OrderFilter}, this filter does not extend AbstractFilter and is designed + * exclusively for use with Parameters (QueryParameter). + * + * Usage: `new QueryParameter(filter: new SortFilter(), property: 'department.name')`. + * + * @author Antoine Bluchet + */ +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct( + private readonly ?string $nullsComparison = null, + ) { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter'] ?? null; + if (null === $parameter) { + return; + } + + $value = $context['filters'][$parameter->getProperty() ?? ''] ?? null; + if (null === $value) { + return; + } + + $direction = strtoupper($value); + if (!\in_array($direction, ['ASC', 'DESC'], true)) { + return; + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + + [$alias, $field] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter, Join::LEFT_JOIN); + + if (null !== $nullsComparison = $this->nullsComparison) { + $nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction]; + $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); + $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); + $queryBuilder->addOrderBy($nullRankHiddenField, $nullsDirection); + } + + $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); + } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']]; + } +} diff --git a/NestedPropertyHelperTrait.php b/NestedPropertyHelperTrait.php index c76c6cc..1abb8be 100644 --- a/NestedPropertyHelperTrait.php +++ b/NestedPropertyHelperTrait.php @@ -31,12 +31,13 @@ trait NestedPropertyHelperTrait * @return array An array where the first element is the join $alias of the leaf entity, * the second element is the leaf property */ - protected function addJoinsForNestedProperty( + protected function addNestedParameterJoins( string $property, string $alias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, Parameter $parameter, + ?string $joinType = null, ): array { $extraProperties = $parameter->getExtraProperties(); $nestedInfo = $extraProperties['nested_property_info'] ?? null; @@ -50,7 +51,8 @@ protected function addJoinsForNestedProperty( $queryBuilder, $queryNameGenerator, $alias, - $association + $association, + $joinType ); } From b88899ee92dbc54fd16a935bc3ea282b576d196a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 20 Feb 2026 21:30:08 +0100 Subject: [PATCH 120/152] fix(doctrine): enforce api-platform/serializer dependency (#7781) --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index c098e6b..b8de0c3 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "php": ">=8.2", "api-platform/doctrine-common": "^4.2.9", "api-platform/metadata": "^4.2", + "api-platform/serializer": "^4.2.16", "api-platform/state": "^4.2.4", "composer/semver": "^3.4", "doctrine/orm": "^2.17 || ^3.0.1" From a7d4c255519ac0438f9293b3e97d2b3bd9ca43d7 Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Mon, 15 Dec 2025 14:07:25 +0100 Subject: [PATCH 121/152] ci: php 8.5 (#7585) --- Filter/OrderFilter.php | 2 +- Tests/Extension/PaginationExtensionTest.php | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index f924aab..54de602 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -238,7 +238,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q } $parameter = $context['parameter'] ?? null; - if (null !== ($value = $context['filters'][$parameter?->getProperty()] ?? null)) { + if (null !== ($value = $context['filters'][$parameter?->getProperty() ?? ''] ?? null)) { $this->filterProperty($this->denormalizePropertyName($parameter->getProperty()), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); return; diff --git a/Tests/Extension/PaginationExtensionTest.php b/Tests/Extension/PaginationExtensionTest.php index f4acdd7..005ffae 100644 --- a/Tests/Extension/PaginationExtensionTest.php +++ b/Tests/Extension/PaginationExtensionTest.php @@ -388,7 +388,6 @@ public function testGetResultWithFetchJoinCollection(bool $paginationFetchJoinCo $this->assertInstanceOf(PaginatorInterface::class, $result); $doctrinePaginatorReflectionProperty = new \ReflectionProperty(AbstractPaginator::class, 'paginator'); - $doctrinePaginatorReflectionProperty->setAccessible(true); $doctrinePaginator = $doctrinePaginatorReflectionProperty->getValue($result); $this->assertSame($expected, $doctrinePaginator->getFetchJoinCollection()); @@ -436,7 +435,6 @@ public function testGetResultWithUseOutputWalkers(bool $paginationUseOutputWalke $this->assertInstanceOf(PaginatorInterface::class, $result); $doctrinePaginatorReflectionProperty = new \ReflectionProperty(AbstractPaginator::class, 'paginator'); - $doctrinePaginatorReflectionProperty->setAccessible(true); $doctrinePaginator = $doctrinePaginatorReflectionProperty->getValue($result); $this->assertSame($expected, $doctrinePaginator->getUseOutputWalkers()); From e13a41983ed78d47f9cd2c75bef02ff573ef871b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 2 Mar 2026 12:08:33 +0100 Subject: [PATCH 122/152] feat(doctrine): ComparisonFilter decorator for range filtering (#7760) Co-authored-by: Claude Opus 4.6 --- Filter/AbstractUuidFilter.php | 26 +++++-- Filter/ComparisonFilter.php | 133 ++++++++++++++++++++++++++++++++++ Filter/ExactFilter.php | 6 +- 3 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 Filter/ComparisonFilter.php diff --git a/Filter/AbstractUuidFilter.php b/Filter/AbstractUuidFilter.php index 84b73db..40b2a40 100644 --- a/Filter/AbstractUuidFilter.php +++ b/Filter/AbstractUuidFilter.php @@ -96,9 +96,14 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder $metadata = $this->getClassMetadata($targetResourceClass); + $operator = $context['operator'] ?? '='; + if (!\in_array($operator, ComparisonFilter::ALLOWED_DQL_OPERATORS, true)) { + throw new InvalidArgumentException(\sprintf('Unsupported operator "%s".', $operator)); + } + if ($metadata->hasField($field)) { $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($field, $targetResourceClass), $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value, $operator, $context); return; } @@ -129,7 +134,7 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder } $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value, $operator, $context); } /** @@ -162,21 +167,28 @@ private function convertValuesToTheDatabaseRepresentation(QueryBuilder $queryBui /** * Adds where clause. */ - private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value): void + private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value, string $operator = '=', array $context = []): void { $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); $aliasedField = \sprintf('%s.%s', $alias, $field); + $whereClause = $context['whereClause'] ?? 'andWhere'; if (!\is_array($value)) { - $queryBuilder - ->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter)) - ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + if ('=' === $operator) { + $queryBuilder + ->{$whereClause}($queryBuilder->expr()->eq($aliasedField, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + } else { + $queryBuilder + ->{$whereClause}(\sprintf('%s %s %s', $aliasedField, $operator, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + } return; } $queryBuilder - ->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter)) + ->{$whereClause}($queryBuilder->expr()->in($aliasedField, $valueParameter)) ->setParameter($valueParameter, $value, $this->getDoctrineArrayParameterType()); } diff --git a/Filter/ComparisonFilter.php b/Filter/ComparisonFilter.php new file mode 100644 index 0000000..41a1bce --- /dev/null +++ b/Filter/ComparisonFilter.php @@ -0,0 +1,133 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Doctrine\ORM\QueryBuilder; + +/** + * Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte). + * + * @experimental + */ +final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + private const OPERATORS = [ + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + ]; + + public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>']; + + public function __construct(private readonly FilterInterface $filter) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + $values = $parameter->getValue(); + + if (!\is_array($values)) { + return; + } + + foreach ($values as $operator => $value) { + if ('' === $value || null === $value) { + continue; + } + + if (isset(self::OPERATORS[$operator])) { + $this->applyOperator($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); + } + } + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: "{$key}[gt]", in: $in), + new OpenApiParameter(name: "{$key}[gte]", in: $in), + new OpenApiParameter(name: "{$key}[lt]", in: $in), + new OpenApiParameter(name: "{$key}[lte]", in: $in), + ]; + } + + public function getSchema(Parameter $parameter): array + { + $innerSchema = ['type' => 'string']; + if ($this->filter instanceof JsonSchemaFilterInterface) { + $innerSchema = $this->filter->getSchema($parameter); + } + + return [ + 'type' => 'object', + 'properties' => [ + 'gt' => $innerSchema, + 'gte' => $innerSchema, + 'lt' => $innerSchema, + 'lte' => $innerSchema, + ], + ]; + } + + /** + * @param array $context + */ + private function applyOperator(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation, array $context, Parameter $parameter, string $operator, mixed $value): void + { + if (!\is_string($value) && !is_numeric($value) && !$value instanceof \DateTimeInterface) { + return; + } + + $subParameter = (clone $parameter)->setValue($value); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => $operator, 'parameter' => $subParameter] + $context + ); + } +} diff --git a/Filter/ExactFilter.php b/Filter/ExactFilter.php index 1420fcb..bd45dbf 100644 --- a/Filter/ExactFilter.php +++ b/Filter/ExactFilter.php @@ -50,8 +50,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); } else { + $operator = $context['operator'] ?? '='; + if (!\in_array($operator, ComparisonFilter::ALLOWED_DQL_OPERATORS, true)) { + throw new InvalidArgumentException(\sprintf('Unsupported operator "%s".', $operator)); + } $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)); + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s %s :%s', $alias, $property, $operator, $parameterName)); } $queryBuilder->setParameter($parameterName, $value); From c972dc2c37808304fe126e97a879207d08b53737 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 2 Mar 2026 15:33:48 +0100 Subject: [PATCH 123/152] feat(doctrine): ne (not equal) operator for ComparisonFilter (#7814) Co-authored-by: Claude Opus 4.6 --- Filter/ComparisonFilter.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Filter/ComparisonFilter.php b/Filter/ComparisonFilter.php index 41a1bce..474973d 100644 --- a/Filter/ComparisonFilter.php +++ b/Filter/ComparisonFilter.php @@ -45,6 +45,7 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI 'gte' => '>=', 'lt' => '<', 'lte' => '<=', + 'ne' => '!=', ]; public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>']; @@ -91,6 +92,7 @@ public function getOpenApiParameters(Parameter $parameter): array new OpenApiParameter(name: "{$key}[gte]", in: $in), new OpenApiParameter(name: "{$key}[lt]", in: $in), new OpenApiParameter(name: "{$key}[lte]", in: $in), + new OpenApiParameter(name: "{$key}[ne]", in: $in), ]; } @@ -108,6 +110,7 @@ public function getSchema(Parameter $parameter): array 'gte' => $innerSchema, 'lt' => $innerSchema, 'lte' => $innerSchema, + 'ne' => $innerSchema, ], ]; } From b8cb57e7fb80ff2319f1375a16f49682fecfed1e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 2 Mar 2026 17:06:37 +0100 Subject: [PATCH 124/152] feat(doctrine): add ODM SortFilter and nested property support for parameter-based filters (#7780) Co-authored-by: Claude Opus 4.6 --- Filter/AbstractUuidFilter.php | 3 +- Filter/FreeTextQueryFilter.php | 2 +- Filter/IriFilter.php | 86 ++++-- Filter/SortFilter.php | 14 +- ...meterResourceMetadataCollectionFactory.php | 172 +++++++++++ NestedPropertyHelperTrait.php | 2 +- ...rResourceMetadataCollectionFactoryTest.php | 285 ++++++++++++++++++ 7 files changed, 533 insertions(+), 31 deletions(-) create mode 100644 Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php create mode 100644 Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php diff --git a/Filter/AbstractUuidFilter.php b/Filter/AbstractUuidFilter.php index 40b2a40..043d7c1 100644 --- a/Filter/AbstractUuidFilter.php +++ b/Filter/AbstractUuidFilter.php @@ -91,7 +91,8 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder [$alias, $field] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); // Get the target resource class for nested properties - $nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null; + $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; + $nestedInfo = $nestedPropertiesInfo ? reset($nestedPropertiesInfo) : null; $targetResourceClass = $nestedInfo['leaf_class'] ?? $resourceClass; $metadata = $this->getClassMetadata($targetResourceClass); diff --git a/Filter/FreeTextQueryFilter.php b/Filter/FreeTextQueryFilter.php index 2a17847..b2f1f06 100644 --- a/Filter/FreeTextQueryFilter.php +++ b/Filter/FreeTextQueryFilter.php @@ -58,7 +58,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q if (isset($nestedPropertiesInfo[$property])) { $subParameter = $subParameter->withExtraProperties([ ...$subParameter->getExtraProperties(), - 'nested_property_info' => $nestedPropertiesInfo[$property], + 'nested_properties_info' => [$property => $nestedPropertiesInfo[$property]], ]); } diff --git a/Filter/IriFilter.php b/Filter/IriFilter.php index 7bd0963..4cac01d 100644 --- a/Filter/IriFilter.php +++ b/Filter/IriFilter.php @@ -49,25 +49,16 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $parameterName = $queryNameGenerator->generateParameterName($property); - // Resolve the metadata for the entity that owns the leaf property. - // For nested properties like "department.company", we need to walk the association chain - // to get the metadata of the entity that owns "company" (i.e. FilterDepartment). - $em = $queryBuilder->getEntityManager(); - $metadata = $em->getClassMetadata($resourceClass); - $originalProperty = $parameter->getProperty(); - $segments = explode('.', $originalProperty); - // Walk all segments except the last (which is the leaf property) - for ($i = 0, $count = \count($segments) - 1; $i < $count; ++$i) { - $associationMapping = $metadata->getAssociationMapping($segments[$i]); - $metadata = $em->getClassMetadata($associationMapping['targetEntity']); - } + // Use precomputed ORM leaf metadata when available (nested properties), + // otherwise resolve at runtime by walking the association chain. + $ormLeafMetadata = $this->getOrmLeafMetadata($parameter); - // Determine if the association is a collection (OneToMany/ManyToMany) or single-valued (ManyToOne/OneToOne). - // Collection associations require a JOIN to compare individual elements. - // Single-valued associations can be compared directly, which avoids issues with custom ID types (e.g. UUID). - $isCollectionAssociation = $metadata->isCollectionValuedAssociation($property); + if (null === $ormLeafMetadata) { + $ormLeafMetadata = $this->resolveLeafMetadataAtRuntime($queryBuilder, $resourceClass, $parameter->getProperty(), $property); + } - if ($isCollectionAssociation) { + // Collection associations (OneToMany/ManyToMany) require a JOIN to compare individual elements. + if ($ormLeafMetadata['is_collection_valued']) { $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); if (is_iterable($value)) { @@ -83,6 +74,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } + // Single-valued associations can be compared directly. $propertyExpr = \sprintf('%s.%s', $alias, $property); if (is_iterable($value)) { @@ -98,16 +90,66 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q // Extract the identifier value and its type from the target entity metadata // to properly handle custom ID types (e.g. UUID). - $associationMapping = $metadata->getAssociationMapping($property); - $targetMetadata = $em->getClassMetadata($associationMapping['targetEntity']); - $idFieldNames = $targetMetadata->getIdentifierFieldNames(); - $idType = $targetMetadata->getTypeOfField($idFieldNames[0]); + $targetClass = $ormLeafMetadata['association_target_class']; + $em = $queryBuilder->getEntityManager(); + $targetMetadata = $em->getClassMetadata($targetClass); $identifierValues = $targetMetadata->getIdentifierValues($value); - $queryBuilder->setParameter($parameterName, reset($identifierValues), $idType); + $queryBuilder->setParameter($parameterName, reset($identifierValues), $ormLeafMetadata['identifier_type']); } public static function getParameterProvider(): string { return IriConverterParameterProvider::class; } + + /** + * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null + */ + private function getOrmLeafMetadata(mixed $parameter): ?array + { + $extraProperties = $parameter->getExtraProperties(); + $nestedPropertiesInfo = $extraProperties['nested_properties_info'] ?? null; + if (!$nestedPropertiesInfo) { + return null; + } + + $info = $nestedPropertiesInfo[$parameter->getProperty()] ?? null; + + return $info['orm_leaf_metadata'] ?? null; + } + + /** + * Resolves leaf metadata at runtime by walking the association chain. + * Used as fallback when precomputed orm_leaf_metadata is not available. + * + * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string} + */ + private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): array + { + $em = $queryBuilder->getEntityManager(); + $metadata = $em->getClassMetadata($resourceClass); + $segments = explode('.', $originalProperty); + + for ($i = 0, $count = \count($segments) - 1; $i < $count; ++$i) { + $associationMapping = $metadata->getAssociationMapping($segments[$i]); + $metadata = $em->getClassMetadata($associationMapping['targetEntity']); + } + + $isCollectionValued = $metadata->isCollectionValuedAssociation($leafProperty); + $associationMapping = $metadata->getAssociationMapping($leafProperty); + $targetClass = $associationMapping['targetEntity']; + + $identifierType = null; + if (!$isCollectionValued) { + $targetMetadata = $em->getClassMetadata($targetClass); + $idFieldNames = $targetMetadata->getIdentifierFieldNames(); + $identifierType = $targetMetadata->getTypeOfField($idFieldNames[0]); + } + + return [ + 'is_collection_valued' => $isCollectionValued, + 'association_target_class' => $targetClass, + 'identifier_type' => $identifierType, + ]; + } } diff --git a/Filter/SortFilter.php b/Filter/SortFilter.php index 9ec3b57..c1bf315 100644 --- a/Filter/SortFilter.php +++ b/Filter/SortFilter.php @@ -53,8 +53,8 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } - $value = $context['filters'][$parameter->getProperty() ?? ''] ?? null; - if (null === $value) { + $value = $parameter->getValue(null); + if (!\is_string($value)) { return; } @@ -69,10 +69,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q [$alias, $field] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter, Join::LEFT_JOIN); if (null !== $nullsComparison = $this->nullsComparison) { - $nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction]; - $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); - $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); - $queryBuilder->addOrderBy($nullRankHiddenField, $nullsDirection); + $nullsDirection = OrderFilterInterface::NULLS_DIRECTION_MAP[$nullsComparison][$direction] ?? null; + if (null !== $nullsDirection) { + $nullRankHiddenField = \sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); + $queryBuilder->addSelect(\sprintf('CASE WHEN %s.%s IS NULL THEN 0 ELSE 1 END AS HIDDEN %s', $alias, $field, $nullRankHiddenField)); + $queryBuilder->addOrderBy($nullRankHiddenField, $nullsDirection); + } } $queryBuilder->addOrderBy(\sprintf('%s.%s', $alias, $field), $direction); diff --git a/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php b/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php new file mode 100644 index 0000000..be342c7 --- /dev/null +++ b/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactory.php @@ -0,0 +1,172 @@ + + * + * 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\Orm\Metadata\Resource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; + +/** + * Enriches nested_properties_info with ORM-specific leaf metadata (orm_leaf_metadata) + * so that filters don't need to resolve association chains at runtime. + * + * @author Antoine Bluchet + */ +final class DoctrineOrmParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + use StateOptionsTrait; + + public function __construct( + private readonly ManagerRegistry $managerRegistry, + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + + foreach ($resourceMetadataCollection as $i => $resourceMetadata) { + $operations = $resourceMetadata->getOperations(); + + if ($operations) { + foreach ($operations as $operationName => $operation) { + $operation = $this->enrichOperation($operation, $resourceClass); + $operations->add($operationName, $operation); + } + + $resourceMetadata = $resourceMetadata->withOperations($operations); + } + + $graphQlOperations = $resourceMetadata->getGraphQlOperations(); + + if ($graphQlOperations) { + foreach ($graphQlOperations as $operationName => $graphQlOperation) { + $graphQlOperation = $this->enrichOperation($graphQlOperation, $resourceClass); + $graphQlOperations[$operationName] = $graphQlOperation; + } + + $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); + } + + $resourceMetadataCollection[$i] = $resourceMetadata; + } + + return $resourceMetadataCollection; + } + + private function enrichOperation(Operation $operation, string $resourceClass): Operation + { + $parameters = $operation->getParameters(); + if (!$parameters) { + return $operation; + } + + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); + if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface) { + return $operation; + } + + $operationChanged = false; + + foreach ($parameters as $key => $parameter) { + $extraProperties = $parameter->getExtraProperties(); + $parameterChanged = false; + + $nestedPropertiesInfo = $extraProperties['nested_properties_info'] ?? null; + if ($nestedPropertiesInfo) { + foreach ($nestedPropertiesInfo as $propPath => $propNestedInfo) { + if (!isset($propNestedInfo['orm_leaf_metadata'])) { + $ormLeafMetadata = $this->buildOrmLeafMetadata($propNestedInfo); + if (null !== $ormLeafMetadata) { + $nestedPropertiesInfo[$propPath]['orm_leaf_metadata'] = $ormLeafMetadata; + $parameterChanged = true; + } + } + } + + if ($parameterChanged) { + $extraProperties['nested_properties_info'] = $nestedPropertiesInfo; + } + } + + if ($parameterChanged) { + $parameters->add($key, $parameter->withExtraProperties($extraProperties)); + $operationChanged = true; + } + } + + if ($operationChanged) { + $operation = $operation->withParameters($parameters); + } + + return $operation; + } + + /** + * @param array{leaf_property?: string, leaf_class?: class-string} $nestedInfo + * + * @return array{has_field: bool, has_association: bool, is_collection_valued: bool, is_inverse_side: bool, association_target_class: ?string, identifier_field: ?string, identifier_type: ?string}|null + */ + private function buildOrmLeafMetadata(array $nestedInfo): ?array + { + $leafClass = $nestedInfo['leaf_class'] ?? null; + $leafProperty = $nestedInfo['leaf_property'] ?? null; + + if (!$leafClass || !$leafProperty) { + return null; + } + + $manager = $this->managerRegistry->getManagerForClass($leafClass); + if (!$manager instanceof EntityManagerInterface) { + return null; + } + + $classMetadata = $manager->getClassMetadata($leafClass); + + $result = [ + 'has_field' => $classMetadata->hasField($leafProperty), + 'has_association' => $classMetadata->hasAssociation($leafProperty), + 'is_collection_valued' => false, + 'is_inverse_side' => false, + 'association_target_class' => null, + 'identifier_field' => null, + 'identifier_type' => null, + ]; + + if (!$result['has_association']) { + return $result; + } + + $result['is_collection_valued'] = $classMetadata->isCollectionValuedAssociation($leafProperty); + + $associationMapping = $classMetadata->getAssociationMapping($leafProperty); + $targetClass = $associationMapping['targetEntity']; + $result['association_target_class'] = $targetClass; + $result['is_inverse_side'] = !($associationMapping['isOwningSide'] ?? true); + + $targetMetadata = $manager->getClassMetadata($targetClass); + $idFieldNames = $targetMetadata->getIdentifierFieldNames(); + if ($idFieldNames) { + $result['identifier_field'] = $idFieldNames[0]; + $result['identifier_type'] = $targetMetadata->getTypeOfField($idFieldNames[0]); + } + + return $result; + } +} diff --git a/NestedPropertyHelperTrait.php b/NestedPropertyHelperTrait.php index 1abb8be..da2d45b 100644 --- a/NestedPropertyHelperTrait.php +++ b/NestedPropertyHelperTrait.php @@ -40,7 +40,7 @@ protected function addNestedParameterJoins( ?string $joinType = null, ): array { $extraProperties = $parameter->getExtraProperties(); - $nestedInfo = $extraProperties['nested_property_info'] ?? null; + $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { return [$alias, $property]; diff --git a/Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php b/Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php new file mode 100644 index 0000000..b10ada2 --- /dev/null +++ b/Tests/Metadata/Resource/DoctrineOrmParameterResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,285 @@ + + * + * 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\Orm\Tests\Metadata\Resource; + +use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmParameterResourceMetadataCollectionFactory; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\RelatedDummy; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\ThirdLevel; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\MockObject\Stub; +use PHPUnit\Framework\TestCase; + +class DoctrineOrmParameterResourceMetadataCollectionFactoryTest extends TestCase +{ + private ManagerRegistry $managerRegistry; + private EntityManagerInterface&Stub $entityManager; + + protected function setUp(): void + { + $this->entityManager = $this->createStub(EntityManagerInterface::class); + + $this->managerRegistry = $this->createStub(ManagerRegistry::class); + $this->managerRegistry->method('getManagerForClass')->willReturn($this->entityManager); + } + + public function testParameterWithoutNestedInfoPassedThrough(): void + { + $parameter = new QueryParameter(property: 'name', key: 'name'); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $this->assertArrayNotHasKey('nested_properties_info', $resultParameter->getExtraProperties()); + } + + public function testParameterWithNestedInfoGetsOrmLeafMetadata(): void + { + $leafMetadata = new ClassMetadata(RelatedDummy::class); + $leafMetadata->mapField(['fieldName' => 'name', 'type' => 'string', 'columnName' => 'name']); + $leafMetadata->mapManyToOne(['fieldName' => 'thirdLevel', 'targetEntity' => ThirdLevel::class, 'joinColumns' => [['name' => 'third_level_id', 'referencedColumnName' => 'id']]]); + + $targetMetadata = new ClassMetadata(ThirdLevel::class); + $targetMetadata->mapField(['fieldName' => 'id', 'type' => 'integer', 'columnName' => 'id', 'id' => true]); + $targetMetadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + + $this->entityManager->method('getClassMetadata')->willReturnMap([ + [RelatedDummy::class, $leafMetadata], + [ThirdLevel::class, $targetMetadata], + ]); + + $parameter = new QueryParameter( + property: 'relatedDummy.thirdLevel', + key: 'relatedDummy.thirdLevel', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.thirdLevel' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'thirdLevel', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.thirdLevel']; + $this->assertArrayHasKey('orm_leaf_metadata', $nestedInfo); + + $ormLeaf = $nestedInfo['orm_leaf_metadata']; + $this->assertFalse($ormLeaf['has_field']); + $this->assertTrue($ormLeaf['has_association']); + $this->assertFalse($ormLeaf['is_collection_valued']); + $this->assertFalse($ormLeaf['is_inverse_side']); + $this->assertSame(ThirdLevel::class, $ormLeaf['association_target_class']); + $this->assertSame('id', $ormLeaf['identifier_field']); + $this->assertSame('integer', $ormLeaf['identifier_type']); + } + + public function testCollectionValuedAssociationDetected(): void + { + $leafMetadata = new ClassMetadata(Dummy::class); + $leafMetadata->mapField(['fieldName' => 'id', 'type' => 'integer', 'columnName' => 'id', 'id' => true]); + $leafMetadata->mapManyToMany(['fieldName' => 'relatedDummies', 'targetEntity' => RelatedDummy::class, 'joinTable' => ['name' => 'dummy_related', 'joinColumns' => [['name' => 'dummy_id', 'referencedColumnName' => 'id']], 'inverseJoinColumns' => [['name' => 'related_id', 'referencedColumnName' => 'id']]]]); + + $targetMetadata = new ClassMetadata(RelatedDummy::class); + $targetMetadata->mapField(['fieldName' => 'id', 'type' => 'integer', 'columnName' => 'id', 'id' => true]); + $targetMetadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + + $this->entityManager->method('getClassMetadata')->willReturnMap([ + [Dummy::class, $leafMetadata], + [RelatedDummy::class, $targetMetadata], + ]); + + $parameter = new QueryParameter( + property: 'parent.relatedDummies', + key: 'parent.relatedDummies', + extraProperties: [ + 'nested_properties_info' => ['parent.relatedDummies' => [ + 'relation_segments' => ['parent'], + 'relation_classes' => [RelatedDummy::class], + 'leaf_property' => 'relatedDummies', + 'leaf_class' => Dummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, RelatedDummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(RelatedDummy::class); + $resultParameter = $this->getFirstParameter($result); + + $ormLeaf = $resultParameter->getExtraProperties()['nested_properties_info']['parent.relatedDummies']['orm_leaf_metadata']; + $this->assertTrue($ormLeaf['has_association']); + $this->assertTrue($ormLeaf['is_collection_valued']); + $this->assertSame(RelatedDummy::class, $ormLeaf['association_target_class']); + } + + public function testFieldPropertyProducesFieldMetadata(): void + { + $leafMetadata = new ClassMetadata(RelatedDummy::class); + $leafMetadata->mapField(['fieldName' => 'name', 'type' => 'string', 'columnName' => 'name']); + + $this->entityManager->method('getClassMetadata')->willReturnMap([ + [RelatedDummy::class, $leafMetadata], + ]); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $ormLeaf = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.name']['orm_leaf_metadata']; + $this->assertTrue($ormLeaf['has_field']); + $this->assertFalse($ormLeaf['has_association']); + $this->assertFalse($ormLeaf['is_collection_valued']); + $this->assertNull($ormLeaf['association_target_class']); + } + + public function testAlreadyEnrichedParameterNotProcessedAgain(): void + { + $parameter = new QueryParameter( + property: 'relatedDummy.thirdLevel', + key: 'relatedDummy.thirdLevel', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.thirdLevel' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'thirdLevel', + 'leaf_class' => RelatedDummy::class, + 'orm_leaf_metadata' => [ + 'has_field' => false, + 'has_association' => true, + 'is_collection_valued' => false, + 'is_inverse_side' => false, + 'association_target_class' => ThirdLevel::class, + 'identifier_field' => 'id', + 'identifier_type' => 'integer', + ], + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + $factory = $this->createFactory($collection); + + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.thirdLevel']; + $this->assertSame('integer', $nestedInfo['orm_leaf_metadata']['identifier_type']); + } + + public function testNonOrmManagedClassSkippedGracefully(): void + { + $managerRegistry = $this->createStub(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->willReturn(null); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + ]], + ], + ); + + $collection = $this->createCollectionWithParameter($parameter, Dummy::class); + + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn($collection); + + $factory = new DoctrineOrmParameterResourceMetadataCollectionFactory($managerRegistry, $decorated); + $result = $factory->create(Dummy::class); + $resultParameter = $this->getFirstParameter($result); + + $nestedInfo = $resultParameter->getExtraProperties()['nested_properties_info']['relatedDummy.name']; + $this->assertArrayNotHasKey('orm_leaf_metadata', $nestedInfo); + } + + private function createFactory(ResourceMetadataCollection $collection): DoctrineOrmParameterResourceMetadataCollectionFactory + { + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn($collection); + + return new DoctrineOrmParameterResourceMetadataCollectionFactory($this->managerRegistry, $decorated); + } + + private function createCollectionWithParameter(QueryParameter $parameter, string $resourceClass): ResourceMetadataCollection + { + $parameters = new Parameters(); + $parameters->add($parameter->getKey(), $parameter); + + $operation = (new GetCollection())->withClass($resourceClass)->withStateOptions(new Options(entityClass: $resourceClass))->withParameters($parameters); + $operations = new Operations(); + $operations->add('_api_'.$resourceClass.'_GetCollection', $operation); + + $resource = (new ApiResource())->withOperations($operations)->withClass($resourceClass); + + return new ResourceMetadataCollection($resourceClass, [$resource]); + } + + private function getFirstParameter(ResourceMetadataCollection $collection): QueryParameter + { + foreach ($collection as $resource) { + foreach ($resource->getOperations() as $operation) { + foreach ($operation->getParameters() as $parameter) { + if (!$parameter instanceof QueryParameter) { + continue; + } + + return $parameter; + } + } + } + + $this->fail('No parameter found in collection'); + } +} From 4771d6100d3c1ae3776167d1d3d21b60e65ed17b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Mar 2026 10:30:10 +0100 Subject: [PATCH 125/152] chore: bump sub-components dependencies to ^4.3 (#7820) --- composer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b8de0c3..4418cbf 100644 --- a/composer.json +++ b/composer.json @@ -78,5 +78,7 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "minimum-stability": "beta", + "prefer-stable": true } From 1f120e701de7514b558f4987085ebb1b096abf99 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Mar 2026 16:07:49 +0100 Subject: [PATCH 126/152] chore: switch dev-main branch alias to 4.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4418cbf..eb1a60c 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.3.x-dev", + "dev-main": "4.4.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" From b947b0e29d5f345430bc29958db8efce4e7c2450 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 20 Mar 2026 10:11:10 +0100 Subject: [PATCH 127/152] fix(doctrine): reset nested_properties_info for non-nested properties in FreeTextQueryFilter (#7850) Fixes #7845 --- Filter/FreeTextQueryFilter.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Filter/FreeTextQueryFilter.php b/Filter/FreeTextQueryFilter.php index b2f1f06..a269ac4 100644 --- a/Filter/FreeTextQueryFilter.php +++ b/Filter/FreeTextQueryFilter.php @@ -55,12 +55,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; - if (isset($nestedPropertiesInfo[$property])) { - $subParameter = $subParameter->withExtraProperties([ - ...$subParameter->getExtraProperties(), - 'nested_properties_info' => [$property => $nestedPropertiesInfo[$property]], - ]); - } + $subParameter = $subParameter->withExtraProperties([ + ...$subParameter->getExtraProperties(), + 'nested_properties_info' => isset($nestedPropertiesInfo[$property]) + ? [$property => $nestedPropertiesInfo[$property]] + : [], + ]); $this->filter->apply( $qb, From 9a8e3a9b8f023659bb8eff0b6ab0f5b97de9699f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 25 Mar 2026 14:06:10 +0100 Subject: [PATCH 128/152] fix(doctrine): skip links with no join info when fromClass differs from entityClass (#7878) Closes #7797 --- State/LinksHandlerTrait.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/State/LinksHandlerTrait.php b/State/LinksHandlerTrait.php index d44805a..65f062b 100644 --- a/State/LinksHandlerTrait.php +++ b/State/LinksHandlerTrait.php @@ -68,7 +68,11 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que $hasCompositeIdentifiers = 1 < \count($identifierProperties); if (!$link->getFromProperty() && !$link->getToProperty()) { - $currentAlias = $fromClass === $entityClass ? $alias : $queryNameGenerator->generateJoinAlias($alias); + if ($fromClass !== $entityClass) { + continue; + } + + $currentAlias = $alias; foreach ($identifierProperties as $identifierProperty) { $placeholder = $queryNameGenerator->generateParameterName($identifierProperty); From 7ef64fe109df9c7c5903b2f05ced9a2c02d0f0ee Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 27 Mar 2026 07:39:28 +0100 Subject: [PATCH 129/152] fix(doctrine): inject nameConverter into AbstractFilter via QueryParameter (#7877) Fixes #7866 --- Extension/ParameterExtension.php | 3 +++ Filter/AbstractFilter.php | 18 +++++++++++++++++- composer.json | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Extension/ParameterExtension.php b/Extension/ParameterExtension.php index c21c101..f9a4f95 100644 --- a/Extension/ParameterExtension.php +++ b/Extension/ParameterExtension.php @@ -22,6 +22,7 @@ use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Reads operation parameters and execute its filter. @@ -36,10 +37,12 @@ public function __construct( ContainerInterface $filterLocator, ?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, + ?NameConverterInterface $nameConverter = null, ) { $this->filterLocator = $filterLocator; $this->managerRegistry = $managerRegistry; $this->logger = $logger; + $this->nameConverter = $nameConverter; } /** diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index 0f9e741..e07597b 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; @@ -26,7 +27,7 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; @@ -78,6 +79,21 @@ public function setManagerRegistry(ManagerRegistry $managerRegistry): void $this->managerRegistry = $managerRegistry; } + public function hasNameConverter(): bool + { + return $this->nameConverter instanceof NameConverterInterface; + } + + public function getNameConverter(): ?NameConverterInterface + { + return $this->nameConverter; + } + + public function setNameConverter(NameConverterInterface $nameConverter): void + { + $this->nameConverter = $nameConverter; + } + public function getProperties(): ?array { return $this->properties; diff --git a/composer.json b/composer.json index 0fb956e..01bfac2 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2.9", + "api-platform/doctrine-common": "^4.2.23", "api-platform/metadata": "^4.2", "api-platform/state": "^4.2.4", "doctrine/orm": "^2.17 || ^3.0" From b83957f4aea060ed3cc005168e661b81bae04d1e Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:14:58 +0200 Subject: [PATCH 130/152] fix(metadata): nested property filters fail to generate JOINs when relation target entity is not directly declared as ApiResource (#7926) Co-authored-by: soyuka --- NestedPropertyHelperTrait.php | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/NestedPropertyHelperTrait.php b/NestedPropertyHelperTrait.php index da2d45b..e93f17d 100644 --- a/NestedPropertyHelperTrait.php +++ b/NestedPropertyHelperTrait.php @@ -46,16 +46,31 @@ protected function addNestedParameterJoins( return [$alias, $property]; } - foreach ($nestedInfo['converted_relation_segments'] as $association) { - $alias = QueryBuilderHelper::addJoinOnce( - $queryBuilder, - $queryNameGenerator, - $alias, - $association, - $joinType - ); + $relationClasses = $nestedInfo['relation_classes'] ?? []; + $association = null; + $embedded = false; + + foreach ($nestedInfo['converted_relation_segments'] as $id => $association) { + $entityClass = $relationClasses[$id] ?? null; + if (!$entityClass) { + continue; + } + + $embedded = !$queryBuilder->getEntityManager()->getClassMetadata($entityClass)->hasAssociation($association); + + if (!$embedded) { + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + $alias, + $association, + $joinType + ); + } } - return [$alias, $nestedInfo['leaf_property']]; + $leafProperty = $nestedInfo['leaf_property']; + + return [$alias, $embedded && $association ? $association.'.'.$leafProperty : $leafProperty]; } } From 3dc88ee48ffcdb6eee45ec1d3e9f25ea2aad4eaa Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 30 Apr 2026 14:21:24 +0200 Subject: [PATCH 131/152] chore: allow phpunit ^11.5 for PHP 8.2 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHPUnit 12 requires PHP 8.3+. Widen the constraint in the root and each component composer.json so PHP 8.2 resolves to 11.5 (LTS) and 8.3+ to 12.x. Removes the per-job "Force PHPUnit 11.5" workaround steps from CI. | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | ∅ | License | MIT | Doc PR | ∅ --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e669cd2..3f78c59 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", From 095a4c56cdd9986208100dedd5d28be50a4830ba Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 7 May 2026 13:45:31 +0200 Subject: [PATCH 132/152] test: phpunit exception not restored (#7959) --- Tests/AppKernel.php | 7 ------ Tests/DoctrineOrmFilterTestCase.php | 33 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index 2037665..66c5948 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -18,7 +18,6 @@ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; /** @@ -44,12 +43,6 @@ public function registerBundles(): array new FrameworkBundle(), new DoctrineBundle(), new TestBundle(), - new class extends Bundle { - public function shutdown(): void - { - restore_exception_handler(); - } - }, ]; } diff --git a/Tests/DoctrineOrmFilterTestCase.php b/Tests/DoctrineOrmFilterTestCase.php index e588c48..d43e67b 100644 --- a/Tests/DoctrineOrmFilterTestCase.php +++ b/Tests/DoctrineOrmFilterTestCase.php @@ -18,8 +18,11 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\ErrorHandler\ErrorHandler; /** * @internal @@ -38,6 +41,8 @@ abstract class DoctrineOrmFilterTestCase extends KernelTestCase protected string $filterClass; + private bool $symfonyErrorHandlerWasRegistered = false; + protected function setUp(): void { self::bootKernel(); @@ -46,6 +51,34 @@ protected function setUp(): void $this->repository = $this->managerRegistry->getManagerForClass(Dummy::class)->getRepository(Dummy::class); } + /** + * Symfony\Bundle\FrameworkBundle\FrameworkBundle::boot() registers Symfony's ErrorHandler via + * set_exception_handler() but never unregisters it: each kernel boot leaks one entry on the + * exception handler stack, which PHPUnit flags as Risky. Track whether the handler was already + * present before the test so we only pop the entry our own test introduced. + */ + #[Before] + protected function captureExceptionHandlerStack(): void + { + $this->symfonyErrorHandlerWasRegistered = self::isSymfonyErrorHandlerRegistered(); + } + + #[After] + protected function restoreExceptionHandlerStack(): void + { + if (!$this->symfonyErrorHandlerWasRegistered && self::isSymfonyErrorHandlerRegistered()) { + restore_exception_handler(); + } + } + + private static function isSymfonyErrorHandlerRegistered(): bool + { + $current = set_exception_handler(static fn () => null); + restore_exception_handler(); + + return \is_array($current) && $current[0] instanceof ErrorHandler; + } + #[DataProvider('provideApplyTestData')] public function testApply(?array $properties, array $filterParameters, string $expectedDql, ?array $expectedParameters = null, ?callable $factory = null, ?string $resourceClass = null): void { From f3068210e7c01875d8e45ab9ced41762330aabde Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 21 May 2026 11:55:10 +0200 Subject: [PATCH 133/152] feat(doctrine): state options repositoryMethod for query builder (#7115) --- State/CollectionProvider.php | 21 ++++++++++++++++++--- State/ItemProvider.php | 21 ++++++++++++++++++--- State/Options.php | 3 ++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/State/CollectionProvider.php b/State/CollectionProvider.php index 3815447..5bc181a 100644 --- a/State/CollectionProvider.php +++ b/State/CollectionProvider.php @@ -23,6 +23,7 @@ use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -56,11 +57,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $manager = $this->managerRegistry->getManagerForClass($entityClass); $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { - throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $queryBuilder = $repository->{$method}(); + + if (!$queryBuilder instanceof QueryBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $queryBuilder = $repository->createQueryBuilder('o'); } - $queryBuilder = $repository->createQueryBuilder('o'); $queryNameGenerator = new QueryNameGenerator(); if ($handleLinks = $this->getLinksHandler($operation)) { diff --git a/State/ItemProvider.php b/State/ItemProvider.php index b201d03..ba3f06b 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -23,6 +23,7 @@ use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -65,11 +66,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { - throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $queryBuilder = $repository->{$method}(); + + if (!$queryBuilder instanceof QueryBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $queryBuilder = $repository->createQueryBuilder('o'); } - $queryBuilder = $repository->createQueryBuilder('o'); $queryNameGenerator = new QueryNameGenerator(); if ($handleLinks = $this->getLinksHandler($operation)) { diff --git a/State/Options.php b/State/Options.php index 3a9a46c..00f791d 100644 --- a/State/Options.php +++ b/State/Options.php @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface public function __construct( protected ?string $entityClass = null, mixed $handleLinks = null, + ?string $repositoryMethod = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod); } public function getEntityClass(): ?string From 0c22cf1758e64d2d41b3a45708337e8572bb91f0 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 17:50:53 +0200 Subject: [PATCH 134/152] test: migrate doctrine/graphql behat features to ApiTestCase (#8205) --- Tests/AppKernel.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index 66c5948..3abb43d 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } From f5c186d5a3101ccc4e5a72e123f5f05edce000e7 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 17:50:53 +0200 Subject: [PATCH 135/152] test: migrate doctrine/graphql behat features to ApiTestCase (#8205) (cherry picked from commit 4911cfd1402b7fecb2389b3392361b0afe496f63) --- Tests/AppKernel.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/AppKernel.php b/Tests/AppKernel.php index 66c5948..3abb43d 100644 --- a/Tests/AppKernel.php +++ b/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } From db75bac977cc9fe76cee90c1e5361c744cfa2144 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 4 Jun 2026 15:58:17 +0200 Subject: [PATCH 136/152] fix(doctrine): extract alias from sql function in orderby parts (#8240) Fixes #8083 --- Tests/Util/QueryCheckerTest.php | 27 +++++++++++++++++++++++++++ Util/QueryChecker.php | 3 +++ 2 files changed, 30 insertions(+) diff --git a/Tests/Util/QueryCheckerTest.php b/Tests/Util/QueryCheckerTest.php index 3796916..0d1a09b 100644 --- a/Tests/Util/QueryCheckerTest.php +++ b/Tests/Util/QueryCheckerTest.php @@ -204,6 +204,33 @@ public function testHasOrderByOnFetchJoinedToManyAssociationWithJoinByAssociatio $this->assertTrue(QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $managerRegistryProphecy->reveal())); } + public function testHasOrderByOnFetchJoinedToManyAssociationWithSqlFunctionInOrderBy(): void + { + $dummyMetadata = new ClassMetadata(Dummy::class); + $dummyMetadata->mapManyToMany([ + 'fieldName' => 'relatedDummies', + 'targetEntity' => RelatedDummy::class, + ]); + + $relatedDummyMetadata = new ClassMetadata(RelatedDummy::class); + + $entityManagerProphecy = $this->prophesize(EntityManagerInterface::class); + $entityManagerProphecy->getClassMetadata(Dummy::class)->willReturn($dummyMetadata); + $entityManagerProphecy->getClassMetadata(RelatedDummy::class)->willReturn($relatedDummyMetadata); + + $queryBuilder = new QueryBuilder($entityManagerProphecy->reveal()); + $queryBuilder->select('d', 'a_1'); + $queryBuilder->from(Dummy::class, 'd'); + $queryBuilder->leftJoin('d.relatedDummies', 'a_1'); + $queryBuilder->addOrderBy("find_in_set(d.name, 'INTERVIEW')", 'ASC'); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($entityManagerProphecy); + $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->willReturn($entityManagerProphecy); + + $this->assertFalse(QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $managerRegistryProphecy->reveal())); + } + public function testHasJoinedToManyAssociationWithoutJoin(): void { $entityManagerProphecy = $this->prophesize(EntityManagerInterface::class); diff --git a/Util/QueryChecker.php b/Util/QueryChecker.php index fc1bad9..ff49a3d 100644 --- a/Util/QueryChecker.php +++ b/Util/QueryChecker.php @@ -107,6 +107,9 @@ public static function hasOrderByOnFetchJoinedToManyAssociation(QueryBuilder $qu if (str_contains((string) $part, '.')) { [$alias] = explode('.', (string) $part); + // strip leading SQL function call, e.g. "find_in_set(t.type, ...)" → "t" + $alias = str_contains($alias, '(') ? substr($alias, strpos($alias, '(') + 1) : $alias; + $orderByAliases[] = $alias; } } From 482cc128f64449008eeabe50b1c80f76a1dd2f8b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 8 Jun 2026 09:35:45 +0200 Subject: [PATCH 137/152] feat(doctrine): per-property filter map in FreeTextQueryFilter (#8257) --- Filter/FreeTextQueryFilter.php | 45 +++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/Filter/FreeTextQueryFilter.php b/Filter/FreeTextQueryFilter.php index a269ac4..5f4b76e 100644 --- a/Filter/FreeTextQueryFilter.php +++ b/Filter/FreeTextQueryFilter.php @@ -31,27 +31,54 @@ final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAware use ManagerRegistryAwareTrait; /** - * @param list $properties an array of properties, defaults to `parameter->getProperties()` + * @param FilterInterface|array $filter a filter applied to every property, + * or a map of `property => filter` to use a + * dedicated filter per property + * @param list|null $properties an array of properties, defaults to + * the map keys when `$filter` is a map, + * otherwise to `parameter->getProperties()` */ - public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + public function __construct(private readonly FilterInterface|array $filter, private readonly ?array $properties = null) { } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if ($this->filter instanceof ManagerRegistryAwareInterface) { - $this->filter->setManagerRegistry($this->getManagerRegistry()); - } + $filterMap = \is_array($this->filter) ? $this->filter : null; + + if (null === $filterMap) { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($this->filter instanceof LoggerAwareInterface) { - $this->filter->setLogger($this->getLogger()); + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } } $parameter = $context['parameter']; $qb = clone $queryBuilder; $qb->resetDQLPart('where'); $qb->setParameters(new ArrayCollection()); - foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $properties = $this->properties ?? (null !== $filterMap ? array_keys($filterMap) : $parameter->getProperties()) ?? []; + + foreach ($properties as $property) { + $filter = null !== $filterMap ? ($filterMap[$property] ?? null) : $this->filter; + + if (null === $filter) { + continue; + } + + if (null !== $filterMap) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + } + $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; @@ -62,7 +89,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q : [], ]); - $this->filter->apply( + $filter->apply( $qb, $queryNameGenerator, $resourceClass, From 7d8851966a55d2f53e4d86b281ba543fa14e953b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 13 Jun 2026 06:46:03 +0200 Subject: [PATCH 138/152] fix(doctrine): guard unmapped relation links in ORM handleLinks (#8293) Closes #8292 --- State/LinksHandlerTrait.php | 7 +++++++ Tests/State/ItemProviderTest.php | 1 + 2 files changed, 8 insertions(+) diff --git a/State/LinksHandlerTrait.php b/State/LinksHandlerTrait.php index 65f062b..72a445d 100644 --- a/State/LinksHandlerTrait.php +++ b/State/LinksHandlerTrait.php @@ -88,6 +88,13 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que $joinProperties = $doctrineClassMetadata->getIdentifierFieldNames(); if ($link->getFromProperty() && !$link->getToProperty()) { + // The link was built from the property's native type (LinkFactory), but the + // property may not be a mapped Doctrine association (e.g. a transient, + // resource-typed self reference). There is nothing to join on, skip it. + if (!$fromClassMetadata->hasAssociation($link->getFromProperty())) { + continue; + } + $joinAlias = $queryNameGenerator->generateJoinAlias('m'); $associationMapping = $fromClassMetadata->getAssociationMapping($link->getFromProperty()); // @phpstan-ignore-line $relationType = $associationMapping['type']; diff --git a/Tests/State/ItemProviderTest.php b/Tests/State/ItemProviderTest.php index 168939c..bcb1663 100644 --- a/Tests/State/ItemProviderTest.php +++ b/Tests/State/ItemProviderTest.php @@ -282,6 +282,7 @@ public function testGetSubresourceFromProperty(): void $queryBuilderMock->expects($this->once())->method('setParameter')->with('id_p1', 1, Types::INTEGER); $employeeClassMetadataMock = $this->createMock(ClassMetadata::class); + $employeeClassMetadataMock->method('hasAssociation')->with('company')->willReturn(true); $employeeClassMetadataMock->method('getAssociationMapping')->with('company')->willReturn( class_exists(ManyToOneAssociationMapping::class) ? new ManyToOneAssociationMapping('company', Employee::class, Company::class) : From 6af3eeefc7d483b83e56bcbdbff0dd0dde3c7fc1 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 16 Jun 2026 15:14:05 +0200 Subject: [PATCH 139/152] fix(doctrine): filter parent link from uri variables in fetch_data=false reference (#8295) Fixes #8124 --- State/ItemProvider.php | 51 ++++++++++- Tests/State/ItemProviderTest.php | 140 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/State/ItemProvider.php b/State/ItemProvider.php index b201d03..536f6bb 100644 --- a/State/ItemProvider.php +++ b/State/ItemProvider.php @@ -59,9 +59,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $fetchData = $context['fetch_data'] ?? true; - if (!$fetchData && \array_key_exists('id', $uriVariables)) { - // todo : if uriVariables don't contain the id, this fails. This should behave like it does in the following code - return $manager->getReference($entityClass, $uriVariables); + if (!$fetchData && null !== ($identifiers = $this->getReferenceIdentifiers($manager, $entityClass, $operation, $uriVariables, $context))) { + return $manager->getReference($entityClass, $identifiers); } $repository = $manager->getRepository($entityClass); @@ -88,4 +87,50 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $queryBuilder->getQuery()->getOneOrNullResult(); } + + /** + * Builds the [identifierField => value] map for getReference() from the resource's own identifier + * links, ignoring parent/relation links a subresource carries (e.g. "companyId") which are not + * identifiers of the entity and would make getReference() throw UnrecognizedIdentifierFields. + * + * Returns null (so the caller falls through to the link-resolving query) when an own identifier + * value is missing, or when a resource identifier is not a Doctrine identifier of the entity + * (e.g. a uuid exposed as the API identifier while the table key is "id") and therefore cannot be + * turned into a reference. + * + * @param array $uriVariables + * @param array $context + * + * @return array|null + */ + private function getReferenceIdentifiers(EntityManagerInterface $manager, string $entityClass, Operation $operation, array $uriVariables, array $context): ?array + { + $identifierFields = array_flip($manager->getClassMetadata($entityClass)->getIdentifierFieldNames()); + + $identifiers = []; + foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) { + // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself. + if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { + continue; + } + + $identifierProperties = $link->getIdentifiers(); + $hasCompositeIdentifiers = 1 < \count($identifierProperties); + foreach ($identifierProperties as $identifierProperty) { + if (!isset($identifierFields[$identifierProperty])) { + return null; + } + + // Composite identifiers are exploded by field name upstream; a single identifier is keyed by its uriVariable name. + $key = $hasCompositeIdentifiers ? $identifierProperty : $parameterName; + if (!\array_key_exists($key, $uriVariables)) { + return null; + } + + $identifiers[$identifierProperty] = $uriVariables[$key]; + } + } + + return $identifiers ?: null; + } } diff --git a/Tests/State/ItemProviderTest.php b/Tests/State/ItemProviderTest.php index bcb1663..8fbb549 100644 --- a/Tests/State/ItemProviderTest.php +++ b/Tests/State/ItemProviderTest.php @@ -138,6 +138,146 @@ public function testGetItemDoubleIdentifier(): void $this->assertEquals($returnObject, $dataProvider->provide($operation, ['ida' => 1, 'idb' => 2], $context)); } + public function testGetItemWithFetchDataFalseOnSubresourceFiltersParentLink(): void + { + $reference = new Employee(); + + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock); + $managerMock->expects($this->once()) + ->method('getReference') + ->with(Employee::class, ['id' => 2]) + ->willReturn($reference); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock); + + $operation = (new Get())->withUriVariables([ + 'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'), + 'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']), + ])->withName('get')->withClass(Employee::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'id' => 2], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseMapsRenamedIdentifierUriVariable(): void + { + $reference = new Employee(); + + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock); + $managerMock->expects($this->once()) + ->method('getReference') + ->with(Employee::class, ['id' => 2]) + ->willReturn($reference); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock); + + // The identifier uriVariable is named "employeeId" while the entity's own identifier field is "id". + $operation = (new Get())->withUriVariables([ + 'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'), + 'employeeId' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id'])->withParameterName('employeeId'), + ])->withName('get')->withClass(Employee::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'employeeId' => 2], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseFallsBackToQueryWhenOwnIdentifierMissing(): void + { + $returnObject = new \stdClass(); + + $queryMock = $this->createMock($this->getQueryClass()); + $queryMock->method('getOneOrNullResult')->willReturn($returnObject); + + $queryBuilderMock = $this->createMock(QueryBuilder::class); + $queryBuilderMock->method('getQuery')->willReturn($queryMock); + $queryBuilderMock->method('getRootAliases')->willReturn(['o']); + + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $repositoryMock = $this->createMock(EntityRepository::class); + $repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->willReturn($classMetadataMock); + $managerMock->method('getRepository')->willReturn($repositoryMock); + // Only the parent link is provided: the own identifier cannot be resolved to a reference, + // so we must fall back to the query that resolves the link instead of calling getReference(). + $managerMock->expects($this->never())->method('getReference'); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->willReturn($managerMock); + + $operation = (new Get())->withUriVariables([ + 'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company')->withIdentifiers(['id']), + 'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']), + ])->withName('get')->withClass(Employee::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($returnObject, $dataProvider->provide($operation, ['companyId' => 1], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseFallsBackToQueryWhenIdentifierIsNotADoctrineIdentifier(): void + { + $returnObject = new \stdClass(); + + $queryMock = $this->createMock($this->getQueryClass()); + $queryMock->method('getOneOrNullResult')->willReturn($returnObject); + + $queryBuilderMock = $this->createMock(QueryBuilder::class); + $queryBuilderMock->method('getQuery')->willReturn($queryMock); + $queryBuilderMock->method('getRootAliases')->willReturn(['o']); + + // The Doctrine identifier is "id" while the resource exposes "uuid" as its API identifier: + // getReference() cannot be built from "uuid", so we must fall back to the query. + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $repositoryMock = $this->createMock(EntityRepository::class); + $repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->willReturn($classMetadataMock); + $managerMock->method('getRepository')->willReturn($repositoryMock); + $managerMock->expects($this->never())->method('getReference'); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->willReturn($managerMock); + + $operation = (new Get())->withUriVariables([ + 'uuid' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['uuid'])->withParameterName('uuid'), + ])->withName('get')->withClass(Employee::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false])); + } + public function testQueryResultExtension(): void { $returnObject = new \stdClass(); From 9dd49d1f30364f48b2eb522e31c91fab5f2a9ba2 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 12:10:16 +0200 Subject: [PATCH 140/152] feat(doctrine): add EndSearchFilter primary for ORM and ODM (#8319) --- Filter/EndSearchFilter.php | 83 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 Filter/EndSearchFilter.php diff --git a/Filter/EndSearchFilter.php b/Filter/EndSearchFilter.php new file mode 100644 index 0000000..242e791 --- /dev/null +++ b/Filter/EndSearchFilter.php @@ -0,0 +1,83 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by the end of a string property, using a `LIKE '%value'` clause. + */ +final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); + + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } + + private function formatLikeValue(string $value): string + { + return '%'.addcslashes($value, '\\%_'); + } +} From 619d9561334a7aa04a1c2bd7b7cc35036623527d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 15:17:04 +0200 Subject: [PATCH 141/152] feat(doctrine): promote ComparisonFilter out of @experimental (#8323) --- Filter/ComparisonFilter.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/Filter/ComparisonFilter.php b/Filter/ComparisonFilter.php index 474973d..c136232 100644 --- a/Filter/ComparisonFilter.php +++ b/Filter/ComparisonFilter.php @@ -30,8 +30,6 @@ /** * Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte). - * - * @experimental */ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { From a0842d7e8e858f2d7bea7b0754134b3c34c9fa48 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 15:17:20 +0200 Subject: [PATCH 142/152] feat(doctrine): promote OrFilter out of @experimental (#8324) --- Filter/OrFilter.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/Filter/OrFilter.php b/Filter/OrFilter.php index d8e0202..792eeb5 100644 --- a/Filter/OrFilter.php +++ b/Filter/OrFilter.php @@ -26,8 +26,6 @@ /** * @author Vincent Amstoutz - * - * @experimental */ final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { From 0e98c7107bc30422caa06a9bb20767b78bb8e605 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 20:20:36 +0200 Subject: [PATCH 143/152] feat(doctrine): add StartSearchFilter and WordStartSearchFilter (ORM + ODM) (#8328) --- Filter/StartSearchFilter.php | 83 ++++++++++++++++++++++++++++ Filter/WordStartSearchFilter.php | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 Filter/StartSearchFilter.php create mode 100644 Filter/WordStartSearchFilter.php diff --git a/Filter/StartSearchFilter.php b/Filter/StartSearchFilter.php new file mode 100644 index 0000000..4cca023 --- /dev/null +++ b/Filter/StartSearchFilter.php @@ -0,0 +1,83 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by the beginning of a string property, using a `LIKE 'value%'` clause. + */ +final class StartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); + + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } + + private function formatLikeValue(string $value): string + { + return addcslashes($value, '\\%_').'%'; + } +} diff --git a/Filter/WordStartSearchFilter.php b/Filter/WordStartSearchFilter.php new file mode 100644 index 0000000..e872b2d --- /dev/null +++ b/Filter/WordStartSearchFilter.php @@ -0,0 +1,92 @@ + + * + * 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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by a word boundary prefix, matching fields that contain a word starting with the value, + * using a `LIKE 'value%' OR LIKE '% value%'` clause. + */ +final class WordStartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $values = [$values]; + } + + $expressions = []; + foreach ($values as $val) { + $startName = $queryNameGenerator->generateParameterName($property); + $wordName = $queryNameGenerator->generateParameterName($property); + + $expressions[] = $queryBuilder->expr()->orX( + $this->createLikeExpression($field, $startName), + $this->createLikeExpression($field, $wordName), + ); + + $queryBuilder->setParameter($startName, $this->formatStartValue($val)); + $queryBuilder->setParameter($wordName, $this->formatWordValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$expressions) + ); + } + + private function createLikeExpression(string $field, string $parameterName): string + { + return $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + } + + private function formatStartValue(string $value): string + { + return addcslashes($value, '\\%_').'%'; + } + + private function formatWordValue(string $value): string + { + return '% '.addcslashes($value, '\\%_').'%'; + } +} From a21e9467a7ec5a621eb0e72b2f43ac685733d476 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 20:29:35 +0200 Subject: [PATCH 144/152] feat(doctrine): deprecate AbstractFilter base class (#8330) --- Filter/AbstractFilter.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Filter/AbstractFilter.php b/Filter/AbstractFilter.php index e07597b..55a3110 100644 --- a/Filter/AbstractFilter.php +++ b/Filter/AbstractFilter.php @@ -19,7 +19,9 @@ use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\FilterInterface as MetadataFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -27,6 +29,9 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +/** + * @deprecated since API Platform 4.4, implement {@see MetadataFilterInterface} directly together with {@see BackwardCompatibleFilterDescriptionTrait} and the canonical QueryParameter-based filters (ExactFilter, PartialSearchFilter, EndSearchFilter, ComparisonFilter, OrFilter, …) instead; this class is removed in 6.0 + */ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface { use OrmPropertyHelperTrait; From 95a6f12036c1dd08e2e8487ded34cabaacfa7d8f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 22 Jun 2026 12:03:29 +0200 Subject: [PATCH 145/152] feat(doctrine): deprecate the extends-AbstractFilter form of Date/Range/Exists filters (#8340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DateFilter, RangeFilter and ExistsFilter (ORM + ODM) still extend the AbstractFilter base deprecated in 4.4. Unlike the doomed legacy filters, these three survive: in 5.0 they are rewritten standalone (Date/Range as overlays over ComparisonFilter, Exists reading its value from the QueryParameter) — same class name, same URL syntax, drop-in. Add an @deprecated note on the legacy extends-AbstractFilter form so users migrate their declarations to QueryParameter ahead of the 5.0 rewrite. The classes themselves are not removed. PHPDoc-only, no runtime trigger. --- Filter/DateFilter.php | 2 ++ Filter/ExistsFilter.php | 2 ++ Filter/RangeFilter.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index b7f7af5..33301e4 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -124,6 +124,8 @@ * * @author Kévin Dunglas * @author Théo FIDRY + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index b9f2385..057e731 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -116,6 +116,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`. * * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index 2400100..fbfa3ad 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -108,6 +108,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price[between]=12.99..15.99`. * * @author Lee Siong Chan + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { From 45cbfafd6ef444aa5bd5fc4072d38eb2b3b57ffd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 22 Jun 2026 12:04:09 +0200 Subject: [PATCH 146/152] feat(doctrine): deprecate the legacy SearchFilter/Boolean/Numeric/BackedEnum/OrderFilter (#8341) --- Filter/BackedEnumFilter.php | 2 ++ Filter/BooleanFilter.php | 2 ++ Filter/NumericFilter.php | 2 ++ Filter/OrderFilter.php | 2 ++ Filter/SearchFilter.php | 2 ++ 5 files changed, 10 insertions(+) diff --git a/Filter/BackedEnumFilter.php b/Filter/BackedEnumFilter.php index ab39bd0..29e30c6 100644 --- a/Filter/BackedEnumFilter.php +++ b/Filter/BackedEnumFilter.php @@ -107,6 +107,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?status=published`. * * @author Rémi Marseille + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a backed-enum `nativeType` instead. Removed in 6.0. */ final class BackedEnumFilter extends AbstractFilter { diff --git a/Filter/BooleanFilter.php b/Filter/BooleanFilter.php index 9fda1f5..785bb4e 100644 --- a/Filter/BooleanFilter.php +++ b/Filter/BooleanFilter.php @@ -107,6 +107,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a boolean `nativeType` instead. Removed in 6.0. */ final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/Filter/NumericFilter.php b/Filter/NumericFilter.php index 661e96a..a02b6c6 100644 --- a/Filter/NumericFilter.php +++ b/Filter/NumericFilter.php @@ -107,6 +107,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a numeric `nativeType` (int/float) instead. Removed in 6.0. */ final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/Filter/OrderFilter.php b/Filter/OrderFilter.php index 54de602..bdf59d6 100644 --- a/Filter/OrderFilter.php +++ b/Filter/OrderFilter.php @@ -198,6 +198,8 @@ * * @author Kévin Dunglas * @author Théo FIDRY + * + * @deprecated since API Platform 4.4: use {@see SortFilter} instead. Removed in 6.0. */ final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/Filter/SearchFilter.php b/Filter/SearchFilter.php index a93a8c1..398d76c 100644 --- a/Filter/SearchFilter.php +++ b/Filter/SearchFilter.php @@ -132,6 +132,8 @@ * * * @author Kévin Dunglas + * + * @deprecated since API Platform 4.4: use the per-strategy QueryParameter-based filters instead — {@see ExactFilter} (`exact`), {@see PartialSearchFilter} (`partial`), {@see StartSearchFilter} (`start`), {@see EndSearchFilter} (`end`); for relation properties matched by IRI use {@see IriFilter}. Removed in 6.0. */ final class SearchFilter extends AbstractFilter implements SearchFilterInterface { From a4dbbf8f8422be64483d49fa9dafd9401d883934 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 23 Jun 2026 16:10:25 +0200 Subject: [PATCH 147/152] fix(graphql): build filter args from parameters (#8347) --- Filter/SortFilter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Filter/SortFilter.php b/Filter/SortFilter.php index c1bf315..d77da91 100644 --- a/Filter/SortFilter.php +++ b/Filter/SortFilter.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -35,7 +36,7 @@ * * @author Antoine Bluchet */ -final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, SortFilterInterface { use BackwardCompatibleFilterDescriptionTrait; use NestedPropertyHelperTrait; From bfee97b24b97267ed1c7e118c0c337ab65422860 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 17:18:03 +0200 Subject: [PATCH 148/152] chore: open 5.0 development line Bump dev-main branch alias 4.4.x-dev -> 5.0.x-dev across root and all 21 subpackage composer.json files. Update COMPOSER_ROOT_VERSION to 5.0.x-dev (was stale at 4.3.x-dev). The 4.4 branch was cut from the prior 4.4.x-dev HEAD before this bump to carry the 4.4 maintenance line. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3f78c59..27b9d51 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" From ba51116e901d7a9a6dbdba479e30c703e614ea43 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 18:15:00 +0200 Subject: [PATCH 149/152] chore: require ^4.4@alpha for inter-package dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7115 added cross-package calls new in 4.4 — getStateOptionsRepositoryMethod() in api-platform/state and the repositoryMethod constructor argument in api-platform/doctrine-common — that 4.4 provider code invokes. The old ^4.2/^4.3 sibling floors let `composer update --prefer-lowest` pull releases lacking these symbols, so the per-component lowest CI jobs failed with "undefined method" / "unknown named parameter". Floor all api-platform/* inter-package constraints at ^4.4. The @alpha stability flag is required because the subpackages set minimum-stability:beta, under which a plain ^4.4 would not match the 4.4.0-alpha prereleases. Revert to plain ^4.4 once 4.4.0 stable ships. --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 3f78c59..53ac622 100644 --- a/composer.json +++ b/composer.json @@ -24,10 +24,10 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2.23", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.16", - "api-platform/state": "^4.2.4", + "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "composer/semver": "^3.4", "doctrine/orm": "^2.17 || ^3.0.1" }, From a8df27da222aefb02afa60dd3656b3ac6d4c7c89 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 29 Jun 2026 13:22:12 +0200 Subject: [PATCH 150/152] chore: bump inter-package constraints to ^5.0@alpha Completes 22ece5199 (open 5.0 dev line): root is 5.0.x-dev but the self-referential api-platform/* constraints were left at ^4.4@alpha, which is unresolvable in the component split-tests (published 4.4 alphas require metadata ^4.4, conflicting with the 5.0 root). --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index dc60ce9..26365f5 100644 --- a/composer.json +++ b/composer.json @@ -24,10 +24,10 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "composer/semver": "^3.4", "doctrine/orm": "^2.17 || ^3.0.1" }, From 5059ed8d579dfcac1344edde9838b8e253033b5e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 29 Jun 2026 16:28:25 +0200 Subject: [PATCH 151/152] feat(doctrine): standalone Date/Exists filters, ComparisonFilter [between], deprecate RangeFilter (#8351) --- Filter/ComparisonFilter.php | 39 ++++++++++++++ Filter/DateFilter.php | 100 ++++++++++++++++++++++++++++++++++-- Filter/ExactFilter.php | 18 +++++++ Filter/ExistsFilter.php | 86 +++++++++++++++++++++++++++++-- Filter/RangeFilter.php | 2 +- 5 files changed, 236 insertions(+), 9 deletions(-) diff --git a/Filter/ComparisonFilter.php b/Filter/ComparisonFilter.php index c136232..785136e 100644 --- a/Filter/ComparisonFilter.php +++ b/Filter/ComparisonFilter.php @@ -48,6 +48,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>']; + /** + * Friendly range syntax: `?price[between]=10..100`. Translates to a single BETWEEN clause + * (or `=` when both bounds are equal), letting the SQL optimizer treat it as a bounded range. + */ + public const OPERATOR_BETWEEN = 'between'; + public function __construct(private readonly FilterInterface $filter) { } @@ -74,6 +80,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q continue; } + if (self::OPERATOR_BETWEEN === $operator) { + $this->applyBetween($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, $value); + + continue; + } + if (isset(self::OPERATORS[$operator])) { $this->applyOperator($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); } @@ -91,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array new OpenApiParameter(name: "{$key}[lt]", in: $in), new OpenApiParameter(name: "{$key}[lte]", in: $in), new OpenApiParameter(name: "{$key}[ne]", in: $in), + new OpenApiParameter(name: "{$key}[between]", in: $in), ]; } @@ -109,6 +122,7 @@ public function getSchema(Parameter $parameter): array 'lt' => $innerSchema, 'lte' => $innerSchema, 'ne' => $innerSchema, + 'between' => ['type' => 'string'], ], ]; } @@ -131,4 +145,29 @@ private function applyOperator(QueryBuilder $queryBuilder, QueryNameGeneratorInt ['operator' => $operator, 'parameter' => $subParameter] + $context ); } + + /** + * @param array $context + */ + private function applyBetween(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation, array $context, Parameter $parameter, mixed $value): void + { + if (!\is_string($value)) { + return; + } + + $bounds = explode('..', $value, 2); + if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) { + return; + } + + // coerce to int|float so the bound is bound as a number, not a string + $subParameter = (clone $parameter)->setValue([$bounds[0] + 0, $bounds[1] + 0]); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => self::OPERATOR_BETWEEN, 'parameter' => $subParameter] + $context + ); + } } diff --git a/Filter/DateFilter.php b/Filter/DateFilter.php index 33301e4..1632645 100644 --- a/Filter/DateFilter.php +++ b/Filter/DateFilter.php @@ -15,6 +15,13 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; @@ -25,8 +32,15 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\DBAL\Types\Type as DBALType; use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * The date filter allows to filter a collection by date intervals. @@ -124,12 +138,13 @@ * * @author Kévin Dunglas * @author Théo FIDRY - * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ -final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use DateFilterTrait; + use ManagerRegistryAwareTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; public const DOCTRINE_DATE_TYPES = [ Types::DATE_MUTABLE => true, @@ -142,6 +157,85 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface, Js Types::TIME_IMMUTABLE => true, ]; + private LoggerInterface $logger; + + /** + * Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path + * never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and + * for BC injection through ManagerRegistryAwareInterface). + */ + private ?EntityManagerInterface $entityManager = null; + + /** + * @param array|null $properties + */ + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null) + { + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->entityManager = $queryBuilder->getEntityManager(); + + foreach ($context['filters'] ?? [] as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + + protected function getClassMetadata(string $resourceClass): LegacyClassMetadata + { + if ($this->entityManager instanceof EntityManagerInterface) { + return $this->entityManager->getClassMetadata($resourceClass); + } + + // Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry. + if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) { + return $manager->getClassMetadata($resourceClass); + } + + return new ClassMetadata($resourceClass); + } + + /** + * @return array{0: string, 1: string, 2: string[]} + */ + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + $alias = null; + + foreach ($propertyParts['associations'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType); + $parentAlias = $alias; + } + + if (null === $alias) { + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; + } + /** * {@inheritdoc} */ diff --git a/Filter/ExactFilter.php b/Filter/ExactFilter.php index bd45dbf..dfaa415 100644 --- a/Filter/ExactFilter.php +++ b/Filter/ExactFilter.php @@ -46,6 +46,24 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + if (ComparisonFilter::OPERATOR_BETWEEN === ($context['operator'] ?? null)) { + $whereClause = $context['whereClause'] ?? 'andWhere'; + + // equal bounds collapse to an equality so the optimizer skips the range scan + if ($value[0] === $value[1]) { + $queryBuilder->{$whereClause}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value[0]); + + return; + } + + $queryBuilder->{$whereClause}(\sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $property, $parameterName)) + ->setParameter($parameterName.'_1', $value[0]) + ->setParameter($parameterName.'_2', $value[1]); + + return; + } + if (\is_array($value)) { $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); diff --git a/Filter/ExistsFilter.php b/Filter/ExistsFilter.php index 057e731..28fad25 100644 --- a/Filter/ExistsFilter.php +++ b/Filter/ExistsFilter.php @@ -15,13 +15,21 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface; use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; @@ -29,7 +37,9 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -116,19 +126,84 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`. * * @author Teoh Han Hui - * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use ExistsFilterTrait; + use ManagerRegistryAwareTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; use PropertyPlaceholderOpenApiParameterTrait; + private LoggerInterface $logger; + + /** + * Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path + * never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and + * for BC injection through ManagerRegistryAwareInterface). + */ + private ?EntityManagerInterface $entityManager = null; + + /** + * @param array|null $properties + */ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { - parent::__construct($managerRegistry, $logger, $properties, $nameConverter); - + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); $this->existsParameterName = $existsParameterName; + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + + protected function getClassMetadata(string $resourceClass): LegacyClassMetadata + { + if ($this->entityManager instanceof EntityManagerInterface) { + return $this->entityManager->getClassMetadata($resourceClass); + } + + // Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry. + if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) { + return $manager->getClassMetadata($resourceClass); + } + + return new ClassMetadata($resourceClass); + } + + /** + * @return array{0: string, 1: string, 2: string[]} + */ + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + $alias = null; + + foreach ($propertyParts['associations'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType); + $parentAlias = $alias; + } + + if (null === $alias) { + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; } /** @@ -136,6 +211,7 @@ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInt */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + $this->entityManager = $queryBuilder->getEntityManager(); $parameter = $context['parameter'] ?? null; $propertyKey = $parameter?->getProperty(); diff --git a/Filter/RangeFilter.php b/Filter/RangeFilter.php index fbfa3ad..7410b9d 100644 --- a/Filter/RangeFilter.php +++ b/Filter/RangeFilter.php @@ -109,7 +109,7 @@ * * @author Lee Siong Chan * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. + * @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { From 933c16ea730baa6b9e06bb91b5453019982cdbd0 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 19:15:47 +0200 Subject: [PATCH 152/152] feat!: remove legacy PropertyInfo Type system (#8364) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 26365f5..c5b0c14 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/uid": "^6.4 || ^7.0 || ^8.0", "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", "symfony/yaml": "^6.4 || ^7.0 || ^8.0",