diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index 36917cf1..2a4ee942 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -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 <>. -| `@Arguments` -| For access to all field arguments bound to a higher-level, typed Object. -See <>. - | `@Argument Map` | 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 <>. + +| `@Arguments` +| For access to all field arguments bound to a higher-level, typed Object. +See <>. + | `@Arguments Map` | 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` 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 <> +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) { + 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` diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/ArgumentValue.java b/spring-graphql/src/main/java/org/springframework/graphql/data/ArgumentValue.java new file mode 100644 index 00000000..14199ec8 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/ArgumentValue.java @@ -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. + * + *

Supported in one of the following places: + *

    + *
  • 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. + *
  • 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. + *
+ * + * @author Rossen Stoyanchev + * @param the type of value contained + * @since 1.1 + * @see Nullable vs Optional + */ +public final class ArgumentValue { + + 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 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 ArgumentValue ofNullable(@Nullable T value) { + return new ArgumentValue<>(value, false); + } + + /** + * Static factory method for an argument value that was omitted. + */ + @SuppressWarnings("unchecked") + public static ArgumentValue omitted() { + return (ArgumentValue) OMITTED; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java b/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java index 9a8901a2..175e4d3a 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java @@ -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 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); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolver.java index dc554292..87f49bf7 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolver.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolver.java @@ -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. + * + *

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. + * + *

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."); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolver.java index c9d794c8..4b4bfa6c 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolver.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolver.java @@ -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 { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/GraphQlArgumentBinderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/GraphQlArgumentBinderTests.java index be471924..10be6907 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/GraphQlArgumentBinderTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/GraphQlArgumentBinderTests.java @@ -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 name; + + private final ArgumentValue item; + + public PrimaryConstructorOptionalArgumentItemBean(ArgumentValue name, ArgumentValue item) { + this.name = name; + this.item = item; + } + + public ArgumentValue getName() { + return this.name; + } + + public ArgumentValue getItem() { + return item; + } + } + + @SuppressWarnings("unused") static class NoPrimaryConstructorBean { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java index 3c836841..0aba8a85 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java @@ -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) { + return null; + } + @MutationMapping public List addBooks(@Argument List books) { return null; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolverTests.java index 867befdb..f05d69c3 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolverTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolverTests.java @@ -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 name; Long authorId; - public String getName() { + public ArgumentValue getName() { return this.name; } - public void setName(String name) { + public void setName(ArgumentValue name) { this.name = name; }