Skip to content

Commit 5e01477

Browse files
committed
docs: document how to compute and sort a virtual field
1 parent ed1ef25 commit 5e01477

File tree

5 files changed

+348
-0
lines changed

5 files changed

+348
-0
lines changed

‎src/Hydra/Serializer/CollectionFiltersNormalizer.php

Lines changed: 15 additions & 0 deletions
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
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\GetCollection;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\QueryParameter;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter;
22+
use Doctrine\Common\Collections\ArrayCollection;
23+
use Doctrine\Common\Collections\Collection;
24+
use Doctrine\ORM\Mapping as ORM;
25+
use Doctrine\ORM\QueryBuilder;
26+
27+
#[ORM\Entity]
28+
#[GetCollection(
29+
normalizationContext: ['hydra_prefix' => false],
30+
paginationItemsPerPage: 3,
31+
paginationPartial: false,
32+
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
33+
processor: [self::class, 'process'],
34+
write: true,
35+
parameters: [
36+
'sort[:property]' => new QueryParameter(
37+
filter: new SortComputedFieldFilter(),
38+
properties: ['totalQuantity']
39+
),
40+
]
41+
)]
42+
class Cart
43+
{
44+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
45+
{
46+
foreach ($data as &$value) {
47+
$cart = $value[0];
48+
$cart->totalQuantity = $value['totalQuantity'] ?? 0;
49+
$value = $cart;
50+
}
51+
52+
return $data;
53+
}
54+
55+
public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void
56+
{
57+
$rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';
58+
$itemsAlias = $queryNameGenerator->generateParameterName('items');
59+
$queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias)
60+
->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias))
61+
->addGroupBy(\sprintf('%s.id', $rootAlias));
62+
}
63+
64+
public ?int $totalQuantity;
65+
66+
#[ORM\Id]
67+
#[ORM\GeneratedValue]
68+
#[ORM\Column(type: 'integer')]
69+
private ?int $id = null;
70+
71+
#[ORM\Column(type: 'datetime_immutable')]
72+
private ?\DateTimeImmutable $createdAt = null;
73+
74+
/**
75+
* @var Collection<int, CartProduct> the items in this cart
76+
*/
77+
#[ORM\OneToMany(targetEntity: CartProduct::class, mappedBy: 'cart', cascade: ['persist', 'remove'], orphanRemoval: true)]
78+
private Collection $items;
79+
80+
public function __construct()
81+
{
82+
$this->items = new ArrayCollection();
83+
$this->createdAt = new \DateTimeImmutable();
84+
}
85+
86+
public function getId(): ?int
87+
{
88+
return $this->id;
89+
}
90+
91+
public function getCreatedAt(): ?\DateTimeImmutable
92+
{
93+
return $this->createdAt;
94+
}
95+
96+
/**
97+
* @return Collection<int, CartProduct>
98+
*/
99+
public function getItems(): Collection
100+
{
101+
return $this->items;
102+
}
103+
104+
public function addItem(CartProduct $item): self
105+
{
106+
if (!$this->items->contains($item)) {
107+
$this->items[] = $item;
108+
$item->setCart($this);
109+
}
110+
111+
return $this;
112+
}
113+
114+
public function removeItem(CartProduct $item): self
115+
{
116+
if ($this->items->removeElement($item)) {
117+
// set the owning side to null (unless already changed)
118+
if ($item->getCart() === $this) {
119+
$item->setCart(null);
120+
}
121+
}
122+
123+
return $this;
124+
}
125+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\NotExposed;
17+
use Doctrine\ORM\Mapping as ORM;
18+
19+
#[ORM\Entity]
20+
#[NotExposed()]
21+
class CartProduct
22+
{
23+
#[ORM\Id]
24+
#[ORM\GeneratedValue]
25+
#[ORM\Column(type: 'integer')]
26+
private ?int $id = null;
27+
28+
#[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')]
29+
#[ORM\JoinColumn(nullable: false)]
30+
private ?Cart $cart = null;
31+
32+
#[ORM\Column(type: 'integer')]
33+
private int $quantity = 1;
34+
35+
public function getId(): ?int
36+
{
37+
return $this->id;
38+
}
39+
40+
public function getCart(): ?Cart
41+
{
42+
return $this->cart;
43+
}
44+
45+
public function setCart(?Cart $cart): self
46+
{
47+
$this->cart = $cart;
48+
49+
return $this;
50+
}
51+
52+
public function getQuantity(): int
53+
{
54+
return $this->quantity;
55+
}
56+
57+
public function setQuantity(int $quantity): self
58+
{
59+
$this->quantity = $quantity;
60+
61+
return $this;
62+
}
63+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter;
15+
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+
class SortComputedFieldFilter implements FilterInterface, JsonSchemaFilterInterface
25+
{
26+
public function getDescription(string $resourceClass): array
27+
{
28+
return [];
29+
}
30+
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+
$queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC');
38+
}
39+
40+
/**
41+
* @return array<string, mixed>
42+
*/
43+
public function getSchema(Parameter $parameter): array
44+
{
45+
return ['type' => 'string', 'enum' => ['asc', 'desc']];
46+
}
47+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
namespace ApiPlatform\Tests\Functional\Doctrine;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Cart;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CartProduct;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
22+
final class ComputedFieldTest extends ApiTestCase
23+
{
24+
use RecreateSchemaTrait;
25+
use SetupClassResourcesTrait;
26+
27+
protected static ?bool $alwaysBootKernel = false;
28+
29+
/**
30+
* @return class-string[]
31+
*/
32+
public static function getResources(): array
33+
{
34+
return [CartProduct::class, Cart::class];
35+
}
36+
37+
public function testWrongOrder(): void
38+
{
39+
$this->recreateSchema($this->getResources());
40+
$this->loadFixtures();
41+
42+
$res = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=wrong');
43+
$this->assertResponseStatusCodeSame(422);
44+
}
45+
46+
public function testComputedField(): void
47+
{
48+
$this->recreateSchema($this->getResources());
49+
$this->loadFixtures();
50+
51+
$ascReq = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=asc');
52+
53+
$asc = $ascReq->toArray();
54+
55+
$this->assertArrayHasKey('view', $asc);
56+
$this->assertArrayHasKey('first', $asc['view']);
57+
$this->assertArrayHasKey('last', $asc['view']);
58+
$this->assertArrayHasKey('next', $asc['view']);
59+
60+
$this->assertArrayHasKey('search', $asc);
61+
$this->assertEquals('/carts{?sort[totalQuantity]}', $asc['search']['template']);
62+
63+
$this->assertGreaterThan(
64+
$asc['member'][0]['totalQuantity'],
65+
$asc['member'][1]['totalQuantity']
66+
);
67+
68+
$descReq = $this->createClient()->request('GET', '/carts?sort[totalQuantity]=desc');
69+
70+
$desc = $descReq->toArray();
71+
72+
$this->assertLessThan(
73+
$desc['member'][0]['totalQuantity'],
74+
$desc['member'][1]['totalQuantity']
75+
);
76+
}
77+
78+
protected function loadFixtures(): void
79+
{
80+
/** @var ObjectManager $manager */
81+
$manager = static::getContainer()->get('doctrine.orm.entity_manager');
82+
83+
for ($i = 1; $i <= 10; ++$i) {
84+
$cart = new Cart();
85+
86+
for ($j = 1; $j <= 10; ++$j) {
87+
$cartProduct = new CartProduct();
88+
$cartProduct->setQuantity((int) abs($j / $i) + 1);
89+
90+
$cart->addItem($cartProduct);
91+
}
92+
93+
$manager->persist($cart);
94+
}
95+
96+
$manager->flush();
97+
}
98+
}

0 commit comments

Comments
 (0)