Skip to content

Commit 6d8f789

Browse files
committed
SchemaMappingInspector detects unmapped DataFetcher's
Closes gh-671
1 parent a868331 commit 6d8f789

File tree

7 files changed

+168
-39
lines changed

7 files changed

+168
-39
lines changed

spring-graphql-docs/src/docs/asciidoc/index.adoc

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -418,21 +418,25 @@ controllers, the return type is derived from the declared return type on a
418418
`@SchemaMapping` method.
419419

420420
On startup, Spring for GraphQL inspects all schema fields, `DataFetcher` registrations,
421-
and the properties of Java objects returned from `DataFetcher` implementations in order
422-
to ensure that every schema field has either an explicitly registered `DataFetcher`, or
423-
a matching Java object property. This inspection is performed automatically, and results
424-
in a report that is always logged on startup at INFO level. For example:
421+
and the properties of Java objects returned from `DataFetcher` implementations to check
422+
if all schema fields are covered either by an explicitly registered `DataFetcher`, or
423+
a matching Java object property. The inspection also performs a reverse check looking for
424+
`DataFetcher` registrations against schema fields that don't exist. This inspection is
425+
performed automatically, and results in a report that is logged at INFO level on startup.
426+
For example:
425427

426428
----
427429
GraphQL schema inspection:
428430
Unmapped fields: {Book=[title], Author[firstName, lastName]} // <1>
429-
Skipped types: [BookOrAuthor] // <2>
431+
Unmapped DataFetcher registrations: {Book.reviews=BookController#reviews[1 args]} <2>
432+
Skipped types: [BookOrAuthor] // <3>
430433
----
431434

432435
<1> List of schema fields and their source types that are not mapped
433-
<2> List of schema types that are skipped, as explained next
436+
<2> List of `DataFetcher` registrations on fields that don't exist
437+
<3> List of schema types that are skipped, as explained next
434438

435-
There are limits to what schema mappings inspection can do, in particular when there is
439+
There are limits to what schema field inspection can do, in particular when there is
436440
insufficient Java type information. This is the case if an annotated controller method is
437441
declared to return `java.lang.Object`, or if the return type has an unspecified generic
438442
parameter such as `List<?>`, or if the `DataFetcher` does not implement

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
@@ -640,6 +640,11 @@ static class SchemaMappingDataFetcher implements SelfDescribingDataFetcher<Objec
640640
this.subscription = this.info.getCoordinates().getTypeName().equalsIgnoreCase("Subscription");
641641
}
642642

643+
@Override
644+
public String getDescription() {
645+
return this.info.getHandlerMethod().getShortLogMessage();
646+
}
647+
643648
@Override
644649
public ResolvableType getReturnType() {
645650
return ResolvableType.forMethodReturnType(this.info.getHandlerMethod().getMethod());
@@ -702,6 +707,11 @@ private <T> Publisher<T> handleSubscriptionError(
702707
.switchIfEmpty(Mono.error(ex));
703708
}
704709

710+
@Override
711+
public String toString() {
712+
return getDescription();
713+
}
714+
705715
}
706716

707717

spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ public abstract class QueryByExampleDataFetcher<T> {
120120
}
121121

122122

