Skip to content

Commit f03653b

Browse files
committed
Refine nullness checks in the schema report inspection
Prior to this commit, gh-1220 introduced nullness checks in the schema mapping inspection process. This generally reports all mismatches between the GraphQL schema nullness information and the corresponding Java type nullness. This commit refines the implementation with the following changes: If an application type is "non-null" and the GraphQL schema type is "nullable", we should not report that as a problem. Partial responses is a key GraphQL feature and we should not force developers to make fields "non-null" just because applications types are "non-null". If a data fetching error happens, the engine will null out fields in the hierarchy up until null is a valid value. The method names and report information now use "errors" instead of "mismatches" as a consequence. Closes gh-1344
1 parent f623bb0 commit f03653b

File tree

7 files changed

+116
-86
lines changed

7 files changed

+116
-86
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
@@ -261,13 +261,18 @@ to inspect schema mappings on startup to ensure the following:
261261

262262
If the application is written in Kotlin, or is using {spring-framework-ref-docs}/core/null-safety.html[Null-safety]
263263
annotations, further inspections can be performed. The GraphQL schema can declare nullable types (`Book`) and
264-
non-nullable types (`Book!`). We can ensure that both the schema and the application are in sync when it comes
265-
to nullability information:
266-
267-
- For schema fields, we can check that the relevant `Class` properties and `DataFetcher` return types with the same
268-
nullability.
269-
- For field arguments, we can ensure that `DataFetcher` parameters have the same nullability
270-
264+
non-nullable types (`Book!`).
265+
As a result, we can ensure that the application does not break the nullness requirements of the schema.
266+
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`
272+
and the application declares a `@NonNull Author getAuthor();`, the inspector does not raise this as an error.
273+
Applications should not necessarily make fields non-null in the schema, as any error during a data fetching operation
274+
will force the GraphQL engine tu null out fields in the hierarchy up until `null` is allowed.
275+
Partial responses is a key GraphQL feature, so the schema should be designed with nullness in mind.
271276

272277
To enable schema inspection, customize `GraphQlSource.Builder` as shown below.
273278
In this case the report is simply logged, but you can choose to take any action:

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

Lines changed: 52 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@
7878
* corresponding Class property.
7979
* <li>{@code DataFetcher} registrations refer to a schema field that exists.
8080
* <li>{@code DataFetcher} arguments have matching schema field arguments.
81-
* <li>The nullness of schema fields matches the nullness of {@link DataFetcher}
82-
* return types, class properties or class methods.
83-
* <li>{@code DataFetcher} arguments match the nullness of schema argument types.
81+
* <li>The nullness of {@link DataFetcher} return types, class properties or class methods
82+
* match, or is more restrictive than, the nullness of schema fields.
83+
* <li>The nullness of {@code DataFetcher} arguments match, or is more restrictive than,
84+
* the nullness of schema argument types.
8485
* </ul>
8586
*
8687
* <p>Use methods of {@link GraphQlSource.SchemaResourceBuilder} to enable schema
@@ -223,11 +224,11 @@ private void checkFieldsContainer(
223224

224225
private void checkFieldNullNess(FieldCoordinates fieldCoordinates, Field javaField, Nullness schemaNullness) {
225226
Nullness applicationNullness = Nullness.forField(javaField);
226-
if (isMismatch(schemaNullness, applicationNullness)) {
227+
if (isNullnessError(schemaNullness, applicationNullness)) {
227228
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(javaField,
228229
javaField.getDeclaringClass().getSimpleName() + "#" + javaField.getName());
229-
this.reportBuilder.fieldNullnessMismatch(fieldCoordinates,
230-
new DefaultNullnessMismatch(schemaNullness, applicationNullness, annotatedElement));
230+
this.reportBuilder.fieldNullnessError(fieldCoordinates,
231+
new DefaultNullnessError(schemaNullness, applicationNullness, annotatedElement));
231232

232233
}
233234
}
@@ -246,10 +247,10 @@ else if (dataFetcher.usesDataLoader() && Map.class.isAssignableFrom(dataFetcherM
246247
// we cannot infer nullness if batch loader method returns a Map
247248
logger.debug("Skip nullness check for data fetcher '" + dataFetcherMethod.getName() + "' because of batch loading.");
248249
}
249-
else if (isMismatch(schemaNullness, applicationNullness)) {
250+
else if (isNullnessError(schemaNullness, applicationNullness)) {
250251
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(dataFetcherMethod, dataFetcher.getDescription());
251-
this.reportBuilder.fieldNullnessMismatch(fieldCoordinates,
252-
new DefaultNullnessMismatch(schemaNullness, applicationNullness, annotatedElement));
252+
this.reportBuilder.fieldNullnessError(fieldCoordinates,
253+
new DefaultNullnessError(schemaNullness, applicationNullness, annotatedElement));
253254

254255
}
255256
}
@@ -270,30 +271,30 @@ private void checkFieldArguments(GraphQLFieldDefinition field, SelfDescribingDat
270271
private void checkFieldArgumentsNullness(GraphQLFieldDefinition field, SelfDescribingDataFetcher<?> dataFetcher) {
271272
Method dataFetcherMethod = dataFetcher.asMethod();
272273
if (dataFetcherMethod != null) {
273-
List<SchemaReport.NullnessMismatch> mismatches = new ArrayList<>();
274+
List<SchemaReport.NullnessError> nullnessErrors = new ArrayList<>();
274275
for (Parameter parameter : dataFetcherMethod.getParameters()) {
275276
GraphQLArgument argument = field.getArgument(parameter.getName());
276-
if (argument != null) {
277+
if (argument != null && argument.getDefinition() != null) {
277278
Nullness schemaNullness = resolveNullness(argument.getDefinition().getType());
278279
Nullness applicationNullness = Nullness.forMethodParameter(MethodParameter.forParameter(parameter));
279-
if (isMismatch(schemaNullness, applicationNullness)) {
280-
mismatches.add(new DefaultNullnessMismatch(schemaNullness, applicationNullness, parameter));
280+
if (isNullnessError(schemaNullness, applicationNullness)) {
281+
nullnessErrors.add(new DefaultNullnessError(schemaNullness, applicationNullness, parameter));
281282
}
282283
}
283284
}
284-
if (!mismatches.isEmpty()) {
285-
this.reportBuilder.argumentsNullnessMismatches(dataFetcher, mismatches);
285+
if (!nullnessErrors.isEmpty()) {
286+
this.reportBuilder.argumentsNullnessErrors(dataFetcher, nullnessErrors);
286287
}
287288
}
288289
}
289290

290291
private void checkReadMethodNullness(FieldCoordinates fieldCoordinates, ResolvableType resolvableType, PropertyDescriptor descriptor, Nullness schemaNullness) {
291292
Nullness applicationNullness = Nullness.forMethodReturnType(descriptor.getReadMethod());
292-
if (isMismatch(schemaNullness, applicationNullness)) {
293+
if (isNullnessError(schemaNullness, applicationNullness)) {
293294
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(descriptor.getReadMethod(),
294295
resolvableType.toClass().getSimpleName() + "#" + descriptor.getName());
295-
this.reportBuilder.fieldNullnessMismatch(fieldCoordinates,
296-
new DefaultNullnessMismatch(schemaNullness, applicationNullness, annotatedElement));
296+
this.reportBuilder.fieldNullnessError(fieldCoordinates,
297+
new DefaultNullnessError(schemaNullness, applicationNullness, annotatedElement));
297298
}
298299
}
299300

@@ -408,12 +409,11 @@ private Nullness resolveNullness(Type type) {
408409
if (type instanceof NonNullType) {
409410
return Nullness.NON_NULL;
410411
}
411-
return Nullness.NULLABLE;
412+
return Nullness.NULLABLE;
412413
}
413414

414-
private boolean isMismatch(Nullness first, Nullness second) {
415-
return (first == Nullness.NON_NULL && second == Nullness.NULLABLE) ||
416-
(first == Nullness.NULLABLE && second == Nullness.NON_NULL);
415+
private boolean isNullnessError(Nullness schemaNullness, Nullness applicationNullness) {
416+
return (schemaNullness == Nullness.NON_NULL && applicationNullness == Nullness.NULLABLE);
417417
}
418418

419419

@@ -909,9 +909,9 @@ private final class ReportBuilder {
909909

910910
private final MultiValueMap<DataFetcher<?>, String> unmappedArguments = new LinkedMultiValueMap<>();
911911

912-
private final Map<FieldCoordinates, SchemaReport.NullnessMismatch> fieldNullnessMismatches = new LinkedHashMap<>();
912+
private final Map<FieldCoordinates, SchemaReport.NullnessError> fieldNullnessErrors = new LinkedHashMap<>();
913913

914-
private final MultiValueMap<DataFetcher<?>, SchemaReport.NullnessMismatch> argumentsNullnessMismatches = new LinkedMultiValueMap<>();
914+
private final MultiValueMap<DataFetcher<?>, SchemaReport.NullnessError> argumentsNullnessErrors = new LinkedMultiValueMap<>();
915915

916916
private final List<DefaultSkippedType> skippedTypes = new ArrayList<>();
917917

@@ -929,12 +929,12 @@ void unmappedArgument(DataFetcher<?> dataFetcher, List<String> arguments) {
929929
this.unmappedArguments.put(dataFetcher, arguments);
930930
}
931931

932-
void fieldNullnessMismatch(FieldCoordinates coordinates, SchemaReport.NullnessMismatch nullnessMismatch) {
933-
this.fieldNullnessMismatches.put(coordinates, nullnessMismatch);
932+
void fieldNullnessError(FieldCoordinates coordinates, SchemaReport.NullnessError nullnessError) {
933+
this.fieldNullnessErrors.put(coordinates, nullnessError);
934934
}
935935

936-
void argumentsNullnessMismatches(DataFetcher<?> dataFetcher, List<SchemaReport.NullnessMismatch> nullnessMismatches) {
937-
this.argumentsNullnessMismatches.put(dataFetcher, nullnessMismatches);
936+
void argumentsNullnessErrors(DataFetcher<?> dataFetcher, List<SchemaReport.NullnessError> nullnessErrors) {
937+
this.argumentsNullnessErrors.put(dataFetcher, nullnessErrors);
938938
}
939939

940940
void skippedType(
@@ -973,7 +973,7 @@ SchemaReport build() {
973973
});
974974

975975
return new DefaultSchemaReport(this.unmappedFields, this.unmappedRegistrations,
976-
this.unmappedArguments, this.fieldNullnessMismatches, this.argumentsNullnessMismatches, this.skippedTypes);
976+
this.unmappedArguments, this.fieldNullnessErrors, this.argumentsNullnessErrors, this.skippedTypes);
977977
}
978978
}
979979

@@ -989,24 +989,24 @@ private final class DefaultSchemaReport implements SchemaReport {
989989

990990
private final MultiValueMap<DataFetcher<?>, String> unmappedArguments;
991991

992-
private final Map<FieldCoordinates, NullnessMismatch> fieldsNullnessMismatches;
992+
private final Map<FieldCoordinates, NullnessError> fieldNullnessErrors;
993993

994-
private final MultiValueMap<DataFetcher<?>, NullnessMismatch> argumentsNullnessMismatches;
994+
private final MultiValueMap<DataFetcher<?>, NullnessError> argumentNullnessErrors;
995995

996996
private final List<SchemaReport.SkippedType> skippedTypes;
997997

998998
DefaultSchemaReport(
999999
List<FieldCoordinates> unmappedFields, Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations,
10001000
MultiValueMap<DataFetcher<?>, String> unmappedArguments,
1001-
Map<FieldCoordinates, NullnessMismatch> fieldsNullnessMismatches,
1002-
MultiValueMap<DataFetcher<?>, NullnessMismatch> argumentsNullnessMismatches,
1001+
Map<FieldCoordinates, NullnessError> fieldNullnessErrors,
1002+
MultiValueMap<DataFetcher<?>, NullnessError> argumentNullnessErrors,
10031003
List<DefaultSkippedType> skippedTypes) {
10041004

10051005
this.unmappedFields = Collections.unmodifiableList(unmappedFields);
10061006
this.unmappedRegistrations = Collections.unmodifiableMap(unmappedRegistrations);
10071007
this.unmappedArguments = CollectionUtils.unmodifiableMultiValueMap(unmappedArguments);
1008-
this.fieldsNullnessMismatches = Collections.unmodifiableMap(fieldsNullnessMismatches);
1009-
this.argumentsNullnessMismatches = CollectionUtils.unmodifiableMultiValueMap(argumentsNullnessMismatches);
1008+
this.fieldNullnessErrors = Collections.unmodifiableMap(fieldNullnessErrors);
1009+
this.argumentNullnessErrors = CollectionUtils.unmodifiableMultiValueMap(argumentNullnessErrors);
10101010
this.skippedTypes = Collections.unmodifiableList(skippedTypes);
10111011
}
10121012

@@ -1026,13 +1026,13 @@ public MultiValueMap<DataFetcher<?>, String> unmappedArguments() {
10261026
}
10271027

10281028
@Override
1029-
public Map<FieldCoordinates, NullnessMismatch> fieldsNullnessMismatches() {
1030-
return this.fieldsNullnessMismatches;
1029+
public Map<FieldCoordinates, NullnessError> fieldNullnessErrors() {
1030+
return this.fieldNullnessErrors;
10311031
}
10321032

10331033
@Override
1034-
public MultiValueMap<DataFetcher<?>, NullnessMismatch> argumentsNullnessMismatches() {
1035-
return this.argumentsNullnessMismatches;
1034+
public MultiValueMap<DataFetcher<?>, NullnessError> argumentNullnessErrors() {
1035+
return this.argumentNullnessErrors;
10361036
}
10371037

10381038
@Override
@@ -1058,8 +1058,8 @@ public String toString() {
10581058
"\tUnmapped fields: " + formatUnmappedFields() + "\n" +
10591059
"\tUnmapped registrations: " + this.unmappedRegistrations + "\n" +
10601060
"\tUnmapped arguments: " + this.unmappedArguments + "\n" +
1061-
"\tFields nullness mismatches: " + formatFieldsNullnessMismatches() + "\n" +
1062-
"\tArguments nullness mismatches: " + formatArgumentsNullnessMismatches() + "\n" +
1061+
"\tField nullness errors: " + formatFieldNullnessErrors() + "\n" +
1062+
"\tArgument nullness errors: " + formatArgumentNullnessErrors() + "\n" +
10631063
"\tSkipped types: " + this.skippedTypes;
10641064
}
10651065

@@ -1072,21 +1072,21 @@ private String formatUnmappedFields() {
10721072
return map.toString();
10731073
}
10741074

1075-
private String formatFieldsNullnessMismatches() {
1075+
private String formatFieldNullnessErrors() {
10761076
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
1077-
this.fieldsNullnessMismatches.forEach((coordinates, mismatch) -> {
1077+
this.fieldNullnessErrors.forEach((coordinates, nullnessError) -> {
10781078
List<String> fields = map.computeIfAbsent(coordinates.getTypeName(), (s) -> new ArrayList<>());
1079-
fields.add(String.format("%s is %s -> '%s' is %s", coordinates.getFieldName(), mismatch.schemaNullness(),
1080-
mismatch.annotatedElement(), mismatch.applicationNullness()));
1079+
fields.add(String.format("%s is %s -> '%s' is %s", coordinates.getFieldName(), nullnessError.schemaNullness(),
1080+
nullnessError.annotatedElement(), nullnessError.applicationNullness()));
10811081
});
10821082
return map.toString();
10831083
}
10841084

1085-
private String formatArgumentsNullnessMismatches() {
1085+
private String formatArgumentNullnessErrors() {
10861086
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
1087-
this.argumentsNullnessMismatches.forEach((dataFetcher, mismatches) -> {
1088-
List<String> arguments = mismatches.stream()
1089-
.map((mismatch) -> String.format("%s should be %s", mismatch.annotatedElement(), mismatch.schemaNullness()))
1087+
this.argumentNullnessErrors.forEach((dataFetcher, nullnessErrors) -> {
1088+
List<String> arguments = nullnessErrors.stream()
1089+
.map((nullnessError) -> String.format("%s should be %s", nullnessError.annotatedElement(), nullnessError.schemaNullness()))
10901090
.toList();
10911091
map.put(dataFetcher.toString(), arguments);
10921092
});
@@ -1116,11 +1116,11 @@ public static DefaultSkippedType create(
11161116
}
11171117

11181118
/**
1119-
* Default implementation of a {@link SchemaReport.NullnessMismatch}.
1119+
* Default implementation of a {@link SchemaReport.NullnessError}.
11201120
*/
1121-
private record DefaultNullnessMismatch(
1121+
private record DefaultNullnessError(
11221122
Nullness schemaNullness, Nullness applicationNullness, AnnotatedElement annotatedElement)
1123-
implements SchemaReport.NullnessMismatch {
1123+
implements SchemaReport.NullnessError {
11241124

11251125
}
11261126

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,19 @@ public interface SchemaReport {
7272
MultiValueMap<DataFetcher<?>, String> unmappedArguments();
7373

7474
/**
75-
* Return the coordinates for mismatches when a schema field type
76-
* nullness information does not match the nullness of the
77-
* corresponding {@link AnnotatedElement} in the application.
75+
* Return the coordinates for nullness errors between
76+
* schema fields and the corresponding {@link AnnotatedElement} in the application.
77+
* @see NullnessError
7878
*/
79-
Map<FieldCoordinates, NullnessMismatch> fieldsNullnessMismatches();
79+
Map<FieldCoordinates, NullnessError> fieldNullnessErrors();
8080

8181
/**
8282
* Return a map with {@link DataFetcher}s and information for its arguments
83-
* if the schema nullness does not match the nullness of the
83+
* if there is a nullness error between the schema arguments and the
8484
* corresponding {@link AnnotatedElement} in the application.
85+
* @see NullnessError
8586
*/
86-
MultiValueMap<DataFetcher<?>, NullnessMismatch> argumentsNullnessMismatches();
87+
MultiValueMap<DataFetcher<?>, NullnessError> argumentNullnessErrors();
8788

8889
/**
8990
* Return types skipped during the inspection, either because the schema type
@@ -121,9 +122,18 @@ interface SkippedType {
121122
}
122123

123124
/**
124-
* Information about a Nullness mismatch between the GraphQL schema and the application code.
125+
* Information about a Nullness error between the GraphQL schema and the application code.
126+
*
127+
* <p>Errors are raised when the application nullness information breaks requirements
128+
* from the schema: a schema field is marked as "non-null" (like {@code "id: ID!"}),
129+
* but the application element is "nullable" (like {@code @Nullable String getId();}).
130+
*
131+
* <p>The opposite ({@code "author: Author"} and {@code @NonNull Author getAuthor();}
132+
* is not considered as an error, because if an exception is raised while fetching a
133+
* field, the GraphQL engine will null out fields in the hierarchy up until {@code null}
134+
* is allowed. A schema consistently using "non-null" types will not return partial responses.
125135
*/
126-
interface NullnessMismatch {
136+
interface NullnessError {
127137

128138
/**
129139
* Nullness expected in the schema.

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ void reportHasEntriesWhenSchemaFieldNonNullAndFieldMethodsNullable() {
112112
}
113113
""";
114114
SchemaReport report = inspectSchema(schema, NullableRecordFieldsBookController.class);
115-
assertThatReport(report).containsFieldsNullnessMismatches("NullableRecordFieldsBook", "id", "title");
115+
assertThatReport(report).containsFieldsNullnessErrors("NullableRecordFieldsBook", "id", "title");
116116
}
117117

118118
@Test
@@ -135,7 +135,7 @@ void reportFormatWhenSchemaFieldNonNullAndFieldMethodsNullable() {
135135
}
136136

137137
@Test
138-
void reportHasEntriesWhenSchemaFieldNullableAndFieldTypeNonNull() {
138+
void reportIsEmptyWhenSchemaFieldNullableAndFieldTypeNonNull() {
139139
String schema = """
140140
type Query {
141141
bookById(id: ID): NonNullFieldBook
@@ -146,7 +146,7 @@ void reportHasEntriesWhenSchemaFieldNullableAndFieldTypeNonNull() {
146146
}
147147
""";
148148
SchemaReport report = inspectSchema(schema, NonNullFieldBookController.class);
149-
assertThatReport(report).containsFieldsNullnessMismatches("NonNullFieldBook", "id", "title");
149+
assertThatReport(report).isEmpty();
150150
}
151151

152152

@@ -162,7 +162,7 @@ void reportHasEntriesWhenSchemaFieldNonNullAndFieldNullable() {
162162
}
163163
""";
164164
SchemaReport report = inspectSchema(schema, NullableFieldBookController.class);
165-
assertThatReport(report).containsFieldsNullnessMismatches("NullableFieldBook", "title");
165+
assertThatReport(report).containsFieldsNullnessErrors("NullableFieldBook", "title");
166166
}
167167

168168
@Test
@@ -316,6 +316,21 @@ void reportIsEmptyWhenSchemaFieldNonNullAndReturnTypeNonNull() {
316316
assertThatReport(report).isEmpty();
317317
}
318318

319+
@Test
320+
void reportIsEmptyWhenSchemaFieldNullableAndReturnTypeNonNull() {
321+
String schema = """
322+
type Query {
323+
bookById(id: ID): Book
324+
}
325+
type Book {
326+
id: ID!
327+
title: String!
328+
}
329+
""";
330+
SchemaReport report = inspectSchema(schema, NonNullBookController.class);
331+
assertThatReport(report).isEmpty();
332+
}
333+
319334

320335
@Test
321336
void reportHasEntryWhenSchemaFieldNonNullAndReturnTypeNullable() {
@@ -329,7 +344,7 @@ void reportHasEntryWhenSchemaFieldNonNullAndReturnTypeNullable() {
329344
}
330345
""";
331346
SchemaReport report = inspectSchema(schema, NullableBookController.class);
332-
assertThatReport(report).containsFieldsNullnessMismatches("Query", "bookById");
347+
assertThatReport(report).containsFieldsNullnessErrors("Query", "bookById");
333348
}
334349

335350
@Test
@@ -506,7 +521,7 @@ void reportHasEntryWhenSchemaFieldNonNullAndReturnTypeNullable() {
506521
}
507522
""";
508523
SchemaReport report = inspectSchema(schema, NullableArgBookController.class);
509-
assertThatReport(report).containsArgumentsNullnessMismatches("java.lang.String id");
524+
assertThatReport(report).containsArgumentsNullnessErrors("java.lang.String id");
510525
}
511526

512527
@Test

0 commit comments

Comments
 (0)