Skip to content

Commit 00f8d50

Browse files
committed
fix(graphql): support nullable embedded relations in GraphQL types
1 parent 804da1b commit 00f8d50

File tree

7 files changed

+269
-64
lines changed

7 files changed

+269
-64
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,30 @@ public function testGetResourceObjectTypeNestedInput(): void
217217
$wrappedType->config['fields']();
218218
}
219219

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

src/GraphQl/Tests/Type/TypeConverterTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public function testConvertTypeInputResource(): void
161161
$this->resourceMetadataCollectionFactoryProphecy->create('dummy')->willReturn($graphqlResourceMetadata);
162162
$this->typeBuilderProphecy->isCollection($type)->willReturn(false);
163163
$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);
164+
$this->typeBuilderProphecy->getResourceObjectType('dummy', $graphqlResourceMetadata, $operation, true, false, 1, true)->shouldBeCalled()->willReturn($expectedGraphqlType);
165165

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

180180
$this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true);
181181
$this->resourceMetadataCollectionFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata);
182-
$this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, $collectionOperation, false, false, 0)->shouldBeCalled()->willReturn($expectedGraphqlType);
182+
$this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, $collectionOperation, false, false, 0, true)->shouldBeCalled()->willReturn($expectedGraphqlType);
183183

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

src/GraphQl/Type/TypeBuilder.php

Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public function __construct(private readonly TypesContainerInterface $typesConta
4848
/**
4949
* {@inheritdoc}
5050
*/
51-
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0): GraphQLType
51+
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0, bool $required = true): GraphQLType
5252
{
5353
$shortName = $operation->getShortName();
5454
$operationName = $operation->getName();
@@ -84,80 +84,82 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadataCo
8484
$shortName .= 'Data';
8585
}
8686

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])));
87+
if (!$this->typesContainer->has($shortName)) {
88+
$ioMetadata = $input ? $operation->getInput() : $operation->getOutput();
89+
if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) {
90+
$resourceClass = $ioMetadata['class'];
9191
}
9292

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

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

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

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;
112+
$wrappedOperationName = $operationName;
119113

120-
$wrappedOperationName = $operationName;
114+
if (!$useWrappedType) {
115+
$wrappedOperationName = $operation instanceof Query ? $operationName : 'item_query';
116+
}
121117

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

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

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

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

138-
return $fields;
133+
return $fields + ['clientMutationId' => GraphQLType::string()];
139134
}
140135

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

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

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-
}
151+
$resourceObjectType = $input ? new InputObjectType($configuration) : new ObjectType($configuration);
152+
$this->typesContainer->set($shortName, $resourceObjectType);
153+
}
153154

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

159-
$resourceObjectType = $input ? GraphQLType::nonNull(new InputObjectType($configuration)) : new ObjectType($configuration);
160-
$this->typesContainer->set($shortName, $resourceObjectType);
160+
if ($required && $input) {
161+
$resourceObjectType = GraphQLType::nonNull($resourceObjectType);
162+
}
161163

162164
return $resourceObjectType;
163165
}

src/GraphQl/Type/TypeBuilderEnumInterface.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616
use ApiPlatform\Metadata\GraphQl\Operation;
1717
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
1818
use GraphQL\Type\Definition\InterfaceType;
19-
use GraphQL\Type\Definition\NonNull;
20-
use GraphQL\Type\Definition\ObjectType;
2119
use GraphQL\Type\Definition\Type as GraphQLType;
2220
use Symfony\Component\PropertyInfo\Type;
2321

@@ -31,9 +29,9 @@ interface TypeBuilderEnumInterface
3129
/**
3230
* Gets the object type of the given resource.
3331
*
34-
* @return ObjectType|NonNull the object type, possibly wrapped by NonNull
32+
* @return GraphQLType the object type, possibly wrapped by NonNull
3533
*/
36-
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0): GraphQLType;
34+
public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0, bool $required = false): GraphQLType;
3735

3836
/**
3937
* Get the interface type of a node.

src/GraphQl/Type/TypeConverter.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ 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+
$required = $propertyMetadata?->isRequired() ?? true;
186+
187+
return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadataCollection, $operation, $input, false, $depth, $required);
186188
}
187189

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

0 commit comments

Comments
 (0)