Skip to content

Commit d9f815e

Browse files
committed
Add support for checking if an argument was omitted
Closes gh-518
1 parent 8d80e5f commit d9f815e

File tree

8 files changed

+332
-44
lines changed

8 files changed

+332
-44
lines changed

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

+46-5
Original file line numberDiff line numberDiff line change
@@ -1272,14 +1272,19 @@ Schema mapping handler methods can have any of the following method arguments:
12721272
| For access to a named field argument bound to a higher-level, typed Object.
12731273
See <<controllers-schema-mapping-argument>>.
12741274

1275-
| `@Arguments`
1276-
| For access to all field arguments bound to a higher-level, typed Object.
1277-
See <<controllers-schema-mapping-arguments>>.
1278-
12791275
| `@Argument Map<String, Object>`
12801276
| For access to the raw map of arguments, where `@Argument` does not have a
12811277
`name` attribute.
12821278

1279+
| `ArgumentValue`
1280+
| For access to a named field argument bound to a higher-level, typed Object along
1281+
with a flag to indicate if the input argument was omitted vs set to `null`.
1282+
See <<controllers-schema-mapping-argument-value>>.
1283+
1284+
| `@Arguments`
1285+
| For access to all field arguments bound to a higher-level, typed Object.
1286+
See <<controllers-schema-mapping-arguments>>.
1287+
12831288
| `@Arguments Map<String, Object>`
12841289
| For access to the raw map of arguments.
12851290

@@ -1330,7 +1335,6 @@ Schema mapping handler methods can return:
13301335
For this to work, `AnnotatedControllerConfigurer` must be configured with an `Executor`.
13311336

13321337

1333-
13341338
[[controllers-schema-mapping-argument]]
13351339
==== `@Argument`
13361340

@@ -1377,6 +1381,43 @@ You can use `@Argument` with a `Map<String, Object>` argument, to obtain the raw
13771381
all argument values. The name attribute on `@Argument` must not be set.
13781382

13791383

1384+
[[controllers-schema-mapping-argument-value]]
1385+
==== `ArgumentValue`
1386+
1387+
By default, input arguments in GraphQL are nullable and optional, which means an argument
1388+
can be set to the `null` literal, or not provided at all. This distinction is useful for
1389+
partial updates with a mutation where the underlying data may also be, either set to
1390+
`null` or not changed at all accordingly. When using <<controllers-schema-mapping-argument>>
1391+
there is no way to make such a distinction, because you would get `null` or an empty
1392+
`Optional` in both cases.
1393+
1394+
If you want to know not whether a value was not provided at all, you can declare an
1395+
`ArgumentValue` method parameter, which is a simple container for the resulting value,
1396+
along with a flag to indicate whether the input argument was omitted altogether. You
1397+
can use this instead of `@Argument`, in which case the argument name is determined from
1398+
the method parameter name, or together with `@Argument` to specify the argument name.
1399+
1400+
For example:
1401+
1402+
[source,java,indent=0,subs="verbatim,quotes"]
1403+
----
1404+
@Controller
1405+
public class BookController {
1406+
1407+
@MutationMapping
1408+
public void addBook(ArgumentValue<BookInput> bookInput) {
1409+
if (!bookInput.isOmitted) {
1410+
BookInput value = bookInput.value();
1411+
// ...
1412+
}
1413+
}
1414+
}
1415+
----
1416+
1417+
`ArgumentValue` is also supported as a field within the object structure of an `@Argument`
1418+
method parameter, either initialized via a constructor argument or via a setter, including
1419+
as a field of an object nested at any level below the top level object.
1420+
13801421

