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
.// src/App/Filter.php
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;
class SortComputedFieldFilter implements FilterInterface, JsonSchemaFilterInterface
{
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if ($context['parameter']->getValue() instanceof ParameterNotFound) {
return;
}
$queryBuilder->addOrderBy('totalQuantity', $context['parameter']->getValue()['totalQuantity'] ?? 'ASC');
}
/**
* @return array<string, mixed>
*/
public function getSchema(Parameter $parameter): array
{
return ['type' => 'string', 'enum' => ['asc', 'desc']];
}
public function getDescription(string $resourceClass): array
{
return [];
}
}
// src/App/Entity.php
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]
#[GetCollection(
normalizationContext: ['hydra_prefix' => false],
paginationItemsPerPage: 3,
paginationPartial: false,
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
processor: [self::class, 'process'],
write: true,
properties: ['totalQuantity'],
property: 'totalQuantity'
),
]
)]
class Cart
{
public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void
{
$rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';
$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));
}
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
foreach ($data as &$value) {
$cart->totalQuantity = $value['totalQuantity'] ?? 0;
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()
{
$this->items = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
/**
* @return Collection<int, CartProduct>
*/
public function getItems(): Collection
{
return $this->items;
}
public function addItem(CartProduct $item): self
{
if (!$this->items->contains($item)) {
$this->items[] = $item;
$item->setCart($this);
}
return $this;
}
public function removeItem(CartProduct $item): self
{
if ($this->items->removeElement($item)) {
if ($item->getCart() === $this) {
$item->setCart(null);
}
}
return $this;
}
}
#[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
{
return $this->id;
}
public function getCart(): ?Cart
{
return $this->cart;
}
public function setCart(?Cart $cart): self
{
$this->cart = $cart;
return $this;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): self
{
$this->quantity = $quantity;
return $this;
}
}
// src/App/Playground.php
namespace App\Playground;
use Symfony\Component\HttpFoundation\Request;
function request(): Request
{
return Request::create('/carts?sort[totalQuantity]=asc', 'GET');
}
// src/DoctrineMigrations.php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Migration extends AbstractMigration
{
public function up(Schema $schema): void
{
$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)');
}
}
// src/App/Tests.php
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
{
$ascReq = static::createClient()->request('GET', '/carts?sort[totalQuantity]=asc');
$this->assertResponseIsSuccessful();
$asc = $ascReq->toArray();
$this->assertGreaterThan(
$asc['member'][0]['totalQuantity'],
$asc['member'][1]['totalQuantity']
);
}
}
// src/App/Fixtures.php
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
{
$cartFactory = anonymous(Cart::class);
if (repository(Cart::class)->count()) {
return;
}
$cartFactory->many(10)->create(fn ($i) => [
'items' => $this->createCartProducts($i),
]);
}
/**
* @return array<CartProduct>
*/
private function createCartProducts($i): array
{
$cartProducts = [];
for ($j = 1; $j <= 10; ++$j) {
$cartProduct = new CartProduct();
$cartProduct->setQuantity((int) abs($j / $i) + 1);
$cartProducts[] = $cartProduct;
}
return $cartProducts;
}
}
Made with love by
Les-Tilleuls.coop can help you design and develop your APIs and web projects, and train your teams in API Platform, Symfony, Next.js, Kubernetes and a wide range of other technologies.
Learn more