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
This commit is contained in:
rstoyanchev
2023-05-09 07:02:42 +01:00
parent d717e80cb9
commit 577014f4f1
5 changed files with 134 additions and 17 deletions

View File

@@ -695,7 +695,6 @@ GraphQlSource.schemaResourceBuilder()
----
and the following type definitions will be transparently added to the schema:
[source,graphql,indent=0,subs="verbatim,quotes"]
----
type BookConnection {
@@ -761,18 +760,16 @@ pagination input.
[[execution.pagination.cursor.strategy]]
==== `CursorStrategy`
`CursorStrategy` is a contract to create a String cursor for an item to reflect its
position within a large result set, e.g. based on an offset or key set.
<<execution.pagination.adapters>> implementations use this to create cursors for returned
items.
`CursorStrategy` is a contract to encode and decode a String cursor that refers to the
position of an item within a large result set. The cursor can be based on an index or
on a keyset.
The strategy also enables <<controllers>> methods, <<data.querydsl>> repositories,
and <<data.querybyexample>> repositories to decode pagination request cursors, and create
a `Subrange`. For this to work, you need to declare a `CursorStrategy` bean in your Spring
configuration.
A <<execution.pagination.adapters>> uses this to encode cursors for returned items.
<<controllers>> methods, <<data.querydsl>> repositories, and <<data.querybyexample>>
repositories use it to decode cursors from pagination requests, and create a `Subrange`.
`CursorEncoder` is a related, supporting strategy to encode and decode cursors to make
them opaque to clients. `EncodingCursorStrategy` combines `CursorStrategy` with a
`CursorEncoder` is a related contract that further encodes and decodes String cursors to
make them opaque to clients. `EncodingCursorStrategy` combines `CursorStrategy` with a
`CursorEncoder`. You can use `Base64CursorEncoder`, `NoOpEncoder` or create your own.
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
`ConnectionFieldTypeVisitor` as shown above if Spring Data is on the classpath.
[[data.pagination.scroll.keyset]]
=== Keyset Position
For `KeysetScrollPosition`, the cursor needs to be created from a keyset, which is
essentially a `Map` of key-value pairs. To decide how to create a cursor from a keyset,
you can configure `ScrollPositionCursorStrategy` with `CursorStrategy<Map<String, Object>>`.
By default, `JsonKeysetCursorStrategy` writes the keyset `Map` to JSON. That works for
simple like String, Boolean, Integer, and Double, but others cannot be restored back to the
same type without target type information. The Jackson library has a default typing feature
that can include type information in the JSON. To use it safely you must specify a list of
allowed types. For example:
[source,java,indent=0,subs="verbatim,quotes"]
----
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Map.class)
.allowIfSubType(ZonedDateTime.class)
.build();
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL);
----
You can then create `JsonKeysetCursorStrategy`:
[source,java,indent=0,subs="verbatim,quotes"]
----
ObjectMapper mapper = ... ;
CodecConfigurer configurer = ServerCodecConfigurer.create();
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper));
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper));
JsonKeysetCursorStrategy strategy = new JsonKeysetCursorStrategy(configurer);
----
By default, if `JsonKeysetCursorStrategy` is created without a `CodecConfigurer` and the
Jackson library is on the classpath, customizations like the above are applied for
`Date`, `Calendar`, and any type from `java.time`.
[[data.pagination.sort]]
=== Sort

View File

