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:
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.graphqldataloader.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface BookRepository extends JpaRepository<Book, Long> {
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
@@ -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"));
|
||||
}
|
||||
}
|
||||
+106
@@ -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
|
||||
);
|
||||
+184
@@ -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);
|
||||
}
|
||||
}
|
||||
+175
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user