Skip to content

Enable item route on collection subresources #1875

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

Merged
merged 1 commit into from
Apr 20, 2018
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
37 changes: 32 additions & 5 deletions features/main/subresource.feature
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Feature: Subresource support
And the response status code should be 404
And the response should be in JSON

Scenario: Get subresource one to one relation
Scenario: Get recursive subresource one to many relation
When I send a "GET" request to "/questions/1/answer/related_questions"
And the response status code should be 200
And the response should be in JSON
Expand Down Expand Up @@ -209,7 +209,7 @@ Feature: Subresource support
}
"""

Scenario: Get filtered embedded relation collection
Scenario: Get filtered embedded relation subresource collection
When I send a "GET" request to "/dummies/1/related_dummies?name=Hello"
And the response status code should be 200
And the response should be in JSON
Expand Down Expand Up @@ -272,7 +272,34 @@ Feature: Subresource support
}
"""

Scenario: Get the embedded relation collection at the third level
Scenario: Get the subresource relation item
When I send a "GET" request to "/dummies/1/related_dummies/2"
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/RelatedDummy",
"@id": "/related_dummies/2",
"@type": "https://schema.org/Product",
"id": 2,
"name": null,
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
"age": null
}
"""

Scenario: Get the embedded relation subresource item 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 @@ -290,7 +317,7 @@ Feature: Subresource support
}
"""

