Support for pagination

ConnectionTypeVisitor to decorate DataFetchers for Connection fields
in order to adapt Window, Slice, and others to Connection.

A ScrollRequest controller method argument to inject the ScrollPosition
and the number of elements requested.

SortStrategy for an application to customize how to extract sort
details from GraphQL arguments.

See gh-620
This commit is contained in:
rstoyanchev
2023-03-17 16:50:32 +00:00
parent 65a0cee19a
commit 104eccb4d9
29 changed files with 1975 additions and 1 deletions

View File

@@ -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"))

View File

@@ -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<HandlerMethodArgumentResolver> 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:
* <ul>
* <li>If Spring Data is present, and the strategy supports {@code ScrollPosition},
* then {@link ScrollRequestMethodArgumentResolver} is
* configured as a method argument resolver.
* <li>Otherwise {@link PaginationRequestMethodArgumentResolver} is added
* instead.
* </ul>
* @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<org.springframework.data.domain.ScrollPosition>) cursorStrategy);
}
}
return new PaginationRequestMethodArgumentResolver<>(cursorStrategy);
}
protected final ApplicationContext obtainApplicationContext() {
Assert.state(this.applicationContext != null, "No ApplicationContext");
return this.applicationContext;

View File

@@ -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<P> implements HandlerMethodArgumentResolver {
private final CursorStrategy<P> cursorStrategy;
public PaginationRequestMethodArgumentResolver(CursorStrategy<P> 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<P> createRequest(@Nullable P position, @Nullable Integer size, boolean forward) {
return new PaginationRequest<>(position, size, forward);
}
}

View File

@@ -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<ScrollPosition> {
public ScrollRequestMethodArgumentResolver(CursorStrategy<ScrollPosition> 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<ConnectionAdapter> adapters;
CompositeConnectionAdapter(List<ConnectionAdapter> adapters) {
Assert.notEmpty(adapters, "ConnectionAdapter's are required");
this.adapters = adapters;
}
@Override
public boolean supports(Class<?> containerType) {
return (getAdapter(containerType) != null);
}
public <T> Collection<T> 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;
}
}

View File

@@ -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.
*/
<T> Collection<T> 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<ConnectionAdapter> adapters) {
return new CompositeConnectionAdapter(adapters);
}
}

View File

@@ -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<P> {
private final CursorStrategy<P> cursorStrategy;
/**
* Constructor with a {@link CursorStrategy} to use.
*/
protected ConnectionAdapterSupport(CursorStrategy<P> cursorStrategy) {
Assert.notNull(cursorStrategy, "CursorStrategy is required");
this.cursorStrategy = cursorStrategy;
}
/**
* Return the configured {@link CursorStrategy}.
*/
public CursorStrategy<P> getCursorStrategy() {
return this.cursorStrategy;
}
}

View File

@@ -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}.
*
* <p>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<GraphQLSchemaElement> 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<ConnectionAdapter> 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<Object> {
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 <T> Connection<T> adapt(Object container) {
if (container instanceof Connection<?> connection) {
return (Connection<T>) connection;
}
Collection<T> nodes = this.adapter.getContent(container);
if (nodes.isEmpty()) {
return (Connection<T>) EMPTY_CONNECTION;
}
int index = 0;
List<Edge<T>> 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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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.
*
* <p>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<P> {
/**
* 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 <T> EncodingCursorStrategy<T> withEncoder(CursorStrategy<T> strategy, CursorEncoder encoder) {
return new EncodingCursorStrategy<>(strategy, encoder);
}
}

View File

@@ -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.
*
* <p>Use {@link CursorStrategy#withEncoder(CursorStrategy, CursorEncoder)} to
* decorate a {@code CursorStrategy}.
*
* @author Rossen Stoyanchev
* @since 1.2
*/
public final class EncodingCursorStrategy<T> implements CursorStrategy<T> {
private final CursorStrategy<T> delegate;
private final CursorEncoder encoder;
EncodingCursorStrategy(CursorStrategy<T> 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<T> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<P> {
@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<P> 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<Integer> 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.
* <p><strong>Note:</strong> 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;
}
}

View File

@@ -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<S> {
/**
* 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);
}

View File

@@ -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;

View File

@@ -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<Map<String, Object>> {
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<String, Object> keys) {
return ((Encoder<Map<String, Object>>) this.encoder).encodeValue(
keys, DefaultDataBufferFactory.sharedInstance, ResolvableType.forClass(keys.getClass()),
MimeTypeUtils.APPLICATION_JSON, null).toString(StandardCharsets.UTF_8);
}
@SuppressWarnings("unchecked")
@Override
public Map<String, Object> fromCursor(String cursor) {
DataBuffer buffer = this.bufferFactory.wrap(cursor.getBytes(StandardCharsets.UTF_8));
Map<String, Object> map = ((Decoder<Map<String, Object>>) this.decoder).decode(buffer, MAP_TYPE, null, null);
return (map != null ? map : Collections.emptyMap());
}
}

View File

@@ -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<ScrollPosition> {
private static final String OFFSET_PREFIX = "O_";
private static final String KEYSET_PREFIX = "K_";
private final CursorStrategy<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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);
}
}

View File

@@ -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.
*
* <p>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<ScrollPosition> {
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<String, Object> keys = keysetPosition.getKeys();
position = KeysetScrollPosition.of(keys, Direction.Backward);
}
}
return position;
}
}

View File

@@ -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<ScrollPosition> implements ConnectionAdapter {
/**
* Constructor with the {@link CursorStrategy} to use to encode the
* {@code ScrollPosition} of page items.
*/
public SliceConnectionAdapter(CursorStrategy<ScrollPosition> strategy) {
super(strategy);
}
@Override
public boolean supports(Class<?> containerType) {
return Slice.class.isAssignableFrom(containerType);
}
@Override
public <T> Collection<T> getContent(Object container) {
Slice<T> 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 <T> Slice<T> slice(Object container) {
return (Slice<T>) container;
}
}

View File

@@ -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<ScrollPosition> implements ConnectionAdapter {
public WindowConnectionAdapter(CursorStrategy<ScrollPosition> strategy) {
super(strategy);
}
@Override
public boolean supports(Class<?> containerType) {
return Window.class.isAssignableFrom(containerType);
}
@Override
public <T> Collection<T> getContent(Object container) {
Window<T> 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 <T> Window<T> window(Object container) {
return (Window<T>) container;
}
}

View File

@@ -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);
}
}

View File

@@ -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 <T> Collection<T> getContent(Object container) {
return (Collection<T>) 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);
}
}
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String, Object> 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();
}
}

View File

@@ -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<Book> books = BookSource.books();
Page<Book> 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<Book> books = BookSource.books();
Page<Book> 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");
}
}

View File

@@ -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<Book> books = BookSource.books();
Window<Book> 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<Book> books = BookSource.books();
Window<Book> 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");
}
}