Skip to content

Commit 577014f

Browse files
committed
Support date types in JsonKeysetCursorStrategy
This adds support for date values to JsonKeysetCursorStrategy by default when Jackson is on the classpath and also updates the documentation to provide guidance. Closes gh-684
1 parent d717e80 commit 577014f

File tree

5 files changed

+134
-17
lines changed

5 files changed

+134
-17
lines changed

spring-graphql-docs/src/docs/asciidoc/index.adoc

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,6 @@ GraphQlSource.schemaResourceBuilder()
695695
----
696696

697697
and the following type definitions will be transparently added to the schema:
698-
699698
[source,graphql,indent=0,subs="verbatim,quotes"]
700699
----
701700
type BookConnection {
@@ -761,18 +760,16 @@ pagination input.
761760
[[execution.pagination.cursor.strategy]]
762761
==== `CursorStrategy`
763762

764-
`CursorStrategy` is a contract to create a String cursor for an item to reflect its
765-
position within a large result set, e.g. based on an offset or key set.
766-
<<execution.pagination.adapters>> implementations use this to create cursors for returned
767-
items.
763+
`CursorStrategy` is a contract to encode and decode a String cursor that refers to the
764+
position of an item within a large result set. The cursor can be based on an index or
765+
on a keyset.
768766

769-
The strategy also enables <<controllers>> methods, <<data.querydsl>> repositories,
770-
and <<data.querybyexample>> repositories to decode pagination request cursors, and create
771-
a `Subrange`. For this to work, you need to declare a `CursorStrategy` bean in your Spring
772-
configuration.
767+
A <<execution.pagination.adapters>> uses this to encode cursors for returned items.
768+
<<controllers>> methods, <<data.querydsl>> repositories, and <<data.querybyexample>>
769+
repositories use it to decode cursors from pagination requests, and create a `Subrange`.
773770

774-
`CursorEncoder` is a related, supporting strategy to encode and decode cursors to make
775-
them opaque to clients. `EncodingCursorStrategy` combines `CursorStrategy` with a
771+
`CursorEncoder` is a related contract that further encodes and decodes String cursors to
772+
make them opaque to clients. `EncodingCursorStrategy` combines `CursorStrategy` with a
776773
`CursorEncoder`. You can use `Base64CursorEncoder`, `NoOpEncoder` or create your own.
777774

778775
There is a <<data.pagination.scroll,built-in>> `CursorStrategy` for the Spring Data
@@ -1334,6 +1331,47 @@ The <<boot-starter>> declares a `CursorStrategy<ScrollPosition>` bean, and regis
13341331
`ConnectionFieldTypeVisitor` as shown above if Spring Data is on the classpath.
13351332

13361333

1334+
[[data.pagination.scroll.keyset]]
1335+
=== Keyset Position
1336+
1337+
For `KeysetScrollPosition`, the cursor needs to be created from a keyset, which is
1338+
essentially a `Map` of key-value pairs. To decide how to create a cursor from a keyset,
1339+
you can configure `ScrollPositionCursorStrategy` with `CursorStrategy<Map<String, Object>>`.
1340+
By default, `JsonKeysetCursorStrategy` writes the keyset `Map` to JSON. That works for
1341+
simple like String, Boolean, Integer, and Double, but others cannot be restored back to the
1342+
same type without target type information. The Jackson library has a default typing feature
1343+
that can include type information in the JSON. To use it safely you must specify a list of
1344+
allowed types. For example:
1345+
1346+
[source,java,indent=0,subs="verbatim,quotes"]
1347+
----
1348+
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
1349+
.allowIfBaseType(Map.class)
1350+
.allowIfSubType(ZonedDateTime.class)
1351+
.build();
1352+
1353+
ObjectMapper mapper = new ObjectMapper();
1354+
mapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL);
1355+
----
1356+
1357+
You can then create `JsonKeysetCursorStrategy`:
1358+
1359+
[source,java,indent=0,subs="verbatim,quotes"]
1360+
----
1361+
ObjectMapper mapper = ... ;
1362+
1363+
CodecConfigurer configurer = ServerCodecConfigurer.create();
1364+
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper));
1365+
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper));
1366+
1367+
JsonKeysetCursorStrategy strategy = new JsonKeysetCursorStrategy(configurer);
1368+
----
1369+
1370+
By default, if `JsonKeysetCursorStrategy` is created without a `CodecConfigurer` and the
1371+
Jackson library is on the classpath, customizations like the above are applied for
1372+
`Date`, `Calendar`, and any type from `java.time`.
1373+
1374+
13371375

