From f4c50033765c72ca93e923c8acc136eac3cdaa1c Mon Sep 17 00:00:00 2001 From: Gerrit Meier Date: Tue, 20 Jun 2023 11:33:56 +0200 Subject: [PATCH] Add tests for Neo4j Also mention Neo4j as compatible with the reactive stack and the QuerydslPredicateExecutor. See gh-734 --- .../src/docs/asciidoc/includes/data.adoc | 4 +- spring-graphql/build.gradle | 2 + .../graphql/data/query/neo4j/Author.java | 57 ++++ .../graphql/data/query/neo4j/Book.java | 69 +++++ .../data/query/neo4j/BookNeo4jRepository.java | 11 + .../neo4j/BookReactiveNeo4jRepository.java | 11 + .../QueryByExampleDataFetcherNeo4jTests.java | 279 ++++++++++++++++++ ...xampleDataFetcherReactiveNeo4jDbTests.java | 272 +++++++++++++++++ 8 files changed, 703 insertions(+), 2 deletions(-) create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/Author.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/Book.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/BookNeo4jRepository.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/BookReactiveNeo4jRepository.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherNeo4jTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherReactiveNeo4jDbTests.java diff --git a/spring-graphql-docs/src/docs/asciidoc/includes/data.adoc b/spring-graphql-docs/src/docs/asciidoc/includes/data.adoc index 7fe1bbb8..6114e37a 100644 --- a/spring-graphql-docs/src/docs/asciidoc/includes/data.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/includes/data.adoc @@ -50,14 +50,14 @@ You can now register the above `DataFetcher` through a <>. The `DataFetcher` builds a Querydsl `Predicate` from GraphQL arguments, and uses it to -fetch data. Spring Data supports `QuerydslPredicateExecutor` for JPA, MongoDB, and LDAP. +fetch data. Spring Data supports `QuerydslPredicateExecutor` for JPA, MongoDB, Neo4j, 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 -variant for MongoDB. +variant for MongoDB and Neo4j. [[data.querydsl.build]] diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 9b268870..9bd1157f 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -51,9 +51,11 @@ dependencies { testImplementation 'org.hibernate:hibernate-core' testImplementation 'org.hibernate.validator:hibernate-validator' testImplementation 'org.springframework.data:spring-data-mongodb' + testImplementation 'org.springframework.data:spring-data-neo4j:7.1.1' testImplementation 'org.mongodb:mongodb-driver-sync' testImplementation 'org.mongodb:mongodb-driver-reactivestreams' testImplementation 'org.testcontainers:mongodb' + testImplementation 'org.testcontainers:neo4j' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.springframework.security:spring-security-core' testImplementation 'com.querydsl:querydsl-core' diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/Author.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/Author.java new file mode 100644 index 00000000..a6c0c636 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/Author.java @@ -0,0 +1,57 @@ +package org.springframework.graphql.data.query.neo4j; + +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; + +/** + * @author Gerrit Meier + */ +@Node +public class Author { + + @Id + String id; + + String firstName; + + String lastName; + + // needed for response mapping + 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/query/neo4j/Book.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/Book.java new file mode 100644 index 00000000..84a97c18 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/Book.java @@ -0,0 +1,69 @@ +/* + * 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.query.neo4j; + +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; + +@Node +public class Book { + + @Id + String id; + + String name; + + @Relationship("AUTHOR") + Author author; + + // needed for response mapping + 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/query/neo4j/BookNeo4jRepository.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/BookNeo4jRepository.java new file mode 100644 index 00000000..e74d18e2 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/BookNeo4jRepository.java @@ -0,0 +1,11 @@ +package org.springframework.graphql.data.query.neo4j; + +import org.springframework.data.neo4j.repository.Neo4jRepository; +import org.springframework.graphql.data.GraphQlRepository; + +/** + * @author Gerrit Meier + */ +@GraphQlRepository +public interface BookNeo4jRepository extends Neo4jRepository { +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/BookReactiveNeo4jRepository.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/BookReactiveNeo4jRepository.java new file mode 100644 index 00000000..8b857717 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/BookReactiveNeo4jRepository.java @@ -0,0 +1,11 @@ +package org.springframework.graphql.data.query.neo4j; + +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; +import org.springframework.graphql.data.GraphQlRepository; + +/** + * @author Gerrit Meier + */ +@GraphQlRepository +public interface BookReactiveNeo4jRepository extends ReactiveNeo4jRepository { +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherNeo4jTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherNeo4jTests.java new file mode 100644 index 00000000..bd9fa19d --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherNeo4jTests.java @@ -0,0 +1,279 @@ +/* + * 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. + * 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.query.neo4j; + +import graphql.schema.DataFetcher; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +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.domain.ScrollPosition; +import org.springframework.data.neo4j.config.AbstractNeo4jConfig; +import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.ResponseHelper; +import org.springframework.graphql.data.query.QueryByExampleDataFetcher; +import org.springframework.graphql.data.query.ScrollPositionCursorStrategy; +import org.springframework.graphql.data.query.ScrollSubrange; +import org.springframework.graphql.data.query.WindowConnectionAdapter; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.testcontainers.containers.Neo4jContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +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}. + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +class QueryByExampleDataFetcherNeo4jTests { + + @Container + static Neo4jContainer neo4jContainer = new Neo4jContainer(DockerImageName.parse("neo4j:5")).withRandomPassword(); + + @Autowired + private BookNeo4jRepository repository; + + + @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 -> { + WebGraphQlRequest request = request("{ bookById(id: 42) {name}}"); + Mono responseMono = setup.toWebGraphQlHandler().handleRequest(request); + Book actualBook = ResponseHelper.forResponse(responseMono).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 -> { + WebGraphQlRequest request = request("{ books {name}}"); + Mono responseMono = graphQlSetup.toWebGraphQlHandler().handleRequest(request); + + List names = ResponseHelper.forResponse(responseMono).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 shouldFetchWindow() { + + repository.saveAll(List.of( + new Book("1", "Nineteen Eighty-Four", new Author("0", "George", "Orwell")), + new Book("2", "The Great Gatsby", new Author("0", "F. Scott", "Fitzgerald")), + new Book("3", "Catch-22", new Author("0", "Joseph", "Heller")), + new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("0", "Douglas", "Adams")), + new Book("53", "Breaking Bad", new Author("0", "", "Heisenberg")))); + + Consumer tester = graphQlSetup -> { + + Mono response = graphQlSetup + .toWebGraphQlHandler() + .handleRequest(request(BookSource.booksConnectionQuery("first:2, after:\"O_3\""))); + + List> edges = ResponseHelper.forResponse(response).toEntity("books.edges", List.class); + assertThat(edges.size()).isEqualTo(2); + assertThat(edges.get(0).get("cursor")).isEqualTo("O_4"); + assertThat(edges.get(1).get("cursor")).isEqualTo("O_5"); + + Map pageInfo = ResponseHelper.forResponse(response).toEntity("books.pageInfo", Map.class); + assertThat(pageInfo.size()).isEqualTo(4); + assertThat(pageInfo.get("startCursor")).isEqualTo("O_4"); + assertThat(pageInfo.get("endCursor")).isEqualTo("O_5"); + assertThat(pageInfo.get("hasPreviousPage")).isEqualTo(true); + assertThat(pageInfo.get("hasNextPage")).isEqualTo(false); + }; + + // explicit wiring + + ScrollPositionCursorStrategy cursorStrategy = new ScrollPositionCursorStrategy(); + + DataFetcher> dataFetcher = + QueryByExampleDataFetcher.builder(repository).cursorStrategy(cursorStrategy).scrollable(); + + GraphQlSetup graphQlSetup = paginationSetup(cursorStrategy).queryFetcher("books", dataFetcher); + tester.accept(graphQlSetup); + + // auto registration + graphQlSetup = paginationSetup(cursorStrategy).runtimeWiring(createRuntimeWiringConfigurer(repository)); + tester.accept(graphQlSetup); + } + + @Test + void shouldFavorExplicitWiring() { + BookNeo4jRepository mockRepository = mock(BookNeo4jRepository.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 responseMono = handler.handleRequest(request("{ bookById(id: 1) {name}}")); + + Book actualBook = ResponseHelper.forResponse(responseMono).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(); + + responseMono = handler.handleRequest(request("{ bookById(id: 1) {name}}")); + + actualBook = ResponseHelper.forResponse(responseMono).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 responseMono = handler.handleRequest(request("{ bookById(id: 42) {name}}")); + + Book actualBook = ResponseHelper.forResponse(responseMono).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 responseMono = handler.handleRequest(request("{ bookById(id: 42) {name}}")); + + Book actualBook = ResponseHelper.forResponse(responseMono).toEntity("bookById", Book.class); + assertThat(actualBook.getName()).isEqualTo("The book is: Hitchhiker's Guide to the Galaxy"); + } + + private static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { + return GraphQlSetup.schemaResource(BookSource.schema).queryFetcher(fieldName, fetcher); + } + + private static GraphQlSetup graphQlSetup(@Nullable QueryByExampleExecutor executor) { + return GraphQlSetup.schemaResource(BookSource.schema) + .runtimeWiring(createRuntimeWiringConfigurer(executor)); + } + + private static GraphQlSetup paginationSetup(ScrollPositionCursorStrategy cursorStrategy) { + return GraphQlSetup.schemaResource(BookSource.paginationSchema) + .connectionSupport(new WindowConnectionAdapter(cursorStrategy)); + } + + private static RuntimeWiringConfigurer createRuntimeWiringConfigurer(QueryByExampleExecutor executor) { + return QueryByExampleDataFetcher.autoRegistrationConfigurer( + (executor != null ? Collections.singletonList(executor) : Collections.emptyList()), + Collections.emptyList(), + new ScrollPositionCursorStrategy(), + new ScrollSubrange(ScrollPosition.offset(), 10, true)); + } + + private WebGraphQlRequest request(String query) { + return new WebGraphQlRequest( + URI.create("/"), new HttpHeaders(), null, Collections.emptyMap(), + Collections.singletonMap("query", query), "1", null); + } + + + 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 + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class TestConfig extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return GraphDatabase.driver(neo4jContainer.getBoltUrl(), AuthTokens.basic("neo4j", neo4jContainer.getAdminPassword())); + } + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherReactiveNeo4jDbTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherReactiveNeo4jDbTests.java new file mode 100644 index 00000000..d492e9ae --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/neo4j/QueryByExampleDataFetcherReactiveNeo4jDbTests.java @@ -0,0 +1,272 @@ +/* + * 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. + * 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.query.neo4j; + +import graphql.schema.DataFetcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.cypherdsl.core.renderer.Dialect; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +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.domain.ScrollPosition; +import org.springframework.data.neo4j.config.AbstractReactiveNeo4jConfig; +import org.springframework.data.neo4j.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.ResponseHelper; +import org.springframework.graphql.data.query.QueryByExampleDataFetcher; +import org.springframework.graphql.data.query.ScrollPositionCursorStrategy; +import org.springframework.graphql.data.query.ScrollSubrange; +import org.springframework.graphql.data.query.WindowConnectionAdapter; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.testcontainers.containers.Neo4jContainer; +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 java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link QueryByExampleDataFetcher}. + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +class QueryByExampleDataFetcherReactiveNeo4jDbTests { + + @Container + static Neo4jContainer neo4jContainer = new Neo4jContainer(DockerImageName.parse("neo4j:5")).withRandomPassword(); + + @Autowired + private BookReactiveNeo4jRepository repository; + + @Autowired + private Driver driver; + + @BeforeEach + void cleanDatabase() { + try (var session = driver.session()) { + session.run("MATCH (n) detach delete n").consume(); + } + } + + @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 -> { + WebGraphQlRequest request = request("{ bookById(id: 42) {name}}"); + Mono responseMono = setup.toWebGraphQlHandler().handleRequest(request); + Book actualBook = ResponseHelper.forResponse(responseMono).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 responseMono = handler.handleRequest(request("{ bookById(id: 42) {name}}")); + + Book actualBook = ResponseHelper.forResponse(responseMono).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 responseMono = handler.handleRequest(request("{ bookById(id: 42) {name}}")); + + Book actualBook = ResponseHelper.forResponse(responseMono).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 -> { + WebGraphQlRequest request = request("{ books {name}}"); + Mono responseMono = setup.toWebGraphQlHandler().handleRequest(request); + + List names = ResponseHelper.forResponse(responseMono).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)); + } + + @Test + void shouldFetchWindow() { + + repository.saveAll(Flux.just( + new Book("1", "Nineteen Eighty-Four", new Author("0", "George", "Orwell")), + new Book("2", "The Great Gatsby", new Author("1", "F. Scott", "Fitzgerald")), + new Book("3", "Catch-22", new Author("2", "Joseph", "Heller")), + new Book("42", "Hitchhiker's Guide to the Galaxy", new Author("3", "Douglas", "Adams")), + new Book("53", "Breaking Bad", new Author("4", "", "Heisenberg")))) + .blockLast(); + + Consumer tester = graphQlSetup -> { + + Mono response = graphQlSetup + .toWebGraphQlHandler() + .handleRequest(request(BookSource.booksConnectionQuery("first:2, after:\"O_3\""))); + + List> edges = ResponseHelper.forResponse(response).toEntity("books.edges", List.class); + assertThat(edges.size()).isEqualTo(2); + assertThat(edges.get(0).get("cursor")).isEqualTo("O_4"); + assertThat(edges.get(1).get("cursor")).isEqualTo("O_5"); + + Map pageInfo = ResponseHelper.forResponse(response).toEntity("books.pageInfo", Map.class); + assertThat(pageInfo.size()).isEqualTo(4); + assertThat(pageInfo.get("startCursor")).isEqualTo("O_4"); + assertThat(pageInfo.get("endCursor")).isEqualTo("O_5"); + assertThat(pageInfo.get("hasPreviousPage")).isEqualTo(true); + assertThat(pageInfo.get("hasNextPage")).isEqualTo(false); + }; + + // explicit wiring + + ScrollPositionCursorStrategy cursorStrategy = new ScrollPositionCursorStrategy(); + + DataFetcher>> dataFetcher = + QueryByExampleDataFetcher.builder(repository).cursorStrategy(cursorStrategy).scrollable(); + + GraphQlSetup graphQlSetup = paginationSetup(cursorStrategy).queryFetcher("books", dataFetcher); + tester.accept(graphQlSetup); + + // auto registration + graphQlSetup = paginationSetup(cursorStrategy).runtimeWiring(createRuntimeWiringConfigurer(repository)); + tester.accept(graphQlSetup); + } + + private static GraphQlSetup graphQlSetup(String fieldName, DataFetcher fetcher) { + return GraphQlSetup.schemaResource(BookSource.schema).queryFetcher(fieldName, fetcher); + } + + private static GraphQlSetup graphQlSetup(@Nullable ReactiveQueryByExampleExecutor executor) { + return GraphQlSetup.schemaResource(BookSource.schema) + .runtimeWiring(createRuntimeWiringConfigurer(executor)); + } + + private static GraphQlSetup paginationSetup(ScrollPositionCursorStrategy cursorStrategy) { + return GraphQlSetup.schemaResource(BookSource.paginationSchema) + .connectionSupport(new WindowConnectionAdapter(cursorStrategy)); + } + + private static RuntimeWiringConfigurer createRuntimeWiringConfigurer(ReactiveQueryByExampleExecutor executor) { + return QueryByExampleDataFetcher.autoRegistrationConfigurer( + Collections.emptyList(), + (executor != null ? Collections.singletonList(executor) : Collections.emptyList()), + new ScrollPositionCursorStrategy(), + new ScrollSubrange(ScrollPosition.offset(), 10, true)); + } + + private WebGraphQlRequest request(String query) { + return new WebGraphQlRequest( + URI.create("/"), new HttpHeaders(), null, Collections.emptyMap(), + Collections.singletonMap("query", query), "1", null); + } + + + 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 + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class TestConfig extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return GraphDatabase.driver(neo4jContainer.getBoltUrl(), AuthTokens.basic("neo4j", neo4jContainer.getAdminPassword())); + } + + @Bean + public org.neo4j.cypherdsl.core.renderer.Configuration cypherDslConfiguration() { + return org.neo4j.cypherdsl.core.renderer.Configuration.newConfig() + .withDialect(Dialect.NEO4J_5) + .build(); + } + } + +}