The international conference on the API Platform Framework
Join the event where Symfony and Laravel communities gather to share their expertise.
Only a few tickets left!/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);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() ?? '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\Metadata\GetCollection;
use ApiPlatform\Metadata\NotExposed;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\QueryParameter;
use App\Filter\SortComputedFieldFilter;
use App\Repository\CartRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CartRepository::class)]
#[GetCollection(
normalizationContext: ['hydra_prefix' => false],
paginationItemsPerPage: 3,
paginationPartial: false, stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'), processor: [self::class, 'process'],
write: true, properties: ['totalQuantity'],
property: 'totalQuantity'
),
]
)]
class Cart
{ 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/Repository.php
namespace App\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
* @extends EntityRepository<Cart::class>
*/
class CartRepository extends EntityRepository
{ public function getCartsWithTotalQuantity(): QueryBuilder
{
$queryBuilder = $this->createQueryBuilder('o');
$queryBuilder->leftJoin('o.items', 'items')
->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity')
->addGroupBy('o.id');
return $queryBuilder;
}
}
// 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