Skip to content

Commit f5def52

Browse files
committed
feat(doctrine): state options repositoryMethod for initial query building
1 parent dc3af76 commit f5def52

File tree

11 files changed

+173
-43
lines changed

11 files changed

+173
-43
lines changed

docs/guides/computed-field.php

+42-20
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
213
// ---
314
// slug: computed-field
415
// name: Compute a field
@@ -12,6 +23,7 @@
1223
// by modifying the SQL query (via `stateOptions`/`handleLinks`), mapping the computed value
1324
// to the entity object (via `processor`/`process`), and optionally enabling sorting on it
1425
// using a custom filter configured via `parameters`.
26+
1527
namespace App\Filter {
1628
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
1729
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
@@ -44,7 +56,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
4456
*/
4557
// Defines the OpenAPI/Swagger schema for this filter parameter.
4658
// Tells API Platform documentation generators that 'sort[totalQuantity]' expects 'asc' or 'desc'.
47-
// This also add constraint violations to the parameter that will reject any wrong values.
59+
// This also add constraint violations to the parameter that will reject any wrong values.
4860
public function getSchema(Parameter $parameter): array
4961
{
5062
return ['type' => 'string', 'enum' => ['asc', 'desc']];
@@ -73,15 +85,15 @@ public function getDescription(string $resourceClass): array
7385
#[ORM\Entity]
7486
// Defines the GetCollection operation for Cart, including computed 'totalQuantity'.
7587
// Recipe involves:
76-
// 1. handleLinks (modify query)
77-
// 2. process (map result)
78-
// 3. parameters (filters)
88+
// 1. setup the repository method (modify query)
89+
// 2. process (map result)
90+
// 3. parameters (filters)
7991
#[GetCollection(
8092
normalizationContext: ['hydra_prefix' => false],
8193
paginationItemsPerPage: 3,
8294
paginationPartial: false,
83-
// stateOptions: Uses handleLinks to modify the query *before* fetching.
84-
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
95+
// stateOptions: Uses repositoryMethod to modify the query *before* fetching. See App\Repository\CartRepository.
96+
stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'),
8597
// processor: Uses process to map the result *after* fetching, *before* serialization.
8698
processor: [self::class, 'process'],
8799
write: true,
@@ -99,20 +111,6 @@ public function getDescription(string $resourceClass): array
99111
)]
100112
class Cart
101113
{
102-
// Handles links/joins and modifications to the QueryBuilder *before* data is fetched (via stateOptions).
103-
// Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level.
104-
// The alias 'totalQuantity' created here is crucial for the filter and processor.
105-
public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void
106-
{
107-
// Get the alias for the root entity (Cart), usually 'o'.
108-
$rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';
109-
// Generate a unique alias for the joined 'items' relation to avoid conflicts.
110-
$itemsAlias = $queryNameGenerator->generateParameterName('items');
111-
$queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias)
112-
->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias))
113-
->addGroupBy(\sprintf('%s.id', $rootAlias));
114-
}
115-
116114
// Processor function called *after* fetching data, *before* serialization.
117115
// Maps the raw 'totalQuantity' from Doctrine result onto the Cart entity's property.
118116
// Handles Doctrine's array result structure: [0 => Entity, 'alias' => computedValue].
@@ -238,6 +236,30 @@ public function setQuantity(int $quantity): self
238236
}
239237
}
240238

239+
namespace App\Repository {
240+
use Doctrine\ORM\EntityRepository;
241+
use Doctrine\ORM\QueryBuilder;
242+
243+
/**
244+
* @extends EntityRepository<Cart::class>
245+
*/
246+
class CartRepository extends EntityRepository
247+
{
248+
// This repository method is used via stateOptions to alter the QueryBuilder *before* data is fetched.
249+
// Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level.
250+
// The alias 'totalQuantity' created here is crucial for the filter and processor.
251+
public function getCartsWithTotalQuantity(): QueryBuilder
252+
{
253+
$queryBuilder = $this->createQueryBuilder('o');
254+
$queryBuilder->leftJoin('o.items', 'items')
255+
->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity')
256+
->addGroupBy('o.id');
257+
258+
return $queryBuilder;
259+
}
260+
}
261+
}
262+
241263
namespace App\Playground {
242264
use Symfony\Component\HttpFoundation\Request;
243265

src/Doctrine/Common/State/Options.php

+14
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Options implements OptionsInterface
2222
*/
2323
public function __construct(
2424
protected mixed $handleLinks = null,
25+
protected ?string $repositoryMethod = null,
2526
) {
2627
}
2728

@@ -37,4 +38,17 @@ public function withHandleLinks(mixed $handleLinks): self
3738

3839
return $self;
3940
}
41+
42+
public function getRepositoryMethod(): ?string
43+
{
44+
return $this->repositoryMethod;
45+
}
46+
47+
public function withRepositoryMethod(?string $repositoryMethod): self
48+
{
49+
$self = clone $this;
50+
$self->repositoryMethod = $repositoryMethod;
51+
52+
return $self;
53+
}
4054
}

