Add support for checking if an argument was omitted

Closes gh-518
This commit is contained in:
rstoyanchev
2022-10-21 14:33:12 +01:00
parent 8d80e5f883
commit d9f815ed70
8 changed files with 332 additions and 44 deletions

View File

@@ -1272,14 +1272,19 @@ Schema mapping handler methods can have any of the following method arguments:
| For access to a named field argument bound to a higher-level, typed Object.
See <<controllers-schema-mapping-argument>>.
| `@Arguments`
| For access to all field arguments bound to a higher-level, typed Object.
See <<controllers-schema-mapping-arguments>>.
| `@Argument Map<String, Object>`
| For access to the raw map of arguments, where `@Argument` does not have a
`name` attribute.
| `ArgumentValue`
| For access to a named field argument bound to a higher-level, typed Object along
with a flag to indicate if the input argument was omitted vs set to `null`.
See <<controllers-schema-mapping-argument-value>>.
| `@Arguments`
| For access to all field arguments bound to a higher-level, typed Object.
See <<controllers-schema-mapping-arguments>>.
| `@Arguments Map<String, Object>`
| For access to the raw map of arguments.
@@ -1330,7 +1335,6 @@ Schema mapping handler methods can return:
For this to work, `AnnotatedControllerConfigurer` must be configured with an `Executor`.
[[controllers-schema-mapping-argument]]
==== `@Argument`
@@ -1377,6 +1381,43 @@ You can use `@Argument` with a `Map<String, Object>` argument, to obtain the raw
all argument values. The name attribute on `@Argument` must not be set.
[[controllers-schema-mapping-argument-value]]
==== `ArgumentValue`
By default, input arguments in GraphQL are nullable and optional, which means an argument
can be set to the `null` literal, or not provided at all. This distinction is useful for
partial updates with a mutation where the underlying data may also be, either set to
`null` or not changed at all accordingly. When using <<controllers-schema-mapping-argument>>
there is no way to make such a distinction, because you would get `null` or an empty
`Optional` in both cases.
If you want to know not whether a value was not provided at all, you can declare an
`ArgumentValue` method parameter, which is a simple container for the resulting value,
along with a flag to indicate whether the input argument was omitted altogether. You
can use this instead of `@Argument`, in which case the argument name is determined from
the method parameter name, or together with `@Argument` to specify the argument name.
For example:
[source,java,indent=0,subs="verbatim,quotes"]
----
@Controller
public class BookController {
@MutationMapping
public void addBook(ArgumentValue<BookInput> bookInput) {
if (!bookInput.isOmitted) {
BookInput value = bookInput.value();
// ...
}
}
}
----
`ArgumentValue` is also supported as a field within the object structure of an `@Argument`
method parameter, either initialized via a constructor argument or via a setter, including
as a field of an object nested at any level below the top level object.
[[controllers-schema-mapping-arguments]]
==== `@Arguments`

View File

