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 { +}