Skip to content

Commit 656c190

Browse files
committed
Further refine nullness checks in schema inspection
This commit further refines nullness checks in the schema mapping inspection to process argument errors differently: unlike fields, where the application should not return `null` when the schema does not allow it, arguments take user input. This means we should only treat as errors if the schema accept nullable fields but the applicaiton code does not. Closes gh-1344
1 parent 49cde94 commit 656c190

File tree

4 files changed

+56
-22
lines changed

4 files changed

+56
-22
lines changed

spring-graphql-docs/modules/ROOT/pages/request-execution.adoc

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,16 +264,17 @@ annotations, further inspections can be performed. The GraphQL schema can declar
264264
non-nullable types (`Book!`).
265265
As a result, we can ensure that the application does not break the nullness requirements of the schema.
266266

267-
- When schema fields are non-null, we ensure that the relevant `Class` properties and `DataFetcher` return types
268-
are also non-null.
269-
- When field arguments are non-null, we ensure that `DataFetcher` parameters are also non-null.
270-
271-
The opposite situation is not considered as an error: when the schema has a nullable field `author: Author`
267+
When schema fields are non-null, we ensure that the relevant `Class` properties and `DataFetcher` return types
268+
are also non-null. The opposite situation is not considered as an error: when the schema has a nullable field `author: Author`
272269
and the application declares a `@NonNull Author getAuthor();`, the inspector does not raise this as an error.
273270
Applications should not necessarily make fields non-null in the schema, as any error during a data fetching operation
274271
will force the GraphQL engine tu null out fields in the hierarchy up until `null` is allowed.
275272
Partial responses is a key GraphQL feature, so the schema should be designed with nullness in mind.
276273

274+
When field arguments are nullable, we ensure that `DataFetcher` parameters are also nullable.
275+
In this case, user input should not be fed to the application if it breaks the nullability contract as this will lead
276+
to runtime failures.
277+
277278
To enable schema inspection, customize `GraphQlSource.Builder` as shown below.
278279
In this case the report is simply logged, but you can choose to take any action:
279280