src/Doctrine/Odm/State/CollectionProvider.php

+18-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2222
use ApiPlatform\State\ProviderInterface;
2323
use ApiPlatform\State\Util\StateOptionsTrait;
24+
use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder;
2425
use Doctrine\ODM\MongoDB\DocumentManager;
2526
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
2627
use Doctrine\Persistence\ManagerRegistry;
@@ -57,7 +58,23 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
5758
throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
5859
}
5960

60-
$aggregationBuilder = $repository->createAggregationBuilder();
61+
if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
62+
if (!method_exists($repository, $method)) {
63+
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
64+
}
65+
66+
$aggregationBuilder = $repository->{$method}();
67+
68+
if (!$aggregationBuilder instanceof AggregationBuilder) {
69+
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class));
70+
}
71+
} else {
72+
if (!method_exists($repository, 'createQueryBuilder')) {
73+
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
74+
}
75+
76+
$aggregationBuilder = $repository->createAggregationBuilder();
77+
}
6178

6279
if ($handleLinks = $this->getLinksHandler($operation)) {
6380
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);

src/Doctrine/Odm/State/ItemProvider.php

+18-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2222
use ApiPlatform\State\ProviderInterface;
2323
use ApiPlatform\State\Util\StateOptionsTrait;
24+
use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder;
2425
use Doctrine\ODM\MongoDB\DocumentManager;
2526
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
2627
use Doctrine\Persistence\ManagerRegistry;
@@ -65,7 +66,23 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
6566
throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
6667
}
6768

68-
$aggregationBuilder = $repository->createAggregationBuilder();
69+
if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
70+
if (!method_exists($repository, $method)) {
71+
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
72+
}
73+
74+
$aggregationBuilder = $repository->{$method}();
75+
76+
if (!$aggregationBuilder instanceof AggregationBuilder) {
77+
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class));
78+
}
79+
} else {
80+
if (!method_exists($repository, 'createQueryBuilder')) {
81+
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
82+
}
83+
84+
$aggregationBuilder = $repository->createAggregationBuilder();
85+
}
6986

7087
if ($handleLinks = $this->getLinksHandler($operation)) {
7188
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);

src/Doctrine/Odm/State/Options.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface
2626
public function __construct(
2727
protected ?string $documentClass = null,
2828
mixed $handleLinks = null,
29+
?string $repositoryMethod = null,
2930
) {
30-
parent::__construct(handleLinks: $handleLinks);
31+
parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod);
3132
}
3233

3334
public function getDocumentClass(): ?string
@@ -42,4 +43,5 @@ public function withDocumentClass(?string $documentClass): self
4243

4344
return $self;
4445
}
46+
4547
}

src/Doctrine/Orm/State/CollectionProvider.php

+18-3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use ApiPlatform\State\ProviderInterface;
2424
use ApiPlatform\State\Util\StateOptionsTrait;
2525
use Doctrine\ORM\EntityManagerInterface;
26+
use Doctrine\ORM\QueryBuilder;
2627
use Doctrine\Persistence\ManagerRegistry;
2728
use Psr\Container\ContainerInterface;
2829

@@ -56,11 +57,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
5657
$manager = $this->managerRegistry->getManagerForClass($entityClass);
5758

5859
$repository = $manager->getRepository($entityClass);
59-
if (!method_exists($repository, 'createQueryBuilder')) {
60-
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
60+
61+
if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
62+
if (!method_exists($repository, $method)) {
63+
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
64+
}
65+
66+
$queryBuilder = $repository->{$method}();
67+
68+
if (!$queryBuilder instanceof QueryBuilder) {
69+
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class));
70+
}
71+
} else {
72+
if (!method_exists($repository, 'createQueryBuilder')) {
73+
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
74+
}
75+
76+
$queryBuilder = $repository->createQueryBuilder('o');
6177
}
6278

63-
$queryBuilder = $repository->createQueryBuilder('o');
6479
$queryNameGenerator = new QueryNameGenerator();
6580

