Skip to content

doc(doctrine): document how to compute and sort a virtual field #7113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 324 additions & 0 deletions docs/guides/computed-field.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
<?php
// ---
// slug: computed-field
// name: Compute a field
// executable: true
// position: 10
// tags: doctrine, expert
// ---

// Computing and Sorting by a Derived Field in API Platform with Doctrine
// This recipe explains how to dynamically calculate a field for an API Platform/Doctrine entity
// 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;
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Parameter;
use ApiPlatform\State\ParameterNotFound;
use Doctrine\ORM\QueryBuilder;

// Custom API Platform filter to allow sorting by the computed 'totalQuantity' field.
// Works with the alias generated by Cart::handleLinks.
class SortComputedFieldFilter implements FilterInterface, JsonSchemaFilterInterface
{
// Applies the sorting logic to the Doctrine QueryBuilder.
// Called by API Platform when the associated query parameter ('sort[totalQuantity]') is present.
// Adds an ORDER BY clause to the query.
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void

Check warning on line 31 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L31

Added line #L31 was not covered by tests
{
if ($context['parameter']->getValue() instanceof ParameterNotFound) {
return;

Check warning on line 34 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L33-L34

Added lines #L33 - L34 were not covered by tests
}

// Extract the desired sort direction ('asc' or 'desc') from the parameter's value.
// IMPORTANT: 'totalQuantity' here MUST match the alias defined in Cart::handleLinks.
$queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC');

Check warning on line 39 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L39

Added line #L39 was not covered by tests
}

/**
* @return array<string, mixed>
*/
// 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.
public function getSchema(Parameter $parameter): array

Check warning on line 48 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L48

Added line #L48 was not covered by tests
{
return ['type' => 'string', 'enum' => ['asc', 'desc']];

Check warning on line 50 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L50

Added line #L50 was not covered by tests
}

public function getDescription(string $resourceClass): array

Check warning on line 53 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L53

Added line #L53 was not covered by tests
{
return [];

Check warning on line 55 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L55

Added line #L55 was not covered by tests
}
}
}

namespace App\Entity {
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\NotExposed;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\QueryParameter;
use App\Filter\SortComputedFieldFilter;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\QueryBuilder;

#[ORM\Entity]
// Defines the GetCollection operation for Cart, including computed 'totalQuantity'.
// Recipe involves:
// 1. handleLinks (modify query)
// 2. process (map result)
// 3. parameters (filters)
#[GetCollection(
normalizationContext: ['hydra_prefix' => false],
paginationItemsPerPage: 3,
paginationPartial: false,

Check warning on line 82 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L80-L82

Added lines #L80 - L82 were not covered by tests
// stateOptions: Uses handleLinks to modify the query *before* fetching.
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),

Check warning on line 84 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L84

Added line #L84 was not covered by tests
// processor: Uses process to map the result *after* fetching, *before* serialization.
processor: [self::class, 'process'],
write: true,

Check warning on line 87 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L86-L87

Added lines #L86 - L87 were not covered by tests
// parameters: Defines query parameters.
parameters: [

Check warning on line 89 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L89

Added line #L89 was not covered by tests
// Define the sorting parameter for 'totalQuantity'.
'sort[:property]' => new QueryParameter(

Check warning on line 91 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L91

Added line #L91 was not covered by tests
// Link this parameter definition to our custom filter.
filter: new SortComputedFieldFilter(),

Check warning on line 93 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L93

Added line #L93 was not covered by tests
// Specify which properties this filter instance should handle.
properties: ['totalQuantity'],
property: 'totalQuantity'
),
]
)]

Check warning on line 99 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L95-L99

Added lines #L95 - L99 were not covered by tests
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

Check warning on line 105 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L105

Added line #L105 was not covered by tests
{
// Get the alias for the root entity (Cart), usually 'o'.
$rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';

Check warning on line 108 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L108

Added line #L108 was not covered by tests
// 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));

Check warning on line 113 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L110-L113

Added lines #L110 - L113 were not covered by tests
}

// 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].
// Reshapes data back into an array of Cart objects.
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])