@@ -294,13 +295,17 @@ GraphQL schema inspection:
294295
Unmapped fields: {Book=[title], Author[firstName, lastName]} // <1>
295296
Unmapped registrations: {Book.reviews=BookController#reviews[1 args]} <2>
296297
Unmapped arguments: {BookController#bookSearch[1 args]=[myAuthor]} // <3>
297-
Skipped types: [BookOrAuthor] // <4>
298+
Field nullness errors: {Book=[title is NON_NULL -> 'Book#title' is NULLABLE]} <4>
299+
Argument nullness errors: {BookController#bookById[1 args]=[java.lang.String id should be NULLABLE]} <5>
300+
Skipped types: [BookOrAuthor] // <6>
298301
----
299302

300303
<1> Schema fields that are not covered in any way
301304
<2> `DataFetcher` registrations to fields that don't exist
302305
<3> `DataFetcher` expected arguments that don't exist
303-
<4> Schema types that have been skipped (explained next)
306+
<4> "title" schema field is non null, but `Book.getTitle()` is `@Nullable`
307+
<5> `bookById(id: ID)` has a nullable "id" argument, but `Book bookById(@NonNull String id)` is non null.
308+
<6> Schema types that have been skipped (explained next)
304309

305310
In some cases, the `Class` type for a schema type is unknown. Maybe the `DataFetcher` does not
306311
implement `SelfDescribingDataFetcher`, or the declared return type is too general

spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaMappingInspector.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ private void checkFieldsContainer(
224224

225225
private void checkFieldNullNess(FieldCoordinates fieldCoordinates, Field javaField, Nullness schemaNullness) {
226226
Nullness applicationNullness = Nullness.forField(javaField);
227-
if (isNullnessError(schemaNullness, applicationNullness)) {
227+
if (isFieldNullnessError(schemaNullness, applicationNullness)) {
228228
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(javaField,
229229
javaField.getDeclaringClass().getSimpleName() + "#" + javaField.getName());
230230
this.reportBuilder.fieldNullnessError(fieldCoordinates,
@@ -247,7 +247,7 @@ else if (dataFetcher.usesDataLoader() && Map.class.isAssignableFrom(dataFetcherM
247247
// we cannot infer nullness if batch loader method returns a Map
248248
logger.debug("Skip nullness check for data fetcher '" + dataFetcherMethod.getName() + "' because of batch loading.");
249249
}
250-
else if (isNullnessError(schemaNullness, applicationNullness)) {
250+
else if (isFieldNullnessError(schemaNullness, applicationNullness)) {
251251
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(dataFetcherMethod, dataFetcher.getDescription());
252252
this.reportBuilder.fieldNullnessError(fieldCoordinates,
253253
new DefaultNullnessError(schemaNullness, applicationNullness, annotatedElement));
@@ -277,7 +277,7 @@ private void checkFieldArgumentsNullness(GraphQLFieldDefinition field, SelfDescr
277277
if (argument != null && argument.getDefinition() != null) {
278278
Nullness schemaNullness = resolveNullness(argument.getDefinition().getType());
279279
Nullness applicationNullness = Nullness.forMethodParameter(MethodParameter.forParameter(parameter));
280-
if (isNullnessError(schemaNullness, applicationNullness)) {
280+
if (isArgumentNullnessError(schemaNullness, applicationNullness)) {
281281
nullnessErrors.add(new DefaultNullnessError(schemaNullness, applicationNullness, parameter));
282282
}
283283
}
@@ -290,7 +290,7 @@ private void checkFieldArgumentsNullness(GraphQLFieldDefinition field, SelfDescr
290290

291291
private void checkReadMethodNullness(FieldCoordinates fieldCoordinates, ResolvableType resolvableType, PropertyDescriptor descriptor, Nullness schemaNullness) {
292292
Nullness applicationNullness = Nullness.forMethodReturnType(descriptor.getReadMethod());
293-
if (isNullnessError(schemaNullness, applicationNullness)) {
293+
if (isFieldNullnessError(schemaNullness, applicationNullness)) {
294294
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(descriptor.getReadMethod(),
295295
resolvableType.toClass().getSimpleName() + "#" + descriptor.getName());
296296
this.reportBuilder.fieldNullnessError(fieldCoordinates,
@@ -412,10 +412,14 @@ private Nullness resolveNullness(Type type) {
412412
return Nullness.NULLABLE;
413413
}
414414

415-
private boolean isNullnessError(Nullness schemaNullness, Nullness applicationNullness) {
415+
private boolean isFieldNullnessError(Nullness schemaNullness, Nullness applicationNullness) {
416416
return (schemaNullness == Nullness.NON_NULL && applicationNullness == Nullness.NULLABLE);
417417
}
418418

419+
private boolean isArgumentNullnessError(Nullness schemaNullness, Nullness applicationNullness) {
420+
return (schemaNullness == Nullness.NULLABLE && applicationNullness == Nullness.NON_NULL);
421+
}
422+
419423

420424
/**
421425
* Check the schema against {@code DataFetcher} registrations, and produce a report.

spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorNullnessTests.java

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ void reportFormatWhenSchemaFieldNonNullAndFieldMethodsNullable() {
138138
void reportIsEmptyWhenSchemaFieldNullableAndFieldTypeNonNull() {
139139
String schema = """
140140
type Query {
141-
bookById(id: ID): NonNullFieldBook
141+
bookById(id: ID!): NonNullFieldBook
142142
}
143143
type NonNullFieldBook {
144144
id: ID
@@ -510,7 +510,7 @@ public TraversalControl visitGraphQLFieldDefinition(GraphQLFieldDefinition node,
510510
class ArgumentsNullnessTests {
511511

512512
@Test
513-
void reportHasEntryWhenSchemaFieldNonNullAndReturnTypeNullable() {
513+
void reportIsEmptyWhenSchemaArgumentIsNonNullAndMethodArgumentNullable() {
514514
String schema = """
515515
type Query {
516516
bookById(id: ID!): Book!
@@ -521,23 +521,38 @@ void reportHasEntryWhenSchemaFieldNonNullAndReturnTypeNullable() {
521521
}
522522
""";
523523
SchemaReport report = inspectSchema(schema, NullableArgBookController.class);
524+
assertThatReport(report).isEmpty();
525+
}
526+
527+
@Test
528+
void reportHasEntryWhenSchemaArgumentNullableAndMethodArgumentNonNull() {
529+
String schema = """
530+
type Query {
531+
bookById(id: ID): Book!
532+
}
533+
type Book {
534+
id: ID!
535+
title: String!
536+
}
537+
""";
538+
SchemaReport report = inspectSchema(schema, NonNullArgBookController.class);
524539
assertThatReport(report).containsArgumentsNullnessErrors("java.lang.String id");
525540
}
526541

527542
@Test
528-
void reportFormatWhenSchemaFieldNonNullAndReturnTypeNullable() {
543+
void reportFormatWhenSchemaArgumentNullableAndMethodArgumentNonNull() {
529544
String schema = """
530545
type Query {
531-
bookById(id: ID!): Book!
546+
bookById(id: ID): Book!
532547
}
533548
type Book {
534549
id: ID!
535550
title: String!
536551
}
537552
""";
538-
SchemaReport report = inspectSchema(schema, NullableArgBookController.class);
553+
SchemaReport report = inspectSchema(schema, NonNullArgBookController.class);
539554
assertThat(report.toString())
540-
.contains("{NullableArgBookController#bookById[1 args]=[java.lang.String id should be NON_NULL]}");
555+
.contains("{NonNullArgBookController#bookById[1 args]=[java.lang.String id should be NULLABLE]}");
541556
}
542557

543558

@@ -552,6 +567,18 @@ static class NullableArgBookController {
552567

553568
}
554569

570+
571+
@Controller
572+
@NullMarked
573+
static class NonNullArgBookController {
574+
575+
@QueryMapping
576+
public Book bookById(String id) {
577+
return new Book("42", "Spring for GraphQL Book");
578+
}
579+
580+
}
581+
555582
record Book(String id, String title) {
556583

557584
}

spring-graphql/src/test/kotlin/org/springframework/graphql/execution/SchemaMappingInspectorNullnessKotlinTests.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package org.springframework.graphql.execution
1818

19-
import org.junit.jupiter.api.Disabled
2019
import org.junit.jupiter.api.Test
2120
import org.springframework.graphql.data.method.annotation.QueryMapping
2221
import org.springframework.stereotype.Controller
@@ -26,7 +25,6 @@ class SchemaMappingInspectorNullnessKotlinTests : SchemaMappingInspectorTestSupp
2625

2726

2827
@Test
29-
@Disabled("until https://github.com/spring-projects/spring-framework/issues/35419 is fixed")
3028
fun reportHasEntriesWhenSchemaFieldNonNullAndTypeFieldNullable() {
3129
val schema = """
3230
type Query {
@@ -59,7 +57,7 @@ class SchemaMappingInspectorNullnessKotlinTests : SchemaMappingInspectorTestSupp
5957
}
6058

6159
@Test
62-
fun reportHasEntriesWhenSchemaFieldNonNullAndDataFetcherArgumentNullable() {
60+
fun reportIsEmptuWhenSchemaFieldNonNullAndDataFetcherArgumentNullable() {
6361
val schema = """
6462
type Query {
6563
bookById(id: ID!): Book
@@ -71,7 +69,7 @@ class SchemaMappingInspectorNullnessKotlinTests : SchemaMappingInspectorTestSupp
7169
7270
""".trimIndent()
7371
val report: SchemaReport = inspectSchema(schema, NullableFetcherArgumentBookController::class.java)
74-
assertThatReport(report).containsArgumentsNullnessErrors("java.lang.String id");
72+
assertThatReport(report).isEmpty();
7573
}
7674

7775

0 commit comments

Comments
 (0)