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
This commit is contained in:
2026-09-17 19:57:41 +00:00
parent 8d0efb0d4b
commit baff043748
29 changed files with 1454 additions and 1 deletions
@@ -0,0 +1,42 @@
package com.ankurm.graphqldataloader;
import com.ankurm.graphqldataloader.support.SqlLog;
import com.ankurm.graphqldataloader.support.StatementLoggingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class GraphqlDataloaderApplication {
public static void main(String[] args) {
SpringApplication.run(GraphqlDataloaderApplication.class, args);
}
@Bean
public SqlLog sqlLog() {
return new SqlLog();
}
@Bean
public DataSource dataSource(SqlLog sqlLog) {
// A random suffix per context, not a fixed name: DB_CLOSE_DELAY=-1 keeps an H2 in-memory
// database alive for as long as this JVM runs, so a fixed name would leak across separate
// Spring ApplicationContexts started in the same test JVM (e.g. the "naive" and "batched"
// profile test classes each boot their own context) and the second one to run schema.sql
// would fail with "Table already exists" against the first one's still-open database.
String dbName = "graphqldataloader-" + java.util.UUID.randomUUID();
HikariDataSource real = DataSourceBuilder.create()
.url("jdbc:h2:mem:" + dbName + ";DB_CLOSE_DELAY=-1")
.username("sa")
.password("")
.type(HikariDataSource.class)
.build();
return StatementLoggingDataSource.wrap(real, sqlLog);
}
}
@@ -0,0 +1,33 @@
package com.ankurm.graphqldataloader.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "AUTHOR")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected Author() {
}
public Author(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.graphqldataloader.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorRepository extends JpaRepository<Author, Long> {
}
@@ -0,0 +1,46 @@
package com.ankurm.graphqldataloader.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* Deliberately NOT a JPA {@code @ManyToOne} to {@link Author} — {@code authorId} is a plain
* column. The whole point of this module is controlling, and observing, exactly how and when
* the Author for a Book gets loaded from a GraphQL resolver, not letting Hibernate's own lazy
* loading decide that for us underneath a completely different mechanism.
*/
@Entity
@Table(name = "BOOK")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private Long authorId;
protected Book() {
}
public Book(String title, Long authorId) {
this.title = title;
this.authorId = authorId;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public Long getAuthorId() {
return authorId;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.graphqldataloader.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
}
@@ -0,0 +1,52 @@
package com.ankurm.graphqldataloader.resolver;
import com.ankurm.graphqldataloader.domain.Author;
import com.ankurm.graphqldataloader.domain.AuthorRepository;
import com.ankurm.graphqldataloader.domain.Book;
import org.springframework.context.annotation.Profile;
import org.springframework.graphql.data.method.annotation.BatchMapping;
import org.springframework.stereotype.Controller;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Same field, same schema, one annotation different: {@code @BatchMapping} instead of
* {@code @SchemaMapping}, and the method signature takes every {@code Book} GraphQL Java is about
* to resolve {@code author} for in this batch, not one. Spring GraphQL wires this into a
* per-request {@code DataLoader} automatically — no {@code BatchLoaderRegistry} bean needed for
* this shortcut form. See docs/03-the-batchmapping-fix.md.
*
* <p>The returned {@code Map} deliberately omits an entry for any {@code Book} whose author
* lookup misses (a dangling {@code authorId}) rather than putting a null value in — Spring
* GraphQL treats a missing key the same as an explicit null for that source object, which is
* what docs/05-the-null-propagation-trap.md is built around.
*/
@Profile("batched")
@Controller
public class BatchedAuthorResolver {
private final AuthorRepository authorRepository;
public BatchedAuthorResolver(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
List<Long> authorIds = books.stream().map(Book::getAuthorId).distinct().toList();
Map<Long, Author> byId = authorRepository.findAllById(authorIds).stream()
.collect(Collectors.toMap(Author::getId, a -> a));
Map<Book, Author> result = new HashMap<>();
for (Book book : books) {
Author author = byId.get(book.getAuthorId());
if (author != null) {
result.put(book, author);
}
}
return result;
}
}
@@ -0,0 +1,23 @@
package com.ankurm.graphqldataloader.resolver;
import com.ankurm.graphqldataloader.domain.Book;
import com.ankurm.graphqldataloader.domain.BookRepository;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import java.util.List;
@Controller
public class BookController {
private final BookRepository bookRepository;
public BookController(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@QueryMapping
public List<Book> books() {
return bookRepository.findAll();
}
}
@@ -0,0 +1,29 @@
package com.ankurm.graphqldataloader.resolver;
import com.ankurm.graphqldataloader.domain.Author;
import com.ankurm.graphqldataloader.domain.AuthorRepository;
import com.ankurm.graphqldataloader.domain.Book;
import org.springframework.context.annotation.Profile;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;
/**
* The resolver nobody would flag in review: one {@code @SchemaMapping} method, one repository
* call, correct output. GraphQL Java calls it once per {@code Book} in the result, independently,
* which is exactly how it becomes N+1 — see docs/02-the-n-plus-one-problem.md.
*/
@Profile("naive")
@Controller
public class NaiveAuthorResolver {
private final AuthorRepository authorRepository;
public NaiveAuthorResolver(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@SchemaMapping(typeName = "Book", field = "author")
public Author author(Book book) {
return authorRepository.findById(book.getAuthorId()).orElse(null);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.graphqldataloader.support;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
/** Thread-safe record of every SQL statement actually executed, since the last {@link #reset()}. */
public class SqlLog {
private final List<String> statements = new CopyOnWriteArrayList<>();
public void record(String sql) {
statements.add(sql);
}
public List<String> all() {
return Collections.unmodifiableList(statements);
}
public int count() {
return statements.size();
}
public long countContaining(String needle) {
return statements.stream().filter(s -> s.contains(needle)).count();
}
public void reset() {
statements.clear();
}
public String render() {
StringBuilder sb = new StringBuilder();
int i = 1;
for (String s : statements) {
sb.append(i++).append(". ").append(s).append('\n');
}
sb.append("\ntotal statements: ").append(statements.size());
return sb.toString();
}
public String renderNumbered() {
return statements.stream()
.map(s -> (statements.indexOf(s) + 1) + ". " + s)
.collect(Collectors.joining("\n"));
}
}
@@ -0,0 +1,106 @@
package com.ankurm.graphqldataloader.support;
import javax.sql.DataSource;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
/**
* A {@link DataSource} decorator that logs every SQL statement actually sent to the driver, via
* a JDK dynamic proxy over {@link Connection} and {@link Statement}/{@link PreparedStatement} —
* so counts reflect what really reached the database, not what Hibernate's own SQL logging
* chooses to print (which, notably, prints one line per {@code addBatch()} call rather than
* counting {@code executeBatch()} as the single round trip it is). Same mechanism used in the
* sdjpa4-demo companion project for "Spring Data JDBC vs Spring Data JPA in 2026".
*/
public class StatementLoggingDataSource implements InvocationHandler {
private final DataSource delegate;
private final SqlLog log;
private StatementLoggingDataSource(DataSource delegate, SqlLog log) {
this.delegate = delegate;
this.log = log;
}
public static DataSource wrap(DataSource delegate, SqlLog log) {
return (DataSource) Proxy.newProxyInstance(
DataSource.class.getClassLoader(),
new Class<?>[]{DataSource.class},
new StatementLoggingDataSource(delegate, log));
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object result;
try {
result = method.invoke(delegate, args);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
if ("getConnection".equals(method.getName()) && result instanceof Connection connection) {
return wrapConnection(connection);
}
return result;
}
private Connection wrapConnection(Connection connection) {
return (Connection) Proxy.newProxyInstance(
Connection.class.getClassLoader(),
new Class<?>[]{Connection.class},
(proxy, method, args) -> {
Object result;
try {
result = method.invoke(connection, args);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
String name = method.getName();
if ("prepareStatement".equals(name) && args != null && args.length > 0 && result instanceof PreparedStatement ps) {
return wrapPreparedStatement(ps, (String) args[0]);
}
if ("createStatement".equals(name) && result instanceof Statement st) {
return wrapStatement(st);
}
return result;
});
}
private PreparedStatement wrapPreparedStatement(PreparedStatement ps, String sql) {
return (PreparedStatement) Proxy.newProxyInstance(
PreparedStatement.class.getClassLoader(),
new Class<?>[]{PreparedStatement.class},
(proxy, method, args) -> {
String name = method.getName();
if (name.startsWith("execute")) {
log.record(sql.trim());
}
try {
return method.invoke(ps, args);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
}
private Statement wrapStatement(Statement st) {
return (Statement) Proxy.newProxyInstance(
Statement.class.getClassLoader(),
new Class<?>[]{Statement.class},
(proxy, method, args) -> {
String name = method.getName();
if (name.startsWith("execute") && args != null && args.length > 0 && args[0] instanceof String sql) {
log.record(sql.trim());
}
try {
return method.invoke(st, args);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
}
}
@@ -0,0 +1,21 @@
spring:
application:
name: graphql-dataloader
sql:
init:
mode: always
jpa:
hibernate:
ddl-auto: none
open-in-view: false
properties:
hibernate:
show_sql: false # real statements come from StatementLoggingDataSource instead
graphql:
graphiql:
enabled: true
logging:
level:
root: WARN
com.ankurm.graphqldataloader: INFO
@@ -0,0 +1,14 @@
type Query {
books: [Book!]!
}
type Book {
id: ID!
title: String!
author: Author!
}
type Author {
id: ID!
name: String!
}
@@ -0,0 +1,10 @@
create table author (
id bigint auto_increment primary key,
name varchar(200) not null
);
create table book (
id bigint auto_increment primary key,
title varchar(200) not null,
author_id bigint
);
@@ -0,0 +1,184 @@
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;
/**
* Same experiments as {@link NaiveResolverSqlLogTest}, run against the {@code batched} profile
* ({@link com.ankurm.graphqldataloader.resolver.BatchedAuthorResolver}) instead — same schema,
* same seed helpers, same GraphQL query text, only the resolver wiring differs. The SQL statement
* counts are what the naive-vs-batched comparison in the article is built on.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("batched")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BatchedResolverSqlLogTest {
@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_fiveBooksFromFiveDistinctAuthorsIssueOneBatchedAuthorQuery() 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() + 1 SELECT ... WHERE id IN (...) from the batched
// authorRepository.findAllById() call — GraphQL Java's DataLoader collected all 5 pending
// author lookups into a single batch before BatchedAuthorResolver.author() ever ran.
assertThat(sqlLog.count()).isEqualTo(2);
assertThat(sqlLog.all().get(1)).containsIgnoringCase(" in (");
writeTranscript("batched-a-five-distinct-authors",
"batched profile / 5 books, 5 distinct authors (no repeats to dedupe)");
}
@Test
@Order(2)
void b_twentyBooksFromFiveAuthorsDedupesToOneBatchedQueryForFiveAuthors() throws Exception {
seedRoundRobinAuthors(5, 20);
sqlLog.reset();
JsonNode data = postGraphql("{ books { title author { name } } }");
assertThat(data.get("books")).hasSize(20);
// Still exactly 2 statements: 1 for books.findAll(), 1 for the batched author lookup — the
// resolver's `.distinct()` call collapses the 20 pending DataLoader keys down to the 5
// unique authorIds actually present before the repository is ever asked.
assertThat(sqlLog.count()).isEqualTo(2);
long placeholders = sqlLog.all().get(1).chars().filter(c -> c == '?').count();
assertThat(placeholders).isEqualTo(5);
writeTranscript("batched-b-twenty-books-five-authors",
"batched profile / 20 books, 5 distinct authors (4 books per author) — one batched IN query");
}
@Test
@Order(3)
void c_danglingAuthorIdNullsTheEntireBooksListViaNonNullPropagationSameAsNaive() 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);
// Same outcome as the naive profile: BatchedAuthorResolver.author() simply omits the map
// entry for the orphan Book, and Spring GraphQL treats a missing DataLoader/batch result
// the same as an explicit null for that source object — the non-null `author: Author!`
// field then propagates null up through the non-null `[Book!]!` list to the whole response.
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("batched-c-dangling-foreign-key-null-propagation.txt"),
"batched 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);
}
}
@@ -0,0 +1,175 @@
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);
}
}