Skip to content

Commit

Permalink
Fix Doctrine query for nested subresources (#1608)
Browse files Browse the repository at this point in the history
  • Loading branch information
alanpoulain authored and dunglas committed Dec 29, 2017
1 parent 3d4fea5 commit a62e089
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 117 deletions.
10 changes: 8 additions & 2 deletions features/main/relation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Feature: Relations support
"@context": "/contexts/ThirdLevel",
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": null,
"id": 1,
"level": 3,
"test": true
Expand Down Expand Up @@ -64,7 +65,11 @@ Feature: Relations support
"name": null,
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": "/third_levels/1",
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": null
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": null,
Expand Down Expand Up @@ -258,7 +263,8 @@ Feature: Relations support
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"level": 3
"level": 3,
"fourthLevel": null
}
}
}
Expand Down
92 changes: 71 additions & 21 deletions features/main/subresource.feature
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ Feature: Subresource support
And the JSON should be equal to:
"""
{
"@context": "/contexts/Answer",
"@id": "/answers/1",
"@type": "Answer",
"id": 1,
"content": "42",
"question": "/questions/1",
"relatedQuestions": [
"/questions/1"
]
"@context": "/contexts/Answer",
"@id": "/answers/1",
"@type": "Answer",
"id": 1,
"content": "42",
"question": "/questions/1",
"relatedQuestions": [
"/questions/1"
]
}
"""

Expand All @@ -35,23 +35,43 @@ Feature: Subresource support
"@id": "/questions/1/answer/related_questions",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/questions/1",
"@type": "Question",
"content": "What's the answer to the Ultimate Question of Life, the Universe and Everything?",
"id": 1,
"answer": "/answers/1"
}
{
"@id": "/questions/1",
"@type": "Question",
"content": "What's the answer to the Ultimate Question of Life, the Universe and Everything?",
"id": 1,
"answer": "/answers/1"
}
],
"hydra:totalItems": 1
}
"""

Scenario: Create a fourth level
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "/fourth_levels" with body:
"""
{"level": 4}
"""
Then the response status code should be 201
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 should be equal to:
"""
{
"@context": "/contexts/FourthLevel",
"@id": "/fourth_levels/1",
"@type": "FourthLevel",
"id": 1,
"level": 4
}
"""

Scenario: Create a third level
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "/third_levels" with body:
"""
{"level": 3}
{"level": 3, "fourthLevel": "/fourth_levels/1"}
"""
Then the response status code should be 201
And the response should be in JSON
Expand All @@ -62,6 +82,7 @@ Feature: Subresource support
"@context": "/contexts/ThirdLevel",
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1",
"id": 1,
"level": 3,
"test": true
Expand Down Expand Up @@ -125,7 +146,11 @@ Feature: Subresource support
"name": "Hello",
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": "/third_levels/1",
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
Expand All @@ -138,7 +163,11 @@ Feature: Subresource support
"name": null,
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": "/third_levels/1",
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
Expand Down Expand Up @@ -193,7 +222,11 @@ Feature: Subresource support
"name": "Hello",
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": "/third_levels/1",
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
Expand Down Expand Up @@ -233,7 +266,7 @@ Feature: Subresource support
}
"""