@@ -65,6 +65,7 @@ dependencies {
testImplementation 'jakarta.validation:jakarta.validation-api'
testImplementation 'com.jayway.jsonpath:json-path'
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
testImplementation 'org.apache.tomcat.embed:tomcat-embed-el:10.0.21'
testRuntimeOnly 'org.apache.logging.log4j:log4j-core'

View File

@@ -17,9 +17,15 @@
package org.springframework.graphql.data.query;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
import org.springframework.core.ResolvableType;
import org.springframework.core.codec.Decoder;
import org.springframework.core.codec.Encoder;
@@ -32,13 +38,16 @@ import org.springframework.http.codec.CodecConfigurer;
import org.springframework.http.codec.DecoderHttpMessageReader;
import org.springframework.http.codec.EncoderHttpMessageWriter;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.http.codec.json.Jackson2JsonDecoder;
import org.springframework.http.codec.json.Jackson2JsonEncoder;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.MimeTypeUtils;
/**
* Strategy to convert a {@link KeysetScrollPosition#getKeys() keyset} to and
* from a JSON String, typically used within {@link ScrollPositionCursorStrategy}
* to assist with converting keys to and from a String.
* from a JSON String for use with {@link ScrollPositionCursorStrategy}.
*
* @author Rossen Stoyanchev
* @since 1.2.0
@@ -48,6 +57,9 @@ public final class JsonKeysetCursorStrategy implements CursorStrategy<Map<String
private static final ResolvableType MAP_TYPE =
ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class);
private static final boolean jackson2Present = ClassUtils.isPresent(
"com.fasterxml.jackson.databind.ObjectMapper", JsonKeysetCursorStrategy.class.getClassLoader());
private final Encoder<?> encoder;
@@ -60,7 +72,15 @@ public final class JsonKeysetCursorStrategy implements CursorStrategy<Map<String
* Shortcut constructor that uses {@link ServerCodecConfigurer}.
*/
public JsonKeysetCursorStrategy() {
this(ServerCodecConfigurer.create());
this(initCodecConfigurer());
}
private static ServerCodecConfigurer initCodecConfigurer() {
ServerCodecConfigurer configurer = ServerCodecConfigurer.create();
if (jackson2Present) {
JacksonObjectMapperCustomizer.customize(configurer);
}
return configurer;
}
/**
@@ -99,7 +119,7 @@ public final class JsonKeysetCursorStrategy implements CursorStrategy<Map<String
@Override
public String toCursor(Map<String, Object> keys) {
return ((Encoder<Map<String, Object>>) this.encoder).encodeValue(
keys, DefaultDataBufferFactory.sharedInstance, ResolvableType.forClass(keys.getClass()),
keys, DefaultDataBufferFactory.sharedInstance, MAP_TYPE,
MimeTypeUtils.APPLICATION_JSON, null).toString(StandardCharsets.UTF_8);
}
@@ -111,4 +131,29 @@ public final class JsonKeysetCursorStrategy implements CursorStrategy<Map<String
return (map != null ? map : Collections.emptyMap());
}
/**
* Customizes the {@link ObjectMapper} to use default typing that supports
* {@link Date}, {@link Calendar}, and classes in {@code java.time}.
*/
private static class JacksonObjectMapperCustomizer {
public static void customize(CodecConfigurer configurer) {
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Map.class)
.allowIfSubType("java.time.")
.allowIfSubType(Calendar.class)
.allowIfSubType(Date.class)
.build();
ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().build();
mapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL);
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper));
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper));
}
}
}

View File

@@ -16,6 +16,11 @@
package org.springframework.graphql.data.query;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -40,7 +45,34 @@ public class JsonKeysetCursorStrategyTests {
keys.put("lastName", "Heller");
keys.put("id", 103);
String json = "{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}";
String json = "[\"java.util.LinkedHashMap\",{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}]";
assertThat(this.cursorStrategy.toCursor(keys)).isEqualTo(json);
assertThat(this.cursorStrategy.fromCursor(json)).isEqualTo(keys);
}
@Test
void toAndFromCursorWithDate() {
Date date = new Date();
Map<String, Object> keys = new LinkedHashMap<>();
keys.put("date", date);
String json = "[\"java.util.LinkedHashMap\",{\"date\":[\"java.util.Date\"," + date.getTime() + "]}]";
assertThat(this.cursorStrategy.toCursor(keys)).isEqualTo(json);
assertThat(this.cursorStrategy.fromCursor(json)).isEqualTo(keys);
}
@Test
void toAndFromCursorWithZonedDateTime() {
ZonedDateTime dateTime = ZonedDateTime.of(
LocalDateTime.of(2023, Month.MAY, 5, 0, 0, 0, 0), ZoneId.of("Z"));
Map<String, Object> keys = new LinkedHashMap<>();
keys.put("date", dateTime);
String json = "[\"java.util.LinkedHashMap\",{\"date\":[\"java.time.ZonedDateTime\",1683244800.000000000]}]";
assertThat(this.cursorStrategy.toCursor(keys)).isEqualTo(json);
assertThat(this.cursorStrategy.fromCursor(json)).isEqualTo(keys);

View File

@@ -49,7 +49,8 @@ public class ScrollPositionCursorStrategyTests {
keys.put("id", 103);
toAndFromCursor(ScrollPosition.forward(keys),
"K_{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}");
"K_[\"java.util.Collections$UnmodifiableMap\"," +
"{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}]");
}
private void toAndFromCursor(ScrollPosition position, String cursor) {