From f5def52f6d2406c6959c7eea49d0cb72d9d1779b Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 29 Apr 2025 10:30:43 +0200 Subject: [PATCH] feat(doctrine): state options repositoryMethod for initial query building --- docs/guides/computed-field.php | 62 +++++++++++++------ src/Doctrine/Common/State/Options.php | 14 +++++ src/Doctrine/Odm/State/CollectionProvider.php | 19 +++++- src/Doctrine/Odm/State/ItemProvider.php | 19 +++++- src/Doctrine/Odm/State/Options.php | 4 +- src/Doctrine/Orm/State/CollectionProvider.php | 21 ++++++- src/Doctrine/Orm/State/ItemProvider.php | 21 ++++++- src/Doctrine/Orm/State/Options.php | 3 +- src/State/Util/StateOptionsTrait.php | 16 +++++ tests/Fixtures/TestBundle/Entity/Cart.php | 16 +---- .../TestBundle/Repository/CartRepository.php | 21 +++++++ 11 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Repository/CartRepository.php diff --git a/docs/guides/computed-field.php b/docs/guides/computed-field.php index 3b3fff27e61..c1001399199 100644 --- a/docs/guides/computed-field.php +++ b/docs/guides/computed-field.php @@ -1,4 +1,15 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); // --- // slug: computed-field // name: Compute a field @@ -12,6 +23,7 @@ // by modifying the SQL query (via `stateOptions`/`handleLinks`), mapping the computed value // to the entity object (via `processor`/`process`), and optionally enabling sorting on it // using a custom filter configured via `parameters`. + namespace App\Filter { use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; @@ -44,7 +56,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q */ // Defines the OpenAPI/Swagger schema for this filter parameter. // Tells API Platform documentation generators that 'sort[totalQuantity]' expects 'asc' or 'desc'. - // This also add constraint violations to the parameter that will reject any wrong values. + // This also add constraint violations to the parameter that will reject any wrong values. public function getSchema(Parameter $parameter): array { return ['type' => 'string', 'enum' => ['asc', 'desc']]; @@ -73,15 +85,15 @@ public function getDescription(string $resourceClass): array #[ORM\Entity] // Defines the GetCollection operation for Cart, including computed 'totalQuantity'. // Recipe involves: - // 1. handleLinks (modify query) - // 2. process (map result) - // 3. parameters (filters) + // 1. setup the repository method (modify query) + // 2. process (map result) + // 3. parameters (filters) #[GetCollection( normalizationContext: ['hydra_prefix' => false], paginationItemsPerPage: 3, paginationPartial: false, - // stateOptions: Uses handleLinks to modify the query *before* fetching. - stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + // stateOptions: Uses repositoryMethod to modify the query *before* fetching. See App\Repository\CartRepository. + stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'), // processor: Uses process to map the result *after* fetching, *before* serialization. processor: [self::class, 'process'], write: true, @@ -99,20 +111,6 @@ public function getDescription(string $resourceClass): array )] class Cart { - // Handles links/joins and modifications to the QueryBuilder *before* data is fetched (via stateOptions). - // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level. - // The alias 'totalQuantity' created here is crucial for the filter and processor. - public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void - { - // Get the alias for the root entity (Cart), usually 'o'. - $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; - // Generate a unique alias for the joined 'items' relation to avoid conflicts. - $itemsAlias = $queryNameGenerator->generateParameterName('items'); - $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) - ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) - ->addGroupBy(\sprintf('%s.id', $rootAlias)); - } - // Processor function called *after* fetching data, *before* serialization. // Maps the raw 'totalQuantity' from Doctrine result onto the Cart entity's property. // Handles Doctrine's array result structure: [0 => Entity, 'alias' => computedValue]. @@ -238,6 +236,30 @@ public function setQuantity(int $quantity): self } } +namespace App\Repository { + use Doctrine\ORM\EntityRepository; + use Doctrine\ORM\QueryBuilder; + + /** + * @extends EntityRepository + */ + class CartRepository extends EntityRepository + { + // This repository method is used via stateOptions to alter the QueryBuilder *before* data is fetched. + // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level. + // The alias 'totalQuantity' created here is crucial for the filter and processor. + public function getCartsWithTotalQuantity(): QueryBuilder + { + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + + return $queryBuilder; + } + } +} + namespace App\Playground { use Symfony\Component\HttpFoundation\Request; diff --git a/src/Doctrine/Common/State/Options.php b/src/Doctrine/Common/State/Options.php index df42fc6cb84..a5815332498 100644 --- a/src/Doctrine/Common/State/Options.php +++ b/src/Doctrine/Common/State/Options.php @@ -22,6 +22,7 @@ class Options implements OptionsInterface */ public function __construct( protected mixed $handleLinks = null, + protected ?string $repositoryMethod = null, ) { } @@ -37,4 +38,17 @@ public function withHandleLinks(mixed $handleLinks): self return $self; } + + public function getRepositoryMethod(): ?string + { + return $this->repositoryMethod; + } + + public function withRepositoryMethod(?string $repositoryMethod): self + { + $self = clone $this; + $self->repositoryMethod = $repositoryMethod; + + return $self; + } } diff --git a/src/Doctrine/Odm/State/CollectionProvider.php b/src/Doctrine/Odm/State/CollectionProvider.php index 6c68b663f3e..53694a4b7b5 100644 --- a/src/Doctrine/Odm/State/CollectionProvider.php +++ b/src/Doctrine/Odm/State/CollectionProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -57,7 +58,23 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class)); } - $aggregationBuilder = $repository->createAggregationBuilder(); + 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)); + } + + $aggregationBuilder = $repository->{$method}(); + + if (!$aggregationBuilder instanceof AggregationBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $aggregationBuilder = $repository->createAggregationBuilder(); + } if ($handleLinks = $this->getLinksHandler($operation)) { $handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context); diff --git a/src/Doctrine/Odm/State/ItemProvider.php b/src/Doctrine/Odm/State/ItemProvider.php index b1b8999e38a..7401ad1b02b 100644 --- a/src/Doctrine/Odm/State/ItemProvider.php +++ b/src/Doctrine/Odm/State/ItemProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -65,7 +66,23 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class)); } - $aggregationBuilder = $repository->createAggregationBuilder(); + 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)); + } + + $aggregationBuilder = $repository->{$method}(); + + if (!$aggregationBuilder instanceof AggregationBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $aggregationBuilder = $repository->createAggregationBuilder(); + } if ($handleLinks = $this->getLinksHandler($operation)) { $handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context); diff --git a/src/Doctrine/Odm/State/Options.php b/src/Doctrine/Odm/State/Options.php index 459d6bc49ec..a9a9d75eedf 100644 --- a/src/Doctrine/Odm/State/Options.php +++ b/src/Doctrine/Odm/State/Options.php @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface public function __construct( protected ?string $documentClass = null, mixed $handleLinks = null, + ?string $repositoryMethod = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod); } public function getDocumentClass(): ?string @@ -42,4 +43,5 @@ public function withDocumentClass(?string $documentClass): self return $self; } + } diff --git a/src/Doctrine/Orm/State/CollectionProvider.php b/src/Doctrine/Orm/State/CollectionProvider.php index 3815447a8d3..5bc181abb57 100644 --- a/src/Doctrine/Orm/State/CollectionProvider.php +++ b/src/Doctrine/Orm/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/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index b201d03b7d0..ba3f06b592f 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/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/src/Doctrine/Orm/State/Options.php b/src/Doctrine/Orm/State/Options.php index 3a9a46c3825..00f791da563 100644 --- a/src/Doctrine/Orm/State/Options.php +++ b/src/Doctrine/Orm/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 diff --git a/src/State/Util/StateOptionsTrait.php b/src/State/Util/StateOptionsTrait.php index 1b27c5534f7..5017cb8ace5 100644 --- a/src/State/Util/StateOptionsTrait.php +++ b/src/State/Util/StateOptionsTrait.php @@ -55,4 +55,20 @@ public function getStateOptionsClass(Operation $operation, ?string $defaultClass return $defaultClass; } + + public function getStateOptionsRepositoryMethod(Operation $operation): ?string + { + if (!$options = $operation->getStateOptions()) { + return null; + } + + if ( + (class_exists(Options::class) && $options instanceof Options) + || (class_exists(ODMOptions::class) && $options instanceof ODMOptions) + ) { + return $options->getRepositoryMethod(); + } + + return null; + } } diff --git a/tests/Fixtures/TestBundle/Entity/Cart.php b/tests/Fixtures/TestBundle/Entity/Cart.php index 844eb65d5d0..dc22adb0cd3 100644 --- a/tests/Fixtures/TestBundle/Entity/Cart.php +++ b/tests/Fixtures/TestBundle/Entity/Cart.php @@ -14,22 +14,21 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter; +use ApiPlatform\Tests\Fixtures\TestBundle\Repository\CartRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Doctrine\ORM\QueryBuilder; -#[ORM\Entity] +#[ORM\Entity(repositoryClass: CartRepository::class)] #[GetCollection( normalizationContext: ['hydra_prefix' => false], paginationItemsPerPage: 3, paginationPartial: false, - stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'), processor: [self::class, 'process'], write: true, parameters: [ @@ -53,15 +52,6 @@ public static function process(mixed $data, Operation $operation, array $uriVari return $data; } - public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void - { - $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; - $itemsAlias = $queryNameGenerator->generateParameterName('items'); - $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) - ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) - ->addGroupBy(\sprintf('%s.id', $rootAlias)); - } - public ?int $totalQuantity; #[ORM\Id] diff --git a/tests/Fixtures/TestBundle/Repository/CartRepository.php b/tests/Fixtures/TestBundle/Repository/CartRepository.php new file mode 100644 index 00000000000..e8d9dcc124c --- /dev/null +++ b/tests/Fixtures/TestBundle/Repository/CartRepository.php @@ -0,0 +1,21 @@ + + */ +class CartRepository extends EntityRepository +{ + public function getCartsWithTotalQuantity(): QueryBuilder + { + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + + return $queryBuilder; + } +}