- 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
176 lines
7.1 KiB
Java
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);
|
|
}
|
|
}
|