From c9b565283318647cae3b0f240edc298a2917d1b3 Mon Sep 17 00:00:00 2001 From: "Greg L. Turnquist" Date: Tue, 23 Nov 2021 10:57:55 -0600 Subject: [PATCH] Support for Query By Example based DataFetcher This adds support for Spring Data's recent fluent query API via Query by Example. It tests against Spring Data JPA and Spring Data MongoDB for imperative, and Spring Data MongoDB for reactive support. To support MongoDB testing, it uses Testcontainers. See gh-191 --- build.gradle | 9 + spring-graphql/build.gradle | 10 +- .../support/GraphQlArgumentInstantiator.java | 3 +- .../querybyexample/PropertySelection.java | 198 ++++++ .../QueryByExampleDataFetcher.java | 662 ++++++++++++++++++ .../data/querybyexample/package-info.java | 26 + .../data/querybyexample/jpa/Author.java | 67 ++ .../graphql/data/querybyexample/jpa/Book.java | 68 ++ .../querybyexample/jpa/BookRepository.java | 8 + .../QueryByExampleDataFetcherJpaTests.java | 249 +++++++ .../data/querybyexample/mongodb/Author.java | 65 ++ .../data/querybyexample/mongodb/Book.java | 63 ++ .../mongodb/BookRepository.java | 8 + ...QueryByExampleDataFetcherMongoDbTests.java | 222 ++++++ ...xampleDataFetcherReactiveMongoDbTests.java | 194 +++++ .../mongodb/ReactiveBookRepository.java | 8 + 16 files changed, 1858 insertions(+), 2 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/PropertySelection.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/QueryByExampleDataFetcher.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/package-info.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Author.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Book.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/BookRepository.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/QueryByExampleDataFetcherJpaTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Author.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Book.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/BookRepository.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherMongoDbTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherReactiveMongoDbTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/ReactiveBookRepository.java diff --git a/build.gradle b/build.gradle index 079cafb2..44d0f1b9 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,7 @@ configure(moduleProjects) { mavenBom "org.jetbrains.kotlin:kotlin-bom:1.6.0" mavenBom "org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.5.2" mavenBom "org.junit:junit-bom:5.8.1" + mavenBom "org.testcontainers:testcontainers-bom:1.16.2" } dependencies { dependency "com.graphql-java:graphql-java:${graphQlJavaVersion}" @@ -74,6 +75,14 @@ configure(moduleProjects) { dependency "org.assertj:assertj-core:3.21.0" dependency "com.jayway.jsonpath:json-path:2.5.0" dependency "org.skyscreamer:jsonassert:1.5.0" + dependency "com.h2database:h2:1.4.200" + dependency "org.hibernate:hibernate-core:5.6.1.Final" + dependencySet(group: 'org.mongodb', version: '4.3.2') { + entry 'mongodb-driver-sync' + entry 'mongodb-driver-reactivestreams' + entry 'mongodb-driver-core' + entry 'bson' + } dependencySet(group: 'org.apache.logging.log4j', version: '2.14.1') { entry 'log4j-api' entry 'log4j-core' diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 00185bea..7fc42d69 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -7,7 +7,7 @@ dependencies { api 'io.projectreactor:reactor-core' api 'org.springframework:spring-context' - compileOnly 'javax.annotation:javax.annotation-api' + compileOnly 'javax.annotation:javax.annotation-api' compileOnly 'org.springframework:spring-webflux' compileOnly 'org.springframework:spring-webmvc' compileOnly 'org.springframework:spring-websocket' @@ -32,6 +32,14 @@ dependencies { testImplementation 'org.springframework:spring-test' testImplementation 'org.springframework.data:spring-data-commons' testImplementation 'org.springframework.data:spring-data-keyvalue' + testImplementation 'org.springframework.data:spring-data-jpa' + testImplementation 'com.h2database:h2' + testImplementation 'org.hibernate:hibernate-core' + testImplementation 'org.springframework.data:spring-data-mongodb' + testImplementation 'org.mongodb:mongodb-driver-sync' + testImplementation 'org.mongodb:mongodb-driver-reactivestreams' + testImplementation 'org.testcontainers:mongodb' + testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.springframework.security:spring-security-core' testImplementation 'com.querydsl:querydsl-core' testImplementation 'com.querydsl:querydsl-collections' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java index eb51f487..df8cbd8e 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/GraphQlArgumentInstantiator.java @@ -39,8 +39,9 @@ import org.springframework.validation.DataBinder; * {@link graphql.schema.DataFetchingEnvironment} arguments. * * @author Brian Clozel + * @author Greg Turnquist */ -class GraphQlArgumentInstantiator { +public class GraphQlArgumentInstantiator { private final DataBinder converter; diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/PropertySelection.java b/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/PropertySelection.java new file mode 100644 index 00000000..383fa271 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/PropertySelection.java @@ -0,0 +1,198 @@ +/* + * Copyright 2002-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 + * + * http://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.querybyexample; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import graphql.schema.DataFetchingFieldSelectionSet; +import graphql.schema.SelectedField; + +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.util.TypeInformation; + +/** + * Utility to compute {@link PropertyPath property paths} from + * a {@link DataFetchingFieldSelectionSet field selection} considering an underlying + * Java type. + *

+ * Property paths are created for each selected field that corresponds with a property + * on the underlying type. Nested properties are represented with nested paths + * if the nesting can be resolved to a concrete type, otherwise the nested path + * is considered to be a composite property without further inspection. + * + * @author Mark Paluch + * @since 1.0.0 + */ +class PropertySelection { + + private final List propertyPaths; + + private PropertySelection(List propertyPaths) { + this.propertyPaths = propertyPaths; + } + + /** + * Create a property selection for the given {@link TypeInformation type} and + * {@link DataFetchingFieldSelectionSet}. + * + * @param typeInformation the type to inspect + * @param selectionSet the field selection to apply + * @return a property selection holding all selectable property paths. + */ + public static PropertySelection create(TypeInformation typeInformation, + DataFetchingFieldSelectionSet selectionSet) { + return create(typeInformation, new DataFetchingFieldSelection(selectionSet)); + } + + private static PropertySelection create(TypeInformation typeInformation, FieldSelection selection) { + List propertyPaths = collectPropertyPaths(typeInformation, + selection, + path -> PropertyPath.from(path, typeInformation)); + return new PropertySelection(propertyPaths); + } + + private static List collectPropertyPaths(TypeInformation typeInformation, + FieldSelection selection, Function propertyPathFactory) { + List propertyPaths = new ArrayList<>(); + + for (SelectedField selectedField : selection) { + + String propertyName = selectedField.getName(); + TypeInformation property = typeInformation.getProperty(propertyName); + + if (property == null) { + continue; + } + + PropertyPath propertyPath = propertyPathFactory.apply(propertyName); + FieldSelection nestedSelection = selection.select(selectedField); + + List pathsToAdd = Collections.singletonList(propertyPath); + + if (!nestedSelection.isEmpty() && property.getActualType() != null) { + List nestedPaths = collectPropertyPaths(property.getRequiredActualType(), + nestedSelection, propertyPath::nested); + + if (!nestedPaths.isEmpty()) { + pathsToAdd = nestedPaths; + } + } + + propertyPaths.addAll(pathsToAdd); + } + + return propertyPaths; + } + + /** + * @return the property paths as list. + */ + public List toList() { + return this.propertyPaths.stream().map(PropertyPath::toDotPath) + .collect(Collectors.toList()); + } + + enum EmptyFieldSelection implements FieldSelection { + + INSTANCE; + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public FieldSelection select(SelectedField field) { + return INSTANCE; + } + + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } + + } + + /** + * Hierarchical representation of selected fields. Allows traversing the + * object graph with nested fields. + */ + interface FieldSelection extends Iterable { + + /** + * @return {@code true} if the field selection is empty + */ + boolean isEmpty(); + + /** + * Obtain the field selection (nested fields) for a given {@code field}. + * + * @param field the field for which nested fields should be obtained + * @return the field selection. Can be empty. + */ + FieldSelection select(SelectedField field); + + } + + static class DataFetchingFieldSelection implements FieldSelection { + + private final List selectedFields; + + private final List allFields; + + DataFetchingFieldSelection(DataFetchingFieldSelectionSet selectionSet) { + this.selectedFields = selectionSet.getImmediateFields(); + this.allFields = selectionSet.getFields(); + } + + private DataFetchingFieldSelection(List selectedFields, + List allFields) { + this.selectedFields = selectedFields; + this.allFields = allFields; + } + + @Override + public boolean isEmpty() { + return selectedFields.isEmpty(); + } + + @Override + public FieldSelection select(SelectedField field) { + List selectedFields = new ArrayList<>(); + + for (SelectedField selectedField : allFields) { + if (field.equals(selectedField.getParentField())) { + selectedFields.add(selectedField); + } + } + + return (selectedFields.isEmpty() ? EmptyFieldSelection.INSTANCE + : new DataFetchingFieldSelection(selectedFields, allFields)); + } + + @Override + public Iterator iterator() { + return this.selectedFields.iterator(); + } + + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/QueryByExampleDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/QueryByExampleDataFetcher.java new file mode 100644 index 00000000..fe397dac --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/QueryByExampleDataFetcher.java @@ -0,0 +1,662 @@ +/* + * Copyright 2002-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. + */ + +package org.springframework.graphql.data.querybyexample; + +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.DataFetchingFieldSelectionSet; +import graphql.schema.GraphQLCodeRegistry; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLFieldsContainer; +import graphql.schema.GraphQLList; +import graphql.schema.GraphQLNamedOutputType; +import graphql.schema.GraphQLSchemaElement; +import graphql.schema.GraphQLType; +import graphql.schema.GraphQLTypeVisitor; +import graphql.schema.GraphQLTypeVisitorStub; +import graphql.schema.PropertyDataFetcher; +import graphql.util.TraversalControl; +import graphql.util.TraverserContext; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.springframework.data.repository.query.FluentQuery; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.TypeInformation; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.data.method.annotation.support.GraphQlArgumentInstantiator; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Main class to create a {@link DataFetcher} from a Query By Example repository. + * To create an instance, use one of the following: + *

+ * + *

For example: + * + *

+ * interface BookRepository extends
+ *         Repository<Book, String>, QueryByExampleExecutor<Book>{}
+ *
+ * TypeRuntimeWiring wiring = … ;
+ * BookRepository repository = … ;
+ *
+ * DataFetcher<?> forMany =
+ *         wiring.dataFetcher("books", QueryByExampleDataFetcher.builder(repository).many());
+ *
+ * DataFetcher<?> forSingle =
+ *         wiring.dataFetcher("book", QueryByExampleDataFetcher.builder(repository).single());
+ * 
+ * + *

See methods on {@link QueryByExampleDataFetcher.Builder} and {@link QueryByExampleDataFetcher.ReactiveBuilder} for further + * options on GraphQL Query argument to Query by Example bindings, result projections, and sorting. + * + * @param returned result type + * @author Greg Turnquist + * @see QueryByExampleExecutor + * @see ReactiveQueryByExampleExecutor + * @see Example + * @see + * Spring Data Query By Example extension + * @since 1.0.0 + */ +public abstract class QueryByExampleDataFetcher { + + private final TypeInformation domainType; + + private GraphQlArgumentInstantiator instantiator; + + QueryByExampleDataFetcher(TypeInformation domainType, @Nullable ConversionService conversionService) { + this.domainType = domainType; + this.instantiator = new GraphQlArgumentInstantiator(conversionService); + } + + /** + * Create a new {@link Builder} accepting {@link QueryByExampleExecutor} + * to build a {@link DataFetcher}. + * + * @param executor the repository object to use + * @param result type + * @return a new builder + */ + public static Builder builder(QueryByExampleExecutor executor) { + return new Builder<>(executor, getDomainType(executor)); + } + + /** + * Create a new {@link ReactiveBuilder} accepting + * {@link ReactiveQueryByExampleExecutor} to build a reactive {@link DataFetcher}. + * + * @param executor the repository object to use + * @param result type + * @return a new builder + */ + public static ReactiveBuilder builder(ReactiveQueryByExampleExecutor executor) { + return new ReactiveBuilder<>(executor, getDomainType(executor)); + } + + /** + * Create a {@link GraphQLTypeVisitor} that finds queries with a return type + * whose name matches to the domain type name of the given repositories and + * registers {@link DataFetcher}s for those queries. + *

Note: currently, this method will match only to + * queries under the top-level "Query" type in the GraphQL schema. + * + * @param executors repositories to consider for registration + * @param reactiveExecutors reactive repositories to consider for registration + * @return the created visitor + */ + public static GraphQLTypeVisitor registrationTypeVisitor( + List> executors, + List> reactiveExecutors) { + + return new RegistrationTypeVisitor(executors, reactiveExecutors); + } + + @SuppressWarnings("unchecked") + private static Class getDomainType(Object executor) { + Class repositoryInterface = getRepositoryInterface(executor); + DefaultRepositoryMetadata metadata = new DefaultRepositoryMetadata(repositoryInterface); + return (Class) metadata.getDomainType(); + } + + private static Class getRepositoryInterface(Object executor) { + Assert.isInstanceOf(Repository.class, executor); + + Type[] genericInterfaces = executor.getClass().getGenericInterfaces(); + for (Type genericInterface : genericInterfaces) { + Class rawClass = ResolvableType.forType(genericInterface).getRawClass(); + if (rawClass == null || MergedAnnotations.from(rawClass).isPresent(NoRepositoryBean.class)) { + continue; + } + if (Repository.class.isAssignableFrom(rawClass)) { + return rawClass; + } + } + + throw new IllegalArgumentException( + String.format("Cannot resolve repository interface from %s", executor)); + } + + /** + * Prepare an {@link Example} from GraphQL query arguments. + * + * @param environment contextual info for the GraphQL query + * @return the resulting example + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + protected Example buildExample(DataFetchingEnvironment environment) { + return Example.of(instantiator.instantiate(environment.getArguments(), domainType.getType())); + } + + protected boolean requiresProjection(Class resultType) { + return !resultType.equals(this.domainType.getType()); + } + + protected Collection buildPropertyPaths(DataFetchingFieldSelectionSet selection, Class resultType) { + + // Compute selection only for non-projections + if (this.domainType.getType().equals(resultType) || + this.domainType.getType().isAssignableFrom(resultType) || + this.domainType.isSubTypeOf(resultType)) { + return PropertySelection.create(this.domainType, selection).toList(); + } + return Collections.emptyList(); + } + + /** + * Builder for a Query by Example-based {@link DataFetcher}. Note that builder + * instances are immutable and return a new instance of the builder + * when calling configuration methods. + * + * @param domain type + * @param result type + */ + public static class Builder { + + private final QueryByExampleExecutor executor; + + private final ClassTypeInformation domainType; + + private final Class resultType; + + private final Sort sort; + + private final ConversionService conversionService; + + @SuppressWarnings("unchecked") + Builder(QueryByExampleExecutor executor, Class domainType) { + this(executor, + ClassTypeInformation.from((Class) domainType), + domainType, + Sort.unsorted(), + null); + } + + Builder(QueryByExampleExecutor executor, ClassTypeInformation domainType, Class resultType, Sort sort, ConversionService conversionService) { + this.executor = executor; + this.domainType = domainType; + this.resultType = resultType; + this.sort = sort; + this.conversionService = conversionService; + } + + /** + * Project results returned from the {@link QueryByExampleExecutor} + * into the target {@code projectionType}. Projection types can be + * either interfaces declaring getters for properties to expose or + * regular classes outside the entity type hierarchy for + * DTO projection. + * + * @param projectionType projection type + * @return a new {@link Builder} instance with all previously + * configured options and {@code projectionType} applied + */ + public

Builder projectAs(Class

projectionType) { + Assert.notNull(projectionType, "Projection type must not be null"); + return new Builder<>(this.executor, this.domainType, projectionType, this.sort, this.conversionService); + } + + /** + * Apply a {@link Sort} order. + * + * @param sort the default sort order + * @return a new {@link Builder} instance with all previously configured + * options and {@code Sort} applied + */ + public Builder sortBy(Sort sort) { + Assert.notNull(sort, "Sort must not be null"); + return new Builder<>(this.executor, this.domainType, this.resultType, sort, this.conversionService); + } + + /** + * Build a {@link DataFetcher} to fetch single object instances. + * + * @return a {@link DataFetcher} based on Query by Example to fetch one object + */ + public DataFetcher single() { + return new SingleEntityFetcher<>( + this.executor, this.domainType, this.resultType, this.sort, this.conversionService); + } + + /** + * Build a {@link DataFetcher} to fetch many object instances. + * + * @return a {@link DataFetcher} based on Query Example to fetch many objects + */ + public DataFetcher> many() { + return new ManyEntityFetcher<>( + this.executor, this.domainType, this.resultType, this.sort, this.conversionService); + } + } + + /** + * Builder for a reactive Query by Example-based {@link DataFetcher}. Note that builder + * instances are immutable and return a new instance of the builder when + * calling configuration methods. + * + * @param domain type + * @param result type + */ + public static class ReactiveBuilder { + + private final ReactiveQueryByExampleExecutor executor; + + private final TypeInformation domainType; + + private final Class resultType; + + private final Sort sort; + + private final ConversionService conversionService; + + @SuppressWarnings("unchecked") + ReactiveBuilder(ReactiveQueryByExampleExecutor executor, Class domainType) { + this(executor, + ClassTypeInformation.from((Class) domainType), + domainType, + Sort.unsorted(), + null); + } + + ReactiveBuilder(ReactiveQueryByExampleExecutor executor, + TypeInformation domainType, + Class resultType, + Sort sort, + ConversionService conversionService) { + this.executor = executor; + this.domainType = domainType; + this.resultType = resultType; + this.sort = sort; + this.conversionService = conversionService; + } + + /** + * Project results returned from the {@link QueryByExampleExecutor} + * into the target {@code projectionType}. Projection types can be + * either interfaces declaring getters for properties to expose or + * regular classes outside the entity type hierarchy for + * DTO projection. + * + * @param projectionType projection type + * @return a new {@link ReactiveBuilder} instance with all previously + * configured options and {@code projectionType} applied + */ + public

ReactiveBuilder projectAs(Class

projectionType) { + Assert.notNull(projectionType, "Projection type must not be null"); + return new ReactiveBuilder<>(this.executor, this.domainType, projectionType, this.sort, this.conversionService); + } + + /** + * Apply a {@link Sort} order. + * + * @param sort the default sort order + * @return a new {@link ReactiveBuilder} instance with all previously configured + * options and {@code Sort} applied + */ + public ReactiveBuilder sortBy(Sort sort) { + Assert.notNull(sort, "Sort must not be null"); + return new ReactiveBuilder<>(this.executor, this.domainType, this.resultType, sort, this.conversionService); + } + + /** + * Build a {@link DataFetcher} to fetch single object instances through {@link Mono}. + * + * @return a {@link DataFetcher} based on Query by Example to fetch one object + */ + public DataFetcher> single() { + return new ReactiveSingleEntityFetcher<>( + this.executor, this.domainType, this.resultType, this.sort, this.conversionService); + } + + /** + * Build a {@link DataFetcher} to fetch many object instances through {@link Flux}. + * + * @return a {@link DataFetcher} based on Query by Example to fetch many objects + */ + public DataFetcher> many() { + return new ReactiveManyEntityFetcher<>( + this.executor, this.domainType, this.resultType, this.sort, this.conversionService); + } + } + + private static class SingleEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher { + + private final QueryByExampleExecutor executor; + + private final Class resultType; + + private final Sort sort; + + @SuppressWarnings({"unchecked", "rawtypes"}) + SingleEntityFetcher(QueryByExampleExecutor executor, + TypeInformation domainType, + Class resultType, + Sort sort, + ConversionService conversionService) { + + super(domainType, conversionService); + this.executor = executor; + this.resultType = resultType; + this.sort = sort; + } + + @Override + @SuppressWarnings({"ConstantConditions", "unchecked"}) + public R get(DataFetchingEnvironment env) { + return this.executor.findBy(buildExample(env), query -> { + FluentQuery.FetchableFluentQuery queryToUse = (FluentQuery.FetchableFluentQuery) query; + + if (this.sort.isSorted()) { + queryToUse = queryToUse.sortBy(this.sort); + } + + Class resultType = this.resultType; + if (requiresProjection(resultType)) { + queryToUse = queryToUse.as(resultType); + } + else { + queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), resultType)); + } + + return queryToUse.first(); + }).orElse(null); + } + + } + + private static class ManyEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher> { + + private final QueryByExampleExecutor executor; + + private final Class resultType; + + private final Sort sort; + + @SuppressWarnings({"unchecked", "rawtypes"}) + ManyEntityFetcher(QueryByExampleExecutor executor, + TypeInformation domainType, + Class resultType, + Sort sort, + ConversionService conversionService) { + super(domainType, conversionService); + this.executor = executor; + this.resultType = resultType; + this.sort = sort; + } + + @Override + @SuppressWarnings("unchecked") + public Iterable get(DataFetchingEnvironment env) { + return this.executor.findBy(buildExample(env), query -> { + FluentQuery.FetchableFluentQuery queryToUse = (FluentQuery.FetchableFluentQuery) query; + + if (this.sort.isSorted()) { + queryToUse = queryToUse.sortBy(this.sort); + } + + if (requiresProjection(this.resultType)) { + queryToUse = queryToUse.as(this.resultType); + } + else { + queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), this.resultType)); + } + + return queryToUse.all(); + }); + } + + } + + private static class ReactiveSingleEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher> { + + private final ReactiveQueryByExampleExecutor executor; + + private final Class resultType; + + private final Sort sort; + + @SuppressWarnings({"unchecked", "rawtypes"}) + ReactiveSingleEntityFetcher(ReactiveQueryByExampleExecutor executor, + TypeInformation domainType, + Class resultType, + Sort sort, + ConversionService conversionService) { + + super(domainType, conversionService); + this.executor = executor; + this.resultType = resultType; + this.sort = sort; + } + + @Override + @SuppressWarnings("unchecked") + public Mono get(DataFetchingEnvironment env) { + return this.executor.findBy(buildExample(env), query -> { + FluentQuery.ReactiveFluentQuery queryToUse = (FluentQuery.ReactiveFluentQuery) query; + + if (this.sort.isSorted()) { + queryToUse = queryToUse.sortBy(this.sort); + } + + if (requiresProjection(this.resultType)) { + queryToUse = queryToUse.as(this.resultType); + } + else { + queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), this.resultType)); + } + + return queryToUse.first(); + }); + } + + } + + private static class ReactiveManyEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher> { + + private final ReactiveQueryByExampleExecutor executor; + + private final Class resultType; + + private final Sort sort; + + @SuppressWarnings({"unchecked", "rawtypes"}) + ReactiveManyEntityFetcher(ReactiveQueryByExampleExecutor executor, + TypeInformation domainType, + Class resultType, + Sort sort, + ConversionService conversionService) { + + super(domainType, conversionService); + this.executor = executor; + this.resultType = resultType; + this.sort = sort; + } + + @Override + @SuppressWarnings("unchecked") + public Flux get(DataFetchingEnvironment env) { + return this.executor.findBy(buildExample(env), query -> { + FluentQuery.ReactiveFluentQuery queryToUse = (FluentQuery.ReactiveFluentQuery) query; + + if (this.sort.isSorted()) { + queryToUse = queryToUse.sortBy(this.sort); + } + + if (requiresProjection(this.resultType)) { + queryToUse = queryToUse.as(this.resultType); + } + else { + queryToUse = queryToUse.project(buildPropertyPaths(env.getSelectionSet(), this.resultType)); + } + + return queryToUse.all(); + }); + } + } + + /** + * GraphQLTypeVisitor that auto-registers Query By Example Spring Data repositories. + */ + private static class RegistrationTypeVisitor extends GraphQLTypeVisitorStub { + + private final Map>> executorMap; + + RegistrationTypeVisitor( + List> executors, + List> reactiveExecutors) { + + this.executorMap = initExecutorMap(executors, reactiveExecutors); + } + + private Map>> initExecutorMap( + List> executors, + List> reactiveExecutors) { + + Map>> map = new HashMap<>(); + + for (QueryByExampleExecutor executor : executors) { + String typeName = getTypeName(executor); + if (typeName != null) { + map.put(typeName, (single) -> single ? + builder(executor).single() : + builder(executor).many()); + } + } + + for (ReactiveQueryByExampleExecutor reactiveExecutor : reactiveExecutors) { + String typeName = getTypeName(reactiveExecutor); + if (typeName != null) { + map.put(typeName, (single) -> single ? + builder(reactiveExecutor).single() : + builder(reactiveExecutor).many()); + } + } + + return map; + } + + @Nullable + private String getTypeName(Object repository) { + GraphQlRepository annotation = + AnnotatedElementUtils.findMergedAnnotation(repository.getClass(), GraphQlRepository.class); + + if (annotation == null) { + return null; + } + if (StringUtils.hasText(annotation.typeName())) { + return annotation.typeName(); + } + Class repositoryInterface = getRepositoryInterface(repository); + RepositoryMetadata metadata = new DefaultRepositoryMetadata(repositoryInterface); + return metadata.getDomainType().getSimpleName(); + } + + @Override + public TraversalControl visitGraphQLFieldDefinition( + GraphQLFieldDefinition fieldDefinition, TraverserContext context) { + + if (this.executorMap.isEmpty()) { + return TraversalControl.QUIT; + } + + GraphQLType fieldType = fieldDefinition.getType(); + GraphQLFieldsContainer parent = (GraphQLFieldsContainer) context.getParentNode(); + if (!parent.getName().equals("Query")) { + return TraversalControl.ABORT; + } + + DataFetcher dataFetcher = (fieldType instanceof GraphQLList ? + getDataFetcher(((GraphQLList) fieldType).getWrappedType(), false) : + getDataFetcher(fieldType, true)); + + if (dataFetcher != null) { + GraphQLCodeRegistry.Builder registry = context.getVarFromParents(GraphQLCodeRegistry.Builder.class); + if (!hasDataFetcher(registry, parent, fieldDefinition)) { + registry.dataFetcher(parent, fieldDefinition, dataFetcher); + } + } + + return TraversalControl.CONTINUE; + } + + @Nullable + private DataFetcher getDataFetcher(GraphQLType type, boolean single) { + if (type instanceof GraphQLNamedOutputType) { + String typeName = ((GraphQLNamedOutputType) type).getName(); + Function> factory = this.executorMap.get(typeName); + if (factory != null) { + return factory.apply(single); + } + } + return null; + } + + private boolean hasDataFetcher( + GraphQLCodeRegistry.Builder registry, GraphQLFieldsContainer parent, + GraphQLFieldDefinition fieldDefinition) { + + DataFetcher fetcher = registry.getDataFetcher(parent, fieldDefinition); + return (fetcher != null && !(fetcher instanceof PropertyDataFetcher)); + } + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/package-info.java new file mode 100644 index 00000000..6b1a7652 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/querybyexample/package-info.java @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** + * Support for {@link graphql.schema.DataFetcher}s backed by Querydsl based + * Spring Data repositories. + */ +@NonNullApi +@NonNullFields +package org.springframework.graphql.data.querybyexample; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Author.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Author.java new file mode 100644 index 00000000..c6aa372a --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Author.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-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. + */ +package org.springframework.graphql.data.querybyexample.jpa; + +import javax.persistence.Entity; +import javax.persistence.Id; + +@Entity +public class Author { + + @Id + Long id; + + String firstName; + + String lastName; + + public Author() { + } + + public Author(Long id, String firstName, String lastName) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + } + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return this.firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return this.lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getFullName() { + return this.firstName + " " + this.lastName; + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Book.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Book.java new file mode 100644 index 00000000..8592e8b4 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/Book.java @@ -0,0 +1,68 @@ +/* + * 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. + */ + +package org.springframework.graphql.data.querybyexample.jpa; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.OneToOne; + +@Entity +public class Book { + + @Id + Long id; + + String name; + + @OneToOne(cascade = CascadeType.ALL) + Author author = new Author(); + + public Book() { + } + + public Book(Long id, String name, Author author) { + this.id = id; + this.name = name; + this.author = author; + } + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public Author getAuthor() { + return this.author; + } + + public void setAuthor(Author author) { + this.author = author; + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/BookRepository.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/BookRepository.java new file mode 100644 index 00000000..62d8cc7b --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/BookRepository.java @@ -0,0 +1,8 @@ +package org.springframework.graphql.data.querybyexample.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.graphql.data.GraphQlRepository; + +@GraphQlRepository +public interface BookRepository extends JpaRepository { +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/QueryByExampleDataFetcherJpaTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/QueryByExampleDataFetcherJpaTests.java new file mode 100644 index 00000000..22ec804e --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/jpa/QueryByExampleDataFetcherJpaTests.java @@ -0,0 +1,249 @@ +/* + * Copyright 2002-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 + * + * http://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.querybyexample.jpa; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; + +import graphql.schema.DataFetcher; +import graphql.schema.GraphQLTypeVisitor; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlResponse; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.data.querybyexample.QueryByExampleDataFetcher; +import org.springframework.graphql.web.WebGraphQlHandler; +import org.springframework.graphql.web.WebInput; +import org.springframework.graphql.web.WebOutput; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.lang.Nullable; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.PlatformTransactionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link QueryByExampleDataFetcher}. + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +class QueryByExampleDataFetcherJpaTests { + + @Autowired + private BookRepository repository; + + static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { + return initGraphQlSetup(null).queryFetcher(fieldName, fetcher); + } + + static GraphQlSetup graphQlSetup(@Nullable QueryByExampleExecutor executor) { + return initGraphQlSetup(executor); + } + + private static GraphQlSetup initGraphQlSetup( + @Nullable QueryByExampleExecutor executor) { + + GraphQLTypeVisitor visitor = QueryByExampleDataFetcher.registrationTypeVisitor( + executor != null + ? Collections.singletonList(executor) + : Collections.emptyList(), + Collections.emptyList()); + + return GraphQlSetup.schemaResource(BookSource.schema).typeVisitor(visitor); + } + + @Test + void shouldFetchSingleItems() { + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); + repository.save(book); + + Consumer tester = setup -> { + Mono output = setup.toWebGraphQlHandler().handleRequest(input("{ bookById(id: 42) {name}}")); + Book actualBook = GraphQlResponse.from(output).toEntity("bookById", Book.class); + + assertThat(actualBook.getName()).isEqualTo(book.getName()); + }; + + // explicit wiring + tester.accept(graphQlSetup("bookById", QueryByExampleDataFetcher.builder(repository).single())); + + // auto registration + tester.accept(graphQlSetup(repository)); + } + + @Test + void shouldFetchMultipleItems() { + Book book1 = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); + Book book2 = new Book(53L, "Breaking Bad", new Author(0L, "", "Heisenberg")); + repository.saveAll(Arrays.asList(book1, book2)); + + Consumer tester = graphQlSetup -> { + Mono output = graphQlSetup.toWebGraphQlHandler().handleRequest(input("{ books {name}}")); + + List names = GraphQlResponse.from(output).toList("books", Book.class) + .stream() + .map(Book::getName) + .collect(Collectors.toList()); + + assertThat(names).containsExactlyInAnyOrder(book1.getName(), book2.getName()); + }; + + // explicit wiring + tester.accept(graphQlSetup("books", QueryByExampleDataFetcher.builder(repository).many())); + + // auto registration + tester.accept(graphQlSetup(repository)); + } + + @Test + void shouldFavorExplicitWiring() { + BookRepository mockRepository = mock(BookRepository.class); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); + when(mockRepository.findBy(any(), any())).thenReturn(Optional.of(book)); + + // 1) Automatic registration only + WebGraphQlHandler handler = graphQlSetup(mockRepository).toWebGraphQlHandler(); + Mono outputMono = handler.handleRequest(input("{ bookById(id: 1) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("Hitchhiker's Guide to the Galaxy"); + + // 2) Automatic registration and explicit wiring + handler = graphQlSetup(mockRepository) + .queryFetcher("bookById", env -> new Book(53L, "Breaking Bad", new Author(0L, "", "Heisenberg"))) + .toWebGraphQlHandler(); + + outputMono = handler.handleRequest(input("{ bookById(id: 1) {name}}")); + + actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("Breaking Bad"); + } + + @Test + void shouldFetchSingleItemsWithInterfaceProjection() { + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); + repository.save(book); + + DataFetcher fetcher = QueryByExampleDataFetcher.builder(repository).projectAs(BookProjection.class).single(); + WebGraphQlHandler handler = graphQlSetup("bookById", fetcher).toWebGraphQlHandler(); + + Mono outputMono = handler.handleRequest(input("{ bookById(id: 42) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("Hitchhiker's Guide to the Galaxy by Douglas Adams"); + } + + @Disabled("Pending https://github.com/spring-projects/spring-data-jpa/issues/2327") + @Test + void shouldFetchSingleItemsWithDtoProjection() { + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); + repository.save(book); + + DataFetcher fetcher = QueryByExampleDataFetcher.builder(repository).projectAs(BookDto.class).single(); + WebGraphQlHandler handler = graphQlSetup("bookById", fetcher).toWebGraphQlHandler(); + + Mono outputMono = handler.handleRequest(input("{ bookById(id: 42) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("The book is: Hitchhiker's Guide to the Galaxy"); + } + + private WebInput input(String query) { + return new WebInput(URI.create("/"), new HttpHeaders(), Collections.singletonMap("query", query), null, "1"); + } + + + interface BookProjection { + + @Value("#{target.name + ' by ' + target.author.firstName + ' ' + target.author.lastName}") + String getName(); + + } + + static class BookDto { + + private final String name; + + public BookDto(String name) { + this.name = name; + } + + public String getName() { + return "The book is: " + name; + } + + } + + @Configuration + @EnableJpaRepositories(considerNestedRepositories = true) + static class TestConfig { + + @Bean + DriverManagerDataSource dataSource() { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.h2.Driver"); + dataSource.setUrl("jdbc:h2:mem:query-by-example-test;DB_CLOSE_DELAY=-1"); + return dataSource; + } + + @Bean + LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + HibernateJpaVendorAdapter jpaVendorAdapter = new HibernateJpaVendorAdapter(); + jpaVendorAdapter.setGenerateDdl(true); + jpaVendorAdapter.setShowSql(true); + + LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); + factory.setDataSource(dataSource); + factory.setJpaVendorAdapter(jpaVendorAdapter); + factory.setPackagesToScan(QueryByExampleDataFetcherJpaTests.class.getPackage().getName()); + return factory; + } + + @Bean + PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { + JpaTransactionManager transactionManager = new JpaTransactionManager(); + transactionManager.setEntityManagerFactory(entityManagerFactory); + return transactionManager; + } + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Author.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Author.java new file mode 100644 index 00000000..fb53cf7c --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Author.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-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. + */ +package org.springframework.graphql.data.querybyexample.mongodb; + +import org.springframework.data.annotation.Id; + +public class Author { + + @Id + String id; + + String firstName; + + String lastName; + + public Author() { + } + + public Author(String id, String firstName, String lastName) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + } + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstName() { + return this.firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return this.lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getFullName() { + return this.firstName + " " + this.lastName; + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Book.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Book.java new file mode 100644 index 00000000..92351380 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/Book.java @@ -0,0 +1,63 @@ +/* + * 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. + */ + +package org.springframework.graphql.data.querybyexample.mongodb; + +import org.springframework.data.annotation.Id; + +public class Book { + + @Id + String id; + + String name; + + Author author = new Author(); + + public Book() { + } + + public Book(String id, String name, Author author) { + this.id = id; + this.name = name; + this.author = author; + } + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public Author getAuthor() { + return this.author; + } + + public void setAuthor(Author author) { + this.author = author; + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/BookRepository.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/BookRepository.java new file mode 100644 index 00000000..3e731fc4 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/BookRepository.java @@ -0,0 +1,8 @@ +package org.springframework.graphql.data.querybyexample.mongodb; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.graphql.data.GraphQlRepository; + +@GraphQlRepository +public interface BookRepository extends MongoRepository { +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherMongoDbTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherMongoDbTests.java new file mode 100644 index 00000000..b6a9e7f8 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherMongoDbTests.java @@ -0,0 +1,222 @@ +/* + * Copyright 2002-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 + * + * http://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.querybyexample.mongodb; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import com.mongodb.client.MongoClients; +import graphql.schema.DataFetcher; +import graphql.schema.GraphQLTypeVisitor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlResponse; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.data.querybyexample.QueryByExampleDataFetcher; +import org.springframework.graphql.web.WebGraphQlHandler; +import org.springframework.graphql.web.WebInput; +import org.springframework.graphql.web.WebOutput; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link QueryByExampleDataFetcher}. + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@Testcontainers +class QueryByExampleDataFetcherMongoDbTests { + + @Container + static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10")); + + @Autowired + private BookRepository repository; + + static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { + return initGraphQlSetup(null).queryFetcher(fieldName, fetcher); + } + + static GraphQlSetup graphQlSetup(@Nullable QueryByExampleExecutor executor) { + return initGraphQlSetup(executor); + } + + private static GraphQlSetup initGraphQlSetup( + @Nullable QueryByExampleExecutor executor) { + + GraphQLTypeVisitor visitor = QueryByExampleDataFetcher.registrationTypeVisitor( + (executor != null ? Collections.singletonList(executor) : Collections.emptyList()), + Collections.emptyList()); + + return GraphQlSetup.schemaResource(BookSource.schema).typeVisitor(visitor); + } + + @Test + void shouldFetchSingleItems() { + Book book = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + repository.save(book); + + Consumer tester = setup -> { + Mono output = setup.toWebGraphQlHandler().handleRequest(input("{ bookById(id: 42) {name}}")); + Book actualBook = GraphQlResponse.from(output).toEntity("bookById", Book.class); + + assertThat(actualBook.getName()).isEqualTo(book.getName()); + }; + + // explicit wiring + tester.accept(graphQlSetup("bookById", QueryByExampleDataFetcher.builder(repository).single())); + + // auto registration + tester.accept(graphQlSetup(repository)); + } + + @Test + void shouldFetchMultipleItems() { + Book book1 = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + Book book2 = new Book("53", "Breaking Bad", new Author("0", "", "Heisenberg")); + repository.saveAll(Arrays.asList(book1, book2)); + + Consumer tester = graphQlSetup -> { + Mono output = graphQlSetup.toWebGraphQlHandler().handleRequest(input("{ books {name}}")); + + List names = GraphQlResponse.from(output).toList("books", Book.class) + .stream().map(Book::getName).collect(Collectors.toList()); + + assertThat(names).containsExactlyInAnyOrder(book1.getName(), book2.getName()); + }; + + // explicit wiring + tester.accept(graphQlSetup("books", QueryByExampleDataFetcher.builder(repository).many())); + + // auto registration + tester.accept(graphQlSetup(repository)); + } + + @Test + void shouldFavorExplicitWiring() { + BookRepository mockRepository = mock(BookRepository.class); + Book book = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + when(mockRepository.findBy(any(), any())).thenReturn(Optional.of(book)); + + // 1) Automatic registration only + WebGraphQlHandler handler = graphQlSetup(mockRepository).toWebGraphQlHandler(); + Mono outputMono = handler.handleRequest(input("{ bookById(id: 1) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("Hitchhiker's Guide to the Galaxy"); + + // 2) Automatic registration and explicit wiring + handler = graphQlSetup(mockRepository) + .queryFetcher("bookById", env -> new Book("53", "Breaking Bad", new Author("0", "", "Heisenberg"))) + .toWebGraphQlHandler(); + + outputMono = handler.handleRequest(input("{ bookById(id: 1) {name}}")); + + actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("Breaking Bad"); + } + + @Test + void shouldFetchSingleItemsWithInterfaceProjection() { + Book book = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + repository.save(book); + + DataFetcher fetcher = QueryByExampleDataFetcher.builder(repository).projectAs(BookProjection.class).single(); + WebGraphQlHandler handler = graphQlSetup("bookById", fetcher).toWebGraphQlHandler(); + + Mono outputMono = handler.handleRequest(input("{ bookById(id: 42) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("Hitchhiker's Guide to the Galaxy by Douglas Adams"); + } + + @Test + void shouldFetchSingleItemsWithDtoProjection() { + Book book = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + repository.save(book); + + DataFetcher fetcher = QueryByExampleDataFetcher.builder(repository).projectAs(BookDto.class).single(); + WebGraphQlHandler handler = graphQlSetup("bookById", fetcher).toWebGraphQlHandler(); + + Mono outputMono = handler.handleRequest(input("{ bookById(id: 42) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("The book is: Hitchhiker's Guide to the Galaxy"); + } + + private WebInput input(String query) { + return new WebInput(URI.create("/"), new HttpHeaders(), Collections.singletonMap("query", query), null, "1"); + } + + interface BookProjection { + + @Value("#{target.name + ' by ' + target.author.firstName + ' ' + target.author.lastName}") + String getName(); + } + + static class BookDto { + + private final String name; + + public BookDto(String name) { + this.name = name; + } + + public String getName() { + return "The book is: " + name; + } + } + + @Configuration + @EnableMongoRepositories(considerNestedRepositories = true) + static class TestConfig { + + @Bean + MongoTemplate mongoTemplate() { + return new MongoTemplate(MongoClients.create(String.format("mongodb://%s:%d", + mongoDBContainer.getContainerIpAddress(), + mongoDBContainer.getFirstMappedPort())), + "test"); + } + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherReactiveMongoDbTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherReactiveMongoDbTests.java new file mode 100644 index 00000000..798a8325 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/QueryByExampleDataFetcherReactiveMongoDbTests.java @@ -0,0 +1,194 @@ +/* + * Copyright 2002-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 + * + * http://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.querybyexample.mongodb; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import com.mongodb.reactivestreams.client.MongoClients; +import graphql.schema.DataFetcher; +import graphql.schema.GraphQLTypeVisitor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.repository.config.EnableReactiveMongoRepositories; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlResponse; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.data.querybyexample.QueryByExampleDataFetcher; +import org.springframework.graphql.web.WebGraphQlHandler; +import org.springframework.graphql.web.WebInput; +import org.springframework.graphql.web.WebOutput; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link QueryByExampleDataFetcher}. + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@Testcontainers +class QueryByExampleDataFetcherReactiveMongoDbTests { + + @Container + static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10")); + + @Autowired + private ReactiveBookRepository repository; + + static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { + return initGraphQlSetup(null).queryFetcher(fieldName, fetcher); + } + + static GraphQlSetup graphQlSetup(@Nullable ReactiveQueryByExampleExecutor executor) { + return initGraphQlSetup(executor); + } + + private static GraphQlSetup initGraphQlSetup( + @Nullable ReactiveQueryByExampleExecutor reactiveExecutor) { + + GraphQLTypeVisitor visitor = QueryByExampleDataFetcher.registrationTypeVisitor( + Collections.emptyList(), + (reactiveExecutor != null ? Collections.singletonList(reactiveExecutor) : Collections.emptyList())); + + return GraphQlSetup.schemaResource(BookSource.schema).typeVisitor(visitor); + } + + @Test + void shouldReactivelyFetchSingleItems() { + Book book = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + repository.save(book).block(); + + Consumer tester = setup -> { + Mono outputMono = setup.toWebGraphQlHandler().handleRequest(input("{ bookById(id: 42) {name}}")); + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + + assertThat(actualBook.getName()).isEqualTo(book.getName()); + }; + + // explicit wiring + tester.accept(graphQlSetup("bookById", QueryByExampleDataFetcher.builder(repository).single())); + + // auto registration + tester.accept(graphQlSetup(repository)); + } + + @Test + void shouldFetchSingleItemsReactivelyWithInterfaceProjection() { + Book book = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + repository.save(book).block(); + + DataFetcher fetcher = QueryByExampleDataFetcher.builder(repository).projectAs(BookProjection.class).single(); + WebGraphQlHandler handler = graphQlSetup("bookById", fetcher).toWebGraphQlHandler(); + + Mono outputMono = handler.handleRequest(input("{ bookById(id: 42) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("Hitchhiker's Guide to the Galaxy by Douglas Adams"); + } + + @Test + void shouldFetchSingleItemsReactivelyWithDtoProjection() { + Book book = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + repository.save(book).block(); + + DataFetcher fetcher = QueryByExampleDataFetcher.builder(repository).projectAs(BookDto.class).single(); + WebGraphQlHandler handler = graphQlSetup("bookById", fetcher).toWebGraphQlHandler(); + + Mono outputMono = handler.handleRequest(input("{ bookById(id: 42) {name}}")); + + Book actualBook = GraphQlResponse.from(outputMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("The book is: Hitchhiker's Guide to the Galaxy"); + } + + @Test + void shouldReactivelyFetchMultipleItems() { + Book book1 = new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")); + Book book2 = new Book("53", "Breaking Bad", new Author("0", "", "Heisenberg")); + repository.saveAll(Flux.just(book1, book2)).blockLast(); + + Consumer tester = setup -> { + Mono outputMono = setup.toWebGraphQlHandler().handleRequest(input("{ books {name}}")); + + List names = GraphQlResponse.from(outputMono).toList("books", Book.class) + .stream().map(Book::getName).collect(Collectors.toList()); + + assertThat(names).containsExactlyInAnyOrder("Breaking Bad", "Hitchhiker's Guide to the Galaxy"); + }; + + // explicit wiring + tester.accept(graphQlSetup("books", QueryByExampleDataFetcher.builder(repository).many())); + + // auto registration + tester.accept(graphQlSetup(repository)); + } + + private WebInput input(String query) { + return new WebInput(URI.create("/"), new HttpHeaders(), Collections.singletonMap("query", query), null, "1"); + } + + interface BookProjection { + + @Value("#{target.name + ' by ' + target.author.firstName + ' ' + target.author.lastName}") + String getName(); + } + + static class BookDto { + + private final String name; + + public BookDto(String name) { + this.name = name; + } + + public String getName() { + return "The book is: " + name; + } + } + + @Configuration + @EnableReactiveMongoRepositories(considerNestedRepositories = true) + static class TestConfig { + + @Bean + ReactiveMongoTemplate reactiveMongoTemplate() { + return new ReactiveMongoTemplate(MongoClients.create(String.format("mongodb://%s:%d", + mongoDBContainer.getContainerIpAddress(), + mongoDBContainer.getFirstMappedPort())), + "test"); + } + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/ReactiveBookRepository.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/ReactiveBookRepository.java new file mode 100644 index 00000000..e901e741 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querybyexample/mongodb/ReactiveBookRepository.java @@ -0,0 +1,8 @@ +package org.springframework.graphql.data.querybyexample.mongodb; + +import org.springframework.data.mongodb.repository.ReactiveMongoRepository; +import org.springframework.graphql.data.GraphQlRepository; + +@GraphQlRepository +public interface ReactiveBookRepository extends ReactiveMongoRepository { +}