1717package org .springframework .graphql .execution ;
1818
1919import java .beans .PropertyDescriptor ;
20+ import java .lang .annotation .Annotation ;
21+ import java .lang .reflect .AnnotatedElement ;
2022import java .lang .reflect .Field ;
2123import java .lang .reflect .Method ;
2224import java .lang .reflect .Modifier ;
25+ import java .lang .reflect .Parameter ;
2326import java .util .ArrayList ;
2427import java .util .Collections ;
2528import java .util .HashSet ;
3134import java .util .function .Function ;
3235import java .util .function .Predicate ;
3336
37+ import graphql .language .NonNullType ;
38+ import graphql .language .Type ;
3439import graphql .schema .DataFetcher ;
3540import graphql .schema .FieldCoordinates ;
41+ import graphql .schema .GraphQLArgument ;
3642import graphql .schema .GraphQLEnumType ;
3743import graphql .schema .GraphQLFieldDefinition ;
3844import graphql .schema .GraphQLFieldsContainer ;
5460import org .springframework .beans .BeanUtils ;
5561import org .springframework .beans .BeansException ;
5662import org .springframework .core .MethodParameter ;
63+ import org .springframework .core .Nullness ;
5764import org .springframework .core .ReactiveAdapter ;
5865import org .springframework .core .ReactiveAdapterRegistry ;
5966import org .springframework .core .ResolvableType ;
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 "\t Unmapped fields: " + formatUnmappedFields () + "\n " +
9201041 "\t Unmapped registrations: " + this .unmappedRegistrations + "\n " +
9211042 "\t Unmapped arguments: " + this .unmappedArguments + "\n " +
1043+ "\t Fields nullness mismatches: " + formatFieldsNullnessMismatches () + "\n " +
1044+ "\t Arguments nullness mismatches: " + formatArgumentsNullnessMismatches () + "\n " +
9221045 "\t Skipped 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