@@ -0,0 +1,132 @@
/*
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.data;
import java.util.Optional;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
/**
* Simple container for the value from binding a GraphQL argument to a higher
* level Object, along with a flag to indicate whether the input argument was
* omitted altogether, as opposed to provided but set to the {@literal "null"}
* literal.
*
* <p>Supported in one of the following places:
* <ul>
* <li>On a controller method parameter, either instead of
* {@link org.springframework.graphql.data.method.annotation.Argument @Argument}
* in which case the argument name is determined from the method parameter name,
* or together with {@code @Argument} to specify the argument name.
* <li>As a field within the object structure of an {@code @Argument} method
* parameter, either initialized via a constructor argument or a setter,
* including as a field of an object nested at any level below the top level
* object.
* </ul>
*
* @author Rossen Stoyanchev
* @param <T> the type of value contained
* @since 1.1
* @see <a href="http://spec.graphql.org/October2021/#sec-Non-Null.Nullable-vs-Optional">Nullable vs Optional</a>
*/
public final class ArgumentValue<T> {
private static final ArgumentValue<?> OMITTED = new ArgumentValue<>(null, false);
@Nullable
private final T value;
private final boolean omitted;
private ArgumentValue(@Nullable T value, boolean omitted) {
this.value = value;
this.omitted = omitted;
}
/**
* Return {@code true} if a non-null value is present, and {@code false} otherwise.
*/
public boolean isPresent() {
return (this.value != null);
}
/**
* Return {@code true} if the input value was omitted altogether from the
* input, and {@code false} if it was provided, but possibly set to the
* {@literal "null"} literal.
*/
public boolean isOmitted() {
return this.omitted;
}
/**
* Return the contained value, or {@code null}.
*/
@Nullable
public T value() {
return this.value;
}
/**
* Return the contained value as a nullable {@link Optional}.
*/
public Optional<T> asOptional() {
return Optional.ofNullable(this.value);
}
@Override
public boolean equals(Object other) {
// This covers OMITTED constant
if (this == other) {
return true;
}
if (!(other instanceof ArgumentValue<?> otherValue)) {
return false;
}
return ObjectUtils.nullSafeEquals(this.value, otherValue.value);
}
@Override
public int hashCode() {
int result = ObjectUtils.nullSafeHashCode(this.value);
result = 31 * result + Boolean.hashCode(this.omitted);
return result;
}
/**
* Static factory method for an argument value that was provided, even if
* it was set to {@literal "null}.
* @param value the value to hold in the instance
*/
public static <T> ArgumentValue<T> ofNullable(@Nullable T value) {
return new ArgumentValue<>(value, false);
}
/**
* Static factory method for an argument value that was omitted.
*/
@SuppressWarnings("unchecked")
public static <T> ArgumentValue<T> omitted() {
return (ArgumentValue<T>) OMITTED;
}
}

View File