6681
if ($handleLinks = $this->getLinksHandler($operation)) {

src/Doctrine/Orm/State/ItemProvider.php

+18-3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use ApiPlatform\State\ProviderInterface;
2424
use ApiPlatform\State\Util\StateOptionsTrait;
2525
use Doctrine\ORM\EntityManagerInterface;
26+
use Doctrine\ORM\QueryBuilder;
2627
use Doctrine\Persistence\ManagerRegistry;
2728
use Psr\Container\ContainerInterface;
2829

@@ -65,11 +66,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
6566
}
6667

6768
$repository = $manager->getRepository($entityClass);
68-
if (!method_exists($repository, 'createQueryBuilder')) {
69-
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
69+
70+
if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
71+
if (!method_exists($repository, $method)) {
72+
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
73+
}
74+
75+
$queryBuilder = $repository->{$method}();
76+
77+
if (!$queryBuilder instanceof QueryBuilder) {
78+
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class));
79+
}
80+
} else {
81+
if (!method_exists($repository, 'createQueryBuilder')) {
82+
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
83+
}
84+
85+
$queryBuilder = $repository->createQueryBuilder('o');
7086
}
7187

72-
$queryBuilder = $repository->createQueryBuilder('o');
7388
$queryNameGenerator = new QueryNameGenerator();
7489

7590
if ($handleLinks = $this->getLinksHandler($operation)) {

src/Doctrine/Orm/State/Options.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface
2626
public function __construct(
2727
protected ?string $entityClass = null,
2828
mixed $handleLinks = null,
29+
?string $repositoryMethod = null,
2930
) {
30-
parent::__construct(handleLinks: $handleLinks);
31+
parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod);
3132
}
3233

3334
public function getEntityClass(): ?string

src/State/Util/StateOptionsTrait.php

+16
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,20 @@ public function getStateOptionsClass(Operation $operation, ?string $defaultClass
5555

5656
return $defaultClass;
5757
}
58+
59+
public function getStateOptionsRepositoryMethod(Operation $operation): ?string
60+
{
61+
if (!$options = $operation->getStateOptions()) {
62+
return null;
63+
}
64+
65+
if (
66+
(class_exists(Options::class) && $options instanceof Options)
67+
|| (class_exists(ODMOptions::class) && $options instanceof ODMOptions)
68+
) {
69+
return $options->getRepositoryMethod();
70+
}
71+
72+
return null;
73+
}
5874
}

tests/Fixtures/TestBundle/Entity/Cart.php

+3-13
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,21 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
1515

1616
use ApiPlatform\Doctrine\Orm\State\Options;
17-
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
1817
use ApiPlatform\Metadata\GetCollection;
1918
use ApiPlatform\Metadata\Operation;
2019
use ApiPlatform\Metadata\QueryParameter;
2120
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Repository\CartRepository;
2222
use Doctrine\Common\Collections\ArrayCollection;
2323
use Doctrine\Common\Collections\Collection;
2424
use Doctrine\ORM\Mapping as ORM;
25-
use Doctrine\ORM\QueryBuilder;
2625

27-
#[ORM\Entity]
26+
#[ORM\Entity(repositoryClass: CartRepository::class)]
2827
#[GetCollection(
2928
normalizationContext: ['hydra_prefix' => false],
3029
paginationItemsPerPage: 3,
3130
paginationPartial: false,
32-
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
31+
stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'),
3332
processor: [self::class, 'process'],
3433
write: true,
3534
parameters: [
@@ -53,15 +52,6 @@ public static function process(mixed $data, Operation $operation, array $uriVari
5352
return $data;
5453
}
5554

56-
public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void
57-
{
58-
$rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';
59-
$itemsAlias = $queryNameGenerator->generateParameterName('items');
60-
$queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias)
61-
->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias))
62-
->addGroupBy(\sprintf('%s.id', $rootAlias));
63-
}
64-
6555
public ?int $totalQuantity;
6656

6757
#[ORM\Id]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Repository;
4+
5+
use Doctrine\ORM\EntityRepository;
6+
use Doctrine\ORM\QueryBuilder;
7+
/**
8+
* @extends EntityRepository<Cart::class>
9+
*/
10+
class CartRepository extends EntityRepository
11+
{
12+
public function getCartsWithTotalQuantity(): QueryBuilder
13+
{
14+
$queryBuilder = $this->createQueryBuilder('o');
15+
$queryBuilder->leftJoin('o.items', 'items')
16+
->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity')
17+
->addGroupBy('o.id');
18+
19+
return $queryBuilder;
20+
}
21+
}

0 commit comments

Comments
 (0)