Skip to content

Commit

Permalink
feat(doctrine): stateOptions can handleLinks for query optimization
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Aug 8, 2023
1 parent 5bc422c commit 57b5273
Show file tree
Hide file tree
Showing 21 changed files with 378 additions and 40 deletions.
1 change: 1 addition & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
'src/Core/Bridge/Symfony/Maker/Resources/skeleton',
'tests/Fixtures/app/var',
'docs/guides',
'docs/var',
])
->notPath('src/Symfony/Bundle/DependencyInjection/Configuration.php')
->notPath('src/Annotation/ApiFilter.php') // temporary
Expand Down
58 changes: 49 additions & 9 deletions features/doctrine/separated_resource.feature
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,6 @@ Feature: Use state options to use an entity that is not a resource
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"

@!mongodb
@createSchema
Scenario: Get item
Given there are 5 separated entities
When I send a "GET" request to "/separated_entities/1"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"

@!mongodb
@createSchema
Scenario: Get all EntityClassAndCustomProviderResources
Expand All @@ -74,3 +65,52 @@ Feature: Use state options to use an entity that is not a resource
Given there are 1 separated entities
When I send a "GET" request to "/entityClassAndCustomProviderResources/1"
Then the response status code should be 200

@mongodb
@createSchema
Scenario: Get collection
Given there are 5 separated entities
When I send a "GET" request to "/separated_documents"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
Then the JSON should be valid according to this schema:
"""
{
"type": "object",
"properties": {
"@context": {"pattern": "^/contexts/SeparatedDocument"},
"@id": {"pattern": "^/separated_documents"},
"@type": {"pattern": "^hydra:Collection$"},
"hydra:member": {
"type": "array",
"items": {
"type": "object"
}
},
"hydra:totalItems": {"type":"number"},
"hydra:view": {
"type": "object"
}
}
}
"""

@mongodb
@createSchema
Scenario: Get ordered collection
Given there are 5 separated entities
When I send a "GET" request to "/separated_documents?order[value]=desc"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "hydra:member[0].value" should be equal to "5"

@mongodb
@createSchema
Scenario: Get item
Given there are 5 separated entities
When I send a "GET" request to "/separated_documents/1"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use ApiPlatform\Doctrine\Odm\State\CollectionProvider;
use ApiPlatform\Doctrine\Odm\State\ItemProvider;
use ApiPlatform\Doctrine\Odm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\DeleteOperationInterface;
Expand Down Expand Up @@ -44,7 +45,12 @@ public function create(string $resourceClass): ResourceMetadataCollection
if ($operations) {
/** @var Operation $operation */
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
if (!$this->managerRegistry->getManagerForClass($operation->getClass()) instanceof DocumentManager) {
$entityClass = $operation->getClass();
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) {
$entityClass = $options->getEntityClass();
}

if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof DocumentManager) {
continue;
}

Expand Down
26 changes: 17 additions & 9 deletions src/Doctrine/Odm/State/CollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,30 +40,38 @@ public function __construct(ResourceMetadataCollectionFactoryInterface $resource

public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
{
$resourceClass = $operation->getClass();
$entityClass = $operation->getClass();
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) {
$entityClass = $options->getEntityClass();
}

/** @var DocumentManager $manager */
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
$manager = $this->managerRegistry->getManagerForClass($entityClass);

$repository = $manager->getRepository($resourceClass);
$repository = $manager->getRepository($entityClass);
if (!$repository instanceof DocumentRepository) {
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $resourceClass, DocumentRepository::class));
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $entityClass, DocumentRepository::class));
}

$aggregationBuilder = $repository->createAggregationBuilder();

$this->handleLinks($aggregationBuilder, $uriVariables, $context, $resourceClass, $operation);
if ($options instanceof Options && \is_callable($handleLinks = $options->getHandleLinks())) {
$handleLinks($aggregationBuilder, $uriVariables, $context, $entityClass, $operation);
} else {
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $entityClass, $operation);
}

foreach ($this->collectionExtensions as $extension) {
$extension->applyToCollection($aggregationBuilder, $resourceClass, $operation, $context);
$extension->applyToCollection($aggregationBuilder, $entityClass, $operation, $context);

if ($extension instanceof AggregationResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operation, $context)) {
return $extension->getResult($aggregationBuilder, $resourceClass, $operation, $context);
if ($extension instanceof AggregationResultCollectionExtensionInterface && $extension->supportsResult($entityClass, $operation, $context)) {
return $extension->getResult($aggregationBuilder, $entityClass, $operation, $context);
}
}

$attribute = $operation->getExtraProperties()['doctrine_mongodb'] ?? [];
$executeOptions = $attribute['execute_options'] ?? [];

