diff --git a/platform/build.gradle b/platform/build.gradle index 0d85c2b8..a1e36aa0 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -12,7 +12,7 @@ dependencies { api(platform("io.projectreactor:reactor-bom:2022.0.3")) api(platform("io.micrometer:micrometer-bom:1.10.4")) api(platform("io.micrometer:micrometer-tracing-bom:1.0.2")) - api(platform("org.springframework.data:spring-data-bom:2022.0.2")) + api(platform("org.springframework.data:spring-data-bom:2023.0.0-SNAPSHOT")) api(platform("org.springframework.security:spring-security-bom:6.0.1")) 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/method/annotation/support/AnnotatedControllerConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java index 3f5a4206..7701c4b7 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java @@ -67,6 +67,8 @@ import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite; import org.springframework.graphql.data.method.annotation.BatchMapping; import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.pagination.CursorStrategy; +import org.springframework.graphql.data.pagination.SortStrategy; import org.springframework.graphql.execution.BatchLoaderRegistry; import org.springframework.graphql.execution.DataFetcherExceptionResolver; import org.springframework.graphql.execution.RuntimeWiringConfigurer; @@ -124,6 +126,12 @@ public class AnnotatedControllerConfigurer private final FormattingConversionService conversionService = new DefaultFormattingConversionService(); + @Nullable + private CursorStrategy cursorStrategy; + + @Nullable + private SortStrategy sortStrategy; + private final List customArgumentResolvers = new ArrayList<>(8); @Nullable @@ -152,6 +160,32 @@ public class AnnotatedControllerConfigurer registrar.registerFormatters(this.conversionService); } + /** + * Configure a {@link CursorStrategy} to handle pagination requests, which + * results in one of the following: + * + * @since 1.2 + */ + public void setCursorStrategy(@Nullable CursorStrategy cursorStrategy) { + this.cursorStrategy = cursorStrategy; + } + + /** + * Configure a {@link SortStrategy} to extract sort details for pagination + * requests. This results in {@link SortMethodArgumentResolver} being added + * as a method argument resolver. + * @since 1.2 + */ + public void setSortStrategy(SortStrategy sortStrategy) { + this.sortStrategy = sortStrategy; + } + /** * Add a {@link HandlerMethodArgumentResolver} for custom controller method * arguments. Such custom resolvers are ordered after built-in resolvers @@ -251,6 +285,12 @@ public class AnnotatedControllerConfigurer // Type based resolvers.addResolver(new DataFetchingEnvironmentMethodArgumentResolver()); resolvers.addResolver(new DataLoaderMethodArgumentResolver()); + if (this.cursorStrategy != null) { + resolvers.addResolver(initPaginationResolver(this.cursorStrategy)); + } + if (this.sortStrategy != null) { + resolvers.addResolver(new SortMethodArgumentResolver(this.sortStrategy)); + } if (springSecurityPresent) { resolvers.addResolver(new PrincipalMethodArgumentResolver()); BeanResolver beanResolver = new BeanFactoryResolver(obtainApplicationContext()); @@ -268,6 +308,17 @@ public class AnnotatedControllerConfigurer return resolvers; } + @SuppressWarnings("unchecked") + private HandlerMethodArgumentResolver initPaginationResolver(CursorStrategy cursorStrategy) { + if (springDataPresent) { + if (cursorStrategy.supports(org.springframework.data.domain.ScrollPosition.class)) { + return new ScrollRequestMethodArgumentResolver( + (CursorStrategy) cursorStrategy); + } + } + return new PaginationRequestMethodArgumentResolver<>(cursorStrategy); + } + protected final ApplicationContext obtainApplicationContext() { Assert.state(this.applicationContext != null, "No ApplicationContext"); return this.applicationContext; diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/PaginationRequestMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/PaginationRequestMethodArgumentResolver.java new file mode 100644 index 00000000..17294956 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/PaginationRequestMethodArgumentResolver.java @@ -0,0 +1,69 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.method.annotation.support; + + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.MethodParameter; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; +import org.springframework.graphql.data.pagination.CursorStrategy; +import org.springframework.graphql.data.pagination.PaginationRequest; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Resolver for a method argument of type {@link PaginationRequest} initialized + * from "first", "last", "before", and "after" GraphQL arguments. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public class PaginationRequestMethodArgumentResolver

implements HandlerMethodArgumentResolver { + + private final CursorStrategy

cursorStrategy; + + + public PaginationRequestMethodArgumentResolver(CursorStrategy

cursorStrategy) { + Assert.notNull(cursorStrategy, "CursorStrategy is required"); + this.cursorStrategy = cursorStrategy; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return (parameter.getParameterType().equals(PaginationRequest.class) && + this.cursorStrategy.supports(parameter.nested().getNestedParameterType())); + } + + @Override + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) throws Exception { + boolean forward = !environment.getArguments().containsKey("last"); + String cursor = environment.getArgument(forward ? "before" : "after"); + Integer count = environment.getArgument(forward ? "first" : "last"); + P position = (cursor != null ? this.cursorStrategy.fromCursor(cursor) : null); + return createRequest(position, count, forward); + } + + /** + * Create the {@code PaginationRequest} instance. + */ + protected PaginationRequest

