Skip to content

feat(doctrine): state options repositoryMethod for initial query buil… #7115

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
62 changes: 42 additions & 20 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 <[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);
// ---
// 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 @@
*/
// 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 Down Expand Up @@ -73,15 +85,15 @@
#[ORM\Entity]
// 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'),

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

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L96

Added line #L96 was not covered by tests
// processor: Uses process to map the result *after* fetching, *before* serialization.
processor: [self::class, 'process'],
write: true,
Expand All @@ -99,20 +111,6 @@
)]
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 +236,30 @@
}
}

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

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

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L251

Added line #L251 was not covered by tests
{
$queryBuilder = $this->createQueryBuilder('o');
$queryBuilder->leftJoin('o.items', 'items')
->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity')
->addGroupBy('o.id');

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#L253-L256

Added lines #L253 - L256 were not covered by tests

return $queryBuilder;

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

View check run for this annotation

Codecov / codecov/patch

docs/guides/computed-field.php#L258

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

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 @@
*/
public function __construct(
protected mixed $handleLinks = null,
protected ?string $repositoryMethod = null,
) {
}

Expand All @@ -37,4 +38,17 @@

return $self;
}

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

public function withRepositoryMethod(?string $repositoryMethod): self

Check warning on line 47 in src/Doctrine/Common/State/Options.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Common/State/Options.php#L47

Added line #L47 was not covered by tests
{
$self = clone $this;
$self->repositoryMethod = $repositoryMethod;

Check warning on line 50 in src/Doctrine/Common/State/Options.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Common/State/Options.php#L49-L50

Added lines #L49 - L50 were not covered by tests

return $self;

Check warning on line 52 in src/Doctrine/Common/State/Options.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Common/State/Options.php#L52

Added line #L52 was not covered by tests
}
}
19 changes: 18 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,23 @@
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));

Check warning on line 63 in src/Doctrine/Odm/State/CollectionProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/CollectionProvider.php#L62-L63

Added lines #L62 - L63 were not covered by tests
}

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

Check warning on line 66 in src/Doctrine/Odm/State/CollectionProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/CollectionProvider.php#L66

Added line #L66 was not covered by tests

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

Check warning on line 69 in src/Doctrine/Odm/State/CollectionProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/CollectionProvider.php#L68-L69

Added lines #L68 - L69 were not covered by tests
}
} else {
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');

Check warning on line 73 in src/Doctrine/Odm/State/CollectionProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/CollectionProvider.php#L73

Added line #L73 was not covered by tests
}

$aggregationBuilder = $repository->createAggregationBuilder();
}

if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
Expand Down
19 changes: 18 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 @@ -65,7 +66,23 @@
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));

Check warning on line 71 in src/Doctrine/Odm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/ItemProvider.php#L70-L71

Added lines #L70 - L71 were not covered by tests
}

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

Check warning on line 74 in src/Doctrine/Odm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/ItemProvider.php#L74

Added line #L74 was not covered by tests

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

Check warning on line 77 in src/Doctrine/Odm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/ItemProvider.php#L76-L77

Added lines #L76 - L77 were not covered by tests
}
} else {
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');

Check warning on line 81 in src/Doctrine/Odm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Odm/State/ItemProvider.php#L81

Added line #L81 was not covered by tests
}

$aggregationBuilder = $repository->createAggregationBuilder();
}

if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
Expand Down
4 changes: 3 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 All @@ -42,4 +43,5 @@ public function withDocumentClass(?string $documentClass): self

return $self;
}

}
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 @@
$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));

Check warning on line 63 in src/Doctrine/Orm/State/CollectionProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Orm/State/CollectionProvider.php#L63

Added line #L63 was not covered by tests
}

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

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

Check warning on line 69 in src/Doctrine/Orm/State/CollectionProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Orm/State/CollectionProvider.php#L69

Added line #L69 was not covered by tests
}
} else {
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');

Check warning on line 73 in src/Doctrine/Orm/State/CollectionProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Orm/State/CollectionProvider.php#L73

Added line #L73 was not covered by tests
}

$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 @@
}

$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));

Check warning on line 72 in src/Doctrine/Orm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Orm/State/ItemProvider.php#L71-L72

Added lines #L71 - L72 were not covered by tests
}

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

Check warning on line 75 in src/Doctrine/Orm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Orm/State/ItemProvider.php#L75

Added line #L75 was not covered by tests

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

Check warning on line 78 in src/Doctrine/Orm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Orm/State/ItemProvider.php#L77-L78

Added lines #L77 - L78 were not covered by tests
}
} else {
if (!method_exists($repository, 'createQueryBuilder')) {
throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');

Check warning on line 82 in src/Doctrine/Orm/State/ItemProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Doctrine/Orm/State/ItemProvider.php#L82

Added line #L82 was not covered by tests
}

$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 @@

return $defaultClass;
}

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

Check warning on line 62 in src/State/Util/StateOptionsTrait.php

View check run for this annotation

Codecov / codecov/patch

src/State/Util/StateOptionsTrait.php#L62

Added line #L62 was not covered by tests
}

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

return null;

Check warning on line 72 in src/State/Util/StateOptionsTrait.php

View check run for this annotation

Codecov / codecov/patch

src/State/Util/StateOptionsTrait.php#L72

Added line #L72 was not covered by tests
}
}
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'),

Check warning on line 31 in tests/Fixtures/TestBundle/Entity/Cart.php

View check run for this annotation

Codecov / codecov/patch

tests/Fixtures/TestBundle/Entity/Cart.php#L31

Added line #L31 was not covered by tests
processor: [self::class, 'process'],
write: true,
parameters: [
Expand All @@ -53,15 +52,6 @@
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 $totalQuantity;

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

namespace ApiPlatform\Tests\Fixtures\TestBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
* @extends EntityRepository<Cart::class>
*/
class CartRepository extends EntityRepository

Check failure on line 10 in tests/Fixtures/TestBundle/Repository/CartRepository.php

View workflow job for this annotation

GitHub Actions / PHPStan (PHP 8.4)

Type mixed in generic type Doctrine\ORM\EntityRepository<mixed> in PHPDoc tag @extends is not subtype of template type TEntityClass of object of class Doctrine\ORM\EntityRepository.
{
public function getCartsWithTotalQuantity(): QueryBuilder

Check warning on line 12 in tests/Fixtures/TestBundle/Repository/CartRepository.php

View check run for this annotation

Codecov / codecov/patch

tests/Fixtures/TestBundle/Repository/CartRepository.php#L12

Added line #L12 was not covered by tests
{
$queryBuilder = $this->createQueryBuilder('o');
$queryBuilder->leftJoin('o.items', 'items')
->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity')
->addGroupBy('o.id');

Check warning on line 17 in tests/Fixtures/TestBundle/Repository/CartRepository.php

View check run for this annotation

Codecov / codecov/patch

tests/Fixtures/TestBundle/Repository/CartRepository.php#L14-L17

Added lines #L14 - L17 were not covered by tests

return $queryBuilder;

Check warning on line 19 in tests/Fixtures/TestBundle/Repository/CartRepository.php

View check run for this annotation

Codecov / codecov/patch

tests/Fixtures/TestBundle/Repository/CartRepository.php#L19

Added line #L19 was not covered by tests
}
}
Loading