From 577014f4f123604b33f07d907a10a655ed6e6cd9 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 9 May 2023 07:02:42 +0100 Subject: [PATCH] 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 --- .../src/docs/asciidoc/index.adoc | 60 +++++++++++++++---- spring-graphql/build.gradle | 1 + .../data/query/JsonKeysetCursorStrategy.java | 53 ++++++++++++++-- .../query/JsonKeysetCursorStrategyTests.java | 34 ++++++++++- .../ScrollPositionCursorStrategyTests.java | 3 +- 5 files changed, 134 insertions(+), 17 deletions(-) diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index a62828bc..ae221df6 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -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. -<> 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 <> methods, <> repositories, -and <> 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 <> uses this to encode cursors for returned items. +<> methods, <> repositories, and <> +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 <> `CursorStrategy` for the Spring Data @@ -1334,6 +1331,47 @@ The <> declares a `CursorStrategy` 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>`. +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 diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 7e0d257c..9b268870 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -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' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategy.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategy.java index 93654724..a8437ca6 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategy.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategy.java @@ -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 encoder; @@ -60,7 +72,15 @@ public final class JsonKeysetCursorStrategy implements CursorStrategy keys) { return ((Encoder>) 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 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 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); diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategyTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategyTests.java index 5a83eee2..5f7dec93 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategyTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategyTests.java @@ -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) {