Check warning on line 120 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L120

Added line #L120 was not covered by tests
{
// Iterate through the raw results. $value will be like [0 => Cart Object, 'totalQuantity' => 15]
foreach ($data as &$value) {

Check warning on line 123 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L123

Added line #L123 was not covered by tests
// Get the Cart entity object.
$cart = $value[0];

Check warning on line 125 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L125

Added line #L125 was not covered by tests
// Get the computed totalQuantity value using the alias defined in handleLinks.
// Use null coalescing operator for safety.
$cart->totalQuantity = $value['totalQuantity'] ?? 0;

Check warning on line 128 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L128

Added line #L128 was not covered by tests
// Replace the raw array structure with just the processed Cart object.
$value = $cart;

Check warning on line 130 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L130

Added line #L130 was not covered by tests
}

// Return the collection of Cart objects with the totalQuantity property populated.
return $data;

Check warning on line 134 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L134

Added line #L134 was not covered by tests
}

// Public property to hold the computed total quantity.
// Not mapped by Doctrine (@ORM\Column) but populated by the 'process' method.
// API Platform will serialize this property.
public ?int $totalQuantity;

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

/**
* @var Collection<int, CartProduct> the items in this cart
*/
#[ORM\OneToMany(targetEntity: CartProduct::class, mappedBy: 'cart', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $items;

public function __construct()

Check warning on line 153 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L153

Added line #L153 was not covered by tests
{
$this->items = new ArrayCollection();

Check warning on line 155 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L155

Added line #L155 was not covered by tests
}

public function getId(): ?int

Check warning on line 158 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L158

Added line #L158 was not covered by tests
{
return $this->id;

Check warning on line 160 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L160

Added line #L160 was not covered by tests
}

/**
* @return Collection<int, CartProduct>
*/
public function getItems(): Collection

Check warning on line 166 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L166

Added line #L166 was not covered by tests
{
return $this->items;

Check warning on line 168 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L168

Added line #L168 was not covered by tests
}

public function addItem(CartProduct $item): self

Check warning on line 171 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L171

Added line #L171 was not covered by tests
{
if (!$this->items->contains($item)) {
$this->items[] = $item;
$item->setCart($this);

Check warning on line 175 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L173-L175

Added lines #L173 - L175 were not covered by tests
}

return $this;

Check warning on line 178 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L178

Added line #L178 was not covered by tests
}

public function removeItem(CartProduct $item): self

Check warning on line 181 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L181

Added line #L181 was not covered by tests
{
if ($this->items->removeElement($item)) {

Check warning on line 183 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L183

Added line #L183 was not covered by tests
// set the owning side to null (unless already changed)
if ($item->getCart() === $this) {
$item->setCart(null);

Check warning on line 186 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L185-L186

Added lines #L185 - L186 were not covered by tests
}
}

return $this;

Check warning on line 190 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L190

Added line #L190 was not covered by tests
}
}

#[NotExposed()]
#[ORM\Entity]
class CartProduct
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false)]
private ?Cart $cart = null;

#[ORM\Column(type: 'integer')]
private int $quantity = 1;

public function getId(): ?int

Check warning on line 210 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L210

Added line #L210 was not covered by tests
{
return $this->id;

Check warning on line 212 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L212

Added line #L212 was not covered by tests
}

public function getCart(): ?Cart

Check warning on line 215 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L215

Added line #L215 was not covered by tests
{
return $this->cart;

Check warning on line 217 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L217

Added line #L217 was not covered by tests
}

public function setCart(?Cart $cart): self

Check warning on line 220 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L220

Added line #L220 was not covered by tests
{
$this->cart = $cart;

Check warning on line 222 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L222

Added line #L222 was not covered by tests

return $this;

Check warning on line 224 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L224

Added line #L224 was not covered by tests
}

public function getQuantity(): int

Check warning on line 227 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L227

Added line #L227 was not covered by tests
{
return $this->quantity;

Check warning on line 229 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L229

Added line #L229 was not covered by tests
}

