package com.ankurm.graphqldataloader; import com.ankurm.graphqldataloader.domain.Author; import com.ankurm.graphqldataloader.domain.AuthorRepository; import com.ankurm.graphqldataloader.domain.Book; import com.ankurm.graphqldataloader.domain.BookRepository; import com.ankurm.graphqldataloader.support.SqlLog; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.test.context.ActiveProfiles; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; /** * Runs the {@code naive} profile ({@link com.ankurm.graphqldataloader.resolver.NaiveAuthorResolver}) * against a real, running server over real HTTP, and counts the SQL statements that actually hit * H2 via {@link com.ankurm.graphqldataloader.support.StatementLoggingDataSource}. Every number * asserted here is also written to a transcript file under docs/output/ so the article can quote * it verbatim instead of restating a claim from memory. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("naive") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class NaiveResolverSqlLogTest { @LocalServerPort int port; @Autowired AuthorRepository authorRepository; @Autowired BookRepository bookRepository; @Autowired SqlLog sqlLog; private final HttpClient http = HttpClient.newHttpClient(); private final ObjectMapper mapper = new ObjectMapper(); @BeforeEach void cleanDatabase() { bookRepository.deleteAll(); authorRepository.deleteAll(); } @Test @Order(1) void a_fiveBooksFromFiveDistinctAuthorsIssueOneAuthorQueryPerBook() throws Exception { seedDistinctAuthorPerBook(5); sqlLog.reset(); JsonNode data = postGraphql("{ books { title author { name } } }"); assertThat(data.get("books")).hasSize(5); for (JsonNode book : data.get("books")) { assertThat(book.get("author").get("name").asText()).isNotBlank(); } // 1 SELECT for books.findAll() + 5 SELECTs, one per book, from author.findById() assertThat(sqlLog.count()).isEqualTo(6); writeTranscript("naive-a-five-distinct-authors", "naive profile / 5 books, 5 distinct authors (no repeats to dedupe)"); } @Test @Order(2) void b_twentyBooksFromFiveAuthorsStillIssuesTwentyAuthorQueries() throws Exception { seedRoundRobinAuthors(5, 20); sqlLog.reset(); JsonNode data = postGraphql("{ books { title author { name } } }"); assertThat(data.get("books")).hasSize(20); // 1 SELECT for books.findAll() + 20 SELECTs — the naive resolver calls findById() once per // Book with no awareness that 4 of those books share the same authorId, so the 5-author, // 20-book case is exactly as expensive as 20 distinct authors would have been. assertThat(sqlLog.count()).isEqualTo(21); writeTranscript("naive-b-twenty-books-five-authors", "naive profile / 20 books, 5 distinct authors (4 books per author) — repeats do not help"); } @Test @Order(3) void c_danglingAuthorIdNullsTheEntireBooksListViaNonNullPropagation() throws Exception { Author real = authorRepository.save(new Author("Ursula K. Le Guin")); bookRepository.save(new Book("The Dispossessed", real.getId())); bookRepository.save(new Book("Orphan Book With No Author Row", 9_999_999L)); sqlLog.reset(); String responseBody = postGraphqlRaw("{ books { title author { name } } }"); JsonNode root = mapper.readTree(responseBody); assertThat(root.get("data").isNull()).isTrue(); assertThat(root.get("errors")).isNotEmpty(); assertThat(root.get("errors").get(0).get("message").asText()) .contains("non-null"); Files.writeString( outputPath("naive-c-dangling-foreign-key-null-propagation.txt"), "naive profile / one Book has authorId=9999999 which matches no Author row\n" + "raw HTTP response body from POST /graphql:\n\n" + responseBody + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } private void seedDistinctAuthorPerBook(int n) { for (int i = 1; i <= n; i++) { Author author = authorRepository.save(new Author("Author " + i)); bookRepository.save(new Book("Book " + i, author.getId())); } } private void seedRoundRobinAuthors(int authorCount, int bookCount) { List authorIds = new ArrayList<>(); for (int i = 1; i <= authorCount; i++) { authorIds.add(authorRepository.save(new Author("Author " + i)).getId()); } for (int i = 1; i <= bookCount; i++) { Long authorId = authorIds.get((i - 1) % authorCount); bookRepository.save(new Book("Book " + i, authorId)); } } private JsonNode postGraphql(String query) throws Exception { String body = postGraphqlRaw(query); JsonNode root = mapper.readTree(body); assertThat(root.has("errors")).isFalse(); return root.get("data"); } private String postGraphqlRaw(String query) throws Exception { String requestBody = mapper.writeValueAsString(java.util.Map.of("query", query)); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + port + "/graphql")) .header("Content-Type", "application/json") .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) .build(); HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); assertThat(response.statusCode()).isEqualTo(200); return response.body(); } private void writeTranscript(String fileNameStem, String heading) throws Exception { String content = heading + "\n\n" + sqlLog.render() + "\n"; Files.writeString(outputPath(fileNameStem + ".txt"), content, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } private Path outputPath(String fileName) throws Exception { Path dir = Path.of(System.getProperty("user.dir"), "docs", "output"); Files.createDirectories(dir); return dir.resolve(fileName); } }