13381376
[[data.pagination.sort]]
13391377
=== Sort

spring-graphql/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ dependencies {
6565
testImplementation 'jakarta.validation:jakarta.validation-api'
6666
testImplementation 'com.jayway.jsonpath:json-path'
6767
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
68+
testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
6869
testImplementation 'org.apache.tomcat.embed:tomcat-embed-el:10.0.21'
6970

7071
testRuntimeOnly 'org.apache.logging.log4j:log4j-core'

spring-graphql/src/main/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategy.java

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717
package org.springframework.graphql.data.query;
1818

1919
import java.nio.charset.StandardCharsets;
20+
import java.util.Calendar;
2021
import java.util.Collections;
22+
import java.util.Date;
2123
import java.util.Map;
2224

25+
import com.fasterxml.jackson.databind.ObjectMapper;
26+
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
27+
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
28+
2329
import org.springframework.core.ResolvableType;
2430
import org.springframework.core.codec.Decoder;
2531
import org.springframework.core.codec.Encoder;
@@ -32,13 +38,16 @@
3238
import org.springframework.http.codec.DecoderHttpMessageReader;
3339
import org.springframework.http.codec.EncoderHttpMessageWriter;
3440
import org.springframework.http.codec.ServerCodecConfigurer;
41+
import org.springframework.http.codec.json.Jackson2JsonDecoder;
42+
import org.springframework.http.codec.json.Jackson2JsonEncoder;
43+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
3544
import org.springframework.util.Assert;
45+
import org.springframework.util.ClassUtils;
3646
import org.springframework.util.MimeTypeUtils;
3747

3848
/**
3949
* Strategy to convert a {@link KeysetScrollPosition#getKeys() keyset} to and
40-
* from a JSON String, typically used within {@link ScrollPositionCursorStrategy}
41-
* to assist with converting keys to and from a String.
50+
* from a JSON String for use with {@link ScrollPositionCursorStrategy}.
4251
*
4352
* @author Rossen Stoyanchev
4453
* @since 1.2.0
@@ -48,6 +57,9 @@ public final class JsonKeysetCursorStrategy implements CursorStrategy<Map<String
4857
private static final ResolvableType MAP_TYPE =
4958
ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class);
5059

60+
private static final boolean jackson2Present = ClassUtils.isPresent(
61+
"com.fasterxml.jackson.databind.ObjectMapper", JsonKeysetCursorStrategy.class.getClassLoader());
62+
5163

5264
private final Encoder<?> encoder;
5365

@@ -60,7 +72,15 @@ public final class JsonKeysetCursorStrategy implements CursorStrategy<Map<String
6072
* Shortcut constructor that uses {@link ServerCodecConfigurer}.
6173
*/
6274
public JsonKeysetCursorStrategy() {
63-
this(ServerCodecConfigurer.create());
75+
this(initCodecConfigurer());
76+
}
77+
78+
private static ServerCodecConfigurer initCodecConfigurer() {
79+
ServerCodecConfigurer configurer = ServerCodecConfigurer.create();
80+
if (jackson2Present) {
81+
JacksonObjectMapperCustomizer.customize(configurer);
82+
}
83+
return configurer;
6484
}
6585

6686
/**
@@ -99,7 +119,7 @@ public boolean supports(Class<?> targetType) {
99119
@Override
100120
public String toCursor(Map<String, Object> keys) {
101121
return ((Encoder<Map<String, Object>>) this.encoder).encodeValue(
102-
keys, DefaultDataBufferFactory.sharedInstance, ResolvableType.forClass(keys.getClass()),
122+
keys, DefaultDataBufferFactory.sharedInstance, MAP_TYPE,
103123
MimeTypeUtils.APPLICATION_JSON, null).toString(StandardCharsets.UTF_8);
104124
}
105125

@@ -111,4 +131,29 @@ public Map<String, Object> fromCursor(String cursor) {
111131
return (map != null ? map : Collections.emptyMap());
112132
}
113133

134+
135+
/**
136+
* Customizes the {@link ObjectMapper} to use default typing that supports
137+
* {@link Date}, {@link Calendar}, and classes in {@code java.time}.
138+
*/
139+
private static class JacksonObjectMapperCustomizer {
140+
141+
public static void customize(CodecConfigurer configurer) {
142+
143+
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
144+
.allowIfBaseType(Map.class)
145+
.allowIfSubType("java.time.")
146+
.allowIfSubType(Calendar.class)
147+
.allowIfSubType(Date.class)
148+
.build();
149+
150+
ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().build();
151+
mapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL);
152+
153+
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper));
154+
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper));
155+
}
156+
157+
}
158+
114159
}

