Skip to content
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
67 changes: 44 additions & 23 deletions docs/guides/computed-field.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);
// ---
// slug: computed-field
// name: Compute a field
Expand All @@ -12,6 +23,7 @@
// 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;
Expand Down Expand Up @@ -44,7 +56,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
*/
// 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.
// This also add constraint violations to the parameter that will reject any wrong values.
public function getSchema(Parameter $parameter): array
{
return ['type' => 'string', 'enum' => ['asc', 'desc']];
Expand All @@ -59,29 +71,28 @@ public function getDescription(string $resourceClass): array

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 App\Repository\CartRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\QueryBuilder;

#[ORM\Entity]
#[ORM\Entity(repositoryClass: CartRepository::class)]
// Defines the GetCollection operation for Cart, including computed 'totalQuantity'.
// Recipe involves:
// 1. handleLinks (modify query)
// 2. process (map result)
// 3. parameters (filters)
// 1. setup the repository method (modify query)
// 2. process (map result)
// 3. parameters (filters)
#[GetCollection(
normalizationContext: ['hydra_prefix' => false],
paginationItemsPerPage: 3,
paginationPartial: false,
// stateOptions: Uses handleLinks to modify the query *before* fetching.
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
// stateOptions: Uses repositoryMethod to modify the query *before* fetching. See App\Repository\CartRepository.
stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'),
// processor: Uses process to map the result *after* fetching, *before* serialization.
processor: [self::class, 'process'],
write: true,
Expand All @@ -99,20 +110,6 @@ public function getDescription(string $resourceClass): array
)]
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
{
// Get the alias for the root entity (Cart), usually 'o'.
$rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o';
// 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));
}

// 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].
Expand Down Expand Up @@ -238,6 +235,30 @@ public function setQuantity(int $quantity): self
}
}

namespace App\Repository {
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;

/**
* @extends EntityRepository<Cart::class>
*/
class CartRepository extends EntityRepository
{
// This repository method is used via stateOptions to alter the QueryBuilder *before* data is fetched.
// 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 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;
}
}
}

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

Expand Down
14 changes: 14 additions & 0 deletions src/Doctrine/Common/State/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Options implements OptionsInterface
*/
public function __construct(
protected mixed $handleLinks = null,
protected ?string $repositoryMethod = null,
) {
}

Expand All @@ -37,4 +38,17 @@ public function withHandleLinks(mixed $handleLinks): self

return $self;
}

public function getRepositoryMethod(): ?string
{
return $this->repositoryMethod;
}

public function withRepositoryMethod(?string $repositoryMethod): self
{
$self = clone $this;
$self->repositoryMethod = $repositoryMethod;

return $self;
}
}
15 changes: 14 additions & 1 deletion src/Doctrine/Odm/State/CollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Util\StateOptionsTrait;
use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\Persistence\ManagerRegistry;
Expand Down Expand Up @@ -57,7 +58,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
}

$aggregationBuilder = $repository->createAggregationBuilder();
if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
if (!method_exists($repository, $method)) {
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
}

$aggregationBuilder = $repository->{$method}();

if (!$aggregationBuilder instanceof AggregationBuilder) {
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class));
}
} else {
$aggregationBuilder = $repository->createAggregationBuilder();
}

if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
Expand Down
15 changes: 14 additions & 1 deletion src/Doctrine/Odm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Util\StateOptionsTrait;
use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\Persistence\ManagerRegistry;
Expand Down Expand Up @@ -71,7 +72,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
}

$aggregationBuilder = $repository->createAggregationBuilder();
if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
if (!method_exists($repository, $method)) {
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
}

$aggregationBuilder = $repository->{$method}();

if (!$aggregationBuilder instanceof AggregationBuilder) {
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class));
}
} else {
$aggregationBuilder = $repository->createAggregationBuilder();
}

if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
Expand Down
3 changes: 2 additions & 1 deletion src/Doctrine/Odm/State/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface
public function __construct(
protected ?string $documentClass = null,
mixed $handleLinks = null,
?string $repositoryMethod = null,
) {
parent::__construct(handleLinks: $handleLinks);
parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod);
}

public function getDocumentClass(): ?string
Expand Down
21 changes: 18 additions & 3 deletions src/Doctrine/Orm/State/CollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Util\StateOptionsTrait;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;

Expand Down Expand Up @@ -56,11 +57,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
$manager = $this->managerRegistry->getManagerForClass($entityClass);

$repository = $manager->getRepository($entityClass);
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');

if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
if (!method_exists($repository, $method)) {
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
}

$queryBuilder = $repository->{$method}();

if (!$queryBuilder instanceof QueryBuilder) {
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class));
}
} else {
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
}

$queryBuilder = $repository->createQueryBuilder('o');
}

$queryBuilder = $repository->createQueryBuilder('o');
$queryNameGenerator = new QueryNameGenerator();

if ($handleLinks = $this->getLinksHandler($operation)) {
Expand Down
21 changes: 18 additions & 3 deletions src/Doctrine/Orm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Util\StateOptionsTrait;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;

Expand Down Expand Up @@ -65,11 +66,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
}

$repository = $manager->getRepository($entityClass);
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');

if ($method = $this->getStateOptionsRepositoryMethod($operation)) {
if (!method_exists($repository, $method)) {
throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method));
}

$queryBuilder = $repository->{$method}();

if (!$queryBuilder instanceof QueryBuilder) {
throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class));
}
} else {
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
}

$queryBuilder = $repository->createQueryBuilder('o');
}

$queryBuilder = $repository->createQueryBuilder('o');
$queryNameGenerator = new QueryNameGenerator();

if ($handleLinks = $this->getLinksHandler($operation)) {
Expand Down
3 changes: 2 additions & 1 deletion src/Doctrine/Orm/State/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface
public function __construct(
protected ?string $entityClass = null,
mixed $handleLinks = null,
?string $repositoryMethod = null,
) {
parent::__construct(handleLinks: $handleLinks);
parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod);
}

public function getEntityClass(): ?string
Expand Down
16 changes: 16 additions & 0 deletions src/State/Util/StateOptionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,20 @@ public function getStateOptionsClass(Operation $operation, ?string $defaultClass

return $defaultClass;
}

public function getStateOptionsRepositoryMethod(Operation $operation): ?string
{
if (!$options = $operation->getStateOptions()) {
return null;
}

if (
(class_exists(Options::class) && $options instanceof Options)
|| (class_exists(ODMOptions::class) && $options instanceof ODMOptions)
) {
return $options->getRepositoryMethod();
}

return null;
}
}
16 changes: 3 additions & 13 deletions tests/Fixtures/TestBundle/Entity/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,21 @@
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;

use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\QueryParameter;
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter;
use ApiPlatform\Tests\Fixtures\TestBundle\Repository\CartRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\QueryBuilder;

#[ORM\Entity]
#[ORM\Entity(repositoryClass: CartRepository::class)]
#[GetCollection(
normalizationContext: ['hydra_prefix' => false],
paginationItemsPerPage: 3,
paginationPartial: false,
stateOptions: new Options(handleLinks: [self::class, 'handleLinks']),
stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'),
processor: [self::class, 'process'],
write: true,
parameters: [
Expand All @@ -53,15 +52,6 @@ public static function process(mixed $data, Operation $operation, array $uriVari
return $data;
}

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 int|string|null $totalQuantity;

#[ORM\Id]
Expand Down
34 changes: 34 additions & 0 deletions tests/Fixtures/TestBundle/Repository/CartRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\Repository;

use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Cart;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;

/**
* @extends EntityRepository<Cart>
*/
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;
}
}
Loading