Scenario: Get the embedded relation collection at the fourth level
Scenario: Get the embedded relation subresource item 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
Expand Down Expand Up @@ -355,7 +382,7 @@ Feature: Subresource support
}
"""

Scenario: test
Scenario: Recursive resource
When I send a "GET" request to "/dummy_products/2"
And the response status code should be 200
And the response should be in JSON
Expand Down
45 changes: 25 additions & 20 deletions src/Bridge/Doctrine/Orm/SubresourceDataProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat

$qb = $manager->createQueryBuilder();
$alias = $queryNameGenerator->generateJoinAlias($identifier);
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
$normalizedIdentifiers = [];

if (isset($identifiers[$identifier])) {
Expand All @@ -164,25 +163,31 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat
}
}

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);
if ($classMetadata->hasAssociation($previousAssociationProperty)) {
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
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);
}
} elseif ($classMetadata->isIdentifier($previousAssociationProperty)) {
$qb->select($alias)
->from($identifierResourceClass, $alias);
}

// Add where clause for identifiers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function create(string $resourceClass, string $property, array $options =
$annotation = $this->reader->getPropertyAnnotation($reflectionClass->getProperty($property), ApiSubresource::class);

if (null !== $annotation) {
return $this->updateMetadata($annotation, $propertyMetadata);
return $this->updateMetadata($annotation, $propertyMetadata, $resourceClass);
}
}

Expand All @@ -70,19 +70,24 @@ public function create(string $resourceClass, string $property, array $options =
$annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiSubresource::class);

if (null !== $annotation) {
return $this->updateMetadata($annotation, $propertyMetadata);
return $this->updateMetadata($annotation, $propertyMetadata, $resourceClass);
}
}

return $propertyMetadata;
}

private function updateMetadata(ApiSubresource $annotation, PropertyMetadata $propertyMetadata): PropertyMetadata
private function updateMetadata(ApiSubresource $annotation, PropertyMetadata $propertyMetadata, string $originResourceClass): PropertyMetadata
{
$type = $propertyMetadata->getType();
$isCollection = $type->isCollection();
$resourceClass = $isCollection ? $type->getCollectionValueType()->getClassName() : $type->getClassName();
$maxDepth = $annotation->maxDepth;
// @ApiSubresource is on the class identifier (/collection/{id}/subcollection/{subcollectionId})
if (null === $resourceClass) {
$resourceClass = $originResourceClass;
$isCollection = false;
}

return $propertyMetadata->withSubresource(new SubresourceMetadata($resourceClass, $isCollection, $maxDepth));
}
Expand Down
24 changes: 19 additions & 5 deletions src/Operation/Factory/SubresourceOperationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
$subresource = $propertyMetadata->getSubresource();
$subresourceClass = $subresource->getResourceClass();
$subresourceMetadata = $this->resourceMetadataFactory->create($subresourceClass);
$isLastItem = $resourceClass === $parentOperation['resource_class'] && $propertyMetadata->isIdentifier();

// A subresource that is also an identifier can't be a start point
if ($isLastItem && (null === $parentOperation || false === $parentOperation['collection'])) {
continue;
}

$visiting = "$resourceClass $property $subresourceClass";

Expand Down Expand Up @@ -135,10 +141,12 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
} else {
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$operation['identifiers'] = $parentOperation['identifiers'];
$operation['identifiers'][] = [$parentOperation['property'], $resourceClass, $parentOperation['collection']];

$operation['operation_name'] = str_replace('get'.self::SUBRESOURCE_SUFFIX, RouteNameGenerator::inflector($property, $operation['collection']).'_get'.self::SUBRESOURCE_SUFFIX, $parentOperation['operation_name']);

$operation['identifiers'][] = [$parentOperation['property'], $resourceClass, $isLastItem ? true : $parentOperation['collection']];
$operation['operation_name'] = str_replace(
'get'.self::SUBRESOURCE_SUFFIX,
RouteNameGenerator::inflector($isLastItem ? 'item' : $property, $operation['collection']).'_get'.self::SUBRESOURCE_SUFFIX,
$parentOperation['operation_name']
);
$operation['route_name'] = str_replace($parentOperation['operation_name'], $operation['operation_name'], $parentOperation['route_name']);

if (!\in_array($resourceMetadata->getShortName(), $operation['shortNames'], true)) {
Expand All @@ -151,11 +159,17 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
$operation['path'] = $subresourceOperation['path'];
} else {
$operation['path'] = str_replace(self::FORMAT_SUFFIX, '', $parentOperation['path']);

if ($parentOperation['collection']) {
list($key) = end($operation['identifiers']);
$operation['path'] .= sprintf('/{%s}', $key);
}
$operation['path'] .= sprintf('/%s%s', $this->pathSegmentNameGenerator->getSegmentName($property, $operation['collection']), self::FORMAT_SUFFIX);

if ($isLastItem) {
$operation['path'] .= self::FORMAT_SUFFIX;
} else {
$operation['path'] .= sprintf('/%s%s', $this->pathSegmentNameGenerator->getSegmentName($property, $operation['collection']), self::FORMAT_SUFFIX);
}
}
}

Expand Down
94 changes: 94 additions & 0 deletions tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ public function testGetSubresource()
$this->assertIdentifierManagerMethodCalls($managerProphecy);

$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);

$managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
Expand Down Expand Up @@ -195,6 +196,7 @@ public function testGetSubSubresourceItem()
$qb->getDQL()->shouldBeCalled()->willReturn($dummyDQL);

$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);

$dummyManagerProphecy = $this->prophesize(EntityManager::class);
Expand All @@ -220,6 +222,7 @@ public function testGetSubSubresourceItem()
$rqb->expr()->shouldBeCalled()->willReturn($relatedExpProphecy->reveal());

$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
$rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true);
$rClassMetadataProphecy->getAssociationMapping('thirdLevel')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_ONE]);

$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
Expand Down Expand Up @@ -279,6 +282,7 @@ public function testQueryResultExtension()
$this->assertIdentifierManagerMethodCalls($managerProphecy);

$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);

$managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
Expand Down Expand Up @@ -384,6 +388,7 @@ public function testGetSubSubresourceItemLegacy()
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
$classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
$classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);

$dummyManagerProphecy = $this->prophesize(EntityManager::class);
Expand Down Expand Up @@ -411,6 +416,7 @@ public function testGetSubSubresourceItemLegacy()
$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
$rClassMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers);
$rClassMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer');
$rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true);
$rClassMetadataProphecy->getAssociationMapping('thirdLevel')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_ONE]);

$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
Expand Down Expand Up @@ -449,4 +455,92 @@ public function testGetSubSubresourceItemLegacy()

$this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => 1, 'relatedDummies' => 1], $context));
}

public function testGetSubresourceCollectionItem()
{
$managerRegistryProphecy = $this->prophesize(ManagerRegistry::class);
$identifiers = ['id'];
$funcProphecy = $this->prophesize(Func::class);
$func = $funcProphecy->reveal();

// First manager (Dummy)
$dummyDQL = 'dql';

$qb = $this->prophesize(QueryBuilder::class);
$qb->select('relatedDummies_a3')->shouldBeCalled()->willReturn($qb);
$qb->from(Dummy::class, 'id_a2')->shouldBeCalled()->willReturn($qb);
$qb->innerJoin('id_a2.relatedDummies', 'relatedDummies_a3')->shouldBeCalled()->willReturn($qb);
$qb->andWhere('id_a2.id = :id_p2')->shouldBeCalled()->willReturn($qb);

$dummyFunc = new Func('in', ['any']);

$dummyExpProphecy = $this->prophesize(Expr::class);
$dummyExpProphecy->in('relatedDummies_a1', $dummyDQL)->willReturn($dummyFunc)->shouldBeCalled();

$qb->expr()->shouldBeCalled()->willReturn($dummyExpProphecy->reveal());

$qb->getDQL()->shouldBeCalled()->willReturn($dummyDQL);

$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled();
$classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]);

$dummyManagerProphecy = $this->prophesize(EntityManager::class);
$dummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($qb->reveal());
$dummyManagerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
$this->assertIdentifierManagerMethodCalls($dummyManagerProphecy);

$managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($dummyManagerProphecy->reveal());

// Second manager (RelatedDummy)
$relatedDQL = 'relateddql';

$rqb = $this->prophesize(QueryBuilder::class);
$rqb->select('relatedDummies_a1')->shouldBeCalled()->willReturn($rqb);
$rqb->from(RelatedDummy::class, 'relatedDummies_a1')->shouldBeCalled()->willReturn($rqb);
$rqb->andWhere('relatedDummies_a1.id = :id_p1')->shouldBeCalled()->willReturn($rqb);
$rqb->andWhere($dummyFunc)->shouldBeCalled()->willReturn($rqb);
$rqb->getDQL()->shouldBeCalled()->willReturn($relatedDQL);

$relatedExpProphecy = $this->prophesize(Expr::class);
$relatedExpProphecy->in('o', $relatedDQL)->willReturn($func)->shouldBeCalled();

$rqb->expr()->shouldBeCalled()->willReturn($relatedExpProphecy->reveal());

$rClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
$rClassMetadataProphecy->hasAssociation('id')->shouldBeCalled()->willReturn(false);
$rClassMetadataProphecy->isIdentifier('id')->shouldBeCalled()->willReturn(true);

$rDummyManagerProphecy = $this->prophesize(EntityManager::class);
$rDummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($rqb->reveal());
$rDummyManagerProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($rClassMetadataProphecy->reveal());
$this->assertIdentifierManagerMethodCalls($rDummyManagerProphecy);

$managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($rDummyManagerProphecy->reveal());

$result = new \StdClass();
$queryProphecy = $this->prophesize(AbstractQuery::class);
$queryProphecy->getOneOrNullResult()->shouldBeCalled()->willReturn($result);

$queryBuilder = $this->prophesize(QueryBuilder::class);

$queryBuilder->andWhere($func)->shouldBeCalled()->willReturn($queryBuilder);

$queryBuilder->getQuery()->shouldBeCalled()->willReturn($queryProphecy->reveal());
$queryBuilder->setParameter('id_p1', 2)->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->setParameter('id_p2', 1)->shouldBeCalled()->willReturn($queryBuilder);

$repositoryProphecy = $this->prophesize(EntityRepository::class);
$repositoryProphecy->createQueryBuilder('o')->shouldBeCalled()->willReturn($queryBuilder->reveal());

$rDummyManagerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal());

list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]);

$dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory);

$context = ['property' => 'id', 'identifiers' => [['id', Dummy::class, true], ['relatedDummies', RelatedDummy::class, true]], 'collection' => false, ChainIdentifierDenormalizer::HAS_IDENTIFIER_DENORMALIZER => true];

$this->assertEquals($result, $dataProvider->getSubresource(RelatedDummy::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 2]], $context));
}
}
1 change: 1 addition & 0 deletions tests/Fixtures/TestBundle/Entity/RelatedDummy.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
class RelatedDummy extends ParentDummy
{
/**
* @ApiSubresource
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
Expand Down
Loading