return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions);
return $aggregationBuilder->hydrate($entityClass)->execute($executeOptions);
}
}
28 changes: 18 additions & 10 deletions src/Doctrine/Odm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,34 +43,42 @@ public function __construct(ResourceMetadataCollectionFactoryInterface $resource

public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$resourceClass = $operation->getClass();
$entityClass = $operation->getClass();
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) {
$entityClass = $options->getEntityClass();
}

/** @var DocumentManager $manager */
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
$manager = $this->managerRegistry->getManagerForClass($entityClass);

$fetchData = $context['fetch_data'] ?? true;
if (!$fetchData) {
return $manager->getReference($resourceClass, reset($uriVariables));
return $manager->getReference($entityClass, reset($uriVariables));
}

$repository = $manager->getRepository($resourceClass);
$repository = $manager->getRepository($entityClass);
if (!$repository instanceof DocumentRepository) {
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $resourceClass, DocumentRepository::class));
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $entityClass, DocumentRepository::class));
}

$aggregationBuilder = $repository->createAggregationBuilder();

$this->handleLinks($aggregationBuilder, $uriVariables, $context, $resourceClass, $operation);
if ($options instanceof Options && \is_callable($handleLinks = $options->getHandleLinks())) {
$handleLinks($aggregationBuilder, $uriVariables, $context, $entityClass, $operation);
} else {
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $entityClass, $operation);
}

foreach ($this->itemExtensions as $extension) {
$extension->applyToItem($aggregationBuilder, $resourceClass, $uriVariables, $operation, $context);
$extension->applyToItem($aggregationBuilder, $entityClass, $uriVariables, $operation, $context);

if ($extension instanceof AggregationResultItemExtensionInterface && $extension->supportsResult($resourceClass, $operation, $context)) {
return $extension->getResult($aggregationBuilder, $resourceClass, $operation, $context);
if ($extension instanceof AggregationResultItemExtensionInterface && $extension->supportsResult($entityClass, $operation, $context)) {
return $extension->getResult($aggregationBuilder, $entityClass, $operation, $context);
}
}

$executeOptions = $operation->getExtraProperties()['doctrine_mongodb']['execute_options'] ?? [];

return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions)->current() ?: null;
return $aggregationBuilder->hydrate($entityClass)->execute($executeOptions)->current() ?: null;
}
}
39 changes: 35 additions & 4 deletions src/Doctrine/Odm/State/LinksHandlerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ private function handleLinks(Builder $aggregationBuilder, array $identifiers, ar

$executeOptions = $operation->getExtraProperties()['doctrine_mongodb']['execute_options'] ?? [];

$this->buildAggregation($resourceClass, array_reverse($links), array_reverse($identifiers), $context, $executeOptions, $resourceClass, $aggregationBuilder);
$this->buildAggregation($resourceClass, array_reverse($links), array_reverse($identifiers), $context, $executeOptions, $resourceClass, $aggregationBuilder, $operation);
}