13811422
[[controllers-schema-mapping-arguments]]
13821423
==== `@Arguments`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.graphql.data;
17+
18+
19+
import java.util.Optional;
20+
21+
import org.springframework.lang.Nullable;
22+
import org.springframework.util.ObjectUtils;
23+
24+
/**
25+
* Simple container for the value from binding a GraphQL argument to a higher
26+
* level Object, along with a flag to indicate whether the input argument was
27+
* omitted altogether, as opposed to provided but set to the {@literal "null"}
28+
* literal.
29+
*
30+
* <p>Supported in one of the following places:
31+
* <ul>
32+
* <li>On a controller method parameter, either instead of
33+
* {@link org.springframework.graphql.data.method.annotation.Argument @Argument}
34+
* in which case the argument name is determined from the method parameter name,
35+
* or together with {@code @Argument} to specify the argument name.
36+
* <li>As a field within the object structure of an {@code @Argument} method
37+
* parameter, either initialized via a constructor argument or a setter,
38+
* including as a field of an object nested at any level below the top level
39+
* object.
40+
* </ul>
41+
*
42+
* @author Rossen Stoyanchev
43+
* @param <T> the type of value contained
44+
* @since 1.1
45+
* @see <a href="http://spec.graphql.org/October2021/#sec-Non-Null.Nullable-vs-Optional">Nullable vs Optional</a>
46+
*/
47+
public final class ArgumentValue<T> {
48+
49+
private static final ArgumentValue<?> OMITTED = new ArgumentValue<>(null, false);
50+
51+
52+
@Nullable
53+
private final T value;
54+
55+
private final boolean omitted;
56+
57+
58+
private ArgumentValue(@Nullable T value, boolean omitted) {
59+
this.value = value;
60+
this.omitted = omitted;
61+
}
62+
63+
64+
/**
65+
* Return {@code true} if a non-null value is present, and {@code false} otherwise.
66+
*/
67+
public boolean isPresent() {
68+
return (this.value != null);
69+
}
70+
71+
/**
72+
* Return {@code true} if the input value was omitted altogether from the
73+
* input, and {@code false} if it was provided, but possibly set to the
74+
* {@literal "null"} literal.
75+
*/
76+
public boolean isOmitted() {
77+
return this.omitted;
78+
}
79+
80+
/**
81+
* Return the contained value, or {@code null}.
82+
*/
83+
@Nullable
84+
public T value() {
85+
return this.value;
86+
}
87+
88+
/**
89+
* Return the contained value as a nullable {@link Optional}.
90+
*/
91+
public Optional<T> asOptional() {
92+
return Optional.ofNullable(this.value);
93+
}
94+
95+
@Override
96+
public boolean equals(Object other) {
97+
// This covers OMITTED constant
98+
if (this == other) {
99+
return true;
100+
}
101+
if (!(other instanceof ArgumentValue<?> otherValue)) {
102+
return false;
103+
}
104+
return ObjectUtils.nullSafeEquals(this.value, otherValue.value);
105+
}
106+
107+
@Override
108+
public int hashCode() {
109+
int result = ObjectUtils.nullSafeHashCode(this.value);
110+
result = 31 * result + Boolean.hashCode(this.omitted);
111+
return result;
112+
}
113+
114+
115+
/**
116+
* Static factory method for an argument value that was provided, even if
117+
* it was set to {@literal "null}.
118+
* @param value the value to hold in the instance
119+
*/
120+
public static <T> ArgumentValue<T> ofNullable(@Nullable T value) {
121+
return new ArgumentValue<>(value, false);
122+
}
123+
124+
/**
125+
* Static factory method for an argument value that was omitted.
126+
*/
127+
@SuppressWarnings("unchecked")
128+
public static <T> ArgumentValue<T> omitted() {
129+
return (ArgumentValue<T>) OMITTED;
130+
}
131+
132+
}

spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java

