From d9d67c7f90f1c341a4c08184b6949d3bcad021d9 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 31 Mar 2023 20:23:23 +0100 Subject: [PATCH] Pagination support for Querydsl and QBE See gh-597 --- platform/build.gradle | 2 +- ...toRegistrationRuntimeWiringConfigurer.java | 33 ++- .../data/query/QueryByExampleDataFetcher.java | 198 +++++++++++++++- .../data/query/QuerydslDataFetcher.java | 211 ++++++++++++++++-- .../graphql/data/query/RepositoryUtils.java | 28 ++- .../data/query/QuerydslDataFetcherTests.java | 73 +++++- .../QueryByExampleDataFetcherJpaTests.java | 75 ++++++- ...QueryByExampleDataFetcherMongoDbTests.java | 72 +++++- 8 files changed, 626 insertions(+), 66 deletions(-) diff --git a/platform/build.gradle b/platform/build.gradle index dfdc290b..92aac6b2 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -12,7 +12,7 @@ dependencies { api(platform("io.projectreactor:reactor-bom:2022.0.5")) api(platform("io.micrometer:micrometer-bom:1.11.0-M2")) api(platform("io.micrometer:micrometer-tracing-bom:1.1.0-M2")) - api(platform("org.springframework.data:spring-data-bom:2023.0.0-M3")) + api(platform("org.springframework.data:spring-data-bom:2023.0.0-SNAPSHOT")) api(platform("org.springframework.security:spring-security-bom:6.1.0-M2")) api(platform("com.querydsl:querydsl-bom:5.0.0")) api(platform("io.rsocket:rsocket-bom:1.1.3")) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/AutoRegistrationRuntimeWiringConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/AutoRegistrationRuntimeWiringConfigurer.java index 5dc7940f..0f2fe3dd 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/AutoRegistrationRuntimeWiringConfigurer.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/AutoRegistrationRuntimeWiringConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import graphql.schema.DataFetcher; import graphql.schema.GraphQLList; import graphql.schema.GraphQLNamedOutputType; import graphql.schema.GraphQLNonNull; +import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLType; import graphql.schema.idl.FieldWiringEnvironment; import graphql.schema.idl.RuntimeWiring; @@ -76,15 +77,20 @@ class AutoRegistrationRuntimeWiringConfigurer implements RuntimeWiringConfigurer interface DataFetcherFactory { /** - * Create a singe item {@code DataFetcher}. + * Create {@code DataFetcher} for a singe item. */ DataFetcher single(); /** - * Create {@code DataFetcher} for multiple items. + * Create {@code DataFetcher} for many items. */ DataFetcher many(); + /** + * Create {@code DataFetcher} for scrolling. + */ + DataFetcher scrollable(); + } @@ -127,6 +133,11 @@ class AutoRegistrationRuntimeWiringConfigurer implements RuntimeWiringConfigurer private String getOutputTypeName(FieldWiringEnvironment environment) { GraphQLType outputType = removeNonNullWrapper(environment.getFieldType()); + if (isConnectionType(outputType)) { + String name = ((GraphQLObjectType) outputType).getName(); + return name.substring(0, name.length() - 10); + } + if (outputType instanceof GraphQLList) { outputType = removeNonNullWrapper(((GraphQLList) outputType).getWrappedType()); } @@ -142,6 +153,12 @@ class AutoRegistrationRuntimeWiringConfigurer implements RuntimeWiringConfigurer return (outputType instanceof GraphQLNonNull wrapper ? wrapper.getWrappedType() : outputType); } + private boolean isConnectionType(GraphQLType type) { + return (type instanceof GraphQLObjectType objectType && + objectType.getName().endsWith("Connection") && + objectType.getField("edges") != null && objectType.getField("pageInfo") != null); + } + private boolean hasDataFetcherFor(FieldDefinition fieldDefinition) { if (this.existingQueryDataFetcherPredicate == null) { Map map = this.builder.build().getDataFetcherForType("Query"); @@ -168,13 +185,9 @@ class AutoRegistrationRuntimeWiringConfigurer implements RuntimeWiringConfigurer DataFetcherFactory factory = dataFetcherFactories.get(outputTypeName); Assert.notNull(factory, "Expected DataFetcher factory for typeName '" + outputTypeName + "'"); - GraphQLType outputType = removeNonNullWrapper(environment.getFieldType()); - if (outputType instanceof GraphQLList) { - return factory.many(); - } - else { - return factory.single(); - } + GraphQLType type = removeNonNullWrapper(environment.getFieldType()); + return (isConnectionType(type) ? factory.scrollable() : + (type instanceof GraphQLList ? factory.many() : factory.single())); } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java index e623b64e..1a2817f6 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java @@ -35,13 +35,17 @@ import reactor.core.publisher.Mono; import org.springframework.core.ResolvableType; import org.springframework.data.domain.Example; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.repository.query.QueryByExampleExecutor; import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; import org.springframework.data.util.TypeInformation; import org.springframework.graphql.data.GraphQlArgumentBinder; import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.data.pagination.CursorStrategy; import org.springframework.graphql.data.query.AutoRegistrationRuntimeWiringConfigurer.DataFetcherFactory; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.execution.SelfDescribingDataFetcher; @@ -102,9 +106,15 @@ public abstract class QueryByExampleDataFetcher { private final GraphQlArgumentBinder argumentBinder; + @Nullable + private final CursorStrategy cursorStrategy; + + + QueryByExampleDataFetcher( + TypeInformation domainType, @Nullable CursorStrategy cursorStrategy) { - QueryByExampleDataFetcher(TypeInformation domainType) { this.domainType = domainType; + this.cursorStrategy = cursorStrategy; this.argumentBinder = new GraphQlArgumentBinder(); } @@ -154,11 +164,14 @@ public abstract class QueryByExampleDataFetcher { return Collections.emptyList(); } + protected ScrollSubrange buildScrollSubrange(DataFetchingEnvironment environment) { + return RepositoryUtils.buildScrollSubrange(environment, this.cursorStrategy); + } + /** * Create a new {@link Builder} accepting {@link QueryByExampleExecutor} * to build a {@link DataFetcher}. - * * @param executor the QBE repository object to use * @param the domain type of the repository * @return a new builder @@ -170,7 +183,6 @@ public abstract class QueryByExampleDataFetcher { /** * Create a new {@link ReactiveBuilder} accepting * {@link ReactiveQueryByExampleExecutor} to build a {@link DataFetcher}. - * * @param executor the QBE repository object to use * @param the domain type of the repository * @return a new builder @@ -179,6 +191,22 @@ public abstract class QueryByExampleDataFetcher { return new ReactiveBuilder<>(executor, RepositoryUtils.getDomainType(executor)); } + /** + * Variation of {@link #autoRegistrationConfigurer(List, List, CursorStrategy, ScrollSubrange)} + * that defaults to the following: + *
    + *
  • {@link ScrollPositionCursorStrategy} with Base64 encoding. + *
  • {@link OffsetScrollPosition Offset}-based scrolling with 20 items at a time + *
+ */ + public static RuntimeWiringConfigurer autoRegistrationConfigurer( + List> executors, + List> reactiveExecutors) { + + return autoRegistrationConfigurer(executors, reactiveExecutors, + RepositoryUtils.defaultCursorStrategy(), RepositoryUtils.defaultScrollSubrange()); + } + /** * Return a {@link RuntimeWiringConfigurer} that installs a * {@link graphql.schema.idl.WiringFactory} to find queries with a return @@ -190,11 +218,16 @@ public abstract class QueryByExampleDataFetcher { * * @param executors repositories to consider for registration * @param reactiveExecutors reactive repositories to consider for registration + * @param cursorStrategy for decoding cursors in pagination requests + * @param defaultScrollSubrange default parameters for scrolling * @return the created configurer + * @since 1.2 */ public static RuntimeWiringConfigurer autoRegistrationConfigurer( List> executors, - List> reactiveExecutors) { + List> reactiveExecutors, + CursorStrategy cursorStrategy, + ScrollSubrange defaultScrollSubrange) { Map factories = new HashMap<>(); @@ -212,6 +245,11 @@ public abstract class QueryByExampleDataFetcher { public DataFetcher many() { return builder.many(); } + + @Override + public DataFetcher scrollable() { + return builder.scrollable(cursorStrategy, defaultScrollSubrange); + } }); } } @@ -230,6 +268,11 @@ public abstract class QueryByExampleDataFetcher { public DataFetcher many() { return builder.many(); } + + @Override + public DataFetcher scrollable() { + return builder.scrollable(cursorStrategy, defaultScrollSubrange); + } }); } } @@ -363,7 +406,19 @@ public abstract class QueryByExampleDataFetcher { * Build a {@link DataFetcher} to fetch many object instances. */ public DataFetcher> many() { - return new ManyEntityFetcher<>(this.executor, this.domainType, this.resultType, this.sort); + return new ManyEntityFetcher<>(this.executor, this.domainType, this.resultType, null, this.sort); + } + + /** + * Build a {@link DataFetcher} that scrolls and returns + * {@link org.springframework.data.domain.Window}. + * @since 1.2 + */ + public DataFetcher> scrollable( + CursorStrategy cursorStrategy, ScrollSubrange defaultScrollSubrange) { + + return new ScrollableEntityFetcher<>( + this.executor, this.domainType, this.resultType, cursorStrategy, defaultScrollSubrange, this.sort); } } @@ -461,6 +516,18 @@ public abstract class QueryByExampleDataFetcher { return new ReactiveManyEntityFetcher<>(this.executor, this.domainType, this.resultType, this.sort); } + /** + * Build a {@link DataFetcher} that scrolls and returns + * {@link org.springframework.data.domain.Window}. + * @since 1.2 + */ + public DataFetcher>> scrollable( + CursorStrategy cursorStrategy, ScrollSubrange defaultScrollSubrange) { + + return new ReactiveScrollableEntityFetcher<>( + this.executor, this.domainType, this.resultType, cursorStrategy, defaultScrollSubrange, this.sort); + } + } /** @@ -495,7 +562,7 @@ public abstract class QueryByExampleDataFetcher { SingleEntityFetcher( QueryByExampleExecutor executor, TypeInformation domainType, Class resultType, Sort sort) { - super(domainType); + super(domainType, null); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -539,10 +606,10 @@ public abstract class QueryByExampleDataFetcher { private final Sort sort; ManyEntityFetcher( - QueryByExampleExecutor executor, TypeInformation domainType, - Class resultType, Sort sort) { + QueryByExampleExecutor executor, TypeInformation domainType, Class resultType, + @Nullable CursorStrategy cursorStrategy, Sort sort) { - super(domainType); + super(domainType, cursorStrategy); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -565,10 +632,14 @@ public abstract class QueryByExampleDataFetcher { queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), this.resultType)); } - return queryToUse.all(); + return getResult(queryToUse, env); }); } + protected Iterable getResult(FluentQuery.FetchableFluentQuery queryToUse, DataFetchingEnvironment env) { + return queryToUse.all(); + } + @Override public ResolvableType getReturnType() { return ResolvableType.forClassWithGenerics(Iterable.class, this.resultType); @@ -577,6 +648,46 @@ public abstract class QueryByExampleDataFetcher { } + private static class ScrollableEntityFetcher extends ManyEntityFetcher { + + private final ScrollSubrange defaultSubrange; + + private final ResolvableType scrollableResultType; + + ScrollableEntityFetcher( + QueryByExampleExecutor executor, TypeInformation domainType, Class resultType, + CursorStrategy cursorStrategy, + ScrollSubrange defaultSubrange, + Sort sort) { + + super(executor, domainType, resultType, cursorStrategy, sort); + + Assert.notNull(cursorStrategy, "CursorStrategy is required"); + Assert.notNull(defaultSubrange, "Default ScrollSubrange is required"); + Assert.isTrue(defaultSubrange.position().isPresent(), "Default ScrollPosition is required"); + Assert.isTrue(defaultSubrange.count().isPresent(), "Default scroll limit is required"); + + this.defaultSubrange = defaultSubrange; + this.scrollableResultType = ResolvableType.forClassWithGenerics(Window.class, resultType); + } + + @SuppressWarnings("OptionalGetWithoutIsPresent") + @Override + protected Iterable getResult(FluentQuery.FetchableFluentQuery queryToUse, DataFetchingEnvironment env) { + ScrollSubrange subrange = buildScrollSubrange(env); + int limit = subrange.count().orElse(this.defaultSubrange.count().getAsInt()); + ScrollPosition position = subrange.position().orElse(this.defaultSubrange.position().get()); + return queryToUse.limit(limit).scroll(position); + } + + @Override + public ResolvableType getReturnType() { + return ResolvableType.forClassWithGenerics(Iterable.class, this.scrollableResultType); + } + + } + + private static class ReactiveSingleEntityFetcher extends QueryByExampleDataFetcher implements SelfDescribingDataFetcher> { private final ReactiveQueryByExampleExecutor executor; @@ -589,7 +700,7 @@ public abstract class QueryByExampleDataFetcher { ReactiveQueryByExampleExecutor executor, TypeInformation domainType, Class resultType, Sort sort) { - super(domainType); + super(domainType, null); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -636,7 +747,7 @@ public abstract class QueryByExampleDataFetcher { ReactiveQueryByExampleExecutor executor, TypeInformation domainType, Class resultType, Sort sort) { - super(domainType); + super(domainType, null); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -670,4 +781,67 @@ public abstract class QueryByExampleDataFetcher { } + + private static class ReactiveScrollableEntityFetcher extends QueryByExampleDataFetcher implements SelfDescribingDataFetcher>> { + + private final ReactiveQueryByExampleExecutor executor; + + private final Class resultType; + + private final ResolvableType scrollableResultType; + + private final ScrollSubrange defaultSubrange; + + private final Sort sort; + + ReactiveScrollableEntityFetcher( + ReactiveQueryByExampleExecutor executor, TypeInformation domainType, Class resultType, + CursorStrategy cursorStrategy, ScrollSubrange defaultSubrange, Sort sort) { + + super(domainType, cursorStrategy); + + Assert.notNull(cursorStrategy, "CursorStrategy is required"); + Assert.notNull(defaultSubrange, "Default ScrollSubrange is required"); + Assert.isTrue(defaultSubrange.position().isPresent(), "Default ScrollPosition is required"); + Assert.isTrue(defaultSubrange.count().isPresent(), "Default scroll limit is required"); + + this.executor = executor; + this.resultType = resultType; + this.scrollableResultType = ResolvableType.forClassWithGenerics(Iterable.class, resultType); + this.defaultSubrange = defaultSubrange; + this.sort = sort; + } + + @Override + @SuppressWarnings({"unchecked", "OptionalGetWithoutIsPresent"}) + public Mono> get(DataFetchingEnvironment env) throws BindException { + return this.executor.findBy(buildExample(env), query -> { + FluentQuery.ReactiveFluentQuery queryToUse = (FluentQuery.ReactiveFluentQuery) query; + + if (this.sort.isSorted()) { + queryToUse = queryToUse.sortBy(this.sort); + } + + if (requiresProjection(this.resultType)) { + queryToUse = queryToUse.as(this.resultType); + } + else { + queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), this.resultType)); + } + + ScrollSubrange subrange = buildScrollSubrange(env); + int limit = subrange.count().orElse(this.defaultSubrange.count().getAsInt()); + ScrollPosition position = subrange.position().orElse(this.defaultSubrange.position().get()); + + return queryToUse.limit(limit).scroll(position).map(Function.identity()); + }); + } + + @Override + public ResolvableType getReturnType() { + return ResolvableType.forClassWithGenerics(Mono.class, this.scrollableResultType); + } + + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java index e7940cbb..ceaf5577 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java @@ -36,6 +36,8 @@ import reactor.core.publisher.Mono; import org.springframework.core.ResolvableType; import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.querydsl.QuerydslPredicateExecutor; import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; @@ -47,9 +49,11 @@ import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; import org.springframework.data.util.TypeInformation; import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.data.pagination.CursorStrategy; import org.springframework.graphql.data.query.AutoRegistrationRuntimeWiringConfigurer.DataFetcherFactory; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.execution.SelfDescribingDataFetcher; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -107,17 +111,25 @@ public abstract class QuerydslDataFetcher { DefaultConversionService.getSharedInstance(), SimpleEntityPathResolver.INSTANCE); @SuppressWarnings("rawtypes") - private static final QuerydslBinderCustomizer NO_OP_BINDER_CUSTOMIZER = (bindings, root) -> {}; + private static final QuerydslBinderCustomizer NO_OP_BINDER_CUSTOMIZER = (bindings, root) -> { + }; private final TypeInformation domainType; private final QuerydslBinderCustomizer> customizer; + @Nullable + private final CursorStrategy cursorStrategy; + + + QuerydslDataFetcher( + TypeInformation domainType, QuerydslBinderCustomizer> customizer, + @Nullable CursorStrategy cursorStrategy) { - QuerydslDataFetcher(TypeInformation domainType, QuerydslBinderCustomizer> customizer) { this.domainType = domainType; this.customizer = customizer; + this.cursorStrategy = cursorStrategy; } @@ -154,7 +166,7 @@ public abstract class QuerydslDataFetcher { if (environment.getFieldDefinition().getArguments().size() == 1) { String name = environment.getFieldDefinition().getArguments().get(0).getName(); Object value = arguments.get(name); - if (value instanceof Map) { + if (value instanceof Map) { return (Map) value; } } @@ -165,17 +177,21 @@ public abstract class QuerydslDataFetcher { return !resultType.equals(this.domainType.getType()); } - protected Collection buildPropertyPaths(DataFetchingFieldSelectionSet selection, Class resultType){ + protected Collection buildPropertyPaths(DataFetchingFieldSelectionSet selection, Class resultType) { // Compute selection only for non-projections if (this.domainType.getType().equals(resultType) || - this.domainType.getType().isAssignableFrom(resultType) || - this.domainType.isSubTypeOf(resultType)) { + this.domainType.getType().isAssignableFrom(resultType) || + this.domainType.isSubTypeOf(resultType)) { return PropertySelection.create(this.domainType, selection).toList(); } return Collections.emptyList(); } + protected ScrollSubrange buildScrollSubrange(DataFetchingEnvironment environment) { + return RepositoryUtils.buildScrollSubrange(environment, this.cursorStrategy); + } + /** * Create a new {@link Builder} accepting {@link QuerydslPredicateExecutor} @@ -199,6 +215,22 @@ public abstract class QuerydslDataFetcher { return new ReactiveBuilder<>(executor, RepositoryUtils.getDomainType(executor)); } + /** + * Variation of {@link #autoRegistrationConfigurer(List, List, CursorStrategy, ScrollSubrange)} + * that defaults to the following: + *
    + *
  • {@link ScrollPositionCursorStrategy} with Base64 encoding. + *
  • {@link OffsetScrollPosition Offset}-based scrolling with 20 items at a time + *
+ */ + public static RuntimeWiringConfigurer autoRegistrationConfigurer( + List> executors, + List> reactiveExecutors) { + + return autoRegistrationConfigurer(executors, reactiveExecutors, + RepositoryUtils.defaultCursorStrategy(), RepositoryUtils.defaultScrollSubrange()); + } + /** * Return a {@link RuntimeWiringConfigurer} that installs a * {@link graphql.schema.idl.WiringFactory} to find queries with a return @@ -214,18 +246,23 @@ public abstract class QuerydslDataFetcher { * @param executors repositories to consider for registration * @param reactiveExecutors reactive repositories to consider for registration * @return the created configurer + * @since 1.2 */ @SuppressWarnings({"unchecked", "rawtypes"}) public static RuntimeWiringConfigurer autoRegistrationConfigurer( List> executors, - List> reactiveExecutors) { + List> reactiveExecutors, + CursorStrategy cursorStrategy, + ScrollSubrange defaultScrollSubrange) { Map factories = new HashMap<>(); for (QuerydslPredicateExecutor executor : executors) { String typeName = RepositoryUtils.getGraphQlTypeName(executor); if (typeName != null) { - Builder builder = customize(executor, QuerydslDataFetcher.builder(executor).customizer(customizer(executor))); + Builder builder = customize( + executor, QuerydslDataFetcher.builder(executor).customizer(customizer(executor))); + factories.put(typeName, new DataFetcherFactory() { @Override public DataFetcher single() { @@ -236,6 +273,11 @@ public abstract class QuerydslDataFetcher { public DataFetcher many() { return builder.many(); } + + @Override + public DataFetcher scrollable() { + return builder.scrollable(cursorStrategy, defaultScrollSubrange); + } }); } } @@ -243,7 +285,9 @@ public abstract class QuerydslDataFetcher { for (ReactiveQuerydslPredicateExecutor executor : reactiveExecutors) { String typeName = RepositoryUtils.getGraphQlTypeName(executor); if (typeName != null) { - ReactiveBuilder builder = customize(executor, QuerydslDataFetcher.builder(executor).customizer(customizer(executor))); + ReactiveBuilder builder = customize( + executor, QuerydslDataFetcher.builder(executor).customizer(customizer(executor))); + factories.put(typeName, new DataFetcherFactory() { @Override public DataFetcher single() { @@ -254,6 +298,11 @@ public abstract class QuerydslDataFetcher { public DataFetcher many() { return builder.many(); } + + @Override + public DataFetcher scrollable() { + return builder.scrollable(cursorStrategy, defaultScrollSubrange); + } }); } } @@ -427,7 +476,20 @@ public abstract class QuerydslDataFetcher { */ public DataFetcher> many() { return new ManyEntityFetcher<>( - this.executor, this.domainType, this.resultType, this.sort, this.customizer); + this.executor, this.domainType, this.resultType, null, this.sort, this.customizer); + } + + /** + * Build a {@link DataFetcher} that scrolls and returns + * {@link org.springframework.data.domain.Window}. + * @since 1.2 + */ + public DataFetcher> scrollable( + CursorStrategy cursorStrategy, ScrollSubrange defaultScrollSubrange) { + + return new ScrollableEntityFetcher<>( + this.executor, this.domainType, this.resultType, cursorStrategy, defaultScrollSubrange, + this.sort, this.customizer); } } @@ -554,6 +616,19 @@ public abstract class QuerydslDataFetcher { this.executor, this.domainType, this.resultType, this.sort, this.customizer); } + /** + * Build a {@link DataFetcher} that scrolls and returns + * {@link org.springframework.data.domain.Window}. + * @since 1.2 + */ + public DataFetcher>> scrollable( + CursorStrategy cursorStrategy, ScrollSubrange defaultScrollSubrange) { + + return new ReactiveScrollableEntityFetcher<>( + this.executor, this.domainType, this.resultType, + cursorStrategy, defaultScrollSubrange, this.sort, this.customizer); + } + } @@ -593,7 +668,7 @@ public abstract class QuerydslDataFetcher { Sort sort, QuerydslBinderCustomizer> customizer) { - super(domainType, (QuerydslBinderCustomizer) customizer); + super(domainType, (QuerydslBinderCustomizer) customizer, null); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -640,9 +715,10 @@ public abstract class QuerydslDataFetcher { ManyEntityFetcher(QuerydslPredicateExecutor executor, TypeInformation domainType, Class resultType, + @Nullable CursorStrategy cursorStrategy, Sort sort, QuerydslBinderCustomizer> customizer) { - super(domainType, (QuerydslBinderCustomizer) customizer); + super(domainType, (QuerydslBinderCustomizer) customizer, cursorStrategy); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -665,10 +741,14 @@ public abstract class QuerydslDataFetcher { queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), this.resultType)); } - return queryToUse.all(); + return getResult(queryToUse, env); }); } + protected Iterable getResult(FetchableFluentQuery queryToUse, DataFetchingEnvironment env) { + return queryToUse.all(); + } + @Override public ResolvableType getReturnType() { return ResolvableType.forClassWithGenerics(Iterable.class, this.resultType); @@ -677,6 +757,40 @@ public abstract class QuerydslDataFetcher { } + private static class ScrollableEntityFetcher extends ManyEntityFetcher { + + private final ScrollSubrange defaultSubrange; + + ScrollableEntityFetcher(QuerydslPredicateExecutor executor, + TypeInformation domainType, + Class resultType, + CursorStrategy cursorStrategy, + ScrollSubrange defaultSubrange, + Sort sort, + QuerydslBinderCustomizer> customizer) { + + super(executor, domainType, resultType, cursorStrategy, sort, customizer); + + Assert.notNull(cursorStrategy, "CursorStrategy is required"); + Assert.notNull(defaultSubrange, "Default ScrollSubrange is required"); + Assert.isTrue(defaultSubrange.position().isPresent(), "Default ScrollPosition is required"); + Assert.isTrue(defaultSubrange.count().isPresent(), "Default scroll limit is required"); + + this.defaultSubrange = defaultSubrange; + } + + @SuppressWarnings("OptionalGetWithoutIsPresent") + @Override + protected Iterable getResult(FetchableFluentQuery queryToUse, DataFetchingEnvironment env) { + ScrollSubrange subrange = buildScrollSubrange(env); + int limit = subrange.count().orElse(this.defaultSubrange.count().getAsInt()); + ScrollPosition position = subrange.position().orElse(this.defaultSubrange.position().get()); + return queryToUse.limit(limit).scroll(position); + } + + } + + private static class ReactiveSingleEntityFetcher extends QuerydslDataFetcher implements SelfDescribingDataFetcher> { private final ReactiveQuerydslPredicateExecutor executor; @@ -692,7 +806,7 @@ public abstract class QuerydslDataFetcher { Sort sort, QuerydslBinderCustomizer> customizer) { - super(domainType, (QuerydslBinderCustomizer) customizer); + super(domainType, (QuerydslBinderCustomizer) customizer, null); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -742,7 +856,7 @@ public abstract class QuerydslDataFetcher { Sort sort, QuerydslBinderCustomizer> customizer) { - super(domainType, (QuerydslBinderCustomizer) customizer); + super(domainType, (QuerydslBinderCustomizer) customizer, null); this.executor = executor; this.resultType = resultType; this.sort = sort; @@ -776,4 +890,71 @@ public abstract class QuerydslDataFetcher { } + + private static class ReactiveScrollableEntityFetcher extends QuerydslDataFetcher implements SelfDescribingDataFetcher>> { + + private final ReactiveQuerydslPredicateExecutor executor; + + private final Class resultType; + + private final ResolvableType scrollableResultType; + + private final ScrollSubrange defaultSubrange; + + private final Sort sort; + + @SuppressWarnings({"unchecked", "rawtypes"}) + ReactiveScrollableEntityFetcher(ReactiveQuerydslPredicateExecutor executor, + TypeInformation domainType, + Class resultType, + CursorStrategy cursorStrategy, ScrollSubrange defaultSubrange, + Sort sort, + QuerydslBinderCustomizer> customizer) { + + super(domainType, (QuerydslBinderCustomizer) customizer, cursorStrategy); + + Assert.notNull(cursorStrategy, "CursorStrategy is required"); + Assert.notNull(defaultSubrange, "Default ScrollSubrange is required"); + Assert.isTrue(defaultSubrange.position().isPresent(), "Default ScrollPosition is required"); + Assert.isTrue(defaultSubrange.count().isPresent(), "Default scroll limit is required"); + + this.executor = executor; + this.resultType = resultType; + this.scrollableResultType = ResolvableType.forClassWithGenerics(Iterable.class, resultType); + this.defaultSubrange = defaultSubrange; + this.sort = sort; + } + + @Override + @SuppressWarnings({"unchecked", "OptionalGetWithoutIsPresent"}) + public Mono> get(DataFetchingEnvironment env) { + return this.executor.findBy(buildPredicate(env), query -> { + FluentQuery.ReactiveFluentQuery queryToUse = (FluentQuery.ReactiveFluentQuery) query; + + if (this.sort.isSorted()){ + queryToUse = queryToUse.sortBy(this.sort); + } + + if (requiresProjection(this.resultType)){ + queryToUse = queryToUse.as(this.resultType); + } + else { + queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), this.resultType)); + } + + ScrollSubrange subrange = buildScrollSubrange(env); + int limit = subrange.count().orElse(this.defaultSubrange.count().getAsInt()); + ScrollPosition position = subrange.position().orElse(this.defaultSubrange.position().get()); + + return queryToUse.limit(limit).scroll(position).map(Function.identity()); + }); + } + + @Override + public ResolvableType getReturnType() { + return ResolvableType.forClassWithGenerics(Mono.class, this.scrollableResultType); + } + + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/RepositoryUtils.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/RepositoryUtils.java index aafcfb27..747aabd2 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/RepositoryUtils.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/RepositoryUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,14 +17,20 @@ package org.springframework.graphql.data.query; import java.lang.reflect.Type; +import graphql.schema.DataFetchingEnvironment; + import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; import org.springframework.data.repository.NoRepositoryBean; import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.data.pagination.CursorEncoder; +import org.springframework.graphql.data.pagination.CursorStrategy; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -73,4 +79,24 @@ class RepositoryUtils { annotation.typeName() : RepositoryUtils.getDomainType(repository).getSimpleName()); } + + public static CursorStrategy defaultCursorStrategy() { + return CursorStrategy.withEncoder(new ScrollPositionCursorStrategy(), CursorEncoder.base64()); + } + + public static ScrollSubrange defaultScrollSubrange() { + return new ScrollSubrange(OffsetScrollPosition.initial(), 20, true); + } + + public static ScrollSubrange buildScrollSubrange( + DataFetchingEnvironment environment, CursorStrategy cursorStrategy) { + + Assert.notNull(cursorStrategy, "CursorStrategy is required to build a ScrollSubrange"); + boolean forward = !environment.getArguments().containsKey("last"); + Integer count = environment.getArgument(forward ? "first" : "last"); + String cursor = environment.getArgument(forward ? "after" : "before"); + ScrollPosition position = (cursor != null ? cursorStrategy.fromCursor(cursor) : null); + return new ScrollSubrange(position, count, forward); + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java index 793b7df0..aba71559 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java @@ -33,6 +33,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.keyvalue.core.KeyValueTemplate; import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory; import org.springframework.data.map.MapKeyValueAdapter; @@ -48,8 +49,10 @@ import org.springframework.graphql.ExecutionGraphQlResponse; import org.springframework.graphql.GraphQlSetup; import org.springframework.graphql.ResponseHelper; import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.data.pagination.ConnectionFieldTypeVisitor; import org.springframework.graphql.data.query.QuerydslDataFetcher.Builder; import org.springframework.graphql.data.query.QuerydslDataFetcher.QuerydslBuilderCustomizer; +import org.springframework.graphql.execution.ConnectionTypeDefinitionConfigurer; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; @@ -117,6 +120,48 @@ class QuerydslDataFetcherTests { tester.accept(graphQlSetup(mockRepository)); } + @Test + void shouldFetchWindow() { + Book book1 = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); + Book book2 = new Book(53L, "Breaking Bad", new Author(0L, "", "Heisenberg")); + mockRepository.saveAll(Arrays.asList(book1, book2)); + + Consumer tester = graphQlSetup -> { + + Mono response = graphQlSetup + .toWebGraphQlHandler() + .handleRequest(request(BookSource.booksConnectionQuery(""))); + + ResponseHelper.forResponse(response).assertData( + "{\"books\":{" + + "\"edges\":[" + + "{\"cursor\":\"O_4\",\"node\":{\"id\":\"42\",\"name\":\"Hitchhiker's Guide to the Galaxy\"}}," + + "{\"cursor\":\"O_5\",\"node\":{\"id\":\"53\",\"name\":\"Breaking Bad\"}}" + + "]," + + "\"pageInfo\":{" + + "\"startCursor\":\"O_4\"," + + "\"endCursor\":\"O_5\"," + + "\"hasPreviousPage\":true," + + "\"hasNextPage\":false" + + "}}}" + ); + }; + + // explicit wiring + + ScrollPositionCursorStrategy cursorStrategy = new ScrollPositionCursorStrategy(); + + DataFetcher> dataFetcher = QuerydslDataFetcher.builder(mockRepository) + .scrollable(cursorStrategy, new ScrollSubrange(OffsetScrollPosition.initial(), 10, true)); + + GraphQlSetup graphQlSetup = paginationSetup(cursorStrategy).queryFetcher("books", dataFetcher); + tester.accept(graphQlSetup); + + // auto registration + graphQlSetup = paginationSetup(cursorStrategy).runtimeWiring(createRuntimeWiringConfigurer(mockRepository, null)); + tester.accept(graphQlSetup); + } + @Test void shouldFetchMultipleItemsWithListInput() { Book book1 = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); @@ -230,7 +275,7 @@ class QuerydslDataFetcherTests { }; // explicit wiring - tester.accept(initGraphQlSetup(mockWithCustomizerRepository, null)); + tester.accept(graphQlSetup(mockWithCustomizerRepository)); } @Test @@ -297,27 +342,33 @@ class QuerydslDataFetcherTests { assertThat(books.get(0).getName()).isEqualTo(book1.getName()); } - static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { - return initGraphQlSetup(null, null).queryFetcher(fieldName, fetcher); + private static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { + return GraphQlSetup.schemaResource(BookSource.schema).queryFetcher(fieldName, fetcher); } - static GraphQlSetup graphQlSetup(@Nullable QuerydslPredicateExecutor executor) { - return initGraphQlSetup(executor, null); + private static GraphQlSetup graphQlSetup(@Nullable QuerydslPredicateExecutor executor) { + return GraphQlSetup.schemaResource(BookSource.schema) + .runtimeWiring(createRuntimeWiringConfigurer(executor, null)); } - static GraphQlSetup graphQlSetup(@Nullable ReactiveQuerydslPredicateExecutor executor) { - return initGraphQlSetup(null, executor); + private static GraphQlSetup graphQlSetup(@Nullable ReactiveQuerydslPredicateExecutor executor) { + return GraphQlSetup.schemaResource(BookSource.schema) + .runtimeWiring(createRuntimeWiringConfigurer(null, executor)); } - private static GraphQlSetup initGraphQlSetup( + private static GraphQlSetup paginationSetup(ScrollPositionCursorStrategy cursorStrategy) { + return GraphQlSetup.schemaResource(BookSource.paginationSchema) + .typeDefinitionConfigurer(new ConnectionTypeDefinitionConfigurer()) + .typeVisitor(ConnectionFieldTypeVisitor.create(List.of(new WindowConnectionAdapter(cursorStrategy)))); + } + + private static RuntimeWiringConfigurer createRuntimeWiringConfigurer( @Nullable QuerydslPredicateExecutor executor, @Nullable ReactiveQuerydslPredicateExecutor reactiveExecutor) { - RuntimeWiringConfigurer configurer = QuerydslDataFetcher.autoRegistrationConfigurer( + return QuerydslDataFetcher.autoRegistrationConfigurer( (executor != null ? Collections.singletonList(executor) : Collections.emptyList()), (reactiveExecutor != null ? Collections.singletonList(reactiveExecutor) : Collections.emptyList())); - - return GraphQlSetup.schemaResource(BookSource.schema).runtimeWiring(configurer); } private WebGraphQlRequest request(String query) { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java index ee8c87a7..3277587f 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java @@ -35,20 +35,25 @@ import reactor.core.publisher.Mono; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.repository.query.QueryByExampleExecutor; import org.springframework.graphql.BookSource; import org.springframework.graphql.ExecutionGraphQlResponse; import org.springframework.graphql.GraphQlSetup; import org.springframework.graphql.ResponseHelper; +import org.springframework.graphql.data.pagination.ConnectionFieldTypeVisitor; import org.springframework.graphql.data.query.QueryByExampleDataFetcher; +import org.springframework.graphql.data.query.ScrollPositionCursorStrategy; +import org.springframework.graphql.data.query.ScrollSubrange; +import org.springframework.graphql.data.query.WindowConnectionAdapter; +import org.springframework.graphql.execution.ConnectionTypeDefinitionConfigurer; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; import org.springframework.graphql.server.WebGraphQlResponse; import org.springframework.http.HttpHeaders; import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.lang.Nullable; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; @@ -118,6 +123,52 @@ class QueryByExampleDataFetcherJpaTests { tester.accept(graphQlSetup(repository)); } + @Test + void shouldFetchWindow() { + + repository.saveAll(List.of( + new Book(1L, "Nineteen Eighty-Four", new Author(0L, "George", "Orwell")), + new Book(2L, "The Great Gatsby", new Author(0L, "F. Scott", "Fitzgerald")), + new Book(3L, "Catch-22", new Author(0L, "Joseph", "Heller")), + new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")), + new Book(53L, "Breaking Bad", new Author(0L, "", "Heisenberg")))); + + Consumer tester = graphQlSetup -> { + + Mono response = graphQlSetup + .toWebGraphQlHandler() + .handleRequest(request(BookSource.booksConnectionQuery("first:2, after:\"O_3\""))); + + ResponseHelper.forResponse(response).assertData( + "{\"books\":{" + + "\"edges\":[" + + "{\"cursor\":\"O_4\",\"node\":{\"id\":\"42\",\"name\":\"Hitchhiker's Guide to the Galaxy\"}}," + + "{\"cursor\":\"O_5\",\"node\":{\"id\":\"53\",\"name\":\"Breaking Bad\"}}" + + "]," + + "\"pageInfo\":{" + + "\"startCursor\":\"O_4\"," + + "\"endCursor\":\"O_5\"," + + "\"hasPreviousPage\":true," + + "\"hasNextPage\":false" + + "}}}" + ); + }; + + // explicit wiring + + ScrollPositionCursorStrategy cursorStrategy = new ScrollPositionCursorStrategy(); + + DataFetcher> dataFetcher = QueryByExampleDataFetcher.builder(repository) + .scrollable(cursorStrategy, new ScrollSubrange(OffsetScrollPosition.initial(), 10, true)); + + GraphQlSetup graphQlSetup = paginationSetup(cursorStrategy).queryFetcher("books", dataFetcher); + tester.accept(graphQlSetup); + + // auto registration + graphQlSetup = paginationSetup(cursorStrategy).runtimeWiring(createRuntimeWiringConfigurer(repository)); + tester.accept(graphQlSetup); + } + @Test void shouldFavorExplicitWiring() { BookJpaRepository mockRepository = mock(BookJpaRepository.class); @@ -190,20 +241,26 @@ class QueryByExampleDataFetcherJpaTests { } private static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { - return initGraphQlSetup(null).queryFetcher(fieldName, fetcher); + return GraphQlSetup.schemaResource(BookSource.schema).queryFetcher(fieldName, fetcher); } - private static GraphQlSetup graphQlSetup(@Nullable QueryByExampleExecutor executor) { - return initGraphQlSetup(executor); + private static GraphQlSetup graphQlSetup(QueryByExampleExecutor executor) { + return GraphQlSetup.schemaResource(BookSource.schema) + .runtimeWiring(createRuntimeWiringConfigurer(executor)); } - private static GraphQlSetup initGraphQlSetup(@Nullable QueryByExampleExecutor executor) { + private static GraphQlSetup paginationSetup(ScrollPositionCursorStrategy cursorStrategy) { + return GraphQlSetup.schemaResource(BookSource.paginationSchema) + .typeDefinitionConfigurer(new ConnectionTypeDefinitionConfigurer()) + .typeVisitor(ConnectionFieldTypeVisitor.create(List.of(new WindowConnectionAdapter(cursorStrategy)))); + } - RuntimeWiringConfigurer configurer = QueryByExampleDataFetcher.autoRegistrationConfigurer( + private static RuntimeWiringConfigurer createRuntimeWiringConfigurer(QueryByExampleExecutor executor) { + return QueryByExampleDataFetcher.autoRegistrationConfigurer( executor != null ? Collections.singletonList(executor) : Collections.emptyList(), - Collections.emptyList()); - - return GraphQlSetup.schemaResource(BookSource.schema).runtimeWiring(configurer); + Collections.emptyList(), + new ScrollPositionCursorStrategy(), + new ScrollSubrange(OffsetScrollPosition.initial(), 10, true)); } private WebGraphQlRequest request(String query) { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java index 2994ec75..529595b7 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java @@ -37,13 +37,19 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; import org.springframework.data.repository.query.QueryByExampleExecutor; import org.springframework.graphql.BookSource; import org.springframework.graphql.GraphQlSetup; import org.springframework.graphql.ResponseHelper; +import org.springframework.graphql.data.pagination.ConnectionFieldTypeVisitor; import org.springframework.graphql.data.query.QueryByExampleDataFetcher; +import org.springframework.graphql.data.query.ScrollPositionCursorStrategy; +import org.springframework.graphql.data.query.ScrollSubrange; +import org.springframework.graphql.data.query.WindowConnectionAdapter; +import org.springframework.graphql.execution.ConnectionTypeDefinitionConfigurer; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; @@ -114,6 +120,52 @@ class QueryByExampleDataFetcherMongoDbTests { tester.accept(graphQlSetup(repository)); } + @Test + void shouldFetchWindow() { + + repository.saveAll(List.of( + new Book("1", "Nineteen Eighty-Four", new Author("0", "George", "Orwell")), + new Book("2", "The Great Gatsby", new Author("0", "F. Scott", "Fitzgerald")), + new Book("3", "Catch-22", new Author("0", "Joseph", "Heller")), + new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")), + new Book("53", "Breaking Bad", new Author("0", "", "Heisenberg")))); + + Consumer tester = graphQlSetup -> { + + Mono response = graphQlSetup + .toWebGraphQlHandler() + .handleRequest(request(BookSource.booksConnectionQuery("first:2, after:\"O_3\""))); + + ResponseHelper.forResponse(response).assertData( + "{\"books\":{" + + "\"edges\":[" + + "{\"cursor\":\"O_4\",\"node\":{\"id\":\"42\",\"name\":\"Hitchhiker's Guide to the Galaxy\"}}," + + "{\"cursor\":\"O_5\",\"node\":{\"id\":\"53\",\"name\":\"Breaking Bad\"}}" + + "]," + + "\"pageInfo\":{" + + "\"startCursor\":\"O_4\"," + + "\"endCursor\":\"O_5\"," + + "\"hasPreviousPage\":true," + + "\"hasNextPage\":false" + + "}}}" + ); + }; + + // explicit wiring + + ScrollPositionCursorStrategy cursorStrategy = new ScrollPositionCursorStrategy(); + + DataFetcher> dataFetcher = QueryByExampleDataFetcher.builder(repository) + .scrollable(cursorStrategy, new ScrollSubrange(OffsetScrollPosition.initial(), 10, true)); + + GraphQlSetup graphQlSetup = paginationSetup(cursorStrategy).queryFetcher("books", dataFetcher); + tester.accept(graphQlSetup); + + // auto registration + graphQlSetup = paginationSetup(cursorStrategy).runtimeWiring(createRuntimeWiringConfigurer(repository)); + tester.accept(graphQlSetup); + } + @Test void shouldFavorExplicitWiring() { BookMongoRepository mockRepository = mock(BookMongoRepository.class); @@ -167,20 +219,26 @@ class QueryByExampleDataFetcherMongoDbTests { } private static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { - return initGraphQlSetup(null).queryFetcher(fieldName, fetcher); + return GraphQlSetup.schemaResource(BookSource.schema).queryFetcher(fieldName, fetcher); } private static GraphQlSetup graphQlSetup(@Nullable QueryByExampleExecutor executor) { - return initGraphQlSetup(executor); + return GraphQlSetup.schemaResource(BookSource.schema) + .runtimeWiring(createRuntimeWiringConfigurer(executor)); } - private static GraphQlSetup initGraphQlSetup(@Nullable QueryByExampleExecutor executor) { + private static GraphQlSetup paginationSetup(ScrollPositionCursorStrategy cursorStrategy) { + return GraphQlSetup.schemaResource(BookSource.paginationSchema) + .typeDefinitionConfigurer(new ConnectionTypeDefinitionConfigurer()) + .typeVisitor(ConnectionFieldTypeVisitor.create(List.of(new WindowConnectionAdapter(cursorStrategy)))); + } - RuntimeWiringConfigurer configurer = QueryByExampleDataFetcher.autoRegistrationConfigurer( + private static RuntimeWiringConfigurer createRuntimeWiringConfigurer(QueryByExampleExecutor executor) { + return QueryByExampleDataFetcher.autoRegistrationConfigurer( (executor != null ? Collections.singletonList(executor) : Collections.emptyList()), - Collections.emptyList()); - - return GraphQlSetup.schemaResource(BookSource.schema).runtimeWiring(configurer); + Collections.emptyList(), + new ScrollPositionCursorStrategy(), + new ScrollSubrange(OffsetScrollPosition.initial(), 10, true)); } private WebGraphQlRequest request(String query) {