From b029c388fa1e4efbecaa883971e1798697a759f0 Mon Sep 17 00:00:00 2001 From: jotwea Date: Wed, 3 Apr 2024 15:43:38 +0200 Subject: [PATCH 1/7] GraphQL: Errors with nullish ManyToOne-Relation when using new ResolverFactory (for version 3.2) (#6092) * fix(graphql): null cases in ResolverFactory * fix: make PropertyMetadataFactoryInterface nullable for backwards compatibility * fix(graphql): pass propertyMetadataFactory from FieldsBuilder to ResolverFactory * use getBuiltinTypes --------- Co-authored-by: josef.wagner --- features/graphql/query.feature | 56 ++++++++++++++++++- .../Factory/CollectionResolverFactory.php | 3 +- .../Factory/ItemMutationResolverFactory.php | 3 +- .../Resolver/Factory/ItemResolverFactory.php | 3 +- .../ItemSubscriptionResolverFactory.php | 3 +- .../Resolver/Factory/ResolverFactory.php | 19 ++++--- .../Factory/ResolverFactoryInterface.php | 3 +- .../Resolver/Factory/ResolverFactoryTest.php | 6 +- src/GraphQl/Type/FieldsBuilder.php | 2 +- .../Factory/DataCollectorResolverFactory.php | 3 +- tests/Behat/DoctrineContext.php | 21 ++++++- .../Document/MultiRelationsDummy.php | 15 +++++ .../Document/MultiRelationsResolveDummy.php | 53 ++++++++++++++++++ .../TestBundle/Entity/MultiRelationsDummy.php | 15 +++++ .../Entity/MultiRelationsResolveDummy.php | 55 ++++++++++++++++++ ...mQueryNotRetrievedItemDocumentResolver.php | 3 +- ...mmyCustomQueryNotRetrievedItemResolver.php | 3 +- ...MultiRelationsResolveQueryItemResolver.php | 31 ++++++++++ tests/Fixtures/app/config/config_common.yml | 5 ++ tests/Fixtures/app/config/config_mongodb.yml | 5 ++ 20 files changed, 283 insertions(+), 24 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/MultiRelationsResolveDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/MultiRelationsResolveDummy.php create mode 100644 tests/Fixtures/TestBundle/GraphQl/Resolver/MultiRelationsResolveQueryItemResolver.php diff --git a/features/graphql/query.feature b/features/graphql/query.feature index ffc931aa350..944c3cb0ed3 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -22,7 +22,7 @@ Feature: GraphQL query support @createSchema Scenario: Retrieve an item with different relations to the same resource - Given there are 2 multiRelationsDummy objects having each a manyToOneRelation, 2 manyToManyRelations and 3 oneToManyRelations + Given there are 2 multiRelationsDummy objects having each 1 manyToOneRelation, 2 manyToManyRelations and 3 oneToManyRelations When I send the following GraphQL request: """ { @@ -33,6 +33,10 @@ Feature: GraphQL query support id name } + manyToOneResolveRelation { + id + name + } manyToManyRelations { edges{ node { @@ -55,10 +59,13 @@ Feature: GraphQL query support 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/json" + And the JSON node "errors" should not exist And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" + And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.id" should not be null + And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.name" should be equal to "RelatedManyToOneResolveDummy #2" And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should match "#RelatedManyToManyDummy(1|2)2#" @@ -68,6 +75,53 @@ Feature: GraphQL query support And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#" And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#" + @createSchema + Scenario: Retrieve an item with different relations (all unset) + Given there are 2 multiRelationsDummy objects having each 0 manyToOneRelation, 0 manyToManyRelations and 0 oneToManyRelations + When I send the following GraphQL request: + """ + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneRelation { + id + name + } + manyToOneResolveRelation { + id + name + } + manyToManyRelations { + edges{ + node { + id + name + } + } + } + oneToManyRelations { + edges{ + node { + id + name + } + } + } + } + } + """ + 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/json" + And the JSON node "errors" should not exist + And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" + And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" + And the JSON node "data.multiRelationsDummy.manyToOneRelation" should be null + And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation" should be null + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 0 element + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 0 element + @createSchema @!mongodb Scenario: Retrieve an item with child relation to the same resource Given there are tree dummies diff --git a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php index b7fbd79845b..72a5cd02543 100644 --- a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php @@ -19,6 +19,7 @@ use ApiPlatform\GraphQl\Resolver\Stage\SecurityStageInterface; use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface; use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; @@ -38,7 +39,7 @@ public function __construct(private readonly ReadStageInterface $readStage, priv { } - public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable { return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation): ?array { // If authorization has failed for a relation field (e.g. via ApiProperty security), the field is not present in the source: null can be returned directly to ensure the collection isn't in the response. diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php index 999ea9a51ae..ffe1e3d04c2 100644 --- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php @@ -24,6 +24,7 @@ use ApiPlatform\GraphQl\Resolver\Stage\WriteStageInterface; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; @@ -44,7 +45,7 @@ public function __construct(private readonly ReadStageInterface $readStage, priv { } - public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable { return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation): ?array { if (null === $resourceClass || null === $operation) { diff --git a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php index a1652b9bca7..45820aaeed4 100644 --- a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; @@ -41,7 +42,7 @@ public function __construct(private readonly ReadStageInterface $readStage, priv { } - public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable { return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation) { // Data already fetched and normalized (field or nested resource) diff --git a/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php index 0335a6ac232..b182ba528ff 100644 --- a/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php @@ -19,6 +19,7 @@ use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; @@ -37,7 +38,7 @@ public function __construct(private readonly ReadStageInterface $readStage, priv { } - public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable { return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation): ?array { if (null === $resourceClass || null === $operation) { diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php index 2ddb2941836..7f0cee3ac4f 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; use GraphQL\Type\Definition\ResolveInfo; @@ -28,16 +29,18 @@ public function __construct( ) { } - public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable { - return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation) { - // Data already fetched and normalized (field or nested resource) - if ($body = $source[$info->fieldName] ?? null) { - return $body; - } + return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation, $propertyMetadataFactory) { + if (\array_key_exists($info->fieldName, $source ?? [])) { + $body = $source[$info->fieldName]; - if (null === $resourceClass && \array_key_exists($info->fieldName, $source ?? [])) { - return $body; + $propertyMetadata = $rootClass ? $propertyMetadataFactory?->create($rootClass, $info->fieldName) : null; + $type = $propertyMetadata?->getBuiltinTypes()[0] ?? null; + // Data already fetched and normalized (field or nested resource) + if ($body || null === $resourceClass || ($type && !$type->isCollection())) { + return $body; + } } // If authorization has failed for a relation field (e.g. via ApiProperty security), the field is not present in the source: null can be returned directly to ensure the collection isn't in the response. diff --git a/src/GraphQl/Resolver/Factory/ResolverFactoryInterface.php b/src/GraphQl/Resolver/Factory/ResolverFactoryInterface.php index c65e8f16682..4fced141822 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactoryInterface.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactoryInterface.php @@ -14,6 +14,7 @@ namespace ApiPlatform\GraphQl\Resolver\Factory; use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; /** * Builds a GraphQL resolver. @@ -22,5 +23,5 @@ */ interface ResolverFactoryInterface { - public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable; + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable; } diff --git a/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php b/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php index 53874bc8889..586cabb2154 100644 --- a/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php +++ b/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php @@ -14,9 +14,11 @@ namespace ApiPlatform\GraphQl\Tests\Resolver\Factory; use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; use GraphQL\Type\Definition\ResolveInfo; @@ -38,11 +40,13 @@ public function testGraphQlResolver(?string $resourceClass = null, ?string $root $provider->expects($this->once())->method('provide')->with($providedOperation ?: $operation, [], $context)->willReturn($body); $processor = $this->createMock(ProcessorInterface::class); $processor->expects($this->once())->method('process')->with($body, $processedOperation ?: $operation, [], $context)->willReturn($returnValue); + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->expects($this->once())->method('create')->with($rootClass, 'test')->willReturn(new ApiProperty(schema: ['type' => 'array'])); $resolveInfo = $this->createMock(ResolveInfo::class); $resolveInfo->fieldName = 'test'; $resolverFactory = new ResolverFactory($provider, $processor); - $this->assertEquals($resolverFactory->__invoke($resourceClass, $rootClass, $operation)(['test' => null], [], [], $resolveInfo), $returnValue); + $this->assertEquals($resolverFactory->__invoke($resourceClass, $rootClass, $operation, $propertyMetadataFactory)(['test' => null], [], [], $resolveInfo), $returnValue); } public function graphQlQueries(): array diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 55a2588a481..5aba2bde5b1 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -348,7 +348,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field if ($isStandardGraphqlType || $input) { $resolve = null; } else { - $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation); + $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation, $this->propertyMetadataFactory); } } else { if ($isStandardGraphqlType || $input) { diff --git a/src/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactory.php b/src/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactory.php index 9091ab3dc9e..72bf61223cc 100644 --- a/src/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactory.php +++ b/src/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactory.php @@ -15,6 +15,7 @@ use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\HttpFoundation\RequestStack; @@ -24,7 +25,7 @@ public function __construct(private readonly ResolverFactoryInterface $resolverF { } - public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable { return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation) { if ($this->requestStack && null !== $request = $this->requestStack->getCurrentRequest()) { diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index b83f0b63810..e0ca6026012 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -67,6 +67,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsResolveDummy as MultiRelationsResolveDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; @@ -160,6 +161,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; @@ -803,17 +805,24 @@ public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated): } /** - * @Given there are :nb multiRelationsDummy objects having each a manyToOneRelation, :nbmtmr manyToManyRelations and :nbotmr oneToManyRelations + * @Given there are :nb multiRelationsDummy objects having each :nbmtor manyToOneRelation, :nbmtmr manyToManyRelations and :nbotmr oneToManyRelations */ - public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsAndOneToManyRelations(int $nb, int $nbmtmr, int $nbotmr): void + public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsAndOneToManyRelations(int $nb, int $nbmtor, int $nbmtmr, int $nbotmr): void { for ($i = 1; $i <= $nb; ++$i) { $relatedDummy = $this->buildMultiRelationsRelatedDummy(); $relatedDummy->name = 'RelatedManyToOneDummy #'.$i; + $resolveDummy = $this->buildMultiRelationsResolveDummy(); + $resolveDummy->name = 'RelatedManyToOneResolveDummy #'.$i; + $dummy = $this->buildMultiRelationsDummy(); $dummy->name = 'Dummy #'.$i; - $dummy->setManyToOneRelation($relatedDummy); + + if ($nbmtor) { + $dummy->setManyToOneRelation($relatedDummy); + $dummy->setManyToOneResolveRelation($resolveDummy); + } for ($j = 1; $j <= $nbmtmr; ++$j) { $manyToManyItem = $this->buildMultiRelationsRelatedDummy(); @@ -833,6 +842,7 @@ public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationMa } $this->manager->persist($relatedDummy); + $this->manager->persist($resolveDummy); $this->manager->persist($dummy); } $this->manager->flush(); @@ -2627,6 +2637,11 @@ private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|M return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument(); } + private function buildMultiRelationsResolveDummy(): MultiRelationsResolveDummy|MultiRelationsResolveDummyDocument + { + return $this->isOrm() ? new MultiRelationsResolveDummy() : new MultiRelationsResolveDummyDocument(); + } + private function buildMusicGroup(): MusicGroup|MusicGroupDocument { return $this->isOrm() ? new MusicGroup() : new MusicGroupDocument(); diff --git a/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php b/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php index dd70130d1a9..a424b690c7b 100644 --- a/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php +++ b/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php @@ -38,6 +38,9 @@ class MultiRelationsDummy #[ODM\ReferenceOne(targetDocument: MultiRelationsRelatedDummy::class, storeAs: 'id', nullable: true)] public ?MultiRelationsRelatedDummy $manyToOneRelation = null; + #[ODM\ReferenceOne(targetDocument: MultiRelationsResolveDummy::class, storeAs: 'id', nullable: true)] + public ?MultiRelationsResolveDummy $manyToOneResolveRelation = null; + /** @var Collection */ #[ODM\ReferenceMany(targetDocument: MultiRelationsRelatedDummy::class, storeAs: 'id', nullable: true)] public Collection $manyToManyRelations; @@ -67,6 +70,18 @@ public function setManyToOneRelation(?MultiRelationsRelatedDummy $relatedMultiUs $this->manyToOneRelation = $relatedMultiUsedDummy; } + public function getManyToOneResolveRelation(): ?MultiRelationsResolveDummy + { + return $this->manyToOneResolveRelation; + } + + public function setManyToOneResolveRelation(?MultiRelationsResolveDummy $manyToOneResolveRelation): self + { + $this->manyToOneResolveRelation = $manyToOneResolveRelation; + + return $this; + } + public function addManyToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void { $this->manyToManyRelations->add($relatedMultiUsedDummy); diff --git a/tests/Fixtures/TestBundle/Document/MultiRelationsResolveDummy.php b/tests/Fixtures/TestBundle/Document/MultiRelationsResolveDummy.php new file mode 100644 index 00000000000..d8f8199bf19 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MultiRelationsResolveDummy.php @@ -0,0 +1,53 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy used in different kind of relations in the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new Query(resolver: 'app.graphql.query_resolver.multi_relations_custom_item', read: false), new QueryCollection(resolver: 'app.graphql.query_resolver.multi_relations_collection', read: false)])] +#[ODM\Document] +class MultiRelationsResolveDummy +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string', nullable: true)] + public ?string $name; + + #[ODM\ReferenceOne(targetDocument: MultiRelationsDummy::class, inversedBy: 'oneToManyResolveRelations', nullable: true, storeAs: 'id')] + private ?MultiRelationsDummy $oneToManyResolveRelation; + + public function getId(): ?int + { + return $this->id; + } + + public function getOneToManyResolveRelation(): ?MultiRelationsDummy + { + return $this->oneToManyResolveRelation; + } + + public function setOneToManyResolveRelation(?MultiRelationsDummy $oneToManyResolveRelation): void + { + $this->oneToManyResolveRelation = $oneToManyResolveRelation; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php b/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php index 14e9d8a0f8b..84dfccd942a 100644 --- a/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php +++ b/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php @@ -40,6 +40,9 @@ class MultiRelationsDummy #[ORM\ManyToOne(targetEntity: MultiRelationsRelatedDummy::class)] public ?MultiRelationsRelatedDummy $manyToOneRelation = null; + #[ORM\ManyToOne(targetEntity: MultiRelationsResolveDummy::class)] + public ?MultiRelationsResolveDummy $manyToOneResolveRelation = null; + /** @var Collection */ #[ORM\ManyToMany(targetEntity: MultiRelationsRelatedDummy::class)] public Collection $manyToManyRelations; @@ -69,6 +72,18 @@ public function setManyToOneRelation(?MultiRelationsRelatedDummy $relatedMultiUs $this->manyToOneRelation = $relatedMultiUsedDummy; } + public function getManyToOneResolveRelation(): ?MultiRelationsResolveDummy + { + return $this->manyToOneResolveRelation; + } + + public function setManyToOneResolveRelation(?MultiRelationsResolveDummy $manyToOneResolveRelation): self + { + $this->manyToOneResolveRelation = $manyToOneResolveRelation; + + return $this; + } + public function addManyToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void { $this->manyToManyRelations->add($relatedMultiUsedDummy); diff --git a/tests/Fixtures/TestBundle/Entity/MultiRelationsResolveDummy.php b/tests/Fixtures/TestBundle/Entity/MultiRelationsResolveDummy.php new file mode 100644 index 00000000000..f724803e219 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MultiRelationsResolveDummy.php @@ -0,0 +1,55 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy used in different kind of relations in the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new Query(resolver: 'app.graphql.query_resolver.multi_relations_custom_item', read: false), new QueryCollection(resolver: 'app.graphql.query_resolver.multi_relations_collection', read: false)])] +#[ORM\Entity] +class MultiRelationsResolveDummy +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: true)] + public ?string $name; + + #[ORM\ManyToOne(targetEntity: MultiRelationsDummy::class, inversedBy: 'oneToManyResolveRelations')] + private ?MultiRelationsDummy $oneToManyResolveRelation = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getOneToManyResolveRelation(): ?MultiRelationsDummy + { + return $this->oneToManyResolveRelation; + } + + public function setOneToManyResolveRelation(?MultiRelationsDummy $oneToManyResolveRelation): void + { + $this->oneToManyResolveRelation = $oneToManyResolveRelation; + } +} diff --git a/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php b/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php index cbf5d8e9be4..16c7b12bf5e 100644 --- a/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php +++ b/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemDocumentResolver.php @@ -15,7 +15,6 @@ use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; /** * Resolver for dummy item custom query. @@ -24,7 +23,7 @@ */ class DummyCustomQueryNotRetrievedItemDocumentResolver implements QueryItemResolverInterface { - public function __invoke(?object $item, array $context): DummyCustomQuery|DummyCustomQueryDocument + public function __invoke(?object $item, array $context): DummyCustomQueryDocument { if (null === $item) { $item = new DummyCustomQueryDocument(); diff --git a/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemResolver.php b/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemResolver.php index 3d9ce628dee..fad1088d953 100644 --- a/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemResolver.php +++ b/tests/Fixtures/TestBundle/GraphQl/Resolver/DummyCustomQueryNotRetrievedItemResolver.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver; use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; /** @@ -24,7 +23,7 @@ */ class DummyCustomQueryNotRetrievedItemResolver implements QueryItemResolverInterface { - public function __invoke(?object $item, array $context): DummyCustomQuery|DummyCustomQueryDocument + public function __invoke(?object $item, array $context): DummyCustomQuery { if (null === $item) { $item = new DummyCustomQuery(); diff --git a/tests/Fixtures/TestBundle/GraphQl/Resolver/MultiRelationsResolveQueryItemResolver.php b/tests/Fixtures/TestBundle/GraphQl/Resolver/MultiRelationsResolveQueryItemResolver.php new file mode 100644 index 00000000000..9559d33a3c8 --- /dev/null +++ b/tests/Fixtures/TestBundle/GraphQl/Resolver/MultiRelationsResolveQueryItemResolver.php @@ -0,0 +1,31 @@ + + * + * 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\Tests\Fixtures\TestBundle\GraphQl\Resolver; + +use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsResolveDummy as MultiRelationsResolveDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; + +/** + * Resolver for dummy item custom query. + * + * @author Lukas Lücke + */ +class MultiRelationsResolveQueryItemResolver implements QueryItemResolverInterface +{ + public function __invoke(?object $item, array $context): MultiRelationsResolveDummy|MultiRelationsResolveDummyDocument + { + return $context['source']['manyToOneResolveRelation']; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 2eeeddbfaa3..e20a46ea77a 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -317,6 +317,11 @@ services: tags: - { name: 'api_platform.graphql.resolver' } + app.graphql.query_resolver.multi_relations_custom_item: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\MultiRelationsResolveQueryItemResolver' + tags: + - { name: 'api_platform.graphql.resolver' } + app.graphql.mutation_resolver.upload_media_object: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\UploadMediaObjectResolver' tags: diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index ed65db4eb9f..5e785c94042 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -110,6 +110,11 @@ services: tags: - { name: 'api_platform.graphql.resolver' } + app.graphql.query_resolver.multi_relations_custom_item_document: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\MultiRelationsResolveQueryItemResolver' + tags: + - { name: 'api_platform.graphql.resolver' } + app.graphql.mutation_resolver.dummy_custom_only_persist_document: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\SumOnlyPersistDocumentMutationResolver' public: false From d061c381120b858c471157dbdccbb5084a39fb04 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 3 Apr 2024 17:06:24 +0200 Subject: [PATCH 2/7] fix(graphql): increment graphql normalizer priority (#6283) * fix(graphql): increment graphql normalizer priority closes #6279 #6264 * improve behat test --- features/openapi/docs.feature | 53 ++++++++----------- .../Bundle/Resources/config/graphql.xml | 4 +- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 1aa379c71b8..6bc34619f4b 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -396,38 +396,27 @@ Feature: Documentation support Then the response status code should be 200 And the response should be in JSON And the JSON node "openapi" should be equal to "3.0.0" - And the JSON node "components.schemas.DummyBoolean" should be equal to: + And the JSON node "components.schemas.DummyBoolean.properties.id.anyOf" should be equal to: """ - { - "type": "object", - "description": "", - "deprecated": false, - "properties": { - "id": { - "readOnly": true, - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, - "isDummyBoolean": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "dummyBoolean": { - "readOnly": true, - "type": "boolean" - } + [ + { + "type": "integer" + }, + { + "type": "null" } - } + ] + """ + And the JSON node "components.schemas.DummyBoolean.properties.isDummyBoolean.anyOf" should be equal to: """ + [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + """ + And the JSON node "components.schemas.DummyBoolean.properties.isDummyBoolean.owl:maxCardinality" should not exist + diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index a089eebb2bc..932feb68d3c 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -213,8 +213,8 @@ - - + + From 90c9fb31a322a2c7891fbbafb75d60b09fd67772 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 3 Apr 2024 17:28:24 +0200 Subject: [PATCH 3/7] fix(symfony): register api_error route (#6281) closes #6269 --- .../Bundle/Resources/config/routing/api.xml | 12 ------------ .../Bundle/Resources/config/routing/errors.xml | 18 ++++++++++++++++++ src/Symfony/Routing/ApiLoader.php | 1 + tests/Symfony/Routing/ApiLoaderTest.php | 1 + 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 src/Symfony/Bundle/Resources/config/routing/errors.xml diff --git a/src/Symfony/Bundle/Resources/config/routing/api.xml b/src/Symfony/Bundle/Resources/config/routing/api.xml index e4524f69a2c..9eb63d600fc 100644 --- a/src/Symfony/Bundle/Resources/config/routing/api.xml +++ b/src/Symfony/Bundle/Resources/config/routing/api.xml @@ -13,16 +13,4 @@ index - - - api_platform.action.not_exposed - 500 - - \d+ - - - - api_platform.action.not_exposed - - diff --git a/src/Symfony/Bundle/Resources/config/routing/errors.xml b/src/Symfony/Bundle/Resources/config/routing/errors.xml new file mode 100644 index 00000000000..574515ce305 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/routing/errors.xml @@ -0,0 +1,18 @@ + + + + + + api_platform.action.not_exposed + 500 + + \d+ + + + + api_platform.action.not_exposed + + diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index 0d30198622b..832c068a78f 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -125,6 +125,7 @@ public function supports(mixed $resource, ?string $type = null): bool private function loadExternalFiles(RouteCollection $routeCollection): void { $routeCollection->addCollection($this->fileLoader->load('genid.xml')); + $routeCollection->addCollection($this->fileLoader->load('errors.xml')); if ($this->entrypointEnabled) { $routeCollection->addCollection($this->fileLoader->load('api.xml')); diff --git a/tests/Symfony/Routing/ApiLoaderTest.php b/tests/Symfony/Routing/ApiLoaderTest.php index 6766527825f..0c9ba60f018 100644 --- a/tests/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Symfony/Routing/ApiLoaderTest.php @@ -213,6 +213,7 @@ public function testApiLoaderWithPrefix(): void $prefixedPath = $prefix.$path; + $this->assertNotNull($routeCollection->get('api_errors')); $this->assertEquals( $this->getRoute( $prefixedPath, From 875cc155e556541c0591b0c182ed64dcc41b9984 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 3 Apr 2024 17:33:01 +0200 Subject: [PATCH 4/7] docs: 3.2.20 changelog [ci skip] --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf0f6c7df2..5833213185f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v3.2.20 + +### Bug fixes + +* [90c9fb31a](https://github.com/api-platform/core/commit/90c9fb31a322a2c7891fbbafb75d60b09fd67772) fix(symfony): register api_error route (#6281) +* [d061c3811](https://github.com/api-platform/core/commit/d061c381120b858c471157dbdccbb5084a39fb04) fix(graphql): increment graphql normalizer priority (#6283) + ## v3.2.19 ### Bug fixes From 2a8767108690c35d873a936f9e2383365fdb4f00 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 4 Apr 2024 15:53:53 +0200 Subject: [PATCH 5/7] revert: fix(graphql): increment graphql normalizer priority --- src/Symfony/Bundle/Resources/config/graphql.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index 932feb68d3c..a8995650b6a 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -214,7 +214,7 @@ - + From 76af4efbdffaf3053d309f41345c4187d5dc3a63 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 5 Apr 2024 12:02:03 +0200 Subject: [PATCH 6/7] Add conflict for symfony/framework-bundle versions that break enum serializing (#6292) Co-authored-by: Gwendolen Lynch --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index fd3d01f08cb..f7476083143 100644 --- a/composer.json +++ b/composer.json @@ -107,6 +107,7 @@ "doctrine/orm": "<2.14.0", "doctrine/mongodb-odm": "<2.4", "doctrine/persistence": "<1.3", + "symfony/framework-bundle": "6.4.6 || 7.0.6", "symfony/var-exporter": "<6.1.1", "phpunit/phpunit": "<9.5", "phpspec/prophecy": "<1.15", From 8f8121865aca950b64559c6aa6acad9d8bef6bda Mon Sep 17 00:00:00 2001 From: "josef.wagner" Date: Wed, 3 Apr 2024 14:07:40 +0200 Subject: [PATCH 7/7] fix(graphql): query nullish ManyToOne-Relation --- .../Resolver/Factory/ResolverFactory.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php index 430a1dc90bc..6577c95521d 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -17,8 +17,8 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; -use ApiPlatform\State\Pagination\ArrayPaginator; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\State\Pagination\ArrayPaginator; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; use GraphQL\Type\Definition\ResolveInfo; @@ -34,17 +34,19 @@ public function __construct( public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable { return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation, $propertyMetadataFactory) { - if ($body = $source[$info->fieldName] ?? null) { - // special treatment for nested resources without a resolver/provider - - return $body; - } - if (\array_key_exists($info->fieldName, $source ?? [])) { $body = $source[$info->fieldName]; + // special treatment for nested resources without a resolver/provider if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && (!$operation->getProvider() || NoopProvider::class === $operation->getProvider())) { - return $this->resolve($source, $args, $info, $rootClass, $operation, new ArrayPaginator($body, 0, \count($body))); + return \is_array($body) ? $this->resolve( + $source, + $args, + $info, + $rootClass, + $operation, + new ArrayPaginator($body, 0, \count($body)) + ) : $body; } $propertyMetadata = $rootClass ? $propertyMetadataFactory?->create($rootClass, $info->fieldName) : null;