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:
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user