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) {