From c49810fd3c197445543c032542f871a5d49726f3 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 1 Apr 2021 12:16:34 +0200 Subject: [PATCH] Add Querydsl integration Spring Data repositories that support Querydsl are now supported as DataFetchers returning single objects and iterables including projection support. --- build.gradle | 3 +- samples/webmvc-http/build.gradle | 15 +- .../repository/ArtifactRepositories.java | 3 +- .../ArtifactRepositoryDataWiring.java | 8 +- spring-graphql/build.gradle | 6 + .../data/DtoInstantiatingConverter.java | 106 ++++++++ .../graphql/data/DtoMappingContext.java | 75 +++++ .../data/QuerydslDataFetcherSupport.java | 256 ++++++++++++++++++ .../graphql/data/package-info.java | 25 ++ .../springframework/graphql/data/Book.java | 63 +++++ .../springframework/graphql/data/QBook.java | 47 ++++ .../data/QuerydslDataFetcherSupportTests.java | 208 ++++++++++++++ .../src/test/resources/books/schema.graphqls | 1 + 13 files changed, 811 insertions(+), 5 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/DtoInstantiatingConverter.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/DtoMappingContext.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/QuerydslDataFetcherSupport.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/package-info.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/Book.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/QBook.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/QuerydslDataFetcherSupportTests.java diff --git a/build.gradle b/build.gradle index 4e427c411..f614aa74f 100644 --- a/build.gradle +++ b/build.gradle @@ -30,12 +30,13 @@ configure(moduleProjects) { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - + dependencyManagement { imports { mavenBom "com.fasterxml.jackson:jackson-bom:2.12.3" mavenBom "io.projectreactor:reactor-bom:2020.0.7" mavenBom "org.springframework:spring-framework-bom:5.3.7" + mavenBom "org.springframework.data:spring-data-bom:2021.0.1" mavenBom "org.junit:junit-bom:5.7.2" } dependencies { diff --git a/samples/webmvc-http/build.gradle b/samples/webmvc-http/build.gradle index 299812d6a..7509c4e08 100644 --- a/samples/webmvc-http/build.gradle +++ b/samples/webmvc-http/build.gradle @@ -18,7 +18,20 @@ dependencies { testImplementation project(':spring-graphql-test') testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.boot:spring-boot-starter-test' + + implementation( + "com.querydsl:querydsl-core:4.4.0", + "com.querydsl:querydsl-jpa:4.4.0" + ) + annotationProcessor "com.querydsl:querydsl-apt:4.4.0:jpa", + "org.hibernate.javax.persistence:hibernate-jpa-2.1-api:1.0.2.Final", + "javax.annotation:javax.annotation-api:1.3.2" +} + +compileJava { + options.annotationProcessorPath = configurations.annotationProcessor } + test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositories.java b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositories.java index 44cd614e4..72aafe2a2 100644 --- a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositories.java +++ b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositories.java @@ -1,7 +1,8 @@ package io.spring.sample.graphql.repository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; import org.springframework.data.repository.CrudRepository; -public interface ArtifactRepositories extends CrudRepository { +public interface ArtifactRepositories extends CrudRepository, QuerydslPredicateExecutor { } diff --git a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositoryDataWiring.java b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositoryDataWiring.java index 2b119fc49..f22ab1ee1 100644 --- a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositoryDataWiring.java +++ b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/repository/ArtifactRepositoryDataWiring.java @@ -1,7 +1,9 @@ package io.spring.sample.graphql.repository; import graphql.schema.idl.RuntimeWiring; + import org.springframework.graphql.boot.RuntimeWiringCustomizer; +import org.springframework.graphql.data.QuerydslDataFetcherSupport; import org.springframework.stereotype.Component; @Component @@ -16,8 +18,10 @@ public ArtifactRepositoryDataWiring(ArtifactRepositories repositories) { @Override public void customize(RuntimeWiring.Builder builder) { builder.type("Query", - typeWiring -> typeWiring.dataFetcher("artifactRepositories", env -> this.repositories.findAll()) - .dataFetcher("artifactRepository", env -> this.repositories.findById(env.getArgument("id")))); + typeWiring -> typeWiring.dataFetcher("artifactRepositories", QuerydslDataFetcherSupport + .builder(repositories).many()) + .dataFetcher("artifactRepository", QuerydslDataFetcherSupport + .builder(repositories).single())); } } diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index eac17b212..ec37410d3 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -11,13 +11,19 @@ dependencies { compileOnly 'org.springframework:spring-websocket' compileOnly 'javax.servlet:javax.servlet-api:4.0.1' + compileOnly 'com.querydsl:querydsl-core:4.4.0' + compileOnly 'org.springframework.data:spring-data-commons' + testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.assertj:assertj-core' + testImplementation 'org.mockito:mockito-core:3.11.1' testImplementation 'io.projectreactor:reactor-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework:spring-webmvc' testImplementation 'org.springframework:spring-websocket' testImplementation 'org.springframework:spring-test' + testImplementation 'org.springframework.data:spring-data-commons' + testImplementation 'com.querydsl:querydsl-core:4.4.0' testImplementation 'javax.servlet:javax.servlet-api:4.0.1' testImplementation 'com.fasterxml.jackson.core:jackson-databind' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/DtoInstantiatingConverter.java b/spring-graphql/src/main/java/org/springframework/graphql/data/DtoInstantiatingConverter.java new file mode 100644 index 000000000..b13b1f2ec --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/DtoInstantiatingConverter.java @@ -0,0 +1,106 @@ +/* + * 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; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PreferredConstructor; +import org.springframework.data.mapping.PreferredConstructor.Parameter; +import org.springframework.data.mapping.SimplePropertyHandler; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.EntityInstantiator; +import org.springframework.data.mapping.model.EntityInstantiators; +import org.springframework.data.mapping.model.ParameterValueProvider; + +/** + * {@link Converter} to instantiate DTOs from fully equipped domain objects. + * + * @author Mark Paluch + * @since 1.0.0 + */ +class DtoInstantiatingConverter implements Converter { + + private final Class targetType; + + private final MappingContext, ? extends PersistentProperty> context; + + private final EntityInstantiator instantiator; + + /** + * Creates a new {@link Converter} to instantiate DTOs. + * + * @param dtoType target type + * @param context mapping context to be used + * @param entityInstantiators the instantiators to use for object creation + */ + public DtoInstantiatingConverter(Class dtoType, + MappingContext, ? extends PersistentProperty> context, + EntityInstantiators entityInstantiators) { + this.targetType = dtoType; + this.context = context; + this.instantiator = entityInstantiators + .getInstantiatorFor(context.getRequiredPersistentEntity(dtoType)); + } + + @SuppressWarnings("unchecked") + @Override + public T convert(Object source) { + + if (targetType.isInterface()) { + return (T) source; + } + + PersistentEntity sourceEntity = context + .getRequiredPersistentEntity(source.getClass()); + + PersistentPropertyAccessor sourceAccessor = sourceEntity + .getPropertyAccessor(source); + PersistentEntity targetEntity = context + .getRequiredPersistentEntity(targetType); + PreferredConstructor> constructor = targetEntity + .getPersistenceConstructor(); + + @SuppressWarnings({"rawtypes", "unchecked"}) + Object dto = instantiator + .createInstance(targetEntity, new ParameterValueProvider() { + + @Override + public Object getParameterValue(Parameter parameter) { + return sourceAccessor.getProperty(sourceEntity + .getRequiredPersistentProperty(parameter.getName())); + } + }); + + PersistentPropertyAccessor dtoAccessor = targetEntity + .getPropertyAccessor(dto); + + targetEntity.doWithProperties((SimplePropertyHandler) property -> { + + if (constructor.isConstructorParameter(property)) { + return; + } + + dtoAccessor.setProperty(property, + sourceAccessor.getProperty(sourceEntity + .getRequiredPersistentProperty(property.getName()))); + }); + + return (T) dto; + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/DtoMappingContext.java b/spring-graphql/src/main/java/org/springframework/graphql/data/DtoMappingContext.java new file mode 100644 index 000000000..a46ade891 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/DtoMappingContext.java @@ -0,0 +1,75 @@ +/* + * 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; + +import org.springframework.data.mapping.Association; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.context.AbstractMappingContext; +import org.springframework.data.mapping.model.AnnotationBasedPersistentProperty; +import org.springframework.data.mapping.model.BasicPersistentEntity; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.TypeInformation; + +/** + * Lightweight {@link org.springframework.data.mapping.context.MappingContext} to provide class metadata for entity to DTO mapping. + * + * @author Mark Paluch + * @since 1.0.0 + */ +class DtoMappingContext extends AbstractMappingContext, DtoMappingContext.DtoPersistentProperty> { + + @Override + protected boolean shouldCreatePersistentEntityFor(TypeInformation type) { + + // No Java std lib type introspection to not interfere with encapsulation. We do not want to get into the business of materializing Java types. + if (type.getType().getName().startsWith("java.") || type.getType().getName() + .startsWith("javax.")) { + return false; + } + return super.shouldCreatePersistentEntityFor(type); + } + + @Override + protected DtoPersistentEntity createPersistentEntity(TypeInformation typeInformation) { + return new DtoPersistentEntity<>(typeInformation); + } + + @Override + protected DtoPersistentProperty createPersistentProperty(Property property, DtoPersistentEntity owner, SimpleTypeHolder simpleTypeHolder) { + return new DtoPersistentProperty(property, owner, simpleTypeHolder); + } + + static class DtoPersistentEntity extends BasicPersistentEntity { + + public DtoPersistentEntity(TypeInformation information) { + super(information); + } + } + + static class DtoPersistentProperty extends AnnotationBasedPersistentProperty { + + public DtoPersistentProperty(Property property, PersistentEntity owner, SimpleTypeHolder simpleTypeHolder) { + super(property, owner, simpleTypeHolder); + } + + @Override + protected Association createAssociation() { + return null; + } + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/QuerydslDataFetcherSupport.java b/spring-graphql/src/main/java/org/springframework/graphql/data/QuerydslDataFetcherSupport.java new file mode 100644 index 000000000..0a57223fe --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/QuerydslDataFetcherSupport.java @@ -0,0 +1,256 @@ +/* + * 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; + +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; + +import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Predicate; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.mapping.model.EntityInstantiators; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.querydsl.SimpleEntityPathResolver; +import org.springframework.data.querydsl.binding.QuerydslBinderCustomizer; +import org.springframework.data.querydsl.binding.QuerydslBindings; +import org.springframework.data.querydsl.binding.QuerydslPredicateBuilder; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.Streamable; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Utility to implement {@link DataFetcher} based on Querydsl {@link Predicate} through {@link QuerydslPredicateExecutor}. Actual instances can be created through a {@link #builder(QuerydslPredicateExecutor) builder} to query for {@link Builder#single()} or {@link Builder#many()} objects. + * Example: + *
+ * interface BookRepository extends Repository<Book, String>, QuerydslPredicateExecutor<Book>{}
+ *
+ * BookRepository repository = …;
+ * TypeRuntimeWiring wiring = …;
+ *
+ * wiring.dataFetcher("books", QuerydslDataFetcherSupport.builder(repository).many())
+ *       .dataFetcher("book", QuerydslDataFetcherSupport.builder(repositories).single());
+ * 
+ * + * @param returned result type + * @author Mark Paluch + * @since 1.0.0 + * @see QuerydslPredicateExecutor + * @see Predicate + * @see QuerydslBinderCustomizer + */ +public abstract class QuerydslDataFetcherSupport { + + private static final QuerydslPredicateBuilder BUILDER = new QuerydslPredicateBuilder(DefaultConversionService + .getSharedInstance(), SimpleEntityPathResolver.INSTANCE); + + private final TypeInformation domainType; + + private final QuerydslBinderCustomizer> customizer; + + QuerydslDataFetcherSupport(QuerydslPredicateExecutor executor, QuerydslBinderCustomizer> customizer) { + + this.customizer = customizer; + + Class repositoryInterface = getRepositoryInterface(executor); + + DefaultRepositoryMetadata metadata = new DefaultRepositoryMetadata(repositoryInterface); + domainType = ClassTypeInformation.from(metadata.getDomainType()); + } + + private static Class getRepositoryInterface(QuerydslPredicateExecutor executor) { + + Type[] genericInterfaces = executor.getClass().getGenericInterfaces(); + for (Type genericInterface : genericInterfaces) { + + ResolvableType resolvableType = ResolvableType.forType(genericInterface); + + if (MergedAnnotations.from(resolvableType.getRawClass()) + .isPresent(NoRepositoryBean.class)) { + continue; + } + + if (Repository.class.isAssignableFrom(resolvableType.getRawClass())) { + return resolvableType.getRawClass(); + } + } + + throw new IllegalArgumentException(String + .format("Cannot resolve repository interface from %s", executor)); + } + + /** + * Create a new {@link Builder} accepting {@link QuerydslPredicateExecutor}. + * + * @param executor the repository object to use + * @param result type + * @return a new builder. + */ + public static Builder builder(QuerydslPredicateExecutor executor) { + return new Builder<>(executor, (bindings, root) -> { + }, Function.identity()); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + Predicate buildPredicate(DataFetchingEnvironment environment) { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + + QuerydslBindings bindings = new QuerydslBindings(); + + EntityPath path = SimpleEntityPathResolver.INSTANCE + .createPath(domainType.getType()); + + customizer.customize(bindings, path); + + for (Map.Entry entry : environment.getArguments().entrySet()) { + parameters.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + + return BUILDER + .getPredicate(domainType, (MultiValueMap) parameters, bindings); + } + + /** + * Builder for a Querydsl-based {@link DataFetcher}. Note that builder instances are immutable and return a new instance of the builder when calling configration methods. + * + * @param domain type. + * @param result type. + */ + public static class Builder { + + private final QuerydslPredicateExecutor executor; + + private final QuerydslBinderCustomizer> customizer; + + private final Function resultConverter; + + Builder(QuerydslPredicateExecutor executor, QuerydslBinderCustomizer> customizer, Function resultConverter) { + this.executor = executor; + this.customizer = customizer; + this.resultConverter = resultConverter; + } + + /** + * Project results returned from the {@link QuerydslPredicateExecutor} 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 + */ + public

Builder projectAs(Class

projectionType) { + + // TODO: SpelAwareProxyProjectionFactory, DtoMappingContext, and EntityInstantiators should be reused to avoid duplicate class metadata. + + Assert.notNull(projectionType, "Projection type must not be null"); + + if (projectionType.isInterface()) { + + ProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); + + return new Builder<>(executor, customizer, element -> projectionFactory + .createProjection(projectionType, element)); + } + + DtoInstantiatingConverter

converter = new DtoInstantiatingConverter<>(projectionType, new DtoMappingContext(), new EntityInstantiators()); + + return new Builder<>(executor, customizer, converter::convert); + } + + /** + * Apply a {@link QuerydslBinderCustomizer}. + * @param customizer the customizer to customize bindings for the actual query. + * @return + */ + public Builder customizer(QuerydslBinderCustomizer> customizer) { + Assert.notNull(customizer, "QuerydslBinderCustomizer must not be null"); + return new Builder<>(executor, customizer, resultConverter); + } + + /** + * Build a {@link DataFetcher} to fetch single object instances. + * @return a {@link DataFetcher} based on Querydsl to fetch one object. + */ + public DataFetcher single() { + return new SingleEntityQuerydslDataFetcher<>(executor, customizer, resultConverter); + } + + /** + * Build a {@link DataFetcher} to fetch many object instances. + * @return a {@link DataFetcher} based on Querydsl to fetch many objects. + */ + public DataFetcher> many() { + return new ManyEntityQuerydslDataFetcher<>(executor, customizer, resultConverter); + } + } + + static class ManyEntityQuerydslDataFetcher extends QuerydslDataFetcherSupport implements DataFetcher> { + + private final QuerydslPredicateExecutor executor; + private final Function resultConverter; + + + @SuppressWarnings({"unchecked", "rawtypes"}) + ManyEntityQuerydslDataFetcher(QuerydslPredicateExecutor executor, QuerydslBinderCustomizer> customizer, Function resultConverter) { + super(executor, (QuerydslBinderCustomizer) customizer); + this.executor = executor; + this.resultConverter = resultConverter; + } + + @Override + public Iterable get(DataFetchingEnvironment environment) { + return Streamable.of(executor.findAll(buildPredicate(environment))) + .map(resultConverter).toList(); + } + + } + + static class SingleEntityQuerydslDataFetcher extends QuerydslDataFetcherSupport implements DataFetcher { + + private final QuerydslPredicateExecutor executor; + private final Function resultConverter; + + @SuppressWarnings({"unchecked", "rawtypes"}) + SingleEntityQuerydslDataFetcher(QuerydslPredicateExecutor executor, QuerydslBinderCustomizer> customizer, Function resultConverter) { + super(executor, (QuerydslBinderCustomizer) customizer); + this.executor = executor; + this.resultConverter = resultConverter; + } + + @Override + public R get(DataFetchingEnvironment environment) { + return executor.findOne(buildPredicate(environment)).map(resultConverter) + .orElse(null); + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/data/package-info.java new file mode 100644 index 000000000..bcf088223 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Support for integrating Spring Data data fetchers. + */ +@NonNullApi +@NonNullFields +package org.springframework.graphql.data; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/Book.java b/spring-graphql/src/test/java/org/springframework/graphql/data/Book.java new file mode 100644 index 000000000..9df315906 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/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; + + +import org.springframework.util.ObjectUtils; + +public class Book { + + Long id; + + String name; + + String author; + + public Book() { + } + + public Book(Long id, String name, String 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 String getAuthor() { + return this.author; + } + + public void setAuthor(String author) { + this.author = author; + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/QBook.java b/spring-graphql/src/test/java/org/springframework/graphql/data/QBook.java new file mode 100644 index 000000000..cccf1f516 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/QBook.java @@ -0,0 +1,47 @@ +/* + * 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; + +import com.querydsl.core.types.Path; +import com.querydsl.core.types.PathMetadata; +import com.querydsl.core.types.PathMetadataFactory; +import com.querydsl.core.types.dsl.EntityPathBase; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.core.types.dsl.StringPath; + +/** + * Generated by Querydsl. + */ +public class QBook extends EntityPathBase { + private static final long serialVersionUID = 1773522017L; + public static final QBook book = new QBook("book"); + public final StringPath author = this.createString("author"); + public final NumberPath id = this.createNumber("id", Long.class); + public final StringPath name = this.createString("name"); + + public QBook(String variable) { + super(Book.class, PathMetadataFactory.forVariable(variable)); + } + + public QBook(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBook(PathMetadata metadata) { + super(Book.class, metadata); + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/QuerydslDataFetcherSupportTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/QuerydslDataFetcherSupportTests.java new file mode 100644 index 000000000..48fa8e155 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/QuerydslDataFetcherSupportTests.java @@ -0,0 +1,208 @@ +/* + * 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; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.function.Consumer; + +import com.querydsl.core.types.Predicate; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.TypeRuntimeWiring; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.querydsl.binding.QuerydslBinderCustomizer; +import org.springframework.data.repository.Repository; +import org.springframework.graphql.execution.ExecutionGraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.web.WebGraphQlHandler; +import org.springframework.graphql.web.WebInput; +import org.springframework.graphql.web.WebOutput; +import org.springframework.http.HttpHeaders; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link QuerydslDataFetcherSupport}. + */ +class QuerydslDataFetcherSupportTests { + + @Test + void shouldFetchSingleItems() { + + MockRepository mockRepository = mock(MockRepository.class); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + when(mockRepository.findOne(any())).thenReturn(Optional.of(book)); + + WebGraphQlHandler handler = initWebGraphQlHandler(builder -> builder + .dataFetcher("bookById", QuerydslDataFetcherSupport + .builder(mockRepository) + .single())); + + WebOutput output = handler.handle(new WebInput( + URI.create("http://abc.org"), new HttpHeaders(), Collections + .singletonMap("query", "{ bookById(id: 1) {name}}"), "1")).block(); + + // TODO: getData interferes with method overries + assertThat((Object) output.getData()) + .isEqualTo(Collections.singletonMap("bookById", Collections + .singletonMap("name", "Hitchhiker's Guide to the Galaxy"))); + } + + @Test + void shouldFetchMultipleItems() { + + MockRepository mockRepository = mock(MockRepository.class); + Book book1 = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + Book book2 = new Book(53L, "Breaking Bad", "Heisenberg"); + when(mockRepository.findAll((Predicate) null)) + .thenReturn(Arrays.asList(book1, book2)); + + WebGraphQlHandler handler = initWebGraphQlHandler(builder -> builder + .dataFetcher("books", QuerydslDataFetcherSupport + .builder(mockRepository) + .many())); + + WebOutput output = handler.handle(new WebInput( + URI.create("http://abc.org"), new HttpHeaders(), Collections + .singletonMap("query", "{ books {name}}"), "1")).block(); + + assertThat((Object) output.getData()) + .isEqualTo(Collections.singletonMap("books", Arrays.asList(Collections + .singletonMap("name", "Hitchhiker's Guide to the Galaxy"), Collections + .singletonMap("name", "Breaking Bad")))); + } + + @Test + void shouldFetchSingleItemsWithInterfaceProjection() { + + MockRepository mockRepository = mock(MockRepository.class); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + when(mockRepository.findOne(any())).thenReturn(Optional.of(book)); + + WebGraphQlHandler handler = initWebGraphQlHandler(builder -> builder + .dataFetcher("bookById", QuerydslDataFetcherSupport + .builder(mockRepository) + .projectAs(BookProjection.class) + .single())); + + WebOutput output = handler.handle(new WebInput( + URI.create("http://abc.org"), new HttpHeaders(), Collections + .singletonMap("query", "{ bookById(id: 1) {name}}"), "1")).block(); + + assertThat((Object) output.getData()) + .isEqualTo(Collections.singletonMap("bookById", Collections + .singletonMap("name", "Hitchhiker's Guide to the Galaxy by Douglas Adams"))); + } + + @Test + void shouldFetchSingleItemsWithDtoProjection() { + + MockRepository mockRepository = mock(MockRepository.class); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + when(mockRepository.findOne(any())).thenReturn(Optional.of(book)); + + WebGraphQlHandler handler = initWebGraphQlHandler(builder -> builder + .dataFetcher("bookById", QuerydslDataFetcherSupport + .builder(mockRepository) + .projectAs(BookDto.class) + .single())); + + WebOutput output = handler.handle(new WebInput( + URI.create("http://abc.org"), new HttpHeaders(), Collections + .singletonMap("query", "{ bookById(id: 1) {name}}"), "1")).block(); + + assertThat((Object) output.getData()) + .isEqualTo(Collections.singletonMap("bookById", Collections + .singletonMap("name", "The book is: Hitchhiker's Guide to the Galaxy"))); + } + + @Test + void shouldConstructPredicateProperly() { + + MockRepository mockRepository = mock(MockRepository.class); + + WebGraphQlHandler handler = initWebGraphQlHandler(builder -> builder + .dataFetcher("books", QuerydslDataFetcherSupport + .builder(mockRepository) + .customizer((QuerydslBinderCustomizer) (bindings, book) -> bindings.bind(book.name).firstOptional((path, value) -> value.map(path::startsWith))) + .many())); + + handler.handle(new WebInput( + URI.create("http://abc.org"), new HttpHeaders(), Collections + .singletonMap("query", "{ books(name: \"H\", author: \"Doug\") {name}}"), "1")).block(); + + + ArgumentCaptor predicateCaptor = ArgumentCaptor.forClass(Predicate.class); + verify(mockRepository).findAll(predicateCaptor.capture()); + + Predicate predicate = predicateCaptor.getValue(); + + assertThat(predicate).isEqualTo(QBook.book.name.startsWith("H") + .and(QBook.book.author.eq("Doug"))); + } + + interface MockRepository extends Repository, QuerydslPredicateExecutor { + + } + + static WebGraphQlHandler initWebGraphQlHandler(Consumer configurer) { + return WebGraphQlHandler + .builder(new ExecutionGraphQlService(graphQlSource(configurer))) + .build(); + } + + private static GraphQlSource graphQlSource(Consumer configurer) { + RuntimeWiring.Builder builder = RuntimeWiring.newRuntimeWiring(); + TypeRuntimeWiring.Builder wiringBuilder = TypeRuntimeWiring + .newTypeWiring("Query"); + configurer.accept(wiringBuilder); + builder.type(wiringBuilder); + return GraphQlSource.builder() + .schemaResource(new ClassPathResource("books/schema.graphqls")) + .runtimeWiring(builder.build()) + .build(); + } + + interface BookProjection { + + @Value("#{target.name + ' by ' + target.author}") + String getName(); + + } + + static class BookDto { + + private final String name; + + public BookDto(String name) { + this.name = name; + } + + public String getName() { + return "The book is: " + name; + } + } + +} diff --git a/spring-graphql/src/test/resources/books/schema.graphqls b/spring-graphql/src/test/resources/books/schema.graphqls index cb787d52a..b6920e1cf 100644 --- a/spring-graphql/src/test/resources/books/schema.graphqls +++ b/spring-graphql/src/test/resources/books/schema.graphqls @@ -1,5 +1,6 @@ type Query { bookById(id: ID): Book + books(id: ID, name: String, author: String): [Book] } type Book {