diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index e40ad011..57543f9c 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -865,9 +865,11 @@ Then use it to create a `DataFetcher`: You can now register the above `DataFetcher` through a <>. -The `DataFetcher` builds a Querydsl `Predicate` from GraphQL request parameters, and -uses it to fetch data. Spring Data supports `QuerydslPredicateExecutor` for JPA, -MongoDB, and LDAP. +The `DataFetcher` builds a Querydsl `Predicate` from GraphQL arguments, and uses it to +fetch data. Spring Data supports `QuerydslPredicateExecutor` for JPA, MongoDB, and LDAP. + +NOTE: For a single argument that is a GraphQL input type, `QuerydslDataFetcher` nests one +level down, and uses the values from the argument sub-map. If the repository is `ReactiveQuerydslPredicateExecutor`, the builder returns `DataFetcher>` or `DataFetcher>`. Spring Data supports this @@ -1058,6 +1060,9 @@ The `DataFetcher` uses the GraphQL arguments map to create the domain type of th repository and use that as the example object to fetch data with. Spring Data supports `QueryByExampleDataFetcher` for JPA, MongoDB, Neo4j, and Redis. +NOTE: For a single argument that is a GraphQL input type, `QueryByExampleDataFetcher` +nests one level down, and binds with the values from the argument sub-map. + If the repository is `ReactiveQueryByExampleExecutor`, the builder returns `DataFetcher>` or `DataFetcher>`. Spring Data supports this variant for MongoDB, Neo4j, Redis, and R2dbc. diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java index 7bbc2126..a2aaed55 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import java.util.function.Function; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import graphql.schema.DataFetchingFieldSelectionSet; +import graphql.schema.GraphQLArgument; import graphql.schema.GraphQLTypeVisitor; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -41,8 +42,9 @@ import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; import org.springframework.data.util.TypeInformation; import org.springframework.graphql.data.GraphQlArgumentBinder; import org.springframework.graphql.data.GraphQlRepository; -import org.springframework.graphql.execution.SelfDescribingDataFetcher; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.execution.SelfDescribingDataFetcher; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.validation.BindException; @@ -108,13 +110,32 @@ public abstract class QueryByExampleDataFetcher { /** * Prepare an {@link Example} from GraphQL request arguments. - * @param env contextual info for the GraphQL request + * @param environment contextual info for the GraphQL request * @return the resulting example */ @SuppressWarnings({"ConstantConditions", "unchecked"}) - protected Example buildExample(DataFetchingEnvironment env) throws BindException { + protected Example buildExample(DataFetchingEnvironment environment) throws BindException { + String name = getArgumentName(environment); ResolvableType targetType = ResolvableType.forClass(this.domainType.getType()); - return (Example) Example.of(this.argumentBinder.bind(env, null, targetType)); + return (Example) Example.of(this.argumentBinder.bind(environment, name, targetType)); + } + + /** + * For a single argument that is a GraphQL input type, return the argument + * name, thereby nesting and having the example Object populated from the + * sub-map. Otherwise, {@code null} to bind using the top-level map. + */ + @Nullable + private static String getArgumentName(DataFetchingEnvironment environment) { + Map arguments = environment.getArguments(); + List definedArguments = environment.getFieldDefinition().getArguments(); + if (definedArguments.size() == 1) { + String name = definedArguments.get(0).getName(); + if (arguments.get(name) instanceof Map) { + return name; + } + } + return null; } protected boolean requiresProjection(Class resultType) { diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java index 82623660..26e0ca8b 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,8 +47,8 @@ import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; import org.springframework.data.util.TypeInformation; import org.springframework.graphql.data.GraphQlRepository; -import org.springframework.graphql.execution.SelfDescribingDataFetcher; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.execution.SelfDescribingDataFetcher; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -134,7 +134,7 @@ public abstract class QuerydslDataFetcher { EntityPath path = SimpleEntityPathResolver.INSTANCE.createPath(this.domainType.getType()); this.customizer.customize(bindings, path); - for (Map.Entry entry : environment.getArguments().entrySet()) { + for (Map.Entry entry : getArgumentValues(environment).entrySet()) { Object value = entry.getValue(); List values = (value instanceof List ? (List) value : Collections.singletonList(value)); parameters.put(entry.getKey(), values); @@ -143,6 +143,23 @@ public abstract class QuerydslDataFetcher { return BUILDER.getPredicate(this.domainType, parameters, bindings); } + /** + * For a single argument that is a GraphQL input type, return the sub-map + * under the argument name, or otherwise the top-level argument map. + */ + @SuppressWarnings("unchecked") + private static Map getArgumentValues(DataFetchingEnvironment environment) { + Map arguments = environment.getArguments(); + if (environment.getFieldDefinition().getArguments().size() == 1) { + String name = environment.getFieldDefinition().getArguments().get(0).getName(); + Object value = arguments.get(name); + if (value instanceof Map) { + return (Map) value; + } + } + return arguments; + } + protected boolean requiresProjection(Class resultType) { return !resultType.equals(this.domainType.getType()); } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java b/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java index 6aaf877a..43c45cbd 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java @@ -106,7 +106,7 @@ public class ResponseHelper { private void assertNoErrors() { if (!this.errorsChecked) { - assertThat(this.errors).as("Errors present in GraphQL response").isEmpty(); + assertThat(this.errors).as("GraphQL response errors: " + this.errors).isEmpty(); this.errorsChecked = true; } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java index c000306e..793b7df0 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/QuerydslDataFetcherTests.java @@ -44,6 +44,7 @@ import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.Repository; import org.springframework.graphql.Author; import org.springframework.graphql.BookSource; +import org.springframework.graphql.ExecutionGraphQlResponse; import org.springframework.graphql.GraphQlSetup; import org.springframework.graphql.ResponseHelper; import org.springframework.graphql.data.GraphQlRepository; @@ -74,7 +75,7 @@ class QuerydslDataFetcherTests { @Test - void shouldFetchSingleItems() { + void shouldFetchSingleItem() { Book book = new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author(0L, "Douglas", "Adams")); mockRepository.save(book); @@ -277,6 +278,25 @@ class QuerydslDataFetcherTests { tester.accept(graphQlSetup(mockRepository)); } + @Test + void shouldNestForSingleArgumentInputType() { + 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)); + + String queryName = "booksByCriteria"; + + Mono responseMono = + graphQlSetup(queryName, QuerydslDataFetcher.builder(mockRepository).many()) + .toGraphQlService() + .execute(request("{" + queryName + "(criteria: {id: 42}) {name}}")); + + List books = ResponseHelper.forResponse(responseMono).toList(queryName, Book.class); + + assertThat(books).hasSize(1); + assertThat(books.get(0).getName()).isEqualTo(book1.getName()); + } + static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { return initGraphQlSetup(null, null).queryFetcher(fieldName, fetcher); } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java index 48f5b3c1..ee8c87a7 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/jpa/QueryByExampleDataFetcherJpaTests.java @@ -38,6 +38,7 @@ 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.ExecutionGraphQlResponse; import org.springframework.graphql.GraphQlSetup; import org.springframework.graphql.ResponseHelper; import org.springframework.graphql.data.query.QueryByExampleDataFetcher; @@ -169,6 +170,25 @@ class QueryByExampleDataFetcherJpaTests { assertThat(actualBook.getName()).isEqualTo("The book is: Hitchhiker's Guide to the Galaxy"); } + @Test + void shouldNestForSingleArgumentInputType() { + 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)); + + String queryName = "booksByCriteria"; + + Mono responseMono = + graphQlSetup(queryName, QueryByExampleDataFetcher.builder(repository).many()) + .toGraphQlService() + .execute(request("{" + queryName + "(criteria: {id: 42}) {name}}")); + + List books = ResponseHelper.forResponse(responseMono).toList(queryName, Book.class); + + assertThat(books).hasSize(1); + assertThat(books.get(0).getName()).isEqualTo(book1.getName()); + } + private static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { return initGraphQlSetup(null).queryFetcher(fieldName, fetcher); }