Skip to content

Commit 089fc59

Browse files
committed
add OptionalInput type to allow explicit null value detection
1 parent b0dedbe commit 089fc59

File tree

5 files changed

+210
-5
lines changed

5 files changed

+210
-5
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.springframework.graphql.data.method;
2+
3+
import javax.annotation.Nullable;
4+
import java.util.Objects;
5+
6+
/**
7+
* Wrapper used to represent optionally defined input arguments that allows us to distinguish between undefined value, explicit NULL value
8+
* and specified value.
9+
*/
10+
public class OptionalInput<T> {
11+
12+
public static<T> OptionalInput<T> defined(@Nullable T value) {
13+
return new OptionalInput.Defined<>(value);
14+
}
15+
16+
@SuppressWarnings("unchecked")
17+
public static<T> OptionalInput<T> undefined() {
18+
return (OptionalInput<T>)new OptionalInput.Undefined();
19+
}
20+
21+
/**
22+
* Represents missing/undefined value.
23+
*/
24+
public static class Undefined extends OptionalInput<Void> {
25+
@Override
26+
public boolean equals(Object obj) {
27+
return obj.getClass() == this.getClass();
28+
}
29+
30+
@Override
31+
public int hashCode() {
32+
return super.hashCode();
33+
}
34+
}
35+
36+
/**
37+
* Wrapper holding explicitly specified value including NULL.
38+
*/
39+
public static class Defined<T> extends OptionalInput<T> {
40+
@Nullable
41+
private final T value;
42+
43+
public Defined(@Nullable T value) {
44+
this.value = value;
45+
}
46+
47+
@Nullable
48+
public T getValue() {
49+
return value;
50+
}
51+
52+
public Boolean isEmpty() {
53+
return value == null;
54+
}
55+
56+
@Override
57+
public boolean equals(Object o) {
58+
if (this == o) return true;
59+
if (o == null || getClass() != o.getClass()) return false;
60+
Defined<?> defined = (Defined<?>) o;
61+
if (isEmpty() == defined.isEmpty()) return true;
62+
if (value == null) return false;
63+
return value.equals(defined.value);
64+
}
65+
66+
@Override
67+
public int hashCode() {
68+
return Objects.hash(value);
69+
}
70+
}
71+
}

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
import org.springframework.beans.MutablePropertyValues;
2929
import org.springframework.core.CollectionFactory;
3030
import org.springframework.core.MethodParameter;
31+
import org.springframework.core.convert.ConversionService;
3132
import org.springframework.core.convert.TypeDescriptor;
3233
import org.springframework.util.Assert;
34+
import org.springframework.graphql.data.method.OptionalInput;
3335
import org.springframework.validation.DataBinder;
3436

