Files
spring-boot-demo/graphql-dataloader/src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java
T
asmhatre baff043748 Add graphql-dataloader: Spring GraphQL 2.0 DataLoader batching vs naive N+1, and non-null propagation on a dangling FK
- naive @SchemaMapping resolver: 6 statements (5 books/5 authors), 21 statements (20 books/5 authors)
- batched @BatchMapping resolver: flat 2 statements in both cases, via DataLoader + .distinct()
- dangling authorId nulls the entire GraphQL response via non-null propagation, byte-identical under both resolver strategies
- 6-test suite over real HTTP against a live embedded Tomcat instance, SQL captured via a JDK dynamic proxy (StatementLoggingDataSource, reused from sdjpa4-demo)
- docs/05: two Boot 4.1 packaging changes hit along the way (DataSourceAutoConfiguration's new package, Jackson 3 by default)
- root README: add row for graphql-dataloader; fix openapi-versioning's placeholder link now that post 7477 is live
2026-09-17 19:57:41 +00:00

176 lines
7.1 KiB
Java

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<Long> 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<String> 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);
}
}