Skip to content

Commit d15faef

Browse files
committed
fix(graphql): support nullable embedded relations in GraphQL types
1 parent ee1e4ee commit d15faef

File tree

7 files changed

+228
-67
lines changed

7 files changed

+228
-67
lines changed

features/graphql/schema.feature

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,16 @@ Feature: GraphQL schema-related features
9898
clientMutationId: String
9999
}
100100
"""
101+
And the command output should contain:
102+
"""
103+
"Updates a OptionalRequiredDummy."
104+
input updateOptionalRequiredDummyInput {
105+
id: ID!
106+
thirdLevel: updateThirdLevelNestedInput
107+
thirdLevelRequired: updateThirdLevelNestedInput!
108+
109+
"Get relatedToDummyFriend."
110+
relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput]
111+
clientMutationId: String
112+
}
113+
"""

src/GraphQl/Tests/Type/TypeBuilderTest.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface;
2020
use ApiPlatform\GraphQl\Type\TypeBuilder;
2121
use ApiPlatform\GraphQl\Type\TypesContainerInterface;
22+
use ApiPlatform\Metadata\ApiProperty;
2223
use ApiPlatform\Metadata\ApiResource;
2324
use ApiPlatform\Metadata\GraphQl\Mutation;
2425
use ApiPlatform\Metadata\GraphQl\Operation;
@@ -171,7 +172,7 @@ public function testGetResourceObjectTypeInput(): void
171172
{
172173
$resourceMetadata = new ResourceMetadataCollection('resourceClass', []);
173174
$this->typesContainerProphecy->has('customShortNameInput')->shouldBeCalled()->willReturn(false);
174-
$this->typesContainerProphecy->set('customShortNameInput', Argument::type(NonNull::class))->shouldBeCalled();
175+
$this->typesContainerProphecy->set('customShortNameInput', Argument::type(InputObjectType::class))->shouldBeCalled();
175176
$this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false);
176177
$this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled();
177178

@@ -196,7 +197,7 @@ public function testGetResourceObjectTypeNestedInput(): void
196197
{
197198
$resourceMetadata = new ResourceMetadataCollection('resourceClass', []);
198199
$this->typesContainerProphecy->has('customShortNameNestedInput')->shouldBeCalled()->willReturn(false);
199-
$this->typesContainerProphecy->set('customShortNameNestedInput', Argument::type(NonNull::class))->shouldBeCalled();
200+
$this->typesContainerProphecy->set('customShortNameNestedInput', Argument::type(InputObjectType::class))->shouldBeCalled();
200201
$this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false);
201202
$this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled();
202203

@@ -217,11 +218,37 @@ public function testGetResourceObjectTypeNestedInput(): void
217218
$wrappedType->config['fields']();
218219
}
219220

221+
public function testGetResourceObjectTypeNestedInputNullable(): void
222+
{
223+
$resourceMetadata = new ResourceMetadataCollection('resourceClass', []);
224+
$this->typesContainerProphecy->has('customShortNameNullableNestedInput')->shouldBeCalled()->willReturn(false);
225+
$this->typesContainerProphecy->set('customShortNameNullableNestedInput', Argument::type(InputObjectType::class))->shouldBeCalled();
226+
$this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false);
227+
$this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled();
228+
229+
/** @var Operation $operation */
230+
$operation = (new Mutation())->withName('custom')->withShortName('shortNameNullable')->withDescription('description nullable');
231+
/** @var ApiProperty $propertyMetadata */
232+
$propertyMetadata = (new ApiProperty())->withRequired(false);
233+
/** @var InputObjectType $resourceObjectType */
234+
$resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, true, false, 1, $propertyMetadata);
235+
236+
$this->assertInstanceOf(InputObjectType::class, $resourceObjectType);
237+
$this->assertSame('customShortNameNullableNestedInput', $resourceObjectType->name);
238+
$this->assertSame('description nullable', $resourceObjectType->description);
239+
$this->assertArrayHasKey('fields', $resourceObjectType->config);
240+
241+
$fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class);
242+
$fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, true, 1, null)->shouldBeCalled();
243+
$this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal());
244+
$resourceObjectType->config['fields']();
245+
}
246+
220247
public function testGetResourceObjectTypeCustomMutationInputArgs(): void
221248
{
222249
$resourceMetadata = new ResourceMetadataCollection('resourceClass', []);
223250
$this->typesContainerProphecy->has('customShortNameInput')->shouldBeCalled()->willReturn(false);
224-
$this->typesContainerProphecy->set('customShortNameInput', Argument::type(NonNull::class))->shouldBeCalled();
251+
$this->typesContainerProphecy->set('customShortNameInput', Argument::type(InputObjectType::class))->shouldBeCalled();
225252
$this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false);
226253
$this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled();
227254

src/GraphQl/Tests/Type/TypeConverterTest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,15 @@ public function testConvertTypeInputResource(): void
155155
$type = new Type(Type::BUILTIN_TYPE_OBJECT, false, 'dummy');
156156
/** @var Operation $operation */
157157
$operation = new Query();
158+
/** @var ApiProperty $propertyMetadata */
159+
$propertyMetadata = (new ApiProperty())->withWritableLink(true);
158160
$graphqlResourceMetadata = new ResourceMetadataCollection('dummy', [(new ApiResource())->withGraphQlOperations(['item_query' => $operation])]);
159161
$expectedGraphqlType = new ObjectType(['name' => 'resourceObjectType', 'fields' => []]);
160162

161163
$this->resourceMetadataCollectionFactoryProphecy->create('dummy')->willReturn($graphqlResourceMetadata);
162164
$this->typeBuilderProphecy->isCollection($type)->willReturn(false);
163165
$this->propertyMetadataFactoryProphecy->create('rootClass', 'dummyProperty', Argument::type('array'))->shouldBeCalled()->willReturn((new ApiProperty())->withWritableLink(true));
164-
$this->typeBuilderProphecy->getResourceObjectType('dummy', $graphqlResourceMetadata, $operation, true, false, 1)->shouldBeCalled()->willReturn($expectedGraphqlType);
166+
$this->typeBuilderProphecy->getResourceObjectType('dummy', $graphqlResourceMetadata, $operation, true, false, 1, $propertyMetadata)->shouldBeCalled()->willReturn($expectedGraphqlType);
165167

166168
$graphqlType = $this->typeConverter->convertType($type, true, $operation, 'dummy', 'rootClass', 'dummyProperty', 1);
167169
$this->assertSame($expectedGraphqlType, $graphqlType);
@@ -179,7 +181,7 @@ public function testConvertTypeCollectionResource(Type $type, ObjectType $expect
179181

180182
$this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true);
181183
$this->resourceMetadataCollectionFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata);
182-
$this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, $collectionOperation, false, false, 0)->shouldBeCalled()->willReturn($expectedGraphqlType);
184+
$this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, $collectionOperation, false, false, 0, null)->shouldBeCalled()->willReturn($expectedGraphqlType);
183185

184186
/** @var Operation $rootOperation */
185187
$rootOperation = (new Query())->withName('test');

src/GraphQl/Type/TypeBuilder.php

Lines changed: 62 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\GraphQl\Type;
1515

1616
use ApiPlatform\GraphQl\Serializer\ItemNormalizer;
17+
use ApiPlatform\Metadata\ApiProperty;
1718
use ApiPlatform\Metadata\CollectionOperationInterface;
1819
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
1920
use ApiPlatform\Metadata\GraphQl\Mutation;
@@ -48,7 +49,7 @@ public function __construct(private readonly TypesContainerInterface $typesConta
4849
/**
4950
* {@inheritdoc}
5051
*/
51-
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0): GraphQLType
52+
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0, ApiProperty $propertyMetadata = null): GraphQLType
5253
{
5354
$shortName = $operation->getShortName();
5455
$operationName = $operation->getName();
@@ -84,80 +85,84 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadataCo
8485
$shortName .= 'Data';
8586
}
8687

87-
if ($this->typesContainer->has($shortName)) {
88-
$resourceObjectType = $this->typesContainer->get($shortName);
89-
if (!($resourceObjectType instanceof ObjectType || $resourceObjectType instanceof NonNull)) {
90-
throw new \LogicException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class])));
88+
$resourceObjectType = null;
89+
if (!$this->typesContainer->has($shortName)) {
90+
$ioMetadata = $input ? $operation->getInput() : $operation->getOutput();
91+
if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) {
92+
$resourceClass = $ioMetadata['class'];
9193
}
9294

93-
return $resourceObjectType;
94-
}
95+
$wrapData = !$wrapped && ($operation instanceof Mutation || $operation instanceof Subscription) && !$input && $depth < 1;
9596

96-
$ioMetadata = $input ? $operation->getInput() : $operation->getOutput();
97-
if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) {
98-
$resourceClass = $ioMetadata['class'];
99-
}
97+
$configuration = [
98+
'name' => $shortName,
99+
'description' => $operation->getDescription(),
100+
'resolveField' => $this->defaultFieldResolver,
101+
'fields' => function () use ($resourceClass, $operation, $operationName, $resourceMetadataCollection, $input, $wrapData, $depth, $ioMetadata) {
102+
if ($wrapData) {
103+
$queryNormalizationContext = $this->getQueryOperation($resourceMetadataCollection)?->getNormalizationContext() ?? [];
100104

101-
$wrapData = !$wrapped && ($operation instanceof Mutation || $operation instanceof Subscription) && !$input && $depth < 1;
105+
try {
106+
$mutationNormalizationContext = $operation instanceof Mutation || $operation instanceof Subscription ? ($resourceMetadataCollection->getOperation($operationName)->getNormalizationContext() ?? []) : [];
107+
} catch (OperationNotFoundException) {
108+
$mutationNormalizationContext = [];
109+
}
110+
// Use a new type for the wrapped object only if there is a specific normalization context for the mutation or the subscription.
111+
// If not, use the query type in order to ensure the client cache could be used.
112+
$useWrappedType = $queryNormalizationContext !== $mutationNormalizationContext;
102113

103-
$configuration = [
104-
'name' => $shortName,
105-
'description' => $operation->getDescription(),
106-
'resolveField' => $this->defaultFieldResolver,
107-
'fields' => function () use ($resourceClass, $operation, $operationName, $resourceMetadataCollection, $input, $wrapData, $depth, $ioMetadata) {
108-
if ($wrapData) {
109-
$queryNormalizationContext = $this->getQueryOperation($resourceMetadataCollection)?->getNormalizationContext() ?? [];
110-
111-
try {
112-
$mutationNormalizationContext = $operation instanceof Mutation || $operation instanceof Subscription ? ($resourceMetadataCollection->getOperation($operationName)->getNormalizationContext() ?? []) : [];
113-
} catch (OperationNotFoundException) {
114-
$mutationNormalizationContext = [];
115-
}
116-
// Use a new type for the wrapped object only if there is a specific normalization context for the mutation or the subscription.
117-
// If not, use the query type in order to ensure the client cache could be used.
118-
$useWrappedType = $queryNormalizationContext !== $mutationNormalizationContext;
114+
$wrappedOperationName = $operationName;
119115

120-
$wrappedOperationName = $operationName;
116+
if (!$useWrappedType) {
117+
$wrappedOperationName = $operation instanceof Query ? $operationName : 'item_query';
118+
}
121119

122-
if (!$useWrappedType) {
123-
$wrappedOperationName = $operation instanceof Query ? $operationName : 'item_query';
124-
}
120+
$wrappedOperation = $resourceMetadataCollection->getOperation($wrappedOperationName);
125121

126-
$wrappedOperation = $resourceMetadataCollection->getOperation($wrappedOperationName);
122+
$fields = [
123+
lcfirst($wrappedOperation->getShortName()) => $this->getResourceObjectType($resourceClass, $resourceMetadataCollection, $wrappedOperation instanceof Operation ? $wrappedOperation : null, $input, true, $depth),
124+
];
127125

128-
$fields = [
129-
lcfirst($wrappedOperation->getShortName()) => $this->getResourceObjectType($resourceClass, $resourceMetadataCollection, $wrappedOperation instanceof Operation ? $wrappedOperation : null, $input, true, $depth),
130-
];
126+
if ($operation instanceof Subscription) {
127+
$fields['clientSubscriptionId'] = GraphQLType::string();
128+
if ($operation->getMercure()) {
129+
$fields['mercureUrl'] = GraphQLType::string();
130+
}
131131

132-
if ($operation instanceof Subscription) {
133-
$fields['clientSubscriptionId'] = GraphQLType::string();
134-
if ($operation->getMercure()) {
135-
$fields['mercureUrl'] = GraphQLType::string();
132+
return $fields;
136133
}
137134

138-
return $fields;
135+
return $fields + ['clientMutationId' => GraphQLType::string()];
139136
}
140137

141-
return $fields + ['clientMutationId' => GraphQLType::string()];
142-
}
138+
$fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder');
139+
$fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $operation, $input, $depth, $ioMetadata);
140+
141+
if ($input && $operation instanceof Mutation && null !== $mutationArgs = $operation->getArgs()) {
142+
return $fieldsBuilder->resolveResourceArgs($mutationArgs, $operation) + ['clientMutationId' => $fields['clientMutationId']];
143+
}
144+
if ($input && $operation instanceof Mutation && null !== $extraMutationArgs = $operation->getExtraArgs()) {
145+
return $fields + $fieldsBuilder->resolveResourceArgs($extraMutationArgs, $operation);
146+
}
143147

144-
$fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder');
145-
$fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $operation, $input, $depth, $ioMetadata);
148+
return $fields;
149+
},
150+
'interfaces' => $wrapData ? [] : [$this->getNodeInterface()],
151+
];
146152

147-
if ($input && $operation instanceof Mutation && null !== $mutationArgs = $operation->getArgs()) {
148-
return $fieldsBuilder->resolveResourceArgs($mutationArgs, $operation) + ['clientMutationId' => $fields['clientMutationId']];
149-
}
150-
if ($input && $operation instanceof Mutation && null !== $extraMutationArgs = $operation->getExtraArgs()) {
151-
return $fields + $fieldsBuilder->resolveResourceArgs($extraMutationArgs, $operation);
152-
}
153+
$resourceObjectType = $input ? new InputObjectType($configuration) : new ObjectType($configuration);
154+
$this->typesContainer->set($shortName, $resourceObjectType);
155+
}
153156

154-
return $fields;
155-
},
156-
'interfaces' => $wrapData ? [] : [$this->getNodeInterface()],
157-
];
157+
$resourceObjectType = $resourceObjectType ?? $this->typesContainer->get($shortName);
158+
if (!($resourceObjectType instanceof ObjectType || $resourceObjectType instanceof NonNull || $resourceObjectType instanceof InputObjectType)) {
159+
throw new \LogicException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class, InputObjectType::class])));
160+
}
158161

159-
$resourceObjectType = $input ? GraphQLType::nonNull(new InputObjectType($configuration)) : new ObjectType($configuration);
160-
$this->typesContainer->set($shortName, $resourceObjectType);
162+
$required = $propertyMetadata?->isRequired() ?? true;
163+
if ($required && $input) {
164+
$resourceObjectType = GraphQLType::nonNull($resourceObjectType);
165+
}
161166

162167
return $resourceObjectType;
163168
}

src/GraphQl/Type/TypeBuilderEnumInterface.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@
1313

1414
namespace ApiPlatform\GraphQl\Type;
1515

16+
use ApiPlatform\Metadata\ApiProperty;
1617
use ApiPlatform\Metadata\GraphQl\Operation;
1718
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
1819
use GraphQL\Type\Definition\InterfaceType;
19-
use GraphQL\Type\Definition\NonNull;
20-
use GraphQL\Type\Definition\ObjectType;
2120
use GraphQL\Type\Definition\Type as GraphQLType;
2221
use Symfony\Component\PropertyInfo\Type;
2322

@@ -31,9 +30,9 @@ interface TypeBuilderEnumInterface
3130
/**
3231
* Gets the object type of the given resource.
3332
*
34-
* @return ObjectType|NonNull the object type, possibly wrapped by NonNull
33+
* @return GraphQLType the object type, possibly wrapped by NonNull
3534
*/
36-
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0): GraphQLType;
35+
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0, ApiProperty $propertyMetadata = null): GraphQLType;
3736

3837
/**
3938
* Get the interface type of a node.

src/GraphQl/Type/TypeConverter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ private function getResourceType(Type $type, bool $input, Operation $rootOperati
182182
throw new OperationNotFoundException();
183183
}
184184

185-
return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadataCollection, $operation, $input, false, $depth);
185+
return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadataCollection, $operation, $input, false, $depth, $propertyMetadata);
186186
}
187187

188188
private function resolveAstTypeNode(TypeNode $astTypeNode, string $fromType): ?GraphQLType

0 commit comments

Comments
 (0)