123+
/**
124+
* Provides shared implementation of
125+
* {@link SelfDescribingDataFetcher#getDescription()} for all subclasses.
126+
* @since 1.2.0
127+
*/
128+
public String getDescription() {
129+
return "QueryByExampleDataFetcher<" + this.domainType.getType().getName() + ">";
130+
}
131+
123132
/**
124133
* Prepare an {@link Example} from GraphQL request arguments.
125134
* @param environment contextual info for the GraphQL request
@@ -170,6 +179,11 @@ protected ScrollSubrange buildScrollSubrange(DataFetchingEnvironment environment
170179
return RepositoryUtils.buildScrollSubrange(environment, this.cursorStrategy);
171180
}
172181

182+
@Override
183+
public String toString() {
184+
return getDescription();
185+
}
186+
173187

174188
/**
175189
* Create a new {@link Builder} accepting {@link QueryByExampleExecutor}

spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ public abstract class QuerydslDataFetcher<T> {
134134
}
135135

136136

137+
/**
138+
* Provides shared implementation of
139+
* {@link SelfDescribingDataFetcher#getDescription()} for all subclasses.
140+
* @since 1.2.0
141+
*/
142+
public String getDescription() {
143+
return "QuerydslDataFetcher<" + this.domainType.getType().getName() + ">";
144+
}
145+
137146
/**
138147
* Prepare a {@link Predicate} from GraphQL request arguments, also applying
139148
* any {@link QuerydslBinderCustomizer} that may have been configured.
@@ -194,6 +203,11 @@ protected ScrollSubrange buildScrollSubrange(DataFetchingEnvironment environment
194203
return RepositoryUtils.buildScrollSubrange(environment, this.cursorStrategy);
195204
}
196205

206+
@Override
207+
public String toString() {
208+
return getDescription();
209+
}
210+
197211

198212
/**
199213
* Create a new {@link Builder} accepting {@link QuerydslPredicateExecutor}

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

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
package org.springframework.graphql.execution;
1818

1919
import java.util.HashSet;
20+
import java.util.LinkedHashMap;
2021
import java.util.LinkedHashSet;
2122
import java.util.Map;
2223
import java.util.Set;
2324

2425
import graphql.schema.DataFetcher;
26+
import graphql.schema.FieldCoordinates;
2527
import graphql.schema.GraphQLEnumType;
2628
import graphql.schema.GraphQLFieldDefinition;
2729
import graphql.schema.GraphQLFieldsContainer;
@@ -51,9 +53,11 @@
5153
* Declares an {@link #inspect(GraphQLSchema, RuntimeWiring)} method that checks
5254
* if schema fields are covered either by a {@link DataFetcher} registration,
5355
* or match a Java object property. Fields that have neither are reported as
54-
* "unmapped" in the resulting {@link Report Resport}.
56+
* "unmapped" in the resulting {@link Report Resport}. The inspection also
57+
* performs a reverse check for {@code DataFetcher} registrations against schema
58+
* fields that don't exist.
5559
*
56-
* <p>The inspection depends on {@code DataFetcher}s to be
60+
* <p>The schema field inspection depends on {@code DataFetcher}s to be
5761
* {@link SelfDescribingDataFetcher} to be able to compare schema type and Java
5862
* object type structure. If a {@code DataFetcher} does not implement this
5963
* interface, then the Java type remains unknown, and the field type is reported
@@ -108,21 +112,23 @@ private SchemaMappingInspector(GraphQLSchema schema, RuntimeWiring runtimeWiring
108112
*/
109113
public Report inspect() {
110114

111-
inspectType(this.schema.getQueryType(), null);
115+
inspectSchemaType(this.schema.getQueryType(), null);
112116

113117
if (this.schema.isSupportingMutations()) {
114-
inspectType(this.schema.getMutationType(), null);
118+
inspectSchemaType(this.schema.getMutationType(), null);
115119
}
116120

117121
if (this.schema.isSupportingSubscriptions()) {
118-
inspectType(this.schema.getSubscriptionType(), null);
122+
inspectSchemaType(this.schema.getSubscriptionType(), null);
119123
}
120124

125+
inspectDataFetcherRegistrations();
126+
121127
return this.reportBuilder.build();
122128
}
123129