public function setQuantity(int $quantity): self

Check warning on line 232 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L232

Added line #L232 was not covered by tests
{
$this->quantity = $quantity;

Check warning on line 234 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L234

Added line #L234 was not covered by tests

return $this;

Check warning on line 236 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L236

Added line #L236 was not covered by tests
}
}
}

namespace App\Playground {
use Symfony\Component\HttpFoundation\Request;

function request(): Request
{
return Request::create('/carts?sort[totalQuantity]=asc', 'GET');

Check warning on line 246 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L246

Added line #L246 was not covered by tests
}
}

namespace DoctrineMigrations {
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Migration extends AbstractMigration
{
public function up(Schema $schema): void

Check warning on line 256 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L256

Added line #L256 was not covered by tests
{
$this->addSql('CREATE TABLE cart (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)');
$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)');
$this->addSql('CREATE INDEX IDX_6DDC373A1AD5CDBF ON cart_product (cart_id)');

Check warning on line 260 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L258-L260

Added lines #L258 - L260 were not covered by tests
}
}
}

namespace App\Tests {
use ApiPlatform\Playground\Test\TestGuideTrait;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;

final class ComputedFieldTest extends ApiTestCase
{
use TestGuideTrait;

public function testCanSortByComputedField(): void

Check warning on line 273 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L273

Added line #L273 was not covered by tests
{
$ascReq = static::createClient()->request('GET', '/carts?sort[totalQuantity]=asc');
$this->assertResponseIsSuccessful();
$asc = $ascReq->toArray();
$this->assertGreaterThan(
$asc['member'][0]['totalQuantity'],
$asc['member'][1]['totalQuantity']
);

Check warning on line 281 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L275-L281

Added lines #L275 - L281 were not covered by tests
}
}
}

namespace App\Fixtures {
use App\Entity\Cart;
use App\Entity\CartProduct;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

use function Zenstruck\Foundry\anonymous;
use function Zenstruck\Foundry\repository;

final class CartFixtures extends Fixture
{
public function load(ObjectManager $manager): void

Check warning on line 297 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L297

Added line #L297 was not covered by tests
{
$cartFactory = anonymous(Cart::class);
if (repository(Cart::class)->count()) {
return;

Check warning on line 301 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L299-L301

Added lines #L299 - L301 were not covered by tests
}

$cartFactory->many(10)->create(fn ($i) => [
'items' => $this->createCartProducts($i),
]);

Check warning on line 306 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L304-L306

Added lines #L304 - L306 were not covered by tests
}

/**
* @return array<CartProduct>
*/
private function createCartProducts($i): array

Check warning on line 312 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L312

Added line #L312 was not covered by tests
{
$cartProducts = [];
for ($j = 1; $j <= 10; ++$j) {
$cartProduct = new CartProduct();
$cartProduct->setQuantity((int) abs($j / $i) + 1);
$cartProducts[] = $cartProduct;

Check warning on line 318 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L314-L318

Added lines #L314 - L318 were not covered by tests
}

return $cartProducts;

Check warning on line 321 in docs/guides/computed-field.php

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L321

Added line #L321 was not covered by tests
}
}
}
15 changes: 15 additions & 0 deletions src/Hydra/Serializer/CollectionFiltersNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,21 @@
}
}

if (str_contains($key, ':property') && $parameter->getProperties()) {
$required = $parameter->getRequired();
foreach ($parameter->getProperties() as $prop) {
$k = str_replace(':property', $prop, $key);
$m = ['@type' => 'IriTemplateMapping', 'variable' => $k, 'property' => $prop];
$variables[] = $k;
if (null !== $required) {
$m['required'] = $required;

Check warning on line 182 in src/Hydra/Serializer/CollectionFiltersNormalizer.php

View check run for this annotation

Codecov / codecov/patch

src/Hydra/Serializer/CollectionFiltersNormalizer.php#L182

Added line #L182 was not covered by tests
}
$mapping[] = $m;
}

continue;
}

if (!($property = $parameter->getProperty())) {
continue;
}
Expand Down
Loading
Loading