17
17
package org .springframework .graphql .execution ;
18
18
19
19
import java .beans .PropertyDescriptor ;
20
+ import java .lang .annotation .Annotation ;
21
+ import java .lang .reflect .AnnotatedElement ;
20
22
import java .lang .reflect .Field ;
21
23
import java .lang .reflect .Method ;
22
24
import java .lang .reflect .Modifier ;
25
+ import java .lang .reflect .Parameter ;
23
26
import java .util .ArrayList ;
24
27
import java .util .Collections ;
25
28
import java .util .HashSet ;
31
34
import java .util .function .Function ;
32
35
import java .util .function .Predicate ;
33
36
37
+ import graphql .language .NonNullType ;
38
+ import graphql .language .Type ;
34
39
import graphql .schema .DataFetcher ;
35
40
import graphql .schema .FieldCoordinates ;
41
+ import graphql .schema .GraphQLArgument ;
36
42
import graphql .schema .GraphQLEnumType ;
37
43
import graphql .schema .GraphQLFieldDefinition ;
38
44
import graphql .schema .GraphQLFieldsContainer ;
54
60
import org .springframework .beans .BeanUtils ;
55
61
import org .springframework .beans .BeansException ;
56
62
import org .springframework .core .MethodParameter ;
63
+ import org .springframework .core .Nullness ;
57
64
import org .springframework .core .ReactiveAdapter ;
58
65
import org .springframework .core .ReactiveAdapterRegistry ;
59
66
import org .springframework .core .ResolvableType ;
70
77
* corresponding Class property.
71
78
* <li>{@code DataFetcher} registrations refer to a schema field that exists.
72
79
* <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.
73
83
* </ul>
74
84
*
75
85
* <p>Use methods of {@link GraphQlSource.SchemaResourceBuilder} to enable schema
@@ -166,10 +176,14 @@ private void checkFieldsContainer(
166
176
for (GraphQLFieldDefinition field : fieldContainer .getFieldDefinitions ()) {
167
177
String fieldName = field .getName ();
168
178
DataFetcher <?> dataFetcher = dataFetcherMap .get (fieldName );
179
+ FieldCoordinates fieldCoordinates = FieldCoordinates .coordinates (typeName , fieldName );
180
+ Nullness schemaNullness = resolveNullness (field );
169
181
170
182
if (dataFetcher != null ) {
171
183
if (dataFetcher instanceof SelfDescribingDataFetcher <?> selfDescribing ) {
184
+ checkDataFetcherNullness (fieldCoordinates , selfDescribing , schemaNullness );
172
185
checkFieldArguments (field , selfDescribing );
186
+ checkFieldArgumentsNullness (field , selfDescribing );
173
187
checkField (fieldContainer , field , selfDescribing .getReturnType ());
174
188
}
175
189
else {
@@ -182,11 +196,13 @@ private void checkFieldsContainer(
182
196
PropertyDescriptor descriptor = getProperty (resolvableType , fieldName );
183
197
if (descriptor != null ) {
184
198
MethodParameter returnType = new MethodParameter (descriptor .getReadMethod (), -1 );
199
+ checkReadMethodNullness (fieldCoordinates , resolvableType , descriptor , schemaNullness );
185
200
checkField (fieldContainer , field , ResolvableType .forMethodParameter (returnType , resolvableType ));
186
201
continue ;
187
202
}
188
203
Field javaField = getField (resolvableType , fieldName );
189
204
if (javaField != null ) {
205
+ checkFieldNullNess (fieldCoordinates , javaField , schemaNullness );
190
206
checkField (fieldContainer , field , ResolvableType .forField (javaField ));
191
207
continue ;
192
208
}
@@ -199,7 +215,34 @@ private void checkFieldsContainer(
199
215
}
200
216
}
201
217
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
+ }
203
246
}
204
247
}
205
248
@@ -215,6 +258,37 @@ private void checkFieldArguments(GraphQLFieldDefinition field, SelfDescribingDat
215
258
}
216
259
}
217
260
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
+
218
292
/**
219
293
* Resolve field wrapper types (connection, list, non-null), nest into generic types,
220
294
* and recurse with {@link #checkFieldsContainer} if there is enough type information.
@@ -308,6 +382,22 @@ private void checkDataFetcherRegistrations() {
308
382
}));
309
383
}
310
384
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
+
311
401
312
402
/**
313
403
* Check the schema against {@code DataFetcher} registrations, and produce a report.
@@ -801,6 +891,10 @@ private final class ReportBuilder {
801
891
802
892
private final MultiValueMap <DataFetcher <?>, String > unmappedArguments = new LinkedMultiValueMap <>();
803
893
894
+ private final Map <FieldCoordinates , SchemaReport .NullnessMismatch > fieldNullnessMismatches = new LinkedHashMap <>();
895
+
896
+ private final MultiValueMap <DataFetcher <?>, SchemaReport .NullnessMismatch > argumentsNullnessMismatches = new LinkedMultiValueMap <>();
897
+
804
898
private final List <DefaultSkippedType > skippedTypes = new ArrayList <>();
805
899
806
900
private final List <DefaultSkippedType > candidateSkippedTypes = new ArrayList <>();
@@ -817,6 +911,14 @@ void unmappedArgument(DataFetcher<?> dataFetcher, List<String> arguments) {
817
911
this .unmappedArguments .put (dataFetcher , arguments );
818
912
}
819
913
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
+
820
922
void skippedType (
821
923
GraphQLType type , GraphQLFieldsContainer parent , GraphQLFieldDefinition field ,
822
924
String reason , boolean isDerivedType ) {
@@ -852,8 +954,8 @@ SchemaReport build() {
852
954
skippedType (skippedType );
853
955
});
854
956
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 );
857
959
}
858
960
}
859
961
@@ -869,15 +971,24 @@ private final class DefaultSchemaReport implements SchemaReport {
869
971
870
972
private final MultiValueMap <DataFetcher <?>, String > unmappedArguments ;
871
973
974
+ private final Map <FieldCoordinates , NullnessMismatch > fieldsNullnessMismatches ;
975
+
976
+ private final MultiValueMap <DataFetcher <?>, NullnessMismatch > argumentsNullnessMismatches ;
977
+
872
978
private final List <SchemaReport .SkippedType > skippedTypes ;
873
979
874
980
DefaultSchemaReport (
875
981
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 ) {
877
986
878
987
this .unmappedFields = Collections .unmodifiableList (unmappedFields );
879
988
this .unmappedRegistrations = Collections .unmodifiableMap (unmappedRegistrations );
880
989
this .unmappedArguments = CollectionUtils .unmodifiableMultiValueMap (unmappedArguments );
990
+ this .fieldsNullnessMismatches = Collections .unmodifiableMap (fieldsNullnessMismatches );
991
+ this .argumentsNullnessMismatches = CollectionUtils .unmodifiableMultiValueMap (argumentsNullnessMismatches );
881
992
this .skippedTypes = Collections .unmodifiableList (skippedTypes );
882
993
}
883
994
@@ -896,6 +1007,16 @@ public MultiValueMap<DataFetcher<?>, String> unmappedArguments() {
896
1007
return this .unmappedArguments ;
897
1008
}
898
1009
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
+
899
1020
@ Override
900
1021
public List <SkippedType > skippedTypes () {
901
1022
return this .skippedTypes ;
@@ -919,6 +1040,8 @@ public String toString() {
919
1040
"\t Unmapped fields: " + formatUnmappedFields () + "\n " +
920
1041
"\t Unmapped registrations: " + this .unmappedRegistrations + "\n " +
921
1042
"\t Unmapped arguments: " + this .unmappedArguments + "\n " +
1043
+ "\t Fields nullness mismatches: " + formatFieldsNullnessMismatches () + "\n " +
1044
+ "\t Arguments nullness mismatches: " + formatArgumentsNullnessMismatches () + "\n " +
922
1045
"\t Skipped types: " + this .skippedTypes ;
923
1046
}
924
1047
@@ -931,6 +1054,27 @@ private String formatUnmappedFields() {
931
1054
return map .toString ();
932
1055
}
933
1056
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
+
934
1078
}
935
1079
936
1080
@@ -953,4 +1097,40 @@ public static DefaultSkippedType create(
953
1097
}
954
1098
}
955
1099
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
+
956
1136
}
0 commit comments