createRequest(@Nullable P position, @Nullable Integer size, boolean forward) { + return new PaginationRequest<>(position, size, forward); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ScrollRequestMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ScrollRequestMethodArgumentResolver.java new file mode 100644 index 00000000..b49f6bb2 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ScrollRequestMethodArgumentResolver.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.method.annotation.support; + + +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.graphql.data.pagination.CursorStrategy; +import org.springframework.graphql.data.query.ScrollRequest; +import org.springframework.lang.Nullable; + + +/** + * Subclass of {@link PaginationRequestMethodArgumentResolver} that supports + * {@link ScrollRequest} with cursors converted to {@link ScrollPosition} for + * forward or backward pagination. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public class ScrollRequestMethodArgumentResolver extends PaginationRequestMethodArgumentResolver { + + + public ScrollRequestMethodArgumentResolver(CursorStrategy cursorStrategy) { + super(cursorStrategy); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(ScrollRequest.class); + } + + protected ScrollRequest createRequest(@Nullable ScrollPosition position, @Nullable Integer size, boolean forward) { + return new ScrollRequest(position, size, forward); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SortMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SortMethodArgumentResolver.java new file mode 100644 index 00000000..e84e2305 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SortMethodArgumentResolver.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.method.annotation.support; + + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.MethodParameter; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; +import org.springframework.graphql.data.pagination.SortStrategy; +import org.springframework.util.Assert; + + +/** + * Resolver for a Sort object decoded with {@link SortStrategy}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public class SortMethodArgumentResolver implements HandlerMethodArgumentResolver { + + private final SortStrategy sortStrategy; + + + public SortMethodArgumentResolver(SortStrategy sortStrategy) { + Assert.notNull(sortStrategy, "SortStrategy is required"); + this.sortStrategy = sortStrategy; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return this.sortStrategy.supports(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) { + return this.sortStrategy.extract(environment); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/Base64CursorEncoder.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/Base64CursorEncoder.java new file mode 100644 index 00000000..af5d2da4 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/Base64CursorEncoder.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + + +/** + * {@link CursorEncoder} that applies Base 64 encoding and decoding. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +final class Base64CursorEncoder implements CursorEncoder { + + private final Charset charset = StandardCharsets.UTF_8; + + + @Override + public String encode(String cursor) { + byte[] bytes = Base64.getEncoder().encode(cursor.getBytes(this.charset)); + return new String(bytes, this.charset); + } + + @Override + public String decode(String cursor) { + byte[] bytes = Base64.getDecoder().decode(cursor.getBytes(this.charset)); + return new String(bytes, this.charset); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CompositeConnectionAdapter.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CompositeConnectionAdapter.java new file mode 100644 index 00000000..5f27674b --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CompositeConnectionAdapter.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +import java.util.Collection; +import java.util.List; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * {@link ConnectionAdapter} that contains a list of others adapter, looks for + * the first one that supports a given Object container type, and delegates to it. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +final class CompositeConnectionAdapter implements ConnectionAdapter { + + private final List adapters; + + + CompositeConnectionAdapter(List adapters) { + Assert.notEmpty(adapters, "ConnectionAdapter's are required"); + this.adapters = adapters; + } + + + @Override + public boolean supports(Class containerType) { + return (getAdapter(containerType) != null); + } + + public Collection getContent(Object container) { + return getRequiredAdapter(container).getContent(container); + } + + public boolean hasPrevious(Object container) { + return getRequiredAdapter(container).hasPrevious(container); + } + + public boolean hasNext(Object container) { + return getRequiredAdapter(container).hasNext(container); + } + + public String cursorAt(Object container, int index) { + return getRequiredAdapter(container).cursorAt(container, index); + } + + private ConnectionAdapter getRequiredAdapter(Object container) { + ConnectionAdapter adapter = getAdapter(container); + Assert.notNull(adapter, "No ConnectionAdapter for: " + container.getClass().getName()); + return adapter; + } + + @Nullable + private ConnectionAdapter getAdapter(Object container) { + for (ConnectionAdapter adapter : this.adapters) { + if (adapter.supports(container.getClass())) { + return adapter; + } + } + return null; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionAdapter.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionAdapter.java new file mode 100644 index 00000000..d538cd4b --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionAdapter.java @@ -0,0 +1,67 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +import java.util.Collection; +import java.util.List; + +/** + * Contract to adapt a container object for a window of elements from a larger + * result set to {@link graphql.relay.Connection}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public interface ConnectionAdapter { + + /** + * Whether the adapter supports the given Object container type. + */ + boolean supports(Class containerType); + + /** + * Return the contained items as a List. + */ + Collection getContent(Object container); + + /** + * Whether there are more pages before this one. + */ + boolean hasPrevious(Object container); + + /** + * Whether there are more pages after this one. + */ + boolean hasNext(Object container); + + /** + * Return a cursor for the item at the given index. + */ + String cursorAt(Object container, int index); + + + /** + * Create a composite {@link ConnectionAdapter} that checks which adapter + * supports a given Object container type and delegates to it. + * @param adapters the adapters to delegate to + * @return the composite adapter instance + */ + static ConnectionAdapter from(List adapters) { + return new CompositeConnectionAdapter(adapters); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionAdapterSupport.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionAdapterSupport.java new file mode 100644 index 00000000..0ff4d4d5 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionAdapterSupport.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +import org.springframework.util.Assert; + +/** + * Convenient base class for implementations of + * {@link org.springframework.graphql.data.pagination.ConnectionAdapter}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public class ConnectionAdapterSupport

{ + + private final CursorStrategy

cursorStrategy; + + + /** + * Constructor with a {@link CursorStrategy} to use. + */ + protected ConnectionAdapterSupport(CursorStrategy

cursorStrategy) { + Assert.notNull(cursorStrategy, "CursorStrategy is required"); + this.cursorStrategy = cursorStrategy; + } + + + /** + * Return the configured {@link CursorStrategy}. + */ + public CursorStrategy

getCursorStrategy() { + return this.cursorStrategy; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionTypeVisitor.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionTypeVisitor.java new file mode 100644 index 00000000..1051bb09 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionTypeVisitor.java @@ -0,0 +1,167 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletionStage; + +import graphql.relay.Connection; +import graphql.relay.DefaultConnection; +import graphql.relay.DefaultConnectionCursor; +import graphql.relay.DefaultEdge; +import graphql.relay.DefaultPageInfo; +import graphql.relay.Edge; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.GraphQLCodeRegistry; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLFieldsContainer; +import graphql.schema.GraphQLNonNull; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLSchemaElement; +import graphql.schema.GraphQLType; +import graphql.schema.GraphQLTypeVisitorStub; +import graphql.util.TraversalControl; +import graphql.util.TraverserContext; +import reactor.core.publisher.Mono; + +import org.springframework.util.Assert; + +/** + * {@link graphql.schema.GraphQLTypeVisitor} that looks for {@code Connection} + * fields in the schema, and decorates their registered {@link DataFetcher} in + * order to adapt return values to {@link Connection}. + * + *

Use {@link #create(List)} to create an instance, and then register it via + * {@link org.springframework.graphql.execution.GraphQlSource.Builder#typeVisitors(List)}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public final class ConnectionTypeVisitor extends GraphQLTypeVisitorStub { + + private final ConnectionAdapter adapter; + + + private ConnectionTypeVisitor(ConnectionAdapter adapter) { + Assert.notNull(adapter, "ConnectionAdapter is required"); + this.adapter = adapter; + } + + + @Override + public TraversalControl visitGraphQLFieldDefinition( + GraphQLFieldDefinition fieldDefinition, TraverserContext context) { + + GraphQLCodeRegistry.Builder codeRegistry = context.getVarFromParents(GraphQLCodeRegistry.Builder.class); + GraphQLFieldsContainer parent = (GraphQLFieldsContainer) context.getParentNode(); + DataFetcher dataFetcher = codeRegistry.getDataFetcher(parent, fieldDefinition); + + if (parent.getName().equalsIgnoreCase("mutation") || parent.getName().equalsIgnoreCase("subscription")) { + return TraversalControl.ABORT; + } + + if (isConnectionField(fieldDefinition)) { + codeRegistry.dataFetcher(parent, fieldDefinition, new ConnectionDataFetcher(dataFetcher, adapter)); + } + + return TraversalControl.CONTINUE; + } + + private static boolean isConnectionField(GraphQLFieldDefinition fieldDefinition) { + GraphQLType returnType = fieldDefinition.getType(); + if (returnType instanceof GraphQLNonNull nonNullType) { + returnType = nonNullType.getWrappedType(); + } + return (returnType instanceof GraphQLObjectType objectType && + objectType.getName().endsWith("Connection") && + objectType.getField("pageInfo") != null); + } + + + /** + * Create a {@code ConnectionTypeVisitor} instance that delegates to the + * given adapters to adapt return values to {@link Connection}. + * @param adapters the adapters to use + * @return the type visitor + */ + public static ConnectionTypeVisitor create(List adapters) { + Assert.notEmpty(adapters, "Expected at least one ConnectionAdapter"); + return new ConnectionTypeVisitor(ConnectionAdapter.from(adapters)); + } + + + /** + * {@code DataFetcher} decorator that adapts return values with an adapter. + */ + private record ConnectionDataFetcher(DataFetcher delegate, ConnectionAdapter adapter) implements DataFetcher { + + private final static Connection EMPTY_CONNECTION = + new DefaultConnection<>(Collections.emptyList(), new DefaultPageInfo(null, null, false, false)); + + + private ConnectionDataFetcher { + Assert.notNull(delegate, "DataFetcher delegate is required"); + Assert.notNull(adapter, "ConnectionAdapter is required"); + } + + + @Override + public Object get(DataFetchingEnvironment environment) throws Exception { + Object result = this.delegate.get(environment); + if (result instanceof Mono mono) { + return mono.map(this::adapt); + } + else if (result instanceof CompletionStage stage) { + return stage.thenApply(this::adapt); + } + else { + return adapt(result); + } + } + + @SuppressWarnings("unchecked") + private Connection adapt(Object container) { + if (container instanceof Connection connection) { + return (Connection) connection; + } + + Collection nodes = this.adapter.getContent(container); + if (nodes.isEmpty()) { + return (Connection) EMPTY_CONNECTION; + } + + int index = 0; + List> edges = new ArrayList<>(nodes.size()); + for (T node : nodes) { + String cursor = this.adapter.cursorAt(container, index++); + edges.add(new DefaultEdge<>(node, new DefaultConnectionCursor(cursor))); + } + + DefaultPageInfo pageInfo = new DefaultPageInfo( + edges.get(0).getCursor(), edges.get(edges.size() - 1).getCursor(), + this.adapter.hasPrevious(container), this.adapter.hasNext(container)); + + return new DefaultConnection<>(edges, pageInfo); + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CursorEncoder.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CursorEncoder.java new file mode 100644 index 00000000..955de314 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CursorEncoder.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +/** + * Strategy to encode and decode a String cursor to make it opaque for clients. + * Typically applied to a {@link CursorStrategy} via + * {@link CursorStrategy#withEncoder(CursorStrategy, CursorEncoder)}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public interface CursorEncoder { + + /** + * Encode the given cursor value for external use. + * @param cursor the cursor to encode + * @return the encoded value + */ + String encode(String cursor); + + /** + * Decode the given cursor from external input. + * @param cursor the raw cursor to decode + * @return the decoded value + */ + String decode(String cursor); + + + /** + * Return a {@code CursorEncoder} for Base64 encoding and decoding. + */ + static CursorEncoder base64() { + return new Base64CursorEncoder(); + } + + /** + * Return a {@code CursorEncoder} that does not encode nor decode. + */ + static CursorEncoder noOpEncoder() { + return new NoOpCursorEncoder(); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CursorStrategy.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CursorStrategy.java new file mode 100644 index 00000000..bf529962 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/CursorStrategy.java @@ -0,0 +1,59 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +/** + * Strategy to convert an Object that represents the position of an item within + * a paginated result set to and from a String cursor. + * + *

A {@link CursorEncoder} may be combined with a {@link CursorEncoder} via + * {@link #withEncoder(CursorStrategy, CursorEncoder)} to further encode and + * decode cursor Strings to make them opaque for clients. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public interface CursorStrategy

{ + + /** + * Whether the strategy supports the given type of position Object. + */ + boolean supports(Class targetType); + + /** + * Format the given position Object as a String cursor. + * @param position the position to serialize + * @return the created String cursor + */ + String toCursor(P position); + + /** + * Parse the given String cursor into a position Object. + * @param cursor the cursor to parse + * @return the position Object + */ + P fromCursor(String cursor); + + + /** + * Decorate the given {@code CursorStrategy} with encoding and decoding + * that makes the String cursor opaque to clients. + */ + static EncodingCursorStrategy withEncoder(CursorStrategy strategy, CursorEncoder encoder) { + return new EncodingCursorStrategy<>(strategy, encoder); + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/EncodingCursorStrategy.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/EncodingCursorStrategy.java new file mode 100644 index 00000000..8114fd08 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/EncodingCursorStrategy.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +import org.springframework.util.Assert; + +/** + * Decorator for a {@link CursorStrategy} that applies a {@link CursorEncoder} + * to the cursor String to make it opaque for external use. + * + *

Use {@link CursorStrategy#withEncoder(CursorStrategy, CursorEncoder)} to + * decorate a {@code CursorStrategy}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public final class EncodingCursorStrategy implements CursorStrategy { + + private final CursorStrategy delegate; + + private final CursorEncoder encoder; + + + EncodingCursorStrategy(CursorStrategy strategy, CursorEncoder encoder) { + Assert.notNull(strategy, "CursorStrategy is required"); + Assert.notNull(encoder, "CursorEncoder is required"); + Assert.isTrue(!(strategy instanceof EncodingCursorStrategy), "CursorStrategy already has encoding"); + this.delegate = strategy; + this.encoder = encoder; + } + + + /** + * Return the decorated {@link CursorStrategy}. + */ + public CursorStrategy getDelegate() { + return this.delegate; + } + + /** + * Return the configured {@link CursorEncoder}. + */ + public CursorEncoder getEncoder() { + return this.encoder; + } + + + @Override + public boolean supports(Class targetType) { + return this.delegate.supports(targetType); + } + + @Override + public String toCursor(T position) { + String cursor = this.delegate.toCursor(position); + return this.encoder.encode(cursor); + } + + @Override + public T fromCursor(String cursor) { + String decodedCursor = this.encoder.decode(cursor); + return this.delegate.fromCursor(decodedCursor); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/NoOpCursorEncoder.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/NoOpCursorEncoder.java new file mode 100644 index 00000000..c4443edd --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/NoOpCursorEncoder.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +/** + * {@link CursorEncoder} that leaves the cursor value unchanged. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +final class NoOpCursorEncoder implements CursorEncoder { + + @Override + public String encode(String cursor) { + return cursor; + } + + @Override + public String decode(String cursor) { + return cursor; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/PaginationRequest.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/PaginationRequest.java new file mode 100644 index 00000000..fdc7820d --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/PaginationRequest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + + +import java.util.Optional; + +import org.springframework.lang.Nullable; + +/** + * Container for a pagination request. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public class PaginationRequest

{ + + @Nullable + private final P position; + + @Nullable + private final Integer count; + + private final boolean forward; + + + /** + * Constructor with the position, count, and direction. + */ + public PaginationRequest(@Nullable P position, @Nullable Integer count, boolean forward) { + this.position = position; + this.forward = forward; + this.count = count; + } + + + /** + * The position of an element relative to which to paginate, decoded from a + * String cursor, e.g. the "before" and "after" arguments from the GraphQL + * Cursor connection spec. + */ + public Optional

position() { + return Optional.ofNullable(this.position); + } + + /** + * The number of elements requested, e.g. "first" and "last" N elements + * arguments from the GraphQL Cursor connection spec. + */ + public Optional count() { + return Optional.ofNullable(this.count); + } + + /** + * Whether forward or backward pagination is requested, e.g. depending on + * whether "fist" or "last" N elements was sent. + *

Note: This value may not reflect the one originally + * sent by the client. For example, for backward pagination, an offset cursor + * may be adjusted down by the number of requested elements, turning into + * forward pagination. + */ + public boolean forward() { + return this.forward; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/SortStrategy.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/SortStrategy.java new file mode 100644 index 00000000..91c90f1f --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/SortStrategy.java @@ -0,0 +1,45 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.lang.Nullable; + +/** + * Strategy to extract sort information from GraphQL request arguments. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public interface SortStrategy { + + /** + * Whether this strategy supports the given Sort object target type. + */ + boolean supports(Class targetType); + + /** + * Return an Object that contains sort order and direction information. + * @param environment the environment to obtain GraphQL request arguments from + * @return the object with sort details, if present + */ + @Nullable + S extract(DataFetchingEnvironment environment); + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/package-info.java new file mode 100644 index 00000000..f2bb459f --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2020-2021 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Core contracts and generic infrastructure classes for pagination. + */ +@NonNullApi +@NonNullFields +package org.springframework.graphql.data.pagination; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; 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 new file mode 100644 index 00000000..e6c7030c --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategy.java @@ -0,0 +1,114 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.graphql.data.pagination.CursorStrategy; +import org.springframework.http.MediaType; +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.util.Assert; +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. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public final class JsonKeysetCursorStrategy implements CursorStrategy> { + + private static final ResolvableType MAP_TYPE = + ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class); + + + private final Encoder encoder; + + private final Decoder decoder; + + private final DefaultDataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; + + + /** + * Shortcut constructor that uses {@link ServerCodecConfigurer}. + */ + public JsonKeysetCursorStrategy() { + this(ServerCodecConfigurer.create()); + } + + /** + * Constructor with a {@link CodecConfigurer} in which to find the JSON + * encoder and decoder to use. + */ + public JsonKeysetCursorStrategy(CodecConfigurer codecConfigurer) { + Assert.notNull(codecConfigurer, "CodecConfigurer is required"); + this.encoder = findJsonEncoder(codecConfigurer); + this.decoder = findJsonDecoder(codecConfigurer); + } + + private static Decoder findJsonDecoder(CodecConfigurer configurer) { + return configurer.getReaders().stream() + .filter((reader) -> reader.canRead(MAP_TYPE, MediaType.APPLICATION_JSON)) + .map((reader) -> ((DecoderHttpMessageReader) reader).getDecoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON Decoder")); + } + + private static Encoder findJsonEncoder(CodecConfigurer configurer) { + return configurer.getWriters().stream() + .filter((writer) -> writer.canWrite(MAP_TYPE, MediaType.APPLICATION_JSON)) + .map((writer) -> ((EncoderHttpMessageWriter) writer).getEncoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON Encoder")); + } + + + @Override + public boolean supports(Class targetType) { + return Map.class.isAssignableFrom(targetType); + } + + @SuppressWarnings("unchecked") + @Override + public String toCursor(Map keys) { + return ((Encoder>) this.encoder).encodeValue( + keys, DefaultDataBufferFactory.sharedInstance, ResolvableType.forClass(keys.getClass()), + MimeTypeUtils.APPLICATION_JSON, null).toString(StandardCharsets.UTF_8); + } + + @SuppressWarnings("unchecked") + @Override + public Map fromCursor(String cursor) { + DataBuffer buffer = this.bufferFactory.wrap(cursor.getBytes(StandardCharsets.UTF_8)); + Map map = ((Decoder>) this.decoder).decode(buffer, MAP_TYPE, null, null); + return (map != null ? map : Collections.emptyMap()); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategy.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategy.java new file mode 100644 index 00000000..ed9e048b --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategy.java @@ -0,0 +1,96 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.Map; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.graphql.data.pagination.CursorStrategy; +import org.springframework.util.Assert; + +/** + * Strategy to convert a {@link ScrollPosition} to and from a String cursor. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public final class ScrollPositionCursorStrategy implements CursorStrategy { + + private static final String OFFSET_PREFIX = "O_"; + + private static final String KEYSET_PREFIX = "K_"; + + + private final CursorStrategy> keysetCursorStrategy; + + + /** + * Shortcut constructor that uses {@link JsonKeysetCursorStrategy}. + */ + public ScrollPositionCursorStrategy() { + this(new JsonKeysetCursorStrategy()); + } + + /** + * Constructor with a given strategy to convert a + * {@link KeysetScrollPosition#getKeys() keyset} to and from a cursor. + */ + public ScrollPositionCursorStrategy(CursorStrategy> keysetCursorStrategy) { + Assert.notNull(keysetCursorStrategy, "'keysetCursorStrategy' is required"); + this.keysetCursorStrategy = keysetCursorStrategy; + } + + + @Override + public boolean supports(Class targetType) { + return ScrollPosition.class.isAssignableFrom(targetType); + } + + @Override + public String toCursor(ScrollPosition position) { + if (position instanceof OffsetScrollPosition offsetPosition) { + return OFFSET_PREFIX + offsetPosition.getOffset(); + } + else if (position instanceof KeysetScrollPosition keysetPosition) { + return KEYSET_PREFIX + this.keysetCursorStrategy.toCursor(keysetPosition.getKeys()); + } + throw new IllegalArgumentException("Unexpected ScrollPosition type: " + position.getClass().getName()); + } + + @Override + public ScrollPosition fromCursor(String cursor) { + if (cursor.length() > 2) { + try { + if (cursor.startsWith(OFFSET_PREFIX)) { + long index = Long.parseLong(cursor.substring(2)); + return OffsetScrollPosition.of(index > 0 ? index : 0); + } + else if (cursor.startsWith(KEYSET_PREFIX)) { + Map keys = this.keysetCursorStrategy.fromCursor(cursor.substring(2)); + return KeysetScrollPosition.of(keys); + } + } + catch (Throwable ex) { + throw new IllegalArgumentException("Failed to parse cursor: " + cursor, ex); + } + } + throw new IllegalArgumentException("Invalid or unknown cursor type: " + cursor); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/ScrollRequest.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/ScrollRequest.java new file mode 100644 index 00000000..d66fd6b7 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/ScrollRequest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + + +import java.util.Map; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.KeysetScrollPosition.Direction; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.graphql.data.pagination.PaginationRequest; +import org.springframework.lang.Nullable; + +/** + * Container for pagination request with a {@link ScrollPosition} cursor. + * + *

An {@link OffsetScrollPosition} is always used for forward pagination. + * When backward pagination is requested, the offset is adjusted down by the + * requested count, thus turning it into forward pagination. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public final class ScrollRequest extends PaginationRequest { + + + public ScrollRequest(@Nullable ScrollPosition position, @Nullable Integer count, boolean forward) { + super(initPosition(position, count, forward), count, + (position instanceof OffsetScrollPosition || forward)); + } + + @Nullable + private static ScrollPosition initPosition( + @Nullable ScrollPosition position, @Nullable Integer count, boolean forward) { + + if (!forward) { + if (position instanceof OffsetScrollPosition offsetPosition && count != null) { + long offset = offsetPosition.getOffset(); + return OffsetScrollPosition.of(offset > count ? offset - count : 0); + } + else if (position instanceof KeysetScrollPosition keysetPosition) { + Map keys = keysetPosition.getKeys(); + position = KeysetScrollPosition.of(keys, Direction.Backward); + } + } + + return position; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/SliceConnectionAdapter.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/SliceConnectionAdapter.java new file mode 100644 index 00000000..4dcabdfc --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/SliceConnectionAdapter.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.Collection; + +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Slice; +import org.springframework.graphql.data.pagination.ConnectionAdapter; +import org.springframework.graphql.data.pagination.ConnectionAdapterSupport; +import org.springframework.graphql.data.pagination.CursorStrategy; + +/** + * Adapter for {@link Slice} to {@link graphql.relay.Connection}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public final class SliceConnectionAdapter + extends ConnectionAdapterSupport implements ConnectionAdapter { + + + /** + * Constructor with the {@link CursorStrategy} to use to encode the + * {@code ScrollPosition} of page items. + */ + public SliceConnectionAdapter(CursorStrategy strategy) { + super(strategy); + } + + + @Override + public boolean supports(Class containerType) { + return Slice.class.isAssignableFrom(containerType); + } + + @Override + public Collection getContent(Object container) { + Slice slice = slice(container); + return slice.getContent(); + } + + @Override + public boolean hasPrevious(Object container) { + return slice(container).hasPrevious(); + } + + @Override + public boolean hasNext(Object container) { + return slice(container).hasNext(); + } + + @Override + public String cursorAt(Object container, int index) { + Slice slice = slice(container); + ScrollPosition position = OffsetScrollPosition.of((long) slice.getNumber() * slice.getSize() + index); + return getCursorStrategy().toCursor(position); + } + + @SuppressWarnings("unchecked") + private Slice slice(Object container) { + return (Slice) container; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/WindowConnectionAdapter.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/WindowConnectionAdapter.java new file mode 100644 index 00000000..5d28a76d --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/WindowConnectionAdapter.java @@ -0,0 +1,75 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.Collection; + +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Window; +import org.springframework.graphql.data.pagination.ConnectionAdapter; +import org.springframework.graphql.data.pagination.ConnectionAdapterSupport; +import org.springframework.graphql.data.pagination.CursorStrategy; + +/** + * Adapter for {@link Window} to {@link graphql.relay.Connection}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public final class WindowConnectionAdapter + extends ConnectionAdapterSupport implements ConnectionAdapter { + + + public WindowConnectionAdapter(CursorStrategy strategy) { + super(strategy); + } + + + @Override + public boolean supports(Class containerType) { + return Window.class.isAssignableFrom(containerType); + } + + @Override + public Collection getContent(Object container) { + Window window = window(container); + return window.getContent(); + } + + @Override + public boolean hasPrevious(Object container) { + Window window = window(container); + return (window.size() > 0 && !window.positionAt(0).isInitial()); + } + + @Override + public boolean hasNext(Object container) { + return window(container).hasNext(); + } + + @Override + public String cursorAt(Object container, int index) { + ScrollPosition position = window(container).positionAt(index); + return getCursorStrategy().toCursor(position); + } + + @SuppressWarnings("unchecked") + private Window window(Object container) { + return (Window) container; + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/Base64CursorEncoderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/Base64CursorEncoderTests.java new file mode 100644 index 00000000..a530c65f --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/Base64CursorEncoderTests.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link Base64CursorEncoder}. + * + * @author Rossen Stoyanchev + */ +public class Base64CursorEncoderTests { + + private final Base64CursorEncoder encoder = new Base64CursorEncoder(); + + + @Test + void encodeAndDecode() { + testEncodeAndDecode("O_43", "T180Mw=="); + testEncodeAndDecode("K_{\"firstName\":\"Joseph\",\"id\":103}", "S197ImZpcnN0TmFtZSI6Ikpvc2VwaCIsImlkIjoxMDN9"); + } + + private void testEncodeAndDecode(String decoded, String encoded) { + assertThat(this.encoder.encode(decoded)).isEqualTo(encoded); + assertThat(this.encoder.decode(encoded)).isEqualTo(decoded); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/ConnectionTypeVisitorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/ConnectionTypeVisitorTests.java new file mode 100644 index 00000000..a33a9329 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/ConnectionTypeVisitorTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.pagination; + +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.graphql.BookSource; +import org.springframework.graphql.ExecutionGraphQlResponse; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.TestExecutionRequest; +import org.springframework.graphql.execution.ConnectionTypeGenerator; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ConnectionTypeVisitor}. + * + * @author Rossen Stoyanchev + */ +public class ConnectionTypeVisitorTests { + + + @Test + void dataFetcherDecoration() throws Exception { + + String schemaContent = """ + type Query { + books: BookConnection + } + type Book { + id: ID + name: String + } + """; + + String document = "{ " + + " books { " + + " edges {" + + " cursor," + + " node {" + + " id" + + " name" + + " }" + + " }" + + " pageInfo {" + + " startCursor," + + " endCursor," + + " hasPreviousPage," + + " hasNextPage" + + " }" + + " }" + + "}"; + + TestConnectionAdapter adapter = new TestConnectionAdapter(); + adapter.setInitialOffset(30); + adapter.setHasNext(true); + + ExecutionGraphQlResponse response = GraphQlSetup.schemaContent(schemaContent) + .dataFetcher("Query", "books", env -> BookSource.books()) + .typeDefinitionRegistryConfigurer(new ConnectionTypeGenerator()::generateConnectionTypes) + .typeVisitor(ConnectionTypeVisitor.create(List.of(adapter))) + .toGraphQlService() + .execute(TestExecutionRequest.forDocument(document)) + .block(); + + assertThat(new ObjectMapper().writeValueAsString(response.getData())).isEqualTo( + "{\"books\":{" + + "\"edges\":[" + + "{\"cursor\":\"T_30\",\"node\":{\"id\":\"1\",\"name\":\"Nineteen Eighty-Four\"}}," + + "{\"cursor\":\"T_31\",\"node\":{\"id\":\"2\",\"name\":\"The Great Gatsby\"}}," + + "{\"cursor\":\"T_32\",\"node\":{\"id\":\"3\",\"name\":\"Catch-22\"}}," + + "{\"cursor\":\"T_33\",\"node\":{\"id\":\"4\",\"name\":\"To The Lighthouse\"}}," + + "{\"cursor\":\"T_34\",\"node\":{\"id\":\"5\",\"name\":\"Animal Farm\"}}," + + "{\"cursor\":\"T_35\",\"node\":{\"id\":\"53\",\"name\":\"Breaking Bad\"}}," + + "{\"cursor\":\"T_36\",\"node\":{\"id\":\"42\",\"name\":\"Hitchhiker's Guide to the Galaxy\"}}" + + "]," + + "\"pageInfo\":{" + + "\"startCursor\":\"T_30\"," + + "\"endCursor\":\"T_36\"," + + "\"hasPreviousPage\":true," + + "\"hasNextPage\":true}" + + "}}" + ); + } + + + private static class TestConnectionAdapter implements ConnectionAdapter { + + private int initialOffset = 0; + + private boolean hasNext = false; + + public void setInitialOffset(int initialOffset) { + this.initialOffset = initialOffset; + } + + public void setHasNext(boolean hasNext) { + this.hasNext = hasNext; + } + + @Override + public boolean supports(Class containerType) { + return Collection.class.isAssignableFrom(containerType); + } + + @Override + public Collection getContent(Object container) { + return (Collection) container; + } + + @Override + public boolean hasPrevious(Object container) { + return (this.initialOffset != 0); + } + + @Override + public boolean hasNext(Object container) { + return this.hasNext; + } + + @Override + public String cursorAt(Object container, int index) { + return "T_" + (this.initialOffset + index); + } + + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategyTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategyTests.java new file mode 100644 index 00000000..c7a4f86d --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/JsonKeysetCursorStrategyTests.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link JsonKeysetCursorStrategy}. + * + * @author Rossen Stoyanchev + */ +public class JsonKeysetCursorStrategyTests { + + private final JsonKeysetCursorStrategy cursorStrategy = new JsonKeysetCursorStrategy(); + + + @Test + void toAndFromCursor() { + Map keys = new LinkedHashMap<>(); + keys.put("firstName", "Joseph"); + keys.put("lastName", "Heller"); + keys.put("id", 103); + + String json = "{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}"; + + 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 new file mode 100644 index 00000000..45089557 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollPositionCursorStrategyTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ScrollPositionCursorStrategy}. + * + * @author Rossen Stoyanchev + */ +public class ScrollPositionCursorStrategyTests { + + private final ScrollPositionCursorStrategy cursorStrategy = new ScrollPositionCursorStrategy(); + + + @Test + void offsetPosition() { + toAndFromCursor(OffsetScrollPosition.of(43), "O_43"); + } + + @Test + void keysetPosition() { + Map keys = new LinkedHashMap<>(); + keys.put("firstName", "Joseph"); + keys.put("lastName", "Heller"); + keys.put("id", 103); + + toAndFromCursor(KeysetScrollPosition.of(keys), + "K_{\"firstName\":\"Joseph\",\"lastName\":\"Heller\",\"id\":103}"); + } + + private void toAndFromCursor(ScrollPosition position, String cursor) { + assertThat(this.cursorStrategy.toCursor(position)).isEqualTo(cursor); + assertThat(this.cursorStrategy.fromCursor(cursor)).isEqualTo(position); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollRequestTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollRequestTests.java new file mode 100644 index 00000000..d260b12a --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/ScrollRequestTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.data.domain.KeysetScrollPosition.Direction; +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.ScrollPosition; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ScrollPositionCursorStrategy}. + * + * @author Rossen Stoyanchev + */ +public class ScrollRequestTests { + + @Test + void offset() { + OffsetScrollPosition position = OffsetScrollPosition.of(30); + int count = 10; + + ScrollRequest request = new ScrollRequest(position, count, true); + assertThat(((OffsetScrollPosition) request.position().get())).isEqualTo(position); + assertThat(request.count().get()).isEqualTo(count); + assertThat(request.forward()).isTrue(); + + request = new ScrollRequest(position, count, false); + assertThat(((OffsetScrollPosition) request.position().get()).getOffset()).isEqualTo(20); + assertThat(request.count().get()).isEqualTo(count); + assertThat(request.forward()).isTrue(); + } + + @Test + void keyset() { + Map keys = new LinkedHashMap<>(); + keys.put("firstName", "Joseph"); + keys.put("lastName", "Heller"); + keys.put("id", 103); + + ScrollPosition position = KeysetScrollPosition.of(keys); + int count = 10; + + ScrollRequest request = new ScrollRequest(position, count, true); + KeysetScrollPosition actualPosition = (KeysetScrollPosition) request.position().get(); + assertThat(actualPosition.getKeys()).isEqualTo(keys); + assertThat(actualPosition.getDirection()).isEqualTo(Direction.Forward); + assertThat(request.count().get()).isEqualTo(count); + assertThat(request.forward()).isTrue(); + + request = new ScrollRequest(position, count, false); + actualPosition = (KeysetScrollPosition) request.position().get(); + assertThat(actualPosition.getKeys()).isEqualTo(keys); + assertThat(actualPosition.getDirection()).isEqualTo(Direction.Backward); + assertThat(request.count().get()).isEqualTo(count); + assertThat(request.forward()).isFalse(); + } + + @Test + void nullInput() { + ScrollRequest request = new ScrollRequest(null, null, true); + + assertThat(request.position()).isNotPresent(); + assertThat(request.count()).isNotPresent(); + assertThat(request.forward()).isTrue(); + } + + @Test + void offsetBackwardPaginationNullSize() { + OffsetScrollPosition position = OffsetScrollPosition.of(30); + ScrollRequest request = new ScrollRequest(position, null, false); + + assertThat(((OffsetScrollPosition) request.position().get())).isEqualTo(position); + assertThat(request.count()).isNotPresent(); + assertThat(request.forward()).isTrue(); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/SliceConnectionAdapterTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/SliceConnectionAdapterTests.java new file mode 100644 index 00000000..f2e78a16 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/SliceConnectionAdapterTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.graphql.Book; +import org.springframework.graphql.BookSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SliceConnectionAdapter}. + * + * @author Rossen Stoyanchev + */ +public class SliceConnectionAdapterTests { + + private final SliceConnectionAdapter adapter = new SliceConnectionAdapter(new ScrollPositionCursorStrategy()); + + + @Test + void paged() { + List books = BookSource.books(); + Page page = new PageImpl<>(books, PageRequest.of(5, books.size()), 50); + + assertThat(this.adapter.getContent(page)).isEqualTo(books); + assertThat(this.adapter.hasNext(page)).isTrue(); + assertThat(this.adapter.hasPrevious(page)).isTrue(); + assertThat(this.adapter.cursorAt(page, 3)).isEqualTo("O_38"); + } + + @Test + void unpaged() { + List books = BookSource.books(); + Page page = new PageImpl<>(books); + + assertThat(this.adapter.getContent(page)).isEqualTo(books); + assertThat(this.adapter.hasNext(page)).isFalse(); + assertThat(this.adapter.hasPrevious(page)).isFalse(); + assertThat(this.adapter.cursorAt(page, 3)).isEqualTo("O_3"); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/WindowConnectionAdapterTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/WindowConnectionAdapterTests.java new file mode 100644 index 00000000..1b94c406 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/WindowConnectionAdapterTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020-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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.query; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.domain.OffsetScrollPosition; +import org.springframework.data.domain.Window; +import org.springframework.graphql.Book; +import org.springframework.graphql.BookSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link WindowConnectionAdapter}. + * + * @author Rossen Stoyanchev + */ +public class WindowConnectionAdapterTests { + + private final WindowConnectionAdapter adapter = new WindowConnectionAdapter(new ScrollPositionCursorStrategy()); + + + @Test + void paged() { + List books = BookSource.books(); + Window window = Window.from(books, offset -> OffsetScrollPosition.of(35 + offset), true); + + assertThat(this.adapter.getContent(window)).isEqualTo(books); + assertThat(this.adapter.hasNext(window)).isTrue(); + assertThat(this.adapter.hasPrevious(window)).isTrue(); + assertThat(this.adapter.cursorAt(window, 3)).isEqualTo("O_38"); + } + + @Test + void unpaged() { + List books = BookSource.books(); + Window window = Window.from(books, OffsetScrollPosition::of); + + assertThat(this.adapter.getContent(window)).isEqualTo(books); + assertThat(this.adapter.hasNext(window)).isFalse(); + assertThat(this.adapter.hasPrevious(window)).isFalse(); + assertThat(this.adapter.cursorAt(window, 3)).isEqualTo("O_3"); + } + +}