Skip to content

Commit 047b36c

Browse files
committed
doc(doctrine): compute and sort a virtual field
1 parent ed1ef25 commit 047b36c

File tree

6 files changed

+646
-0
lines changed

6 files changed

+646
-0
lines changed

docs/guides/computed-field.php

+273
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
<?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);
13+
// ---
14+
// slug: computed-field
15+
// name: Compute a field
16+
// executable: true
17+
// position: 10
18+
// tags: doctrine, expert
19+
// ---
20+
21+
// Custom filters can be written by implementing the `ApiPlatform\Metadata\FilterInterface` interface.
22+
//
23+
// API Platform provides a convenient way to create Doctrine ORM and MongoDB ODM filters. If you use [custom state providers](/docs/guide/state-providers), you can still create filters by implementing the previously mentioned interface, but - as API Platform isn't aware of your persistence system's internals - you have to create the filtering logic by yourself.
24+
//
25+
// Doctrine ORM filters have access to the context created from the HTTP request and to the `QueryBuilder` instance used to retrieve data from the database. They are only applied to collections. If you want to deal with the DQL query generated to retrieve items, [extensions](/docs/core/extensions/) are the way to go.
26+
//
27+
// A Doctrine ORM filter is basically a class implementing the `ApiPlatform\Doctrine\Orm\Filter\FilterInterface`. API Platform includes a convenient abstract class implementing this interface and providing utility methods: `ApiPlatform\Doctrine\Orm\Filter\AbstractFilter`.
28+
//
29+
// Note: Doctrine MongoDB ODM filters have access to the context created from the HTTP request and to the [aggregation builder](https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/aggregation-builder.html) instance used to retrieve data from the database and to execute [complex operations on data](https://docs.mongodb.com/manual/aggregation/). They are only applied to collections. If you want to deal with the aggregation pipeline generated to retrieve items, [extensions](/docs/core/extensions/) are the way to go.
30+
//
31+
// A Doctrine MongoDB ODM filter is basically a class implementing the `ApiPlatform\Doctrine\Odm\Filter\FilterInterface`. API Platform includes a convenient abstract class implementing this interface and providing utility methods: `ApiPlatform\Doctrine\Odm\Filter\AbstractFilter`.
32+
//
33+
// In this example, we create a class to filter a collection by applying a regular expression to a property. The `REGEXP` DQL function used in this example can be found in the [DoctrineExtensions](https://github.com/beberlei/DoctrineExtensions) library. This library must be properly installed and registered to use this example (works only with MySQL).
34+
35+
namespace App\Filter {
36+
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
37+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
38+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
39+
use ApiPlatform\Metadata\Operation;
40+
use ApiPlatform\Metadata\Parameter;
41+
use ApiPlatform\State\ParameterNotFound;
42+
use Doctrine\ORM\QueryBuilder;
43+
44+
class SortComputedFieldFilter implements FilterInterface, JsonSchemaFilterInterface
45+
{
46+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
47+
{
48+
if ($context['parameter']->getValue() instanceof ParameterNotFound) {
49+
return;
50+
}
51+
52+
$queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC');
53+
}
54+
55+
/**
56+
* @return array<string, mixed>
57+
*/
58+
public function getSchema(Parameter $parameter): array
59+
{
60+
return ['type' => 'string', 'enum' => ['asc', 'desc']];
61+
}
62+
63+
public function getDescription(string $resourceClass): array
64+
{
65+
return [];
66+
}
67+
}
68+
}
69+
70+
namespace App\Entity {
71+
use ApiPlatform\Doctrine\Orm\State\Options;
72+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
73+
use ApiPlatform\Metadata\GetCollection;
74+
use ApiPlatform\Metadata\NotExposed;
75+
use ApiPlatform\Metadata\Operation;
76+
use ApiPlatform\Metadata\QueryParameter;
77+
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter;
78+
use Doctrine\Common\Collections\ArrayCollection;
79+
use Doctrine\Common\Collections\Collection;
80+
use Doctrine\ORM\Mapping as ORM;
81+
use Doctrine\ORM\QueryBuilder;
82+
83+
#[ORM\Entity]
84+
#[GetCollection(
85+
normalizationContext: ['hydra_prefix' => false],
86+
paginationItemsPerPage: 3,
87+
paginationPartial: false,
88+
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
89+
processor: [self::class, 'process'],
90+
write: true,
91+
parameters: [
92+
'sort[:property]' => new QueryParameter(
93+
filter: new SortComputedFieldFilter(),
94+
properties: ['totalQuantity'],
95+
property: 'totalQuantity'
96+
),
97+
]
98+
)]
99+
class Cart
100+
{
101+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
102+
{
103+
foreach ($data as &$value) {
104+
$cart = $value[0];
105+
$cart->totalQuantity = $value['totalQuantity'] ?? 0;
106+
$value = $cart;
107+
}
108+
109+
return $data;
110+
}
111+
112+
public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void
113+
{
114+
$rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';
115+
$itemsAlias = $queryNameGenerator->generateParameterName('items');
116+
$queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias)
117+
->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias))
118+
->addGroupBy(\sprintf('%s.id', $rootAlias));
119+
}
120+
121+
public ?int $totalQuantity;
122+
123+
#[ORM\Id]
124+
#[ORM\GeneratedValue]
125+
#[ORM\Column(type: 'integer')]
126+
private ?int $id = null;
127+
128+
#[ORM\Column(type: 'datetime_immutable')]
129+
private ?\DateTimeImmutable $createdAt = null;
130+
131+
/**
132+
* @var Collection<int, CartProduct> the items in this cart
133+
*/
134+
#[ORM\OneToMany(targetEntity: CartProduct::class, mappedBy: 'cart', cascade: ['persist', 'remove'], orphanRemoval: true)]
135+
private Collection $items;
136+
137+
public function __construct()
138+
{
139+
$this->items = new ArrayCollection();
140+
$this->createdAt = new \DateTimeImmutable();
141+
}
142+
143+
public function getId(): ?int
144+
{
145+
return $this->id;
146+
}
147+
148+
public function getCreatedAt(): ?\DateTimeImmutable
149+
{
150+
return $this->createdAt;
151+
}
152+
153+
/**
154+
* @return Collection<int, CartProduct>
155+
*/
156+
public function getItems(): Collection
157+
{
158+
return $this->items;
159+
}
160+
161+
public function addItem(CartProduct $item): self
162+
{
163+
if (!$this->items->contains($item)) {
164+
$this->items[] = $item;
165+
$item->setCart($this);
166+
}
167+
168+
return $this;
169+
}
170+
171+
public function removeItem(CartProduct $item): self
172+
{
173+
if ($this->items->removeElement($item)) {
174+
// set the owning side to null (unless already changed)
175+
if ($item->getCart() === $this) {
176+
$item->setCart(null);
177+
}
178+
}
179+
180+
return $this;
181+
}
182+
}
183+
184+
#[NotExposed()]
185+
class CartProduct
186+
{
187+
#[ORM\Id]
188+
#[ORM\GeneratedValue]
189+
#[ORM\Column(type: 'integer')]
190+
private ?int $id = null;
191+
192+
#[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')]
193+
#[ORM\JoinColumn(nullable: false)]
194+
private ?Cart $cart = null;
195+
196+
#[ORM\Column(type: 'integer')]
197+
private int $quantity = 1;
198+
199+
public function getId(): ?int
200+
{
201+
return $this->id;
202+
}
203+
204+
public function getCart(): ?Cart
205+
{
206+
return $this->cart;
207+
}
208+
209+
public function setCart(?Cart $cart): self
210+
{
211+
$this->cart = $cart;
212+
213+
return $this;
214+
}
215+
216+
public function getQuantity(): int
217+
{
218+
return $this->quantity;
219+
}
220+
221+
public function setQuantity(int $quantity): self
222+
{
223+
$this->quantity = $quantity;
224+
225+
return $this;
226+
}
227+
}
228+
}
229+
230+
namespace App\Playground {
231+
use Symfony\Component\HttpFoundation\Request;
232+
233+
function request(): Request
234+
{
235+
return Request::create('/carts?sort[totalQuantity]=asc', 'GET');
236+
}
237+
}
238+
239+
namespace DoctrineMigrations {
240+
use Doctrine\DBAL\Schema\Schema;
241+
use Doctrine\Migrations\AbstractMigration;
242+
243+
final class Migration extends AbstractMigration
244+
{
245+
public function up(Schema $schema): void
246+
{
247+
$this->addSql('CREATE TABLE CartProduct (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)');
248+
$this->addSql('CREATE INDEX IDX_6DDC373A1AD5CDBF ON CartProduct (cart_id)');
249+
$this->addSql('CREATE TABLE Cart (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, createdAt DATETIME NOT NULL)');
250+
}
251+
}
252+
}
253+
254+
namespace App\Tests {
255+
use ApiPlatform\Playground\Test\TestGuideTrait;
256+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
257+
258+
final class ComputedFieldTest extends ApiTestCase
259+
{
260+
use TestGuideTrait;
261+
262+
public function testCanSortByComputedField(): void
263+
{
264+
$ascReq = static::createClient()->request('GET', '/carts?sort[totalQuantity]=asc');
265+
$this->assertResponseIsSuccessful();
266+
$asc = $ascReq->toArray();
267+
$this->assertGreaterThan(
268+
$asc['member'][0]['totalQuantity'],
269+
$asc['member'][1]['totalQuantity']
270+
);
271+
}
272+
}
273+
}

src/Hydra/Serializer/CollectionFiltersNormalizer.php

+15
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,21 @@ private function getSearch(string $resourceClass, array $parts, array $filters,
172172
}
173173
}
174174

175+
if (str_contains($key, ':property') && $parameter->getProperties()) {
176+
$required = $parameter->getRequired();
177+
foreach ($parameter->getProperties() as $prop) {
178+
$k = str_replace(':property', $prop, $key);
179+
$m = ['@type' => 'IriTemplateMapping', 'variable' => $k, 'property' => $prop];
180+
$variables[] = $k;
181+
if (null !== $required) {
182+
$m['required'] = $required;
183+
}
184+
$mapping[] = $m;
185+
}
186+
187+
continue;
188+
}
189+
175190
if (!($property = $parameter->getProperty())) {
176191
continue;
177192
}

0 commit comments

Comments
 (0)