diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/OptionalInput.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/OptionalInput.java new file mode 100644 index 000000000..dbfd6326f --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/OptionalInput.java @@ -0,0 +1,71 @@ +package org.springframework.graphql.data.method; + +import javax.annotation.Nullable; +import java.util.Objects; + +/** + * Wrapper used to represent optionally defined input arguments that allows us to distinguish between undefined value, explicit NULL value + * and specified value. + */ +public class OptionalInput { + + public static OptionalInput defined(@Nullable T value) { + return new OptionalInput.Defined<>(value); + } + + @SuppressWarnings("unchecked") + public static OptionalInput undefined() { + return (OptionalInput)new OptionalInput.Undefined(); + } + + /** + * Represents missing/undefined value. + */ + public static class Undefined extends OptionalInput { + @Override + public boolean equals(Object obj) { + return obj.getClass() == this.getClass(); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + } + + /** + * Wrapper holding explicitly specified value including NULL. + */ + public static class Defined extends OptionalInput { + @Nullable + private final T value; + + public Defined(@Nullable T value) { + this.value = value; + } + + @Nullable + public T getValue() { + return value; + } + + public Boolean isEmpty() { + return value == null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Defined defined = (Defined) o; + if (isEmpty() == defined.isEmpty()) return true; + if (value == null) return false; + return value.equals(defined.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java index b4dd364c3..41df3f390 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java @@ -26,6 +26,7 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.MutablePropertyValues; import org.springframework.core.MethodParameter; +import org.springframework.graphql.data.method.OptionalInput; import org.springframework.validation.DataBinder; /** @@ -57,11 +58,13 @@ public T instantiate(Class targetType, Map arguments) { if (ctor.getParameterCount() == 0) { target = BeanUtils.instantiateClass(ctor); DataBinder dataBinder = new DataBinder(target); + dataBinder.setConversionService(new OptionalInputArgumentConversionService()); dataBinder.bind(propertyValues); } else { // Data class constructor DataBinder binder = new DataBinder(null); + binder.setConversionService(new OptionalInputArgumentConversionService()); String[] paramNames = BeanUtils.getParameterNames(ctor); Class[] paramTypes = ctor.getParameterTypes(); Object[] args = new Object[paramTypes.length]; @@ -70,8 +73,12 @@ public T instantiate(Class targetType, Map arguments) { Object value = propertyValues.get(paramName); value = (value instanceof List ? ((List) value).toArray() : value); MethodParameter methodParam = new MethodParameter(ctor, i); - if (value == null && methodParam.isOptional()) { - args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null); + if (value == null) { + if (methodParam.getParameterType() == OptionalInput.class) { + args[i] = propertyValues.contains(paramName) ? OptionalInput.defined(null) : OptionalInput.undefined(); + } else if(methodParam.isOptional()) { + args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null); + } } else { args[i] = binder.convertIfNecessary(value, paramTypes[i], methodParam); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/OptionalInputArgumentConversionService.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/OptionalInputArgumentConversionService.java new file mode 100644 index 000000000..0266aa404 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/OptionalInputArgumentConversionService.java @@ -0,0 +1,27 @@ +package org.springframework.graphql.data.method.annotation.support; + +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.graphql.data.method.OptionalInput; + +public class OptionalInputArgumentConversionService implements ConversionService { + @Override + public boolean canConvert(Class sourceType, Class targetType) { + return false; + } + + @Override + public boolean canConvert(TypeDescriptor sourceType, TypeDescriptor targetType) { + return targetType.getType() == OptionalInput.class; + } + + @Override + public T convert(Object source, Class targetType) { + return null; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return OptionalInput.defined(source); + } +} 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 e4d32788d..0179c6053 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 @@ -31,6 +31,7 @@ import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.MethodParameter; import org.springframework.graphql.Book; +import org.springframework.graphql.data.method.OptionalInput; import org.springframework.graphql.data.method.annotation.Argument; import org.springframework.graphql.data.method.annotation.MutationMapping; import org.springframework.graphql.data.method.annotation.QueryMapping; @@ -83,11 +84,40 @@ void shouldResolveJavaBeanArgument() throws Exception { Object result = resolver.resolveArgument(methodParameter, environment); assertThat(result).isNotNull().isInstanceOf(BookInput.class); assertThat((BookInput) result).hasFieldOrPropertyWithValue("name", "test name") - .hasFieldOrPropertyWithValue("authorId", 42L); + .hasFieldOrPropertyWithValue("authorId", 42L) + .hasFieldOrPropertyWithValue("notes", OptionalInput.undefined()); } @Test - void shouldResolveDefaultValue() throws Exception { + void shouldResolveJavaBeanOptionalArgument() throws Exception { + Method addBook = ClassUtils.getMethod(BookController.class, "addBook", BookInput.class); + String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": \"Hello\"} }"; + DataFetchingEnvironment environment = initEnvironment(payload); + MethodParameter methodParameter = getMethodParameter(addBook, 0); + Object result = resolver.resolveArgument(methodParameter, environment); + assertThat(result).isNotNull().isInstanceOf(BookInput.class); + assertThat((BookInput) result) + .hasFieldOrPropertyWithValue("name", "test name") + .hasFieldOrPropertyWithValue("authorId", 42L) + .hasFieldOrPropertyWithValue("notes", OptionalInput.defined("Hello")); + } + + @Test + void shouldResolveJavaBeanOptionalNullArgument() throws Exception { + Method addBook = ClassUtils.getMethod(BookController.class, "addBook", BookInput.class); + String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": null} }"; + DataFetchingEnvironment environment = initEnvironment(payload); + MethodParameter methodParameter = getMethodParameter(addBook, 0); + Object result = resolver.resolveArgument(methodParameter, environment); + assertThat(result).isNotNull().isInstanceOf(BookInput.class); + assertThat((BookInput) result) + .hasFieldOrPropertyWithValue("name", "test name") + .hasFieldOrPropertyWithValue("authorId", 42L) + .hasFieldOrPropertyWithValue("notes", OptionalInput.defined(null)); + } + + @Test + void shouldResolveDefaultValue() throws Exception { Method findWithDefault = ClassUtils.getMethod(BookController.class, "findWithDefault", Long.class); String payload = "{\"name\": \"test\" }"; DataFetchingEnvironment environment = initEnvironment(payload); @@ -96,6 +126,48 @@ void shouldResolveDefaultValue() throws Exception { assertThat(result).isNotNull().isInstanceOf(Long.class).isEqualTo(42L); } + @Test + void shouldResolveKotlinBeanArgument() throws Exception { + Method addBook = ClassUtils.getMethod(BookController.class, "ktAddBook", KotlinBookInput.class); + String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42} }"; + DataFetchingEnvironment environment = initEnvironment(payload); + MethodParameter methodParameter = getMethodParameter(addBook, 0); + Object result = resolver.resolveArgument(methodParameter, environment); + assertThat(result).isNotNull().isInstanceOf(KotlinBookInput.class); + assertThat((KotlinBookInput) result) + .hasFieldOrPropertyWithValue("name", "test name") + .hasFieldOrPropertyWithValue("authorId", 42L) + .hasFieldOrPropertyWithValue("notes", OptionalInput.undefined()); + } + + @Test + void shouldResolveKotlinBeanOptionalArgument() throws Exception { + Method addBook = ClassUtils.getMethod(BookController.class, "ktAddBook", KotlinBookInput.class); + String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": \"Hello\"} }"; + DataFetchingEnvironment environment = initEnvironment(payload); + MethodParameter methodParameter = getMethodParameter(addBook, 0); + Object result = resolver.resolveArgument(methodParameter, environment); + assertThat(result).isNotNull().isInstanceOf(KotlinBookInput.class); + assertThat((KotlinBookInput) result) + .hasFieldOrPropertyWithValue("name", "test name") + .hasFieldOrPropertyWithValue("authorId", 42L) + .hasFieldOrPropertyWithValue("notes", OptionalInput.defined("Hello")); + } + + @Test + void shouldResolveKotlinBeanOptionalNullArgument() throws Exception { + Method addBook = ClassUtils.getMethod(BookController.class, "ktAddBook", KotlinBookInput.class); + String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": null} }"; + DataFetchingEnvironment environment = initEnvironment(payload); + MethodParameter methodParameter = getMethodParameter(addBook, 0); + Object result = resolver.resolveArgument(methodParameter, environment); + assertThat(result).isNotNull().isInstanceOf(KotlinBookInput.class); + assertThat((KotlinBookInput) result) + .hasFieldOrPropertyWithValue("name", "test name") + .hasFieldOrPropertyWithValue("authorId", 42L) + .hasFieldOrPropertyWithValue("notes", OptionalInput.defined(null)); + } + @Test void shouldResolveListOfJavaBeansArgument() throws Exception { Method addBooks = ClassUtils.getMethod(BookController.class, "addBooks", List.class); @@ -142,6 +214,11 @@ public Book addBook(@Argument BookInput bookInput) { return null; } + @MutationMapping + public Book ktAddBook(@Argument KotlinBookInput bookInput) { + return null; + } + @MutationMapping public List addBooks(@Argument List books) { return null; @@ -155,6 +232,8 @@ static class BookInput { Long authorId; + OptionalInput notes = OptionalInput.undefined(); + public String getName() { return this.name; } @@ -170,6 +249,14 @@ public Long getAuthorId() { public void setAuthorId(Long authorId) { this.authorId = authorId; } + + public OptionalInput getNotes() { + return this.notes; + } + + public void setNotes(OptionalInput notes) { + this.notes = (notes == null) ? OptionalInput.defined(null) : notes; + } } } \ No newline at end of file diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/KotlinBookInput.kt b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/KotlinBookInput.kt new file mode 100644 index 000000000..698831df4 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/KotlinBookInput.kt @@ -0,0 +1,8 @@ +package org.springframework.graphql.data.method.annotation.support; + +import org.springframework.graphql.data.method.OptionalInput + +data class KotlinBookInput( + val name: String, val authorId: Long, + val notes: OptionalInput +)