+24-12
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,13 @@ public Object bind(
121121
DataFetchingEnvironment environment, @Nullable String name, ResolvableType targetType)
122122
throws BindException {
123123

124-
Object rawValue = (name != null ?
125-
environment.getArgument(name) : environment.getArguments());
124+
Object rawValue = (name != null ? environment.getArgument(name) : environment.getArguments());
125+
boolean isOmitted = (name != null && !environment.getArguments().containsKey(name));
126126

127127
ArgumentsBindingResult bindingResult = new ArgumentsBindingResult(targetType);
128128

129-
Object value = bindRawValue("$", rawValue, targetType, targetType.resolve(Object.class), bindingResult);
129+
Object value = bindRawValue(
130+
"$", rawValue, isOmitted, targetType, targetType.resolve(Object.class), bindingResult);
130131

131132
if (bindingResult.hasErrors()) {
132133
throw new BindException(bindingResult);
@@ -142,6 +143,8 @@ public Object bind(
142143
* {@code "$"} if binding the top level Object; possibly indexed if binding
143144
* to a Collection element or to a Map value.
144145
* @param rawValue the raw argument value (Collection, Map, or scalar)
146+
* @param isOmitted whether the value with the given name was not provided
147+
* at all, as opposed to provided but set to the {@literal "null"} literal
145148
* @param targetType the type of Object to create
146149
* @param targetClass the resolved class from the targetType
147150
* @param bindingResult for keeping track of the nested path and errors
@@ -153,12 +156,13 @@ public Object bind(
153156
@SuppressWarnings({"ConstantConditions", "unchecked"})
154157
@Nullable
155158
private Object bindRawValue(
156-
String name, @Nullable Object rawValue, ResolvableType targetType, Class<?> targetClass,
157-
ArgumentsBindingResult bindingResult) {
159+
String name, @Nullable Object rawValue, boolean isOmitted,
160+
ResolvableType targetType, Class<?> targetClass, ArgumentsBindingResult bindingResult) {
158161

159162
boolean isOptional = (targetClass == Optional.class);
163+
boolean isArgumentValue = (targetClass == ArgumentValue.class);
160164

161-
if (isOptional) {
165+
if (isOptional || isArgumentValue) {
162166
targetType = targetType.getNested(2);
163167
targetClass = targetType.resolve();
164168
}
@@ -178,7 +182,14 @@ else if (rawValue instanceof Map) {
178182
rawValue : convertValue(name, rawValue, targetClass, bindingResult));
179183
}
180184

181-
return (isOptional ? Optional.ofNullable(value) : value);
185+
if (isOptional) {
186+
value = Optional.ofNullable(value);
187+
}
188+
else if (isArgumentValue) {
189+
value = (isOmitted ? ArgumentValue.omitted() : ArgumentValue.ofNullable(value));
190+
}
191+
192+
return value;
182193
}
183194

184195
private Collection<?> bindCollection(
@@ -198,7 +209,7 @@ private Collection<?> bindCollection(
198209
int index = 0;
199210
for (Object rawValue : rawCollection) {
200211
String indexedName = name + "[" + index++ + "]";
201-
collection.add(bindRawValue(indexedName, rawValue, elementType, elementClass, bindingResult));
212+
collection.add(bindRawValue(indexedName, rawValue, false, elementType, elementClass, bindingResult));
202213
}
203214

204215
return collection;
@@ -242,7 +253,7 @@ private Map<?, Object> bindMapToMap(
242253
for (Map.Entry<String, Object> entry : rawMap.entrySet()) {
243254
String key = entry.getKey();
244255
String indexedName = name + "[" + key + "]";
245-
map.put(key, bindRawValue(indexedName, entry.getValue(), valueType, valueClass, bindingResult));
256+
map.put(key, bindRawValue(indexedName, entry.getValue(), false, valueType, valueClass, bindingResult));
246257
}
247258

248259
return map;
@@ -258,8 +269,9 @@ private Object bindMapToObjectViaConstructor(
258269

259270
for (int i = 0; i < paramNames.length; i++) {
260271
String name = paramNames[i];
272+
boolean isOmitted = !rawMap.containsKey(name);
261273
ResolvableType paramType = ResolvableType.forConstructorParameter(constructor, i);
262-
args[i] = bindRawValue(name, rawMap.get(name), paramType, paramTypes[i], bindingResult);
274+
args[i] = bindRawValue(name, rawMap.get(name), isOmitted, paramType, paramTypes[i], bindingResult);
263275
}
264276

265277
try {
@@ -288,7 +300,7 @@ private Object bindMapToObjectViaSetters(
288300
continue;
289301
}
290302
Object value = bindRawValue(
291-
key, entry.getValue(), type.getResolvableType(), type.getType(), bindingResult);
303+
key, entry.getValue(), false, type.getResolvableType(), type.getType(), bindingResult);
292304
try {
293305
if (value != null) {
294306
beanWrapper.setPropertyValue(key, value);
@@ -313,7 +325,7 @@ private <T> T convertValue(
313325
Object value = null;
314326
try {
315327
TypeConverter converter = (this.typeConverter != null ? this.typeConverter : new SimpleTypeConverter());
316-
value = converter.convertIfNecessary(rawValue, (Class<?>) type, TypeDescriptor.valueOf(type));
328+
value = converter.convertIfNecessary(rawValue, (Class<?>) type);
317329
}
318330
catch (TypeMismatchException ex) {
319331
bindingResult.pushNestedPath(name);

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

+30-9
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,35 @@
1919

2020
import org.springframework.core.MethodParameter;
2121
import org.springframework.core.ResolvableType;
22+
import org.springframework.graphql.data.ArgumentValue;
2223
import org.springframework.graphql.data.GraphQlArgumentBinder;
2324
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
2425
import org.springframework.graphql.data.method.annotation.Argument;
2526
import org.springframework.util.Assert;
2627
import org.springframework.util.StringUtils;
2728

2829
/**
29-
* Resolver for {@link Argument @Argument} annotated method parameters, obtained
30-
* via {@link DataFetchingEnvironment#getArgument(String)} and converted to the
31-
* declared type of the method parameter.
30+
* Resolver for a method parameter that is annotated with
31+
* {@link Argument @Argument}. The specified raw argument value is obtained via
32+
* {@link DataFetchingEnvironment#getArgument(String)} and bound to a higher
33+
* level object, via {@link GraphQlArgumentBinder}, to match the target method
34+
* parameter type.
35+
*
36+
* <p>This resolver also supports wrapping the target object with
37+
* {@link ArgumentValue} if the application wants to differentiate between an
38+
* input argument that was set to {@code null} vs not provided at all. When
39+
* this wrapper type is used, the annotation is optional, and the name of the
40+
* argument is derived from the method parameter name.
41+
*
42+
* <p>An {@link ArgumentValue} can also be nested within the object structure
43+
* of an {@link Argument @Argument}-annotated method parameter.
3244
*
3345
* @author Rossen Stoyanchev
3446
* @author Brian Clozel
3547
* @since 1.0.0
36-
* @see Argument
48+
* @see org.springframework.graphql.data.method.annotation.Argument
49+
* @see org.springframework.graphql.data.method.annotation.Arguments
50+
* @see org.springframework.graphql.data.GraphQlArgumentBinder
3751
*/
3852
public class ArgumentMethodArgumentResolver implements HandlerMethodArgumentResolver {
3953

@@ -48,7 +62,8 @@ public ArgumentMethodArgumentResolver(GraphQlArgumentBinder argumentBinder) {
4862

4963
@Override
5064
public boolean supportsParameter(MethodParameter parameter) {
51-
return (parameter.getParameterAnnotation(Argument.class) != null);
65+
return (parameter.getParameterAnnotation(Argument.class) != null ||
66+
parameter.getParameterType() == ArgumentValue.class);
5267
}
5368

5469
@Override
@@ -59,15 +74,21 @@ public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment
5974
}
6075

6176
static String getArgumentName(MethodParameter parameter) {
62-
Argument annotation = parameter.getParameterAnnotation(Argument.class);
63-
Assert.state(annotation != null, "Expected @Argument annotation");
64-
if (StringUtils.hasText(annotation.name())) {
65-
return annotation.name();
77+
Argument argument = parameter.getParameterAnnotation(Argument.class);
78+
if (argument != null) {
79+
if (StringUtils.hasText(argument.name())) {
80+
return argument.name();
81+
}
82+
}
83+
else if (parameter.getParameterType() != ArgumentValue.class) {
84+
throw new IllegalStateException("Expected @Argument annotation");
6685
}
86+
6787
String parameterName = parameter.getParameterName();
6888
if (parameterName != null) {
6989
return parameterName;
7090
}
91+
7192
throw new IllegalArgumentException(
7293
"Name for argument of type [" + parameter.getNestedParameterType().getName() +
7394
"] not specified, and parameter name information not found in class file either.");

0 commit comments

Comments
 (0)