Skip to content

Commit dc3af76

Browse files
soyukadeguif
andauthored
Merge 4.1 (#7114)
Co-authored-by: François-Xavier de Guillebon <[email protected]>
2 parents 4b1b94c + b5ddc44 commit dc3af76

27 files changed

+911
-172
lines changed

docs/guides/computed-field.php

+324
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
<?php
2+
// ---
3+
// slug: computed-field
4+
// name: Compute a field
5+
// executable: true
6+
// position: 10
7+
// tags: doctrine, expert
8+
// ---
9+
10+
// Computing and Sorting by a Derived Field in API Platform with Doctrine
11+
// This recipe explains how to dynamically calculate a field for an API Platform/Doctrine entity
12+
// by modifying the SQL query (via `stateOptions`/`handleLinks`), mapping the computed value
13+
// to the entity object (via `processor`/`process`), and optionally enabling sorting on it
14+
// using a custom filter configured via `parameters`.
15+
namespace App\Filter {
16+
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\State\ParameterNotFound;
22+
use Doctrine\ORM\QueryBuilder;
23+
24+
// Custom API Platform filter to allow sorting by the computed 'totalQuantity' field.
25+
// Works with the alias generated by Cart::handleLinks.
26+
class SortComputedFieldFilter implements FilterInterface, JsonSchemaFilterInterface
27+
{
28+
// Applies the sorting logic to the Doctrine QueryBuilder.
29+
// Called by API Platform when the associated query parameter ('sort[totalQuantity]') is present.
30+
// Adds an ORDER BY clause to the query.
31+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
32+
{
33+
if ($context['parameter']->getValue() instanceof ParameterNotFound) {
34+
return;
35+
}
36+
37+
// Extract the desired sort direction ('asc' or 'desc') from the parameter's value.
38+
// IMPORTANT: 'totalQuantity' here MUST match the alias defined in Cart::handleLinks.
39+
$queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC');
40+
}
41+
42+
/**
43+
* @return array<string, mixed>
44+
*/
45+
// Defines the OpenAPI/Swagger schema for this filter parameter.
46+
// 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.
48+
public function getSchema(Parameter $parameter): array
49+
{
50+
return ['type' => 'string', 'enum' => ['asc', 'desc']];
51+
}
52+
53+
public function getDescription(string $resourceClass): array
54+
{
55+
return [];
56+
}
57+
}
58+
}
59+
60+
namespace App\Entity {
61+
use ApiPlatform\Doctrine\Orm\State\Options;
62+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
63+
use ApiPlatform\Metadata\GetCollection;
64+
use ApiPlatform\Metadata\NotExposed;
65+
use ApiPlatform\Metadata\Operation;
66+
use ApiPlatform\Metadata\QueryParameter;
67+
use App\Filter\SortComputedFieldFilter;
68+
use Doctrine\Common\Collections\ArrayCollection;
69+
use Doctrine\Common\Collections\Collection;
70+
use Doctrine\ORM\Mapping as ORM;
71+
use Doctrine\ORM\QueryBuilder;
72+
73+
#[ORM\Entity]
74+
// Defines the GetCollection operation for Cart, including computed 'totalQuantity'.
75+
// Recipe involves:
76+
// 1. handleLinks (modify query)
77+
// 2. process (map result)
78+
// 3. parameters (filters)
79+
#[GetCollection(
80+
normalizationContext: ['hydra_prefix' => false],
81+
paginationItemsPerPage: 3,
82+
paginationPartial: false,
83+
// stateOptions: Uses handleLinks to modify the query *before* fetching.
84+
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
85+
// processor: Uses process to map the result *after* fetching, *before* serialization.
86+
processor: [self::class, 'process'],
87+
write: true,
88+
// parameters: Defines query parameters.
89+
parameters: [
90+
// Define the sorting parameter for 'totalQuantity'.
91+
'sort[:property]' => new QueryParameter(
92+
// Link this parameter definition to our custom filter.
93+
filter: new SortComputedFieldFilter(),
94+
// Specify which properties this filter instance should handle.
95+
properties: ['totalQuantity'],
96+
property: 'totalQuantity'
97+
),
98+
]
99+
)]
100+
class Cart
101+
{
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+
116+
// Processor function called *after* fetching data, *before* serialization.
117+
// Maps the raw 'totalQuantity' from Doctrine result onto the Cart entity's property.
118+
// Handles Doctrine's array result structure: [0 => Entity, 'alias' => computedValue].
119+
// Reshapes data back into an array of Cart objects.
120+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
121+
{
122+
// Iterate through the raw results. $value will be like [0 => Cart Object, 'totalQuantity' => 15]
123+
foreach ($data as &$value) {
124+
// Get the Cart entity object.
125+
$cart = $value[0];
126+
// Get the computed totalQuantity value using the alias defined in handleLinks.
127+
// Use null coalescing operator for safety.
128+
$cart->totalQuantity = $value['totalQuantity'] ?? 0;
129+
// Replace the raw array structure with just the processed Cart object.
130+
$value = $cart;
131+
}
132+
133+
// Return the collection of Cart objects with the totalQuantity property populated.
134+
return $data;
135+
}
136+
137+
// Public property to hold the computed total quantity.
138+
// Not mapped by Doctrine (@ORM\Column) but populated by the 'process' method.
139+
// API Platform will serialize this property.
140+
public ?int $totalQuantity;
141+
142+
#[ORM\Id]
143+
#[ORM\GeneratedValue]
144+
#[ORM\Column(type: 'integer')]
145+
private ?int $id = null;
146+
147+
/**
148+
* @var Collection<int, CartProduct> the items in this cart
149+
*/
150+
#[ORM\OneToMany(targetEntity: CartProduct::class, mappedBy: 'cart', cascade: ['persist', 'remove'], orphanRemoval: true)]
151+
private Collection $items;
152+
153+
public function __construct()
154+
{
155+
$this->items = new ArrayCollection();
156+
}
157+
158+
public function getId(): ?int
159+
{
160+
return $this->id;
161+
}
162+
163+
/**
164+
* @return Collection<int, CartProduct>
165+
*/
166+
public function getItems(): Collection
167+
{
168+
return $this->items;
169+
}
170+
171+
public function addItem(CartProduct $item): self
172+
{
173+
if (!$this->items->contains($item)) {
174+
$this->items[] = $item;
175+
$item->setCart($this);
176+
}
177+
178+
return $this;
179+
}
180+
181+
public function removeItem(CartProduct $item): self
182+
{
183+
if ($this->items->removeElement($item)) {
184+
// set the owning side to null (unless already changed)
185+
if ($item->getCart() === $this) {
186+
$item->setCart(null);
187+
}
188+
}
189+
190+
return $this;
191+
}
192+
}
193+
194+
#[NotExposed()]
195+
#[ORM\Entity]
196+
class CartProduct
197+
{
198+
#[ORM\Id]
199+
#[ORM\GeneratedValue]
200+
#[ORM\Column(type: 'integer')]
201+
private ?int $id = null;
202+
203+
#[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')]
204+
#[ORM\JoinColumn(nullable: false)]
205+
private ?Cart $cart = null;
206+
207+
#[ORM\Column(type: 'integer')]
208+
private int $quantity = 1;
209+
210+
public function getId(): ?int
211+
{
212+
return $this->id;
213+
}
214+
215+
public function getCart(): ?Cart
216+
{
217+
return $this->cart;
218+
}
219+
220+
public function setCart(?Cart $cart): self
221+
{
222+
$this->cart = $cart;
223+
224+
return $this;
225+
}
226+
227+
public function getQuantity(): int
228+
{
229+
return $this->quantity;
230+
}
231+
232+
public function setQuantity(int $quantity): self
233+
{
234+
$this->quantity = $quantity;
235+
236+
return $this;
237+
}
238+
}
239+
}
240+
241+
namespace App\Playground {
242+
use Symfony\Component\HttpFoundation\Request;
243+
244+
function request(): Request
245+
{
246+
return Request::create('/carts?sort[totalQuantity]=asc', 'GET');
247+
}
248+
}
249+
250+
namespace DoctrineMigrations {
251+
use Doctrine\DBAL\Schema\Schema;
252+
use Doctrine\Migrations\AbstractMigration;
253+
254+
final class Migration extends AbstractMigration
255+
{
256+
public function up(Schema $schema): void
257+
{
258+
$this->addSql('CREATE TABLE cart (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)');
259+
$this->addSql('CREATE TABLE cart_product (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, quantity INTEGER NOT NULL, cart_id INTEGER NOT NULL, CONSTRAINT FK_6DDC373A1AD5CDBF FOREIGN KEY (cart_id) REFERENCES cart (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
260+
$this->addSql('CREATE INDEX IDX_6DDC373A1AD5CDBF ON cart_product (cart_id)');
261+
}
262+
}
263+
}
264+
265+
namespace App\Tests {
266+
use ApiPlatform\Playground\Test\TestGuideTrait;
267+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
268+
269+
final class ComputedFieldTest extends ApiTestCase
270+
{
271+
use TestGuideTrait;
272+
273+
public function testCanSortByComputedField(): void
274+
{
275+
$ascReq = static::createClient()->request('GET', '/carts?sort[totalQuantity]=asc');
276+
$this->assertResponseIsSuccessful();
277+
$asc = $ascReq->toArray();
278+
$this->assertGreaterThan(
279+
$asc['member'][0]['totalQuantity'],
280+
$asc['member'][1]['totalQuantity']
281+
);
282+
}
283+
}
284+
}
285+
286+
namespace App\Fixtures {
287+
use App\Entity\Cart;
288+
use App\Entity\CartProduct;
289+
use Doctrine\Bundle\FixturesBundle\Fixture;
290+
use Doctrine\Persistence\ObjectManager;
291+
292+
use function Zenstruck\Foundry\anonymous;
293+
use function Zenstruck\Foundry\repository;
294+
295+
final class CartFixtures extends Fixture
296+
{
297+
public function load(ObjectManager $manager): void
298+
{
299+
$cartFactory = anonymous(Cart::class);
300+
if (repository(Cart::class)->count()) {
301+
return;
302+
}
303+
304+
$cartFactory->many(10)->create(fn ($i) => [
305+
'items' => $this->createCartProducts($i),
306+
]);
307+
}
308+
309+
/**
310+
* @return array<CartProduct>
311+
*/
312+
private function createCartProducts($i): array
313+
{
314+
$cartProducts = [];
315+
for ($j = 1; $j <= 10; ++$j) {
316+
$cartProduct = new CartProduct();
317+
$cartProduct->setQuantity((int) abs($j / $i) + 1);
318+
$cartProducts[] = $cartProduct;
319+
}
320+
321+
return $cartProducts;
322+
}
323+
}
324+
}

src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php

+4-5
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@
2222
use ApiPlatform\Metadata\Operation;
2323
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2424
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
25+
use ApiPlatform\State\Util\StateOptionsTrait;
2526
use Doctrine\ODM\MongoDB\DocumentManager;
2627
use Doctrine\Persistence\ManagerRegistry;
2728

2829
final class DoctrineMongoDbOdmResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface
2930
{
31+
use StateOptionsTrait;
32+
3033
public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly ResourceMetadataCollectionFactoryInterface $decorated)
3134
{
3235
}
@@ -45,11 +48,7 @@ public function create(string $resourceClass): ResourceMetadataCollection
4548
if ($operations) {
4649
/** @var Operation $operation */
4750
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
48-
$documentClass = $operation->getClass();
49-
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
50-
$documentClass = $options->getDocumentClass();
51-
}
52-
51+
$documentClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class);
5352
if (!$this->managerRegistry->getManagerForClass($documentClass) instanceof DocumentManager) {
5453
continue;
5554
}

src/Doctrine/Odm/State/CollectionProvider.php

+3-4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use ApiPlatform\Metadata\Operation;
2121
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2222
use ApiPlatform\State\ProviderInterface;
23+
use ApiPlatform\State\Util\StateOptionsTrait;
2324
use Doctrine\ODM\MongoDB\DocumentManager;
2425
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
2526
use Doctrine\Persistence\ManagerRegistry;
@@ -32,6 +33,7 @@ final class CollectionProvider implements ProviderInterface
3233
{
3334
use LinksHandlerLocatorTrait;
3435
use LinksHandlerTrait;
36+
use StateOptionsTrait;
3537

3638
/**
3739
* @param AggregationCollectionExtensionInterface[] $collectionExtensions
@@ -45,10 +47,7 @@ public function __construct(ResourceMetadataCollectionFactoryInterface $resource
4547

4648
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
4749
{
48-
$documentClass = $operation->getClass();
49-
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
50-
$documentClass = $options->getDocumentClass();
51-
}
50+
$documentClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class);
5251

5352
/** @var DocumentManager $manager */
5453
$manager = $this->managerRegistry->getManagerForClass($documentClass);

0 commit comments

Comments
 (0)