124130
@SuppressWarnings("rawtypes")
125-
private void inspectType(GraphQLType type, @Nullable ResolvableType resolvableType) {
131+
private void inspectSchemaType(GraphQLType type, @Nullable ResolvableType resolvableType) {
126132
Assert.notNull(type, "No GraphQLType");
127133

128134
type = unwrapNonNull(type);
@@ -166,9 +172,9 @@ else if (resolvableType != null && resolveClassToCompare(resolvableType) == Obje
166172
for (GraphQLFieldDefinition field : fieldContainer.getFieldDefinitions()) {
167173
String fieldName = field.getName();
168174
if (dataFetcherMap.containsKey(fieldName)) {
169-
DataFetcher fetcher = dataFetcherMap.get(fieldName);
175+
DataFetcher<?> fetcher = dataFetcherMap.get(fieldName);
170176
if (fetcher instanceof SelfDescribingDataFetcher<?> selfDescribingDataFetcher) {
171-
inspectType(field.getType(), selfDescribingDataFetcher.getReturnType());
177+
inspectSchemaType(field.getType(), selfDescribingDataFetcher.getReturnType());
172178
}
173179
else if (isNotScalarOrEnumType(field.getType())) {
174180
if (logger.isDebugEnabled()) {
@@ -233,6 +239,17 @@ private Class<?> resolveClassToCompare(ResolvableType resolvableType) {
233239
return (adapter != null ? resolvableType.getNested(2).resolve(Object.class) : clazz);
234240
}
235241

242+
@SuppressWarnings("rawtypes")
243+
private void inspectDataFetcherRegistrations() {
244+
this.runtimeWiring.getDataFetchers().forEach((typeName, registrations) ->
245+
registrations.forEach((fieldName, fetcher) -> {
246+
FieldCoordinates coordinates = FieldCoordinates.coordinates(typeName, fieldName);
247+
if (this.schema.getFieldDefinition(coordinates) == null) {
248+
this.reportBuilder.addUnmappedDataFetcher(coordinates, fetcher);
249+
}
250+
}));
251+
}
252+
236253

237254
/**
238255
* Check the schema against {@code DataFetcher} registrations, and produce a report.
@@ -249,15 +266,20 @@ public static Report inspect(GraphQLSchema schema, RuntimeWiring runtimeWiring)
249266

250267
/**
251268
* The report produced as a result of schema mappings inspection.
252-
* @param unmappedFields a map with type names as keys, and unmapped field names as values
269+
* @param unmappedFields map with type names as keys, and unmapped field names as values
270+
* @param unmappedDataFetchers map with unmapped {@code DataFetcher}s and their field coordinates
253271
* @param skippedTypes the names of types skipped by the inspection
254272
*/
255-
public record Report(MultiValueMap<String, String> unmappedFields, Set<String> skippedTypes) {
273+
public record Report(
274+
MultiValueMap<String, String> unmappedFields,
275+
Map<FieldCoordinates, DataFetcher<?>> unmappedDataFetchers,
276+
Set<String> skippedTypes) {
256277

257278
@Override
258279
public String toString() {
259280
return "GraphQL schema inspection:\n" +
260281
"\tUnmapped fields: " + this.unmappedFields + "\n" +
282+
"\tUnmapped DataFetcher registrations: " + this.unmappedDataFetchers + "\n" +
261283
"\tSkipped types: " + this.skippedTypes;
262284
}
263285
}
@@ -270,6 +292,8 @@ private static class ReportBuilder {
270292

271293
private final MultiValueMap<String, String> unmappedFields = new LinkedMultiValueMap<>();
272294

295+
private final Map<FieldCoordinates, DataFetcher<?>> unmappedDataFetchers = new LinkedHashMap<>();
296+
273297
private final Set<String> skippedTypes = new LinkedHashSet<>();
274298

275299
/**
@@ -279,6 +303,13 @@ public void addUnmappedField(String typeName, String fieldName) {
279303
this.unmappedFields.add(typeName, fieldName);
280304
}
281305

306+
/**
307+
* Add an unmapped {@code DataFetcher} registration.
308+
*/
309+
public void addUnmappedDataFetcher(FieldCoordinates coordinates, DataFetcher<?> dataFetcher) {
310+
this.unmappedDataFetchers.put(coordinates, dataFetcher);
311+
}
312+
282313
/**
283314
* Add a skipped type name.
284315
*/
@@ -289,6 +320,7 @@ public void addSkippedType(String typeName) {
289320
public Report build() {
290321
return new Report(
291322
new LinkedMultiValueMap<>(this.unmappedFields),
323+
new LinkedHashMap<>(this.unmappedDataFetchers),
292324
new LinkedHashSet<>(this.skippedTypes));
293325
}
294326

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
*/
3131
public interface SelfDescribingDataFetcher<T> extends DataFetcher<T> {
3232

33+
/**
34+
* Provide a description of the {@code DataFetcher} for display or logging
35+
* purposes. Depending on the underlying implementation, this could be a
36+
* controller method, a Spring Data repository backed {@code DataFetcher},
37+
* or other.
38+
*/
39+
String getDescription();
40+
3341
/**
3442
* The return type of this {@link DataFetcher}.
3543
* <p>This could be derived from the method signature of an annotated

0 commit comments

Comments
 (0)