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 Sep 8, 2023
1 parent 46e84ff commit c7dcd36
Show file tree
Hide file tree
Showing 39 changed files with 776 additions and 67 deletions.
1 change: 1 addition & 0 deletions .php-cs-fixer.dist.php
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
17 changes: 17 additions & 0 deletions features/doctrine/handle_links.feature
@@ -0,0 +1,17 @@
Feature: Use a link handler to retrieve a resource

@createSchema
Scenario: Get collection
Given there are a few link handled dummies
When I send a "GET" request to "/link_handled_dummies"
Then the response status code should be 200
And the response should be in JSON
And the JSON node "hydra:totalItems" should be equal to 1

@createSchema
Scenario: Get item
Given there are a few link handled dummies
When I send a "GET" request to "/link_handled_dummies/1"
Then the response status code should be 200
And the response should be in JSON
And the JSON node "slug" should be equal to "foo"
58 changes: 49 additions & 9 deletions features/doctrine/separated_resource.feature
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"
4 changes: 2 additions & 2 deletions src/Doctrine/Common/Filter/OrderFilterTrait.php
Expand Up @@ -39,8 +39,8 @@ public function getDescription(string $resourceClass): array
$description = [];

$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
if (null === $properties && $fieldNames = $this->getClassMetadata($resourceClass)->getFieldNames()) {
$properties = array_fill_keys($fieldNames, null);
}

foreach ($properties as $property => $propertyOptions) {
Expand Down
17 changes: 4 additions & 13 deletions src/Doctrine/Common/PropertyHelperTrait.php
Expand Up @@ -13,7 +13,6 @@

namespace ApiPlatform\Doctrine\Common;

use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;

/**
Expand All @@ -25,7 +24,10 @@
*/
trait PropertyHelperTrait
{
abstract protected function getManagerRegistry(): ManagerRegistry;
/**
* Gets class metadata for the given resource.
*/
abstract protected function getClassMetadata(string $resourceClass): ClassMetadata;

/**
* Determines whether the given property is mapped.
Expand Down Expand Up @@ -125,15 +127,4 @@ protected function getNestedMetadata(string $resourceClass, array $associations)

return $metadata;
}

/**
* Gets class metadata for the given resource.
*/
protected function getClassMetadata(string $resourceClass): ClassMetadata
{
return $this
->getManagerRegistry()
->getManagerForClass($resourceClass)
->getClassMetadata($resourceClass);
}
}
22 changes: 21 additions & 1 deletion src/Doctrine/Common/State/LinksHandlerTrait.php
Expand Up @@ -14,17 +14,19 @@
namespace ApiPlatform\Doctrine\Common\State;

use ApiPlatform\Exception\OperationNotFoundException;
use ApiPlatform\Exception\RuntimeException;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use Psr\Container\ContainerInterface;

trait LinksHandlerTrait
{
private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory;
private ?ContainerInterface $handleLinksLocator;

/**
* @return Link[]
Expand Down Expand Up @@ -112,4 +114,22 @@ private function getOperationLinks(Operation $operation = null): array

return [];
}

private function getLinksHandler(Operation $operation): ?callable
{
if (!($options = $operation->getStateOptions()) || !method_exists($options, 'getHandleLinks') || null === $options->getHandleLinks()) {
return null;
}

$handleLinks = $options->getHandleLinks(); // @phpstan-ignore-line method_exists called above
if (\is_callable($handleLinks)) {
return $handleLinks;
}

if ($this->handleLinksLocator && \is_string($handleLinks) && $this->handleLinksLocator->has($handleLinks)) {
return [$this->handleLinksLocator->get($handleLinks), 'handleLinks'];
}

throw new RuntimeException(sprintf('Could not find handleLinks service "%s"', $handleLinks));
}
}
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) {
$documentClass = $operation->getClass();
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
$documentClass = $options->getDocumentClass();
}

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

Expand Down
16 changes: 15 additions & 1 deletion src/Doctrine/Odm/PropertyHelperTrait.php
Expand Up @@ -17,6 +17,7 @@
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbOdmClassMetadata;
use Doctrine\ODM\MongoDB\Mapping\MappingException;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;

/**
Expand All @@ -26,6 +27,8 @@
*/
trait PropertyHelperTrait
{
abstract protected function getManagerRegistry(): ManagerRegistry;

/**
* Splits the given property into parts.
*/
Expand All @@ -34,7 +37,18 @@ abstract protected function splitPropertyParts(string $property, string $resourc
/**
* Gets class metadata for the given resource.
*/
abstract protected function getClassMetadata(string $resourceClass): ClassMetadata;
protected function getClassMetadata(string $resourceClass): ClassMetadata
{
$manager = $this
->getManagerRegistry()
->getManagerForClass($resourceClass);

if ($manager) {
return $manager->getClassMetadata($resourceClass);
}

return new MongoDbOdmClassMetadata($resourceClass);
}

/**
* Adds the necessary lookups for a nested property.
Expand Down
30 changes: 20 additions & 10 deletions src/Doctrine/Odm/State/CollectionProvider.php
Expand Up @@ -22,6 +22,7 @@
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;

/**
* Collection state provider using the Doctrine ODM.
Expand All @@ -33,37 +34,46 @@ final class CollectionProvider implements ProviderInterface
/**
* @param AggregationCollectionExtensionInterface[] $collectionExtensions
*/
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ContainerInterface $handleLinksLocator = null)
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->handleLinksLocator = $handleLinksLocator;
}

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

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

$repository = $manager->getRepository($resourceClass);
$repository = $manager->getRepository($documentClass);
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".', $documentClass, DocumentRepository::class));
}

$aggregationBuilder = $repository->createAggregationBuilder();

$this->handleLinks($aggregationBuilder, $uriVariables, $context, $resourceClass, $operation);
if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
} else {
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $documentClass, $operation);
}

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

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

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

return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions);
return $aggregationBuilder->hydrate($documentClass)->execute($executeOptions);
}
}
32 changes: 21 additions & 11 deletions src/Doctrine/Odm/State/ItemProvider.php
Expand Up @@ -22,6 +22,7 @@
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;

/**
* Item state provider using the Doctrine ODM.
Expand All @@ -36,41 +37,50 @@ final class ItemProvider implements ProviderInterface
/**
* @param AggregationItemExtensionInterface[] $itemExtensions
*/
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ContainerInterface $handleLinksLocator = null)
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->handleLinksLocator = $handleLinksLocator;
}

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

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

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

$repository = $manager->getRepository($resourceClass);
$repository = $manager->getRepository($documentClass);
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".', $documentClass, DocumentRepository::class));
}

$aggregationBuilder = $repository->createAggregationBuilder();

$this->handleLinks($aggregationBuilder, $uriVariables, $context, $resourceClass, $operation);
if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
} else {
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $documentClass, $operation);
}

foreach ($this->itemExtensions as $extension) {
$extension->applyToItem($aggregationBuilder, $resourceClass, $uriVariables, $operation, $context);
$extension->applyToItem($aggregationBuilder, $documentClass, $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($documentClass, $operation, $context)) {
return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
}
}

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

return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions)->current() ?: null;
return $aggregationBuilder->hydrate($documentClass)->execute($executeOptions)->current() ?: null;
}
}

0 comments on commit c7dcd36

Please sign in to comment.