3537
/**
@@ -42,6 +44,12 @@ class GraphQlArgumentInstantiator {
4244

4345
private final DataBinder converter = new DataBinder(null);
4446

47+
private final ConversionService conversionService = new OptionalInputArgumentConversionService();
48+
49+
GraphQlArgumentInstantiator() {
50+
this.converter.setConversionService(this.conversionService);
51+
}
52+
4553
/**
4654
* Instantiate the given target type and bind data from
4755
* {@link graphql.schema.DataFetchingEnvironment} arguments.
@@ -63,6 +71,7 @@ public <T> T instantiate(Map<String, Object> arguments, Class<T> targetType) {
6371
MutablePropertyValues propertyValues = extractPropertyValues(arguments);
6472
target = BeanUtils.instantiateClass(ctor);
6573
DataBinder dataBinder = new DataBinder(target);
74+
dataBinder.setConversionService(this.conversionService);
6675
dataBinder.bind(propertyValues);
6776
}
6877
else {
@@ -74,10 +83,14 @@ public <T> T instantiate(Map<String, Object> arguments, Class<T> targetType) {
7483
String paramName = paramNames[i];
7584
Object value = arguments.get(paramName);
7685
MethodParameter methodParam = new MethodParameter(ctor, i);
77-
if (value == null && methodParam.isOptional()) {
78-
args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null);
86+
if (value == null) {
87+
if (methodParam.getParameterType() == OptionalInput.class) {
88+
args[i] = arguments.containsKey(paramName) ? OptionalInput.defined(null) : OptionalInput.undefined();
89+
} else if(methodParam.isOptional()) {
90+
args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null);
91+
}
7992
}
80-
else if (value != null && CollectionFactory.isApproximableCollectionType(value.getClass())) {
93+
else if (CollectionFactory.isApproximableCollectionType(value.getClass())) {
8194
TypeDescriptor typeDescriptor = new TypeDescriptor(methodParam);
8295
Class<?> elementType = typeDescriptor.getElementTypeDescriptor().getType();
8396
args[i] = instantiateCollection(elementType, (Collection<Object>) value);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.springframework.graphql.data.method.annotation.support;
2+
3+
import org.springframework.core.convert.ConversionService;
4+
import org.springframework.core.convert.TypeDescriptor;
5+
import org.springframework.graphql.data.method.OptionalInput;
6+
7+
public class OptionalInputArgumentConversionService implements ConversionService {
8+
@Override
9+
public boolean canConvert(Class<?> sourceType, Class<?> targetType) {
10+
return false;
11+
}
12+
13+
@Override
14+
public boolean canConvert(TypeDescriptor sourceType, TypeDescriptor targetType) {
15+
return targetType.getType() == OptionalInput.class;
16+
}
17+
18+
@Override
19+
public <T> T convert(Object source, Class<T> targetType) {
20+
return null;
21+
}
22+
23+
@Override
24+
public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
25+
return OptionalInput.defined(source);
26+
}
27+
}

spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.springframework.core.DefaultParameterNameDiscoverer;
3232
import org.springframework.core.MethodParameter;
3333
import org.springframework.graphql.Book;
34+
import org.springframework.graphql.data.method.OptionalInput;
3435
import org.springframework.graphql.data.method.annotation.Argument;
3536
import org.springframework.graphql.data.method.annotation.MutationMapping;
3637
import org.springframework.graphql.data.method.annotation.QueryMapping;
@@ -83,11 +84,40 @@ void shouldResolveJavaBeanArgument() throws Exception {
8384
Object result = resolver.resolveArgument(methodParameter, environment);
8485
assertThat(result).isNotNull().isInstanceOf(BookInput.class);
8586
assertThat((BookInput) result).hasFieldOrPropertyWithValue("name", "test name")
86-
.hasFieldOrPropertyWithValue("authorId", 42L);
87+
.hasFieldOrPropertyWithValue("authorId", 42L)
88+
.hasFieldOrPropertyWithValue("notes", OptionalInput.undefined());
8789
}
8890

8991
@Test
90-
void shouldResolveDefaultValue() throws Exception {
92+
void shouldResolveJavaBeanOptionalArgument() throws Exception {
93+
Method addBook = ClassUtils.getMethod(BookController.class, "addBook", BookInput.class);
94+
String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": \"Hello\"} }";
95+
DataFetchingEnvironment environment = initEnvironment(payload);
96+
MethodParameter methodParameter = getMethodParameter(addBook, 0);
97+
Object result = resolver.resolveArgument(methodParameter, environment);
98+
assertThat(result).isNotNull().isInstanceOf(BookInput.class);
99+
assertThat((BookInput) result)
100+
.hasFieldOrPropertyWithValue("name", "test name")
101+
.hasFieldOrPropertyWithValue("authorId", 42L)
102+
.hasFieldOrPropertyWithValue("notes", OptionalInput.defined("Hello"));
103+
}
104+
105+
@Test
106+
void shouldResolveJavaBeanOptionalNullArgument() throws Exception {
107+
Method addBook = ClassUtils.getMethod(BookController.class, "addBook", BookInput.class);
108+
String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": null} }";
109+
DataFetchingEnvironment environment = initEnvironment(payload);
110+
MethodParameter methodParameter = getMethodParameter(addBook, 0);
111+
Object result = resolver.resolveArgument(methodParameter, environment);
112+
assertThat(result).isNotNull().isInstanceOf(BookInput.class);
113+
assertThat((BookInput) result)
114+
.hasFieldOrPropertyWithValue("name", "test name")
115+
.hasFieldOrPropertyWithValue("authorId", 42L)
116+
.hasFieldOrPropertyWithValue("notes", OptionalInput.defined(null));
117+
}
118+
119+
@Test
120+
void shouldResolveDefaultValue() throws Exception {
91121
Method findWithDefault = ClassUtils.getMethod(BookController.class, "findWithDefault", Long.class);
92122
String payload = "{\"name\": \"test\" }";
93123
DataFetchingEnvironment environment = initEnvironment(payload);
@@ -106,6 +136,47 @@ void shouldNotFailIfArgumentNotRequired() throws Exception {
106136
assertThat(result).isNull();
107137
}
108138

139+
void shouldResolveKotlinBeanArgument() throws Exception {
140+
Method addBook = ClassUtils.getMethod(BookController.class, "ktAddBook", KotlinBookInput.class);
141+
String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42} }";
142+
DataFetchingEnvironment environment = initEnvironment(payload);
143+
MethodParameter methodParameter = getMethodParameter(addBook, 0);
144+
Object result = resolver.resolveArgument(methodParameter, environment);
145+
assertThat(result).isNotNull().isInstanceOf(KotlinBookInput.class);
146+
assertThat((KotlinBookInput) result)
147+
.hasFieldOrPropertyWithValue("name", "test name")
148+
.hasFieldOrPropertyWithValue("authorId", 42L)
149+
.hasFieldOrPropertyWithValue("notes", OptionalInput.undefined());
150+
}
151+
152+
@Test
153+
void shouldResolveKotlinBeanOptionalArgument() throws Exception {
154+
Method addBook = ClassUtils.getMethod(BookController.class, "ktAddBook", KotlinBookInput.class);
155+
String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": \"Hello\"} }";
156+
DataFetchingEnvironment environment = initEnvironment(payload);
157+
MethodParameter methodParameter = getMethodParameter(addBook, 0);
158+
Object result = resolver.resolveArgument(methodParameter, environment);
159+
assertThat(result).isNotNull().isInstanceOf(KotlinBookInput.class);
160+
assertThat((KotlinBookInput) result)
161+
.hasFieldOrPropertyWithValue("name", "test name")
162+
.hasFieldOrPropertyWithValue("authorId", 42L)
163+
.hasFieldOrPropertyWithValue("notes", OptionalInput.defined("Hello"));
164+
}
165+
166+
@Test
167+
void shouldResolveKotlinBeanOptionalNullArgument() throws Exception {
168+
Method addBook = ClassUtils.getMethod(BookController.class, "ktAddBook", KotlinBookInput.class);
169+
String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42, \"notes\": null} }";
170+
DataFetchingEnvironment environment = initEnvironment(payload);
171+
MethodParameter methodParameter = getMethodParameter(addBook, 0);
172+
Object result = resolver.resolveArgument(methodParameter, environment);
173+
assertThat(result).isNotNull().isInstanceOf(KotlinBookInput.class);
174+
assertThat((KotlinBookInput) result)
175+
.hasFieldOrPropertyWithValue("name", "test name")
176+
.hasFieldOrPropertyWithValue("authorId", 42L)
177+
.hasFieldOrPropertyWithValue("notes", OptionalInput.defined(null));
178+
}
179+
109180
@Test
110181
void shouldResolveListOfJavaBeansArgument() throws Exception {
111182
Method addBooks = ClassUtils.getMethod(BookController.class, "addBooks", List.class);
@@ -157,6 +228,11 @@ public Book addBook(@Argument BookInput bookInput) {
157228
return null;
158229
}
159230

231+
@MutationMapping
232+
public Book ktAddBook(@Argument KotlinBookInput bookInput) {
233+
return null;
234+
}
235+
160236
@MutationMapping
161237
public List<Book> addBooks(@Argument List<Book> books) {
162238
return null;
@@ -170,6 +246,8 @@ static class BookInput {
170246

171247
Long authorId;
172248

249+
OptionalInput<String> notes = OptionalInput.undefined();
250+
173251
public String getName() {
174252
return this.name;
175253
}
@@ -185,6 +263,14 @@ public Long getAuthorId() {
185263
public void setAuthorId(Long authorId) {
186264
this.authorId = authorId;
187265
}
266+
267+
public OptionalInput<String> getNotes() {
268+
return this.notes;
269+
}
270+
271+
public void setNotes(OptionalInput<String> notes) {
272+
this.notes = (notes == null) ? OptionalInput.defined(null) : notes;
273+
}
188274
}
189275

190276
static class Keyword {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package org.springframework.graphql.data.method.annotation.support;
2+
3+
import org.springframework.graphql.data.method.OptionalInput
4+
5+
data class KotlinBookInput(
6+
val name: String, val authorId: Long,
7+
val notes: OptionalInput<String?>
8+
)

0 commit comments

Comments
 (0)