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 4ff769157..7a73374ed 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 @@ -28,8 +28,10 @@ import org.springframework.beans.MutablePropertyValues; import org.springframework.core.CollectionFactory; import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; import org.springframework.util.Assert; +import org.springframework.graphql.data.method.OptionalInput; import org.springframework.validation.DataBinder; /** @@ -42,6 +44,12 @@ class GraphQlArgumentInstantiator { private final DataBinder converter = new DataBinder(null); + private final ConversionService conversionService = new OptionalInputArgumentConversionService(); + + GraphQlArgumentInstantiator() { + this.converter.setConversionService(this.conversionService); + } + /** * Instantiate the given target type and bind data from * {@link graphql.schema.DataFetchingEnvironment} arguments. @@ -63,6 +71,7 @@ public T instantiate(Map arguments, Class targetType) { MutablePropertyValues propertyValues = extractPropertyValues(arguments); target = BeanUtils.instantiateClass(ctor); DataBinder dataBinder = new DataBinder(target); + dataBinder.setConversionService(this.conversionService); dataBinder.bind(propertyValues); } else { @@ -74,10 +83,14 @@ public T instantiate(Map arguments, Class targetType) { String paramName = paramNames[i]; Object value = arguments.get(paramName); 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] = arguments.containsKey(paramName) ? OptionalInput.defined(null) : OptionalInput.undefined(); + } else if(methodParam.isOptional()) { + args[i] = (methodParam.getParameterType() == Optional.class && arguments.containsKey(paramName) ? Optional.empty() : null); + } } - else if (value != null && CollectionFactory.isApproximableCollectionType(value.getClass())) { + else if (CollectionFactory.isApproximableCollectionType(value.getClass())) { TypeDescriptor typeDescriptor = new TypeDescriptor(methodParam); Class elementType = typeDescriptor.getElementTypeDescriptor().getType(); args[i] = instantiateCollection(elementType, (Collection) value); 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..13a4c2515 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/OptionalInputArgumentConversionService.java @@ -0,0 +1,34 @@ +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; + +import javax.swing.text.html.Option; +import java.util.Optional; + +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 || targetType.getType() == Optional.class; + } + + @Override + public T convert(Object source, Class targetType) { + return null; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (targetType.getType() == Optional.class) { + return Optional.of(source); + } else { + 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 ee327e266..5710a7a4c 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 @@ -20,6 +20,7 @@ import java.lang.reflect.Method; import java.util.List; import java.util.Map; +import java.util.Optional; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -31,12 +32,15 @@ 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; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; +import javax.annotation.Nullable; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -83,7 +87,78 @@ 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", null); + } + + @Test + 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", Optional.of("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", Optional.empty()); + } + + @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", null); + } + + @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", Optional.of("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", Optional.empty()); } @Test @@ -127,6 +202,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; @@ -140,6 +220,9 @@ static class BookInput { Long authorId; + @Nullable + Optional notes = null; + public String getName() { return this.name; } @@ -155,6 +238,15 @@ public Long getAuthorId() { public void setAuthorId(Long authorId) { this.authorId = authorId; } + + @Nullable + public Optional getNotes() { + return this.notes; + } + + public void setNotes(@Nullable Optional notes) { + this.notes = notes; + } } static class Keyword { 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..0f8b9dcd9 --- /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 java.util.Optional + +data class KotlinBookInput( + val name: String, val authorId: Long, + val notes: Optional? +)