spring-graphql/src/test/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategyTests.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616

1717
package org.springframework.graphql.data.query;
1818

19+
import java.time.LocalDateTime;
20+
import java.time.Month;
21+
import java.time.ZoneId;
22+
import java.time.ZonedDateTime;
23+
import java.util.Date;
1924
import java.util.LinkedHashMap;
2025
import java.util.Map;
2126

@@ -40,7 +45,34 @@ void toAndFromCursor() {
4045
keys.put("lastName", "Heller");
4146
keys.put("id", 103);
4247

43-
String json = "{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}";
48+
String json = "[\"java.util.LinkedHashMap\",{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}]";
49+
50+
assertThat(this.cursorStrategy.toCursor(keys)).isEqualTo(json);
51+
assertThat(this.cursorStrategy.fromCursor(json)).isEqualTo(keys);
52+
}
53+
54+
@Test
55+
void toAndFromCursorWithDate() {
56+
57+
Date date = new Date();
58+
59+
Map<String, Object> keys = new LinkedHashMap<>();
60+
keys.put("date", date);
61+
String json = "[\"java.util.LinkedHashMap\",{\"date\":[\"java.util.Date\"," + date.getTime() + "]}]";
62+
63+
assertThat(this.cursorStrategy.toCursor(keys)).isEqualTo(json);
64+
assertThat(this.cursorStrategy.fromCursor(json)).isEqualTo(keys);
65+
}
66+
67+
@Test
68+
void toAndFromCursorWithZonedDateTime() {
69+
70+
ZonedDateTime dateTime = ZonedDateTime.of(
71+
LocalDateTime.of(2023, Month.MAY, 5, 0, 0, 0, 0), ZoneId.of("Z"));
72+
73+
Map<String, Object> keys = new LinkedHashMap<>();
74+
keys.put("date", dateTime);
75+
String json = "[\"java.util.LinkedHashMap\",{\"date\":[\"java.time.ZonedDateTime\",1683244800.000000000]}]";
4476

4577
assertThat(this.cursorStrategy.toCursor(keys)).isEqualTo(json);
4678
assertThat(this.cursorStrategy.fromCursor(json)).isEqualTo(keys);

spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategyTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ void keysetPosition() {
4949
keys.put("id", 103);
5050

5151
toAndFromCursor(ScrollPosition.forward(keys),
52-
"K_{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}");
52+
"K_[\"java.util.Collections$UnmodifiableMap\"," +
53+
"{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}]");
5354
}
5455

5556
private void toAndFromCursor(ScrollPosition position, String cursor) {

0 commit comments

Comments
 (0)