Skip to content

Commit

Permalink
add OptionalInput type to allow explicit null value detection
Browse files Browse the repository at this point in the history
  • Loading branch information
koenpunt committed Sep 21, 2021
1 parent 02b16bd commit c973402
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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<T> {

public static<T> OptionalInput<T> defined(@Nullable T value) {
return new OptionalInput.Defined<>(value);
}

@SuppressWarnings("unchecked")
public static<T> OptionalInput<T> undefined() {
return (OptionalInput<T>)new OptionalInput.Undefined();
}

/**
* Represents missing/undefined value.
*/
public static class Undefined extends OptionalInput<Void> {
@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<T> extends OptionalInput<T> {
@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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -57,11 +58,13 @@ public <T> T instantiate(Class<T> targetType, Map<String, Object> 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];
Expand All @@ -70,8 +73,12 @@ public <T> T instantiate(Class<T> targetType, Map<String, Object> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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> T convert(Object source, Class<T> targetType) {
return null;
}

@Override
public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
return OptionalInput.defined(source);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -142,6 +214,11 @@ public Book addBook(@Argument BookInput bookInput) {
return null;
}

@MutationMapping
public Book ktAddBook(@Argument KotlinBookInput bookInput) {
return null;
}

@MutationMapping
public List<Book> addBooks(@Argument List<Book> books) {
return null;
Expand All @@ -155,6 +232,8 @@ static class BookInput {

Long authorId;

OptionalInput<String> notes = OptionalInput.undefined();

public String getName() {
return this.name;
}
Expand All @@ -170,6 +249,14 @@ public Long getAuthorId() {
public void setAuthorId(Long authorId) {
this.authorId = authorId;
}

public OptionalInput<String> getNotes() {
return this.notes;
}

public void setNotes(OptionalInput<String> notes) {
this.notes = (notes == null) ? OptionalInput.defined(null) : notes;
}
}

}
Original file line number Diff line number Diff line change
@@ -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<String?>
)

0 comments on commit c973402

Please sign in to comment.