/**
* @throws RuntimeException
*/
private function buildAggregation(string $toClass, array $links, array $identifiers, array $context, array $executeOptions, string $previousAggregationClass, Builder $previousAggregationBuilder): Builder
private function buildAggregation(string $toClass, array $links, array $identifiers, array $context, array $executeOptions, string $previousAggregationClass, Builder $previousAggregationBuilder, Operation $operation): Builder
{
if (\count($links) <= 0) {
return $previousAggregationBuilder;
Expand All @@ -70,12 +70,18 @@ private function buildAggregation(string $toClass, array $links, array $identifi
if ($toProperty) {
$aggregationClass = $toClass;
}

$lookupProperty = $toProperty ?? $fromProperty;
$lookupPropertyAlias = $lookupProperty ? "{$lookupProperty}_lkup" : null;

$manager = $this->managerRegistry->getManagerForClass($aggregationClass);
if (!$manager instanceof DocumentManager) {
throw new RuntimeException(sprintf('The manager for "%s" must be an instance of "%s".', $aggregationClass, DocumentManager::class));
$aggregationClass = $this->getLinkFromClass($link, $operation);
$manager = $this->managerRegistry->getManagerForClass($aggregationClass);

if (!$manager instanceof DocumentManager) {
throw new RuntimeException(sprintf('The manager for "%s" must be an instance of "%s".', $aggregationClass, DocumentManager::class));
}
}

$classMetadata = $manager->getClassMetadata($aggregationClass);
Expand Down Expand Up @@ -104,7 +110,7 @@ private function buildAggregation(string $toClass, array $links, array $identifi
}

// Recurse aggregations
$aggregation = $this->buildAggregation($fromClass, $links, $identifiers, $context, $executeOptions, $aggregationClass, $aggregation);
$aggregation = $this->buildAggregation($fromClass, $links, $identifiers, $context, $executeOptions, $aggregationClass, $aggregation, $operation);

if (null === $fromProperty || null !== $toProperty) {
return $aggregation;
Expand All @@ -121,4 +127,29 @@ private function buildAggregation(string $toClass, array $links, array $identifi

return $previousAggregationBuilder;
}

private function getLinkFromClass(Link $link, Operation $operation): string
{
$fromClass = $link->getFromClass();
if ($fromClass === $operation->getClass() && $entityClass = $this->getStateOptionsEntityClass($operation)) {
return $entityClass;
}

$operation = $this->resourceMetadataCollectionFactory->create($fromClass)->getOperation();

if ($entityClass = $this->getStateOptionsEntityClass($operation)) {
return $entityClass;
}

throw new \Exception('Can not found a doctrine class for this link.');
}

private function getStateOptionsEntityClass(Operation $operation): ?string
{
if (($options = $operation->getStateOptions()) && $options instanceof Options && $entityClass = $options->getEntityClass()) {
return $entityClass;
}

return null;
}
}
57 changes: 57 additions & 0 deletions src/Doctrine/Odm/State/Options.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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\Doctrine\Odm\State;

use ApiPlatform\State\OptionsInterface;

class Options implements OptionsInterface
{
/**
* @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future
*/
public function __construct(
protected ?string $entityClass = null,
/**
* static function handleLinks(Doctrine\ODM\MongoDB\Aggregation\Builder $aggregationBuilder, array $identifiers, array $context, string $resourceClass, ApiPlatform\Metadata\Operation $operation): void.
*/
protected mixed $handleLinks = null,
) {
}

public function getEntityClass(): ?string
{
return $this->entityClass;
}

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

return $self;
}

public function getHandleLinks(): mixed
{
return $this->handleLinks;
}

public function withHandleLinks(mixed $handleLinks): self
{
$self = clone $this;
$self->handleLinks = $handleLinks;

return $self;
}
}
6 changes: 5 additions & 1 deletion src/Doctrine/Orm/State/CollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
$queryBuilder = $repository->createQueryBuilder('o');
$queryNameGenerator = new QueryNameGenerator();

$this->handleLinks($queryBuilder, $uriVariables, $queryNameGenerator, $context, $entityClass, $operation);
if ($options instanceof Options && \is_callable($handleLinks = $options->getHandleLinks())) {
$handleLinks($queryBuilder, $uriVariables, $queryNameGenerator, $context, $entityClass, $operation);
} else {
$this->handleLinks($queryBuilder, $uriVariables, $queryNameGenerator, $context, $entityClass, $operation);
}

foreach ($this->collectionExtensions as $extension) {
$extension->applyToCollection($queryBuilder, $queryNameGenerator, $entityClass, $operation, $context);
Expand Down
6 changes: 5 additions & 1 deletion src/Doctrine/Orm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
$queryBuilder = $repository->createQueryBuilder('o');
$queryNameGenerator = new QueryNameGenerator();

$this->handleLinks($queryBuilder, $uriVariables, $queryNameGenerator, $context, $entityClass, $operation);
if ($options instanceof Options && \is_callable($handleLinks = $options->getHandleLinks())) {
$handleLinks($queryBuilder, $uriVariables, $queryNameGenerator, $context, $entityClass, $operation);
} else {
$this->handleLinks($queryBuilder, $uriVariables, $queryNameGenerator, $context, $entityClass, $operation);
}

foreach ($this->itemExtensions as $extension) {
$extension->applyToItem($queryBuilder, $queryNameGenerator, $entityClass, $uriVariables, $operation, $context);
Expand Down
20 changes: 20 additions & 0 deletions src/Doctrine/Orm/State/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@

class Options implements OptionsInterface
{
/**
* @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future
*/
public function __construct(
protected ?string $entityClass = null,
/**
* static function handleLinks(Doctrine\ORM\QueryBuilder $queryBuilder, array $identifiers, ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator $queryNameGenerator, array $context, string $entityClass, ApiPlatform\Metadata\Operation $operation): void.
*/
protected mixed $handleLinks = null,
) {
}

Expand All @@ -34,4 +41,17 @@ public function withEntityClass(?string $entityClass): self

return $self;
}

public function getHandleLinks(): mixed
{
return $this->handleLinks;
}

public function withHandleLinks(mixed $handleLinks): self
{
$self = clone $this;
$self->handleLinks = $handleLinks;

return $self;
}
}
Loading

0 comments on commit 57b5273

Please sign in to comment.