@@ -121,12 +121,13 @@ public class GraphQlArgumentBinder {
DataFetchingEnvironment environment, @Nullable String name, ResolvableType targetType)
throws BindException {
Object rawValue = (name != null ?
environment.getArgument(name) : environment.getArguments());
Object rawValue = (name != null ? environment.getArgument(name) : environment.getArguments());
boolean isOmitted = (name != null && !environment.getArguments().containsKey(name));
ArgumentsBindingResult bindingResult = new ArgumentsBindingResult(targetType);
Object value = bindRawValue("$", rawValue, targetType, targetType.resolve(Object.class), bindingResult);
Object value = bindRawValue(
"$", rawValue, isOmitted, targetType, targetType.resolve(Object.class), bindingResult);
if (bindingResult.hasErrors()) {
throw new BindException(bindingResult);
@@ -142,6 +143,8 @@ public class GraphQlArgumentBinder {
* {@code "$"} if binding the top level Object; possibly indexed if binding
* to a Collection element or to a Map value.
* @param rawValue the raw argument value (Collection, Map, or scalar)
* @param isOmitted whether the value with the given name was not provided
* at all, as opposed to provided but set to the {@literal "null"} literal
* @param targetType the type of Object to create
* @param targetClass the resolved class from the targetType
* @param bindingResult for keeping track of the nested path and errors
@@ -153,12 +156,13 @@ public class GraphQlArgumentBinder {
@SuppressWarnings({"ConstantConditions", "unchecked"})
@Nullable
private Object bindRawValue(
String name, @Nullable Object rawValue, ResolvableType targetType, Class<?> targetClass,
ArgumentsBindingResult bindingResult) {
String name, @Nullable Object rawValue, boolean isOmitted,
ResolvableType targetType, Class<?> targetClass, ArgumentsBindingResult bindingResult) {
boolean isOptional = (targetClass == Optional.class);
boolean isArgumentValue = (targetClass == ArgumentValue.class);
if (isOptional) {
if (isOptional || isArgumentValue) {
targetType = targetType.getNested(2);
targetClass = targetType.resolve();
}
@@ -178,7 +182,14 @@ public class GraphQlArgumentBinder {
rawValue : convertValue(name, rawValue, targetClass, bindingResult));
}
return (isOptional ? Optional.ofNullable(value) : value);
if (isOptional) {
value = Optional.ofNullable(value);
}
else if (isArgumentValue) {
value = (isOmitted ? ArgumentValue.omitted() : ArgumentValue.ofNullable(value));
}
return value;
}
private Collection<?> bindCollection(
@@ -198,7 +209,7 @@ public class GraphQlArgumentBinder {
int index = 0;
for (Object rawValue : rawCollection) {
String indexedName = name + "[" + index++ + "]";
collection.add(bindRawValue(indexedName, rawValue, elementType, elementClass, bindingResult));
collection.add(bindRawValue(indexedName, rawValue, false, elementType, elementClass, bindingResult));
}
return collection;
@@ -242,7 +253,7 @@ public class GraphQlArgumentBinder {
for (Map.Entry<String, Object> entry : rawMap.entrySet()) {
String key = entry.getKey();
String indexedName = name + "[" + key + "]";
map.put(key, bindRawValue(indexedName, entry.getValue(), valueType, valueClass, bindingResult));
map.put(key, bindRawValue(indexedName, entry.getValue(), false, valueType, valueClass, bindingResult));
}
return map;
@@ -258,8 +269,9 @@ public class GraphQlArgumentBinder {
for (int i = 0; i < paramNames.length; i++) {
String name = paramNames[i];
boolean isOmitted = !rawMap.containsKey(name);
ResolvableType paramType = ResolvableType.forConstructorParameter(constructor, i);
args[i] = bindRawValue(name, rawMap.get(name), paramType, paramTypes[i], bindingResult);
args[i] = bindRawValue(name, rawMap.get(name), isOmitted, paramType, paramTypes[i], bindingResult);
}
try {
@@ -288,7 +300,7 @@ public class GraphQlArgumentBinder {
continue;
}
Object value = bindRawValue(
key, entry.getValue(), type.getResolvableType(), type.getType(), bindingResult);
key, entry.getValue(), false, type.getResolvableType(), type.getType(), bindingResult);
try {
if (value != null) {
beanWrapper.setPropertyValue(key, value);
@@ -313,7 +325,7 @@ public class GraphQlArgumentBinder {
Object value = null;
try {
TypeConverter converter = (this.typeConverter != null ? this.typeConverter : new SimpleTypeConverter());
value = converter.convertIfNecessary(rawValue, (Class<?>) type, TypeDescriptor.valueOf(type));
value = converter.convertIfNecessary(rawValue, (Class<?>) type);
}
catch (TypeMismatchException ex) {
bindingResult.pushNestedPath(name);

View File

@@ -19,6 +19,7 @@ import graphql.schema.DataFetchingEnvironment;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.graphql.data.ArgumentValue;
import org.springframework.graphql.data.GraphQlArgumentBinder;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.Argument;
@@ -26,14 +27,27 @@ import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Resolver for {@link Argument @Argument} annotated method parameters, obtained
* via {@link DataFetchingEnvironment#getArgument(String)} and converted to the
* declared type of the method parameter.
* Resolver for a method parameter that is annotated with
* {@link Argument @Argument}. The specified raw argument value is obtained via
* {@link DataFetchingEnvironment#getArgument(String)} and bound to a higher
* level object, via {@link GraphQlArgumentBinder}, to match the target method
* parameter type.
*
* <p>This resolver also supports wrapping the target object with
* {@link ArgumentValue} if the application wants to differentiate between an
* input argument that was set to {@code null} vs not provided at all. When
* this wrapper type is used, the annotation is optional, and the name of the
* argument is derived from the method parameter name.
*
* <p>An {@link ArgumentValue} can also be nested within the object structure
* of an {@link Argument @Argument}-annotated method parameter.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
* @since 1.0.0
* @see Argument
* @see org.springframework.graphql.data.method.annotation.Argument
* @see org.springframework.graphql.data.method.annotation.Arguments
* @see org.springframework.graphql.data.GraphQlArgumentBinder
*/
public class ArgumentMethodArgumentResolver implements HandlerMethodArgumentResolver {
@@ -48,7 +62,8 @@ public class ArgumentMethodArgumentResolver implements HandlerMethodArgumentReso
@Override
public boolean supportsParameter(MethodParameter parameter) {
return (parameter.getParameterAnnotation(Argument.class) != null);
return (parameter.getParameterAnnotation(Argument.class) != null ||
parameter.getParameterType() == ArgumentValue.class);
}
@Override
@@ -59,15 +74,21 @@ public class ArgumentMethodArgumentResolver implements HandlerMethodArgumentReso
}
static String getArgumentName(MethodParameter parameter) {
Argument annotation = parameter.getParameterAnnotation(Argument.class);
Assert.state(annotation != null, "Expected @Argument annotation");
if (StringUtils.hasText(annotation.name())) {
return annotation.name();
Argument argument = parameter.getParameterAnnotation(Argument.class);
if (argument != null) {
if (StringUtils.hasText(argument.name())) {
return argument.name();
}
}
else if (parameter.getParameterType() != ArgumentValue.class) {
throw new IllegalStateException("Expected @Argument annotation");
}
String parameterName = parameter.getParameterName();
if (parameterName != null) {
return parameterName;
}
throw new IllegalArgumentException(
"Name for argument of type [" + parameter.getNestedParameterType().getName() +
"] not specified, and parameter name information not found in class file either.");

View File

@@ -25,12 +25,18 @@ import org.springframework.graphql.data.method.annotation.Arguments;
import org.springframework.util.Assert;
/**
* Analogous to {@link ArgumentMethodArgumentResolver} but resolving method
* parameters annotated with {@link Arguments @Arguments} and binding with the
* full {@link DataFetchingEnvironment#getArgument(String) arguments} map.
* Resolver for a method parameter that is annotated with
* {@link Arguments @Arguments}, similar to what
* {@link ArgumentMethodArgumentResolver} does but using the full
* full {@link DataFetchingEnvironment#getArgument(String) GraphQL arguments}
* map as the source for binding to the target Object rather than a specific
* argument value within it.
*
* @author Rossen Stoyanchev
* @since 1.0.0
* @see org.springframework.graphql.data.method.annotation.Arguments
* @see org.springframework.graphql.data.method.annotation.Argument
* @see org.springframework.graphql.data.GraphQlArgumentBinder
*/
public class ArgumentsMethodArgumentResolver implements HandlerMethodArgumentResolver {

View File

@@ -52,7 +52,7 @@ class GraphQlArgumentBinderTests {
private final ObjectMapper mapper = new ObjectMapper();
private final GraphQlArgumentBinder binder = new GraphQlArgumentBinder(null);
private final GraphQlArgumentBinder binder = new GraphQlArgumentBinder();
@Test
@@ -204,6 +204,33 @@ class GraphQlArgumentBinderTests {
assertThat(((PrimaryConstructorOptionalItemBean) result).getName().get()).isEqualTo("Hello");
}
@Test
void primaryConstructorWithOptionalArgumentBeanArgument() throws Exception {
ResolvableType targetType =
ResolvableType.forClass(PrimaryConstructorOptionalArgumentItemBean.class);
PrimaryConstructorOptionalArgumentItemBean result =
(PrimaryConstructorOptionalArgumentItemBean) this.binder.bind(
environment(
"{\"key\":{" +
"\"item\":{\"name\":\"Item name\",\"age\":\"30\"}," +
"\"name\":\"Hello\"}}"),
"key", targetType);
assertThat(result).isNotNull();
assertThat(result.getItem().value().getName()).isEqualTo("Item name");
assertThat(result.getItem().value().getAge()).isEqualTo(30);
assertThat(result.getName().value()).isEqualTo("Hello");
result = (PrimaryConstructorOptionalArgumentItemBean)
this.binder.bind(environment("{\"key\":{}}"), "key", targetType);
assertThat(result).isNotNull();
assertThat(result.getItem().isOmitted()).isFalse();
assertThat(result.getName().isOmitted()).isFalse();
}
@Test
void primaryConstructorWithNestedBeanList() throws Exception {
@@ -495,6 +522,27 @@ class GraphQlArgumentBinderTests {
}
static class PrimaryConstructorOptionalArgumentItemBean {
private final ArgumentValue<String> name;
private final ArgumentValue<Item> item;
public PrimaryConstructorOptionalArgumentItemBean(ArgumentValue<String> name, ArgumentValue<Item> item) {
this.name = name;
this.item = item;
}
public ArgumentValue<String> getName() {
return this.name;
}
public ArgumentValue<Item> getItem() {
return item;
}
}
@SuppressWarnings("unused")
static class NoPrimaryConstructorBean {

View File

@@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.graphql.Book;
import org.springframework.graphql.data.ArgumentValue;
import org.springframework.graphql.data.GraphQlArgumentBinder;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.Argument;
@@ -44,15 +45,15 @@ class ArgumentMethodArgumentResolverTests extends ArgumentResolverTestSupport {
@Test
void shouldSupportAnnotatedParameters() {
MethodParameter methodParameter = methodParam(BookController.class, "bookById", Long.class);
assertThat(this.resolver.supportsParameter(methodParameter)).isTrue();
}
void supportsParameter() {
MethodParameter param = methodParam(BookController.class, "bookById", Long.class);
assertThat(this.resolver.supportsParameter(param)).isTrue();
@Test
void shouldNotSupportParametersWithoutAnnotation() {
MethodParameter methodParameter = methodParam(BookController.class, "notSupported", String.class);
assertThat(this.resolver.supportsParameter(methodParameter)).isFalse();
param = methodParam(BookController.class, "addBook", ArgumentValue.class);
assertThat(this.resolver.supportsParameter(param)).isTrue();
param = methodParam(BookController.class, "notSupported", String.class);
assertThat(this.resolver.supportsParameter(param)).isFalse();
}
@Test
@@ -75,6 +76,20 @@ class ArgumentMethodArgumentResolverTests extends ArgumentResolverTestSupport {
.hasFieldOrPropertyWithValue("authorId", 42L);
}
@Test
void shouldResolveJavaBeanArgumentWithWrapper() throws Exception {
Object result = this.resolver.resolveArgument(
methodParam(BookController.class, "addBook", ArgumentValue.class),
environment("{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42} }"));
assertThat(result)
.isNotNull()
.isInstanceOf(ArgumentValue.class)
.extracting(value -> ((ArgumentValue<?>) value).value())
.hasFieldOrPropertyWithValue("name", "test name")
.hasFieldOrPropertyWithValue("authorId", 42L);
}
@Test
void shouldResolveListOfJavaBeansArgument() throws Exception {
Object result = this.resolver.resolveArgument(
@@ -117,6 +132,11 @@ class ArgumentMethodArgumentResolverTests extends ArgumentResolverTestSupport {
return null;
}
@MutationMapping
public Book addBook(ArgumentValue<BookInput> bookInput) {
return null;
}
@MutationMapping
public List<Book> addBooks(@Argument List<Book> books) {
return null;

View File

@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.graphql.Book;
import org.springframework.graphql.data.ArgumentValue;
import org.springframework.graphql.data.GraphQlArgumentBinder;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.Arguments;
@@ -58,9 +59,16 @@ class ArgumentsMethodArgumentResolverTests extends ArgumentResolverTestSupport {
methodParam(BookController.class, "addBook", BookInput.class),
environment("{\"name\":\"test name\", \"authorId\":42}"));
assertThat(result).isNotNull().isInstanceOf(BookInput.class)
.hasFieldOrPropertyWithValue("name", "test name")
.hasFieldOrPropertyWithValue("authorId", 42L);
assertThat(result)
.isNotNull()
.isInstanceOf(BookInput.class)
.satisfies(value -> {
BookInput input = (BookInput) value;
assertThat(input.getName().isPresent()).isTrue();
assertThat(input.getName().isOmitted()).isFalse();
assertThat(input.getName().value()).isEqualTo("test name");
assertThat(input.getAuthorId()).isEqualTo(42L);
});
}
@@ -81,15 +89,15 @@ class ArgumentsMethodArgumentResolverTests extends ArgumentResolverTestSupport {
@SuppressWarnings({"NotNullFieldNotInitialized", "unused"})
static class BookInput {
String name;
ArgumentValue<String> name;
Long authorId;
public String getName() {
public ArgumentValue<String> getName() {
return this.name;
}
public void setName(String name) {
public void setName(ArgumentValue<String> name) {
this.name = name;
}