Scenario: Get the embedded relation collection
Scenario: Get the embedded relation collection at the third level
When I send a "GET" request to "/dummies/1/related_dummies/1/third_level"
And the response status code should be 200
And the response should be in JSON
Expand All @@ -244,12 +277,29 @@ Feature: Subresource support
"@context": "/contexts/ThirdLevel",
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1",
"id": 1,
"level": 3,
"test": true
}
"""

Scenario: Get the embedded relation collection at the fourth level
When I send a "GET" request to "/dummies/1/related_dummies/1/third_level/fourth_level"
And 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 should be equal to:
"""
{
"@context": "/contexts/FourthLevel",
"@id": "/fourth_levels/1",
"@type": "FourthLevel",
"id": 1,
"level": 4
}
"""

Scenario: Get offers subresource from aggregate offers subresource
Given I have a product with offers
When I send a "GET" request to "/dummy_products/2/offers/1/offers"
Expand Down
150 changes: 74 additions & 76 deletions src/Bridge/Doctrine/Orm/SubresourceDataProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\QueryBuilder;

/**
* Subresource data provider for the Doctrine ORM.
Expand Down Expand Up @@ -79,82 +80,10 @@ public function getSubresource(string $resourceClass, array $identifiers, array
throw new ResourceClassNotSupportedException('The given resource class is not a subresource.');
}

$originAlias = 'o';
$queryBuilder = $repository->createQueryBuilder($originAlias);
$queryNameGenerator = new QueryNameGenerator();
$previousQueryBuilder = null;
$previousAlias = null;

$num = \count($context['identifiers']);

while ($num--) {
list($identifier, $identifierResourceClass) = $context['identifiers'][$num];
$previousAssociationProperty = $context['identifiers'][$num + 1][0] ?? $context['property'];

$manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);

if (!$manager instanceof EntityManagerInterface) {
throw new RuntimeException("The manager for $identifierResourceClass must be an EntityManager.");
}

$classMetadata = $manager->getClassMetadata($identifierResourceClass);

if (!$classMetadata instanceof ClassMetadataInfo) {
throw new RuntimeException("The class metadata for $identifierResourceClass must be an instance of ClassMetadataInfo.");
}

$qb = $manager->createQueryBuilder();
$alias = $queryNameGenerator->generateJoinAlias($identifier);
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
$normalizedIdentifiers = isset($identifiers[$identifier]) ? $this->normalizeIdentifiers($identifiers[$identifier], $manager, $identifierResourceClass) : [];

switch ($relationType) {
//MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
case ClassMetadataInfo::MANY_TO_MANY:
$joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);

$qb->select($joinAlias)
->from($identifierResourceClass, $alias)
->innerJoin("$alias.$previousAssociationProperty", $joinAlias);

break;
case ClassMetadataInfo::ONE_TO_MANY:
$mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];

// first pass, o.property instead of alias.property
if (null === $previousQueryBuilder) {
$originAlias = "$originAlias.$mappedBy";
} else {
$previousAlias = "$previousAlias.$mappedBy";
}

$qb->select($alias)
->from($identifierResourceClass, $alias);
break;
default:
$qb->select("IDENTITY($alias.$previousAssociationProperty)")
->from($identifierResourceClass, $alias);
}

// Add where clause for identifiers
foreach ($normalizedIdentifiers as $key => $value) {
$placeholder = $queryNameGenerator->generateParameterName($key);
$qb->andWhere("$alias.$key = :$placeholder");
$queryBuilder->setParameter($placeholder, $value);
}

// recurse queries
if (null === $previousQueryBuilder) {
$previousQueryBuilder = $qb;
} else {
$previousQueryBuilder->andWhere($qb->expr()->in($previousAlias, $qb->getDQL()));
}

$previousAlias = $alias;
}

/*
* The following translate to this pseudo-dql:
* The following recursively translates to this pseudo-dql:
*
* SELECT thirdLevel WHERE thirdLevel IN (
* SELECT thirdLevel FROM relatedDummies WHERE relatedDummies = ? AND relatedDummies IN (
Expand All @@ -164,9 +93,7 @@ public function getSubresource(string $resourceClass, array $identifiers, array
*
* By using subqueries, we're forcing the SQL execution plan to go through indexes on doctrine identifiers.
*/
$queryBuilder->where(
$queryBuilder->expr()->in($originAlias, $previousQueryBuilder->getDQL())
);
$queryBuilder = $this->buildQuery($identifiers, $context, $queryNameGenerator, $repository->createQueryBuilder($alias = 'o'), $alias, \count($context['identifiers']));

if (true === $context['collection']) {
foreach ($this->collectionExtensions as $extension) {
Expand Down Expand Up @@ -195,4 +122,75 @@ public function getSubresource(string $resourceClass, array $identifiers, array

return $context['collection'] ? $query->getResult() : $query->getOneOrNullResult();
}

/**
* @throws RuntimeException
*/
private function buildQuery(array $identifiers, array $context, QueryNameGenerator $queryNameGenerator, QueryBuilder $previousQueryBuilder, string $previousAlias, int $remainingIdentifiers, QueryBuilder $topQueryBuilder = null): QueryBuilder
{
if ($remainingIdentifiers <= 0) {
return $previousQueryBuilder;
}

$topQueryBuilder = $topQueryBuilder ?? $previousQueryBuilder;

list($identifier, $identifierResourceClass) = $context['identifiers'][$remainingIdentifiers - 1];
$previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];

$manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);

if (!$manager instanceof EntityManagerInterface) {
throw new RuntimeException("The manager for $identifierResourceClass must be an EntityManager.");
}

$classMetadata = $manager->getClassMetadata($identifierResourceClass);

if (!$classMetadata instanceof ClassMetadataInfo) {
throw new RuntimeException(
"The class metadata for $identifierResourceClass must be an instance of ClassMetadataInfo."
);
}

$qb = $manager->createQueryBuilder();
$alias = $queryNameGenerator->generateJoinAlias($identifier);
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
$normalizedIdentifiers = isset($identifiers[$identifier]) ? $this->normalizeIdentifiers(
$identifiers[$identifier],
$manager,
$identifierResourceClass
) : [];

switch ($relationType) {
// MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
case ClassMetadataInfo::MANY_TO_MANY:
$joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);

$qb->select($joinAlias)
->from($identifierResourceClass, $alias)
->innerJoin("$alias.$previousAssociationProperty", $joinAlias);
break;
case ClassMetadataInfo::ONE_TO_MANY:
$mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];
$previousAlias = "$previousAlias.$mappedBy";

$qb->select($alias)
->from($identifierResourceClass, $alias);
break;
default:
$qb->select("IDENTITY($alias.$previousAssociationProperty)")
->from($identifierResourceClass, $alias);
}

// Add where clause for identifiers
foreach ($normalizedIdentifiers as $key => $value) {
$placeholder = $queryNameGenerator->generateParameterName($key);
$qb->andWhere("$alias.$key = :$placeholder");
$topQueryBuilder->setParameter($placeholder, $value);
}

// Recurse queries
$qb = $this->buildQuery($identifiers, $context, $queryNameGenerator, $qb, $alias, --$remainingIdentifiers, $topQueryBuilder);

return $previousQueryBuilder->andWhere($qb->expr()->in($previousAlias, $qb->getDQL()));
}
}
Loading

0 comments on commit a62e089

Please sign in to comment.