Skip to content

Commit 29f8337

Browse files
committed
Add schema inspection support for nullness checks
Prior to this commit, the schema inspection feature would detect missing fields and arguments when looking at class properties and registered data fetchers. The GraphQL schema also scpecifies the nullability of fields and arguments, using the "!" marker type. This commit enhances the schema inspector to read the nullability information from the schema and compare it to the nullability information present in the code. This is only active if the application code is written in Kotlin or is using JSpecify annotations. If a nullability mismatch is detected (NULL vs NON_NULL), the information is displayed in the inspection report. Note that by default, Java types have the "UNSPECIFIED" nullness information, because we cannot infer the nullability from typical Java classes. This feature is currently only supported if the `DataFetcher` is actually backed by a Java method, a Kotlin function or a class property. In other cases, the type use information will not be available. Closes gh-1220
1 parent 3c7e328 commit 29f8337

File tree

11 files changed

+885
-5
lines changed

11 files changed

+885
-5
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,16 @@ to inspect schema mappings on startup to ensure the following:
259259
- `DataFetcher` registrations refer to a schema field that exists.
260260
- `DataFetcher` arguments have matching schema field arguments.
261261

262+
If the application is written in Kotlin, or is using {spring-framework-ref-docs}/core/null-safety.html[Null-safety]
263+
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+
271+
262272
To enable schema inspection, customize `GraphQlSource.Builder` as shown below.
263273
In this case the report is simply logged, but you can choose to take any action:
264274

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,11 @@ public Map<String, ResolvableType> getArguments() {
500500
ResolvableType::forMethodParameter));
501501
}
502502

503+
@Override
504+
public Method asMethod() {
505+
return this.mappingInfo.getHandlerMethod().getMethod();
506+
}
507+
503508
@Override
504509
public boolean usesDataLoader() {
505510
return this.usesDataLoader;
@@ -595,6 +600,11 @@ public ResolvableType getReturnType() {
595600
return this.returnType;
596601
}
597602

603+
@Override
604+
public Method asMethod() {
605+
return this.mappingInfo.getHandlerMethod().getMethod();
606+
}
607+
598608
@Override
599609
public Object get(DataFetchingEnvironment env) {
600610
DataLoader<?, ?> dataLoader = env.getDataLoader(this.dataLoaderKey);

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

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

1717
package org.springframework.graphql.execution;
1818

19+
import java.lang.reflect.Method;
1920
import java.util.List;
2021
import java.util.Map;
2122

@@ -217,6 +218,11 @@ public ResolvableType getReturnType() {
217218
return this.delegate.getReturnType();
218219
}
219220

221+
@Override
222+
public @Nullable Method asMethod() {
223+
return this.delegate.asMethod();
224+
}
225+
220226
@Override
221227
public Map<String, ResolvableType> getArguments() {
222228
return this.delegate.getArguments();

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

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
package org.springframework.graphql.execution;
1818

1919
import java.beans.PropertyDescriptor;
20+
import java.lang.annotation.Annotation;
21+
import java.lang.reflect.AnnotatedElement;
2022
import java.lang.reflect.Field;
2123
import java.lang.reflect.Method;
2224
import java.lang.reflect.Modifier;
25+
import java.lang.reflect.Parameter;
2326
import java.util.ArrayList;
2427
import java.util.Collections;
2528
import java.util.HashSet;
@@ -31,8 +34,11 @@
3134
import java.util.function.Function;
3235
import java.util.function.Predicate;
3336

37+
import graphql.language.NonNullType;
38+
import graphql.language.Type;
3439
import graphql.schema.DataFetcher;
3540
import graphql.schema.FieldCoordinates;
41+
import graphql.schema.GraphQLArgument;
3642
import graphql.schema.GraphQLEnumType;
3743
import graphql.schema.GraphQLFieldDefinition;
3844
import graphql.schema.GraphQLFieldsContainer;
@@ -54,6 +60,7 @@
5460
import org.springframework.beans.BeanUtils;
5561
import org.springframework.beans.BeansException;
5662
import org.springframework.core.MethodParameter;
63+
import org.springframework.core.Nullness;
5764
import org.springframework.core.ReactiveAdapter;
5865
import org.springframework.core.ReactiveAdapterRegistry;
5966
import org.springframework.core.ResolvableType;
@@ -70,6 +77,9 @@
7077
* corresponding Class property.
7178
* <li>{@code DataFetcher} registrations refer to a schema field that exists.
7279
* <li>{@code DataFetcher} arguments have matching schema field arguments.
80+
* <li>The nullness of schema fields matches the nullness of {@link DataFetcher}
81+
* return types, class properties or class methods.
82+
* <li>{@code DataFetcher} arguments match the nullness of schema argument types.
7383
* </ul>
7484
*
7585
* <p>Use methods of {@link GraphQlSource.SchemaResourceBuilder} to enable schema
@@ -166,10 +176,14 @@ private void checkFieldsContainer(
166176
for (GraphQLFieldDefinition field : fieldContainer.getFieldDefinitions()) {
167177
String fieldName = field.getName();
168178
DataFetcher<?> dataFetcher = dataFetcherMap.get(fieldName);
179+
FieldCoordinates fieldCoordinates = FieldCoordinates.coordinates(typeName, fieldName);
180+
Nullness schemaNullness = resolveNullness(field);
169181

170182
if (dataFetcher != null) {
171183
if (dataFetcher instanceof SelfDescribingDataFetcher<?> selfDescribing) {
184+
checkDataFetcherNullness(fieldCoordinates, selfDescribing, schemaNullness);
172185
checkFieldArguments(field, selfDescribing);
186+
checkFieldArgumentsNullness(field, selfDescribing);
173187
checkField(fieldContainer, field, selfDescribing.getReturnType());
174188
}
175189
else {
@@ -182,11 +196,13 @@ private void checkFieldsContainer(
182196
PropertyDescriptor descriptor = getProperty(resolvableType, fieldName);
183197
if (descriptor != null) {
184198
MethodParameter returnType = new MethodParameter(descriptor.getReadMethod(), -1);
199+
checkReadMethodNullness(fieldCoordinates, resolvableType, descriptor, schemaNullness);
185200
checkField(fieldContainer, field, ResolvableType.forMethodParameter(returnType, resolvableType));
186201
continue;
187202
}
188203
Field javaField = getField(resolvableType, fieldName);
189204
if (javaField != null) {
205+
checkFieldNullNess(fieldCoordinates, javaField, schemaNullness);
190206
checkField(fieldContainer, field, ResolvableType.forField(javaField));
191207
continue;
192208
}
@@ -199,7 +215,34 @@ private void checkFieldsContainer(
199215
}
200216
}
201217

202-
this.reportBuilder.unmappedField(FieldCoordinates.coordinates(typeName, fieldName));
218+
this.reportBuilder.unmappedField(fieldCoordinates);
219+
}
220+
}
221+
222+
private void checkFieldNullNess(FieldCoordinates fieldCoordinates, Field javaField, Nullness schemaNullness) {
223+
Nullness applicationNullness = Nullness.forField(javaField);
224+
if (isMismatch(schemaNullness, applicationNullness)) {
225+
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(javaField,
226+
javaField.getDeclaringClass().getSimpleName() + "#" + javaField.getName());
227+
this.reportBuilder.fieldNullnessMismatch(fieldCoordinates,
228+
new DefaultNullnessMismatch(schemaNullness, applicationNullness, annotatedElement));
229+
230+
}
231+
}
232+
233+
private void checkDataFetcherNullness(FieldCoordinates fieldCoordinates, SelfDescribingDataFetcher dataFetcher, Nullness schemaNullness) {
234+
Method dataFetcherMethod = dataFetcher.asMethod();
235+
if (dataFetcherMethod != null) {
236+
Nullness applicationNullness = Nullness.forMethodReturnType(dataFetcherMethod);
237+
// we cannot infer nullness if wrapped by reactive types
238+
ReactiveAdapter reactiveAdapter = ReactiveAdapterRegistry.getSharedInstance()
239+
.getAdapter(dataFetcherMethod.getReturnType());
240+
if (reactiveAdapter == null && isMismatch(schemaNullness, applicationNullness)) {
241+
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(dataFetcherMethod, dataFetcher.getDescription());
242+
this.reportBuilder.fieldNullnessMismatch(fieldCoordinates,
243+
new DefaultNullnessMismatch(schemaNullness, applicationNullness, annotatedElement));
244+
245+
}
203246
}
204247
}
205248

@@ -215,6 +258,37 @@ private void checkFieldArguments(GraphQLFieldDefinition field, SelfDescribingDat
215258
}
216259
}
217260

261+
private void checkFieldArgumentsNullness(GraphQLFieldDefinition field, SelfDescribingDataFetcher<?> dataFetcher) {
262+
Method dataFetcherMethod = dataFetcher.asMethod();
263+
if (dataFetcherMethod != null) {
264+
List<SchemaReport.NullnessMismatch> mismatches = new ArrayList<>();
265+
for (Parameter parameter : dataFetcherMethod.getParameters()) {
266+
GraphQLArgument argument = field.getArgument(parameter.getName());
267+
if (argument != null) {
268+
Nullness schemaNullness = resolveNullness(argument.getDefinition().getType());
269+
Nullness applicationNullness = Nullness.forMethodParameter(MethodParameter.forParameter(parameter));
270+
if (isMismatch(schemaNullness, applicationNullness)) {
271+
mismatches.add(new DefaultNullnessMismatch(schemaNullness, applicationNullness, parameter));
272+
}
273+
}
274+
}
275+
if (!mismatches.isEmpty()) {
276+
this.reportBuilder.argumentsNullnessMismatches(dataFetcher, mismatches);
277+
}
278+
}
279+
}
280+
281+
private void checkReadMethodNullness(FieldCoordinates fieldCoordinates, ResolvableType resolvableType, PropertyDescriptor descriptor, Nullness schemaNullness) {
282+
Nullness applicationNullness = Nullness.forMethodReturnType(descriptor.getReadMethod());
283+
if (isMismatch(schemaNullness, applicationNullness)) {
284+
DescribedAnnotatedElement annotatedElement = new DescribedAnnotatedElement(descriptor.getReadMethod(),
285+
resolvableType.toClass().getSimpleName() + "#" + descriptor.getName());
286+
this.reportBuilder.fieldNullnessMismatch(fieldCoordinates,
287+
new DefaultNullnessMismatch(schemaNullness, applicationNullness, annotatedElement));
288+
}
289+
}
290+
291+
218292
/**
219293
* Resolve field wrapper types (connection, list, non-null), nest into generic types,
220294
* and recurse with {@link #checkFieldsContainer} if there is enough type information.
@@ -308,6 +382,22 @@ private void checkDataFetcherRegistrations() {
308382
}));
309383
}
310384

385+
private Nullness resolveNullness(GraphQLFieldDefinition fieldDefinition) {
386+
return resolveNullness(fieldDefinition.getDefinition().getType());
387+
}
388+
389+
private Nullness resolveNullness(Type type) {
390+
if (type instanceof NonNullType) {
391+
return Nullness.NON_NULL;
392+
}
393+
return Nullness.NULLABLE;
394+
}
395+
396+
private boolean isMismatch(Nullness first, Nullness second) {
397+
return (first == Nullness.NON_NULL && second == Nullness.NULLABLE) ||
398+
(first == Nullness.NULLABLE && second == Nullness.NON_NULL);
399+
}
400+
311401

312402
/**
313403
* Check the schema against {@code DataFetcher} registrations, and produce a report.
@@ -801,6 +891,10 @@ private final class ReportBuilder {
801891

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

894+
private final Map<FieldCoordinates, SchemaReport.NullnessMismatch> fieldNullnessMismatches = new LinkedHashMap<>();
895+
896+
private final MultiValueMap<DataFetcher<?>, SchemaReport.NullnessMismatch> argumentsNullnessMismatches = new LinkedMultiValueMap<>();
897+
804898
private final List<DefaultSkippedType> skippedTypes = new ArrayList<>();
805899

806900
private final List<DefaultSkippedType> candidateSkippedTypes = new ArrayList<>();
@@ -817,6 +911,14 @@ void unmappedArgument(DataFetcher<?> dataFetcher, List<String> arguments) {
817911
this.unmappedArguments.put(dataFetcher, arguments);
818912
}
819913

914+
void fieldNullnessMismatch(FieldCoordinates coordinates, SchemaReport.NullnessMismatch nullnessMismatch) {
915+
this.fieldNullnessMismatches.put(coordinates, nullnessMismatch);
916+
}
917+
918+
void argumentsNullnessMismatches(DataFetcher<?> dataFetcher, List<SchemaReport.NullnessMismatch> nullnessMismatches) {
919+
this.argumentsNullnessMismatches.put(dataFetcher, nullnessMismatches);
920+
}
921+
820922
void skippedType(
821923
GraphQLType type, GraphQLFieldsContainer parent, GraphQLFieldDefinition field,
822924
String reason, boolean isDerivedType) {
@@ -852,8 +954,8 @@ SchemaReport build() {
852954
skippedType(skippedType);
853955
});
854956

855-
return new DefaultSchemaReport(
856-
this.unmappedFields, this.unmappedRegistrations, this.unmappedArguments, this.skippedTypes);
957+
return new DefaultSchemaReport(this.unmappedFields, this.unmappedRegistrations,
958+
this.unmappedArguments, this.fieldNullnessMismatches, this.argumentsNullnessMismatches, this.skippedTypes);
857959
}
858960
}
859961

@@ -869,15 +971,24 @@ private final class DefaultSchemaReport implements SchemaReport {
869971

870972
private final MultiValueMap<DataFetcher<?>, String> unmappedArguments;
871973

974+
private final Map<FieldCoordinates, NullnessMismatch> fieldsNullnessMismatches;
975+
976+
private final MultiValueMap<DataFetcher<?>, NullnessMismatch> argumentsNullnessMismatches;
977+
872978
private final List<SchemaReport.SkippedType> skippedTypes;
873979

874980
DefaultSchemaReport(
875981
List<FieldCoordinates> unmappedFields, Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations,
876-
MultiValueMap<DataFetcher<?>, String> unmappedArguments, List<DefaultSkippedType> skippedTypes) {
982+
MultiValueMap<DataFetcher<?>, String> unmappedArguments,
983+
Map<FieldCoordinates, NullnessMismatch> fieldsNullnessMismatches,
984+
MultiValueMap<DataFetcher<?>, NullnessMismatch> argumentsNullnessMismatches,
985+
List<DefaultSkippedType> skippedTypes) {
877986

878987
this.unmappedFields = Collections.unmodifiableList(unmappedFields);
879988
this.unmappedRegistrations = Collections.unmodifiableMap(unmappedRegistrations);
880989
this.unmappedArguments = CollectionUtils.unmodifiableMultiValueMap(unmappedArguments);
990+
this.fieldsNullnessMismatches = Collections.unmodifiableMap(fieldsNullnessMismatches);
991+
this.argumentsNullnessMismatches = CollectionUtils.unmodifiableMultiValueMap(argumentsNullnessMismatches);
881992
this.skippedTypes = Collections.unmodifiableList(skippedTypes);
882993
}
883994

@@ -896,6 +1007,16 @@ public MultiValueMap<DataFetcher<?>, String> unmappedArguments() {
8961007
return this.unmappedArguments;
8971008
}
8981009

1010+
@Override
1011+
public Map<FieldCoordinates, NullnessMismatch> fieldsNullnessMismatches() {
1012+
return this.fieldsNullnessMismatches;
1013+
}
1014+
1015+
@Override
1016+
public MultiValueMap<DataFetcher<?>, NullnessMismatch> argumentsNullnessMismatches() {
1017+
return this.argumentsNullnessMismatches;
1018+
}
1019+
8991020
@Override
9001021
public List<SkippedType> skippedTypes() {
9011022
return this.skippedTypes;
@@ -919,6 +1040,8 @@ public String toString() {
9191040
"\tUnmapped fields: " + formatUnmappedFields() + "\n" +
9201041
"\tUnmapped registrations: " + this.unmappedRegistrations + "\n" +
9211042
"\tUnmapped arguments: " + this.unmappedArguments + "\n" +
1043+
"\tFields nullness mismatches: " + formatFieldsNullnessMismatches() + "\n" +
1044+
"\tArguments nullness mismatches: " + formatArgumentsNullnessMismatches() + "\n" +
9221045
"\tSkipped types: " + this.skippedTypes;
9231046
}
9241047

@@ -931,6 +1054,27 @@ private String formatUnmappedFields() {
9311054
return map.toString();
9321055
}
9331056

1057+
private String formatFieldsNullnessMismatches() {
1058+
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
1059+
this.fieldsNullnessMismatches.forEach((coordinates, mismatch) -> {
1060+
List<String> fields = map.computeIfAbsent(coordinates.getTypeName(), (s) -> new ArrayList<>());
1061+
fields.add(String.format("%s is %s -> '%s' is %s", coordinates.getFieldName(), mismatch.schemaNullness(),
1062+
mismatch.annotatedElement(), mismatch.applicationNullness()));
1063+
});
1064+
return map.toString();
1065+
}
1066+
1067+
private String formatArgumentsNullnessMismatches() {
1068+
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
1069+
this.argumentsNullnessMismatches.forEach((dataFetcher, mismatches) -> {
1070+
List<String> arguments = mismatches.stream()
1071+
.map((mismatch) -> String.format("%s should be %s", mismatch.annotatedElement(), mismatch.schemaNullness()))
1072+
.toList();
1073+
map.put(dataFetcher.toString(), arguments);
1074+
});
1075+
return map.toString();
1076+
}
1077+
9341078
}
9351079

9361080

@@ -953,4 +1097,40 @@ public static DefaultSkippedType create(
9531097
}
9541098
}
9551099

1100+
/**
1101+
* Default implementation of a {@link SchemaReport.NullnessMismatch}.
1102+
*/
1103+
private record DefaultNullnessMismatch(
1104+
Nullness schemaNullness, Nullness applicationNullness, AnnotatedElement annotatedElement)
1105+
implements SchemaReport.NullnessMismatch {
1106+
1107+
}
1108+
1109+
/**
1110+
* {@link AnnotatedElement} that overrides the {@code toString} method for displaying in the report.
1111+
*/
1112+
private record DescribedAnnotatedElement(AnnotatedElement delegate,
1113+
String description) implements AnnotatedElement {
1114+
1115+
@Override
1116+
public String toString() {
1117+
return this.description;
1118+
}
1119+
1120+
@Override
1121+
public <T extends Annotation> T getAnnotation(Class<T> annotationClass) {
1122+
return this.delegate.getAnnotation(annotationClass);
1123+
}
1124+
1125+
@Override
1126+
public Annotation[] getAnnotations() {
1127+
return this.delegate.getAnnotations();
1128+
}
1129+
1130+
@Override
1131+
public Annotation[] getDeclaredAnnotations() {
1132+
return this.delegate.getDeclaredAnnotations();
1133+
}
1134+
}
1135+
9561136
}

0 commit comments

Comments
 (0)