diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/querydsl/PropertySelection.java b/spring-graphql/src/main/java/org/springframework/graphql/data/querydsl/PropertySelection.java new file mode 100644 index 00000000..6615d029 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/querydsl/PropertySelection.java @@ -0,0 +1,196 @@ +/* + * 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.querydsl; + +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()); + } + + /** + * 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(); + } + + } + + 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(); + } + + } +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcher.java index 73beb1ba..48b85f76 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcher.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcher.java @@ -17,6 +17,7 @@ package org.springframework.graphql.data.querydsl; import java.lang.reflect.Type; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -25,18 +26,7 @@ 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 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.schema.*; import graphql.util.TraversalControl; import graphql.util.TraverserContext; import reactor.core.publisher.Flux; @@ -118,8 +108,7 @@ public abstract class QuerydslDataFetcher { private static final QuerydslPredicateBuilder BUILDER = new QuerydslPredicateBuilder( DefaultConversionService.getSharedInstance(), SimpleEntityPathResolver.INSTANCE); - // visible to subtypes in the same package - final TypeInformation domainType; + private final TypeInformation domainType; private final QuerydslBinderCustomizer> customizer; @@ -185,6 +174,20 @@ public abstract class QuerydslDataFetcher { return new RegistrationTypeVisitor(executors, reactiveExecutors); } + 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(resultType.equals(this.domainType.getType()) + || this.domainType.getType().isAssignableFrom(resultType) + || this.domainType.isSubTypeOf(resultType)) { + return PropertySelection.create(this.domainType, selection).toList(); + } + return Collections.emptyList(); + } + @SuppressWarnings({"unchecked", "rawtypes"}) protected Predicate buildPredicate(DataFetchingEnvironment environment) { MultiValueMap parameters = new LinkedMultiValueMap<>(); @@ -430,12 +433,14 @@ public abstract class QuerydslDataFetcher { return this.executor.findBy(buildPredicate(environment), q -> { FetchableFluentQuery queryToUse = (FetchableFluentQuery) q; - if(this.sort.isSorted()){ + if (this.sort.isSorted()){ queryToUse = queryToUse.sortBy(this.sort); } - if(!this.resultType.equals(this.domainType.getType())){ + if (requiresProjection(this.resultType)){ queryToUse = queryToUse.as(this.resultType); + } else { + queryToUse = queryToUse.project(buildPropertyPaths(environment.getSelectionSet(), this.resultType)); } return queryToUse.first(); @@ -470,12 +475,14 @@ public abstract class QuerydslDataFetcher { return this.executor.findBy(buildPredicate(environment), q -> { FetchableFluentQuery queryToUse = (FetchableFluentQuery) q; - if(this.sort.isSorted()){ + if (this.sort.isSorted()){ queryToUse = queryToUse.sortBy(this.sort); } - if(!this.resultType.equals(this.domainType.getType())){ + if (requiresProjection(this.resultType)){ queryToUse = queryToUse.as(this.resultType); + } else { + queryToUse = queryToUse.project(buildPropertyPaths(environment.getSelectionSet(), this.resultType)); } return queryToUse.all(); @@ -511,12 +518,14 @@ public abstract class QuerydslDataFetcher { return this.executor.findBy(buildPredicate(environment), q -> { FluentQuery.ReactiveFluentQuery queryToUse = (FluentQuery.ReactiveFluentQuery) q; - if(this.sort.isSorted()){ + if (this.sort.isSorted()){ queryToUse = queryToUse.sortBy(this.sort); } - if(!this.resultType.equals(this.domainType.getType())){ + if (requiresProjection(this.resultType)){ queryToUse = queryToUse.as(this.resultType); + } else { + queryToUse = queryToUse.project(buildPropertyPaths(environment.getSelectionSet(), this.resultType)); } return queryToUse.first(); @@ -552,12 +561,14 @@ public abstract class QuerydslDataFetcher { return this.executor.findBy(buildPredicate(environment), q -> { FluentQuery.ReactiveFluentQuery queryToUse = (FluentQuery.ReactiveFluentQuery) q; - if(this.sort.isSorted()){ + if (this.sort.isSorted()){ queryToUse = queryToUse.sortBy(this.sort); } - if(!this.resultType.equals(this.domainType.getType())){ + if (requiresProjection(this.resultType)){ queryToUse = queryToUse.as(this.resultType); + } else { + queryToUse = queryToUse.project(buildPropertyPaths(environment.getSelectionSet(), this.resultType)); } return queryToUse.all(); diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/Book.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/Book.java index 956e4538..f7ad96bf 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/Book.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/Book.java @@ -17,6 +17,7 @@ package org.springframework.graphql.data.querydsl; import org.springframework.data.annotation.Id; +import org.springframework.graphql.Author; public class Book { @@ -24,12 +25,12 @@ public class Book { String name; - String author; + Author author; public Book() { } - public Book(Long id, String name, String author) { + public Book(Long id, String name, Author author) { this.id = id; this.name = name; this.author = author; @@ -51,11 +52,11 @@ public class Book { this.name = name; } - public String getAuthor() { + public Author getAuthor() { return this.author; } - public void setAuthor(String author) { + public void setAuthor(Author author) { this.author = author; } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcherTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcherTests.java index 7f797388..e3a93600 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcherTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/querydsl/QuerydslDataFetcherTests.java @@ -41,6 +41,7 @@ import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; import org.springframework.data.querydsl.binding.QuerydslBinderCustomizer; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.Repository; +import org.springframework.graphql.Author; import org.springframework.graphql.data.GraphQlRepository; import org.springframework.graphql.execution.ExecutionGraphQlService; import org.springframework.graphql.execution.GraphQlSource; @@ -66,7 +67,7 @@ class QuerydslDataFetcherTests { @Test void shouldFetchSingleItems() { - Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); mockRepository.save(book); BiConsumer, QuerydslPredicateExecutor> tester = @@ -91,8 +92,8 @@ class QuerydslDataFetcherTests { @Test void shouldFetchMultipleItems() { - Book book1 = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); - Book book2 = new Book(53L, "Breaking Bad", "Heisenberg"); + 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")); mockRepository.saveAll(Arrays.asList(book1, book2)); BiConsumer, QuerydslPredicateExecutor> tester = @@ -118,7 +119,7 @@ class QuerydslDataFetcherTests { @Test void shouldFavorExplicitWiring() { MockRepository mockRepository = mock(MockRepository.class); - Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + 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 @@ -130,7 +131,7 @@ class QuerydslDataFetcherTests { // 2) Automatic registration and explicit wiring handler = initWebGraphQlHandler( - builder -> builder.dataFetcher("bookById", env -> new Book(53L, "Breaking Bad", "Heisenberg")), + builder -> builder.dataFetcher("bookById", env -> new Book(53L, "Breaking Bad", new Author(0L, "", "Heisenberg"))), mockRepository, null); output = handler.handleRequest(input("{ bookById(id: 1) {name}}")).block(); @@ -141,7 +142,7 @@ class QuerydslDataFetcherTests { @Test void shouldFetchSingleItemsWithInterfaceProjection() { - Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); mockRepository.save(book); WebGraphQlHandler handler = initWebGraphQlHandler(builder -> builder @@ -159,7 +160,7 @@ class QuerydslDataFetcherTests { @Test void shouldFetchSingleItemsWithDtoProjection() { - Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); mockRepository.save(book); WebGraphQlHandler handler = initWebGraphQlHandler(builder -> builder @@ -188,7 +189,6 @@ class QuerydslDataFetcherTests { handler.handleRequest(input("{ books(name: \"H\", author: \"Doug\") {name}}")).block(); - ArgumentCaptor predicateCaptor = ArgumentCaptor.forClass(Predicate.class); verify(mockRepository).findBy(predicateCaptor.capture(), any()); @@ -199,7 +199,7 @@ class QuerydslDataFetcherTests { @Test void shouldReactivelyFetchSingleItems() { ReactiveMockRepository mockRepository = mock(ReactiveMockRepository.class); - Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); + Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); when(mockRepository.findBy(any(), any())).thenReturn(Mono.just(book)); BiConsumer, ReactiveQuerydslPredicateExecutor> tester = @@ -225,8 +225,8 @@ class QuerydslDataFetcherTests { @Test void shouldReactivelyFetchMultipleItems() { ReactiveMockRepository mockRepository = mock(ReactiveMockRepository.class); - Book book1 = new Book(42L, "Hitchhiker's Guide to the Galaxy", "Douglas Adams"); - Book book2 = new Book(53L, "Breaking Bad", "Heisenberg"); + 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")); when(mockRepository.findBy(any(), any())).thenReturn(Flux.just(book1, book2)); BiConsumer, ReactiveQuerydslPredicateExecutor> tester = @@ -304,7 +304,7 @@ class QuerydslDataFetcherTests { interface BookProjection { - @Value("#{target.name + ' by ' + target.author}") + @Value("#{target.name + ' by ' + target.author.firstName + ' ' + target.author.lastName}") String getName(); }