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
+2 -1
View File
@@ -23,7 +23,8 @@ files.
| [`spring-batch-partitioning/`](spring-batch-partitioning) | [Spring Batch Partitioning and Parallel Steps: Scaling a 10-Million-Row Job](https://ankurm.com/) | the real grid-size sweep at 10M and 300K rows (best speedup 1.42x, on 2 cores), `MultiResourcePartitioner` ignoring gridSize entirely, a rejected partition's `StepExecution` stuck at `STARTING` forever, and Spring Batch 6.0's new `JobOperator#recover` unsticking it | | [`spring-batch-partitioning/`](spring-batch-partitioning) | [Spring Batch Partitioning and Parallel Steps: Scaling a 10-Million-Row Job](https://ankurm.com/) | the real grid-size sweep at 10M and 300K rows (best speedup 1.42x, on 2 cores), `MultiResourcePartitioner` ignoring gridSize entirely, a rejected partition's `StepExecution` stuck at `STARTING` forever, and Spring Batch 6.0's new `JobOperator#recover` unsticking it |
| [`db-migrations-flyway-liquibase/`](db-migrations-flyway-liquibase) | [Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and Baselines](https://ankurm.com/flyway-vs-liquibase-spring-boot-4-migrations-rollbacks-baselines/) | Flyway Community's `undo` throwing `FlywayRedgateEditionRequiredException` at runtime, a real Liquibase 5.0.3 filename-caching defect that produces a phantom successful run, Liquibase's 10-second default lock-poll rate versus Flyway's near-instant row lock, the FSL license change and its ASF/Keycloak fallout, and what actually happens when both tools are enabled against one database | | [`db-migrations-flyway-liquibase/`](db-migrations-flyway-liquibase) | [Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and Baselines](https://ankurm.com/flyway-vs-liquibase-spring-boot-4-migrations-rollbacks-baselines/) | Flyway Community's `undo` throwing `FlywayRedgateEditionRequiredException` at runtime, a real Liquibase 5.0.3 filename-caching defect that produces a phantom successful run, Liquibase's 10-second default lock-poll rate versus Flyway's near-instant row lock, the FSL license change and its ASF/Keycloak fallout, and what actually happens when both tools are enabled against one database |
| [`db-migrations-expand-contract/`](db-migrations-expand-contract) | [Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot](https://ankurm.com/zero-downtime-database-migrations-expand-contract-spring-boot/) | a real 4-deploy rolling sequence against two live replicas with a load generator proving 99.98% success, H2's `AUTO_SERVER` single-point-of-failure trap, a `NOT NULL` constraint that fails every Stage 4 insert, and `ALTER TABLE` silently dropping a concurrently committed row with no exception thrown | | [`db-migrations-expand-contract/`](db-migrations-expand-contract) | [Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot](https://ankurm.com/zero-downtime-database-migrations-expand-contract-spring-boot/) | a real 4-deploy rolling sequence against two live replicas with a load generator proving 99.98% success, H2's `AUTO_SERVER` single-point-of-failure trap, a `NOT NULL` constraint that fails every Stage 4 insert, and `ALTER TABLE` silently dropping a concurrently committed row with no exception thrown |
| [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions | | [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/springdoc-openapi-spring-boot-4-1-versioning/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions |
| [`graphql-dataloader/`](graphql-dataloader) | [Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1](https://ankurm.com/) | a naive `@SchemaMapping` resolver measured at 21 SQL statements for 20 books versus a `@BatchMapping` resolver's flat 2, a dangling foreign key nulling an entire GraphQL response via non-null propagation identically under both resolver strategies, and two Spring Boot 4.1 packaging changes (`DataSourceAutoConfiguration`'s new package, Jackson 3 by default) hit along the way |
Articles whose text is kept here rather than only on the blog have it under Articles whose text is kept here rather than only on the blog have it under
`<directory>/post/``post.md` for the body and `meta.md` for the title, excerpt and `<directory>/post/``post.md` for the body and `meta.md` for the title, excerpt and
+63
View File
@@ -0,0 +1,63 @@
# graphql-dataloader
Companion code for **Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1**
on [ankurm.com](https://ankurm.com).
One schema, one `Book.author` field, two resolver implementations behind Spring profiles — a naive
`@SchemaMapping` and a `@BatchMapping` — with a JDK dynamic proxy in front of the JDBC driver
counting every SQL statement each one actually sends to the database, and a deliberately dangling
foreign key to observe GraphQL's non-null propagation up close.
**Tested with:** Spring Boot 4.1.1 / Spring Framework 7.0.9 / Spring GraphQL 2.0.5 / GraphQL Java
25.0 / java-dataloader 6.0.0 / JDK 25 (Temurin 25.0.4.1+1).
## Quickstart
```bash
mvn test # runs everything, regenerates docs/output/
mvn spring-boot:run -Dspring-boot.run.profiles=naive # then, in another shell:
curl -s -X POST http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ books { title author { name } } }"}'
mvn spring-boot:run -Dspring-boot.run.profiles=batched # same query, batched resolver
open http://localhost:8080/graphiql # interactive GraphiQL, either profile
```
## Where things are
| | |
|---|---|
| Schema, entities, and why there's no `@ManyToOne` | [docs/01-schema-and-resolvers.md](docs/01-schema-and-resolvers.md) |
| The naive resolver, and exactly how it becomes N+1 | [docs/02-the-n-plus-one-problem.md](docs/02-the-n-plus-one-problem.md) |
| `@BatchMapping`: the same field, one annotation different | [docs/03-the-batchmapping-fix.md](docs/03-the-batchmapping-fix.md) |
| A dangling foreign key and non-null propagation | [docs/04-the-null-propagation-trap.md](docs/04-the-null-propagation-trap.md) |
| Two Boot 4.1 packaging changes this module ran into | [docs/05-known-issues.md](docs/05-known-issues.md) |
## Captured output
Every SQL statement count and every GraphQL error message quoted in the article is one of these
files, regenerated by `mvn test`:
| File | What it shows |
|---|---|
| [docs/output/naive-a-five-distinct-authors.txt](docs/output/naive-a-five-distinct-authors.txt) | naive profile, 5 books / 5 authors — 6 statements |
| [docs/output/naive-b-twenty-books-five-authors.txt](docs/output/naive-b-twenty-books-five-authors.txt) | naive profile, 20 books / 5 authors — 21 statements |
| [docs/output/naive-c-dangling-foreign-key-null-propagation.txt](docs/output/naive-c-dangling-foreign-key-null-propagation.txt) | naive profile, orphan `authorId``data: null` |
| [docs/output/batched-a-five-distinct-authors.txt](docs/output/batched-a-five-distinct-authors.txt) | batched profile, 5 books / 5 authors — 2 statements |
| [docs/output/batched-b-twenty-books-five-authors.txt](docs/output/batched-b-twenty-books-five-authors.txt) | batched profile, 20 books / 5 authors — 2 statements |
| [docs/output/batched-c-dangling-foreign-key-null-propagation.txt](docs/output/batched-c-dangling-foreign-key-null-propagation.txt) | batched profile, orphan `authorId` — identical `data: null` |
## Test suite
| Class | What it covers |
|---|---|
| [`NaiveResolverSqlLogTest`](src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java) | `@ActiveProfiles("naive")` — the three experiments above, against `NaiveAuthorResolver` |
| [`BatchedResolverSqlLogTest`](src/test/java/com/ankurm/graphqldataloader/BatchedResolverSqlLogTest.java) | `@ActiveProfiles("batched")` — the same three experiments, against `BatchedAuthorResolver` |
Both run real HTTP POSTs (`java.net.http.HttpClient`) against a live, randomly-ported embedded
Tomcat instance under `@SpringBootTest`, and read SQL statement counts from
[`StatementLoggingDataSource`](src/main/java/com/ankurm/graphqldataloader/support/StatementLoggingDataSource.java) —
a JDK dynamic proxy over the JDBC driver, reused from the sdjpa4-demo companion project, that
counts what actually reaches H2 rather than trusting Hibernate's own SQL logging.
@@ -0,0 +1,122 @@
# Schema, entities, and why there's no `@ManyToOne`
The schema is deliberately small:
```graphql
type Query {
books: [Book!]!
}
type Book {
id: ID!
title: String!
author: Author!
}
type Author {
id: ID!
name: String!
}
```
Two queryable types, one relationship. `books` returns every `Book`, and each `Book` carries a
non-null `author`. That single non-null arrow (`author: Author!`, and for that matter `[Book!]!`
on the list itself) does two jobs in this module: it's the ordinary shape of a schema-first API,
and it's the trigger for docs/04-the-null-propagation-trap.md later on.
On the JPA side, `Book` does **not** map that relationship as a `@ManyToOne`:
```java
@Entity
@Table(name = "BOOK")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private Long authorId;
// ...
}
```
`authorId` is a plain `Long` column, not a Hibernate association. That's not an oversight — it's
the point of the module. If `Book.author` were a real `@ManyToOne`, Hibernate's own lazy-loading
and fetch-join machinery would decide when the `AUTHOR` row gets loaded, and that decision would
tangle with GraphQL Java's field-resolution order in ways that are hard to observe cleanly. Keeping
`authorId` as an inert column means the *only* thing that decides when and how an `Author` gets
loaded is the GraphQL resolver wired up for the `Book.author` field — which is exactly what
docs/02 and docs/03 compare.
## Three resolver-relevant annotations
`BookController` supplies the query root with an ordinary `@QueryMapping`:
```java
@Controller
public class BookController {
private final BookRepository bookRepository;
public BookController(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@QueryMapping
public List<Book> books() {
return bookRepository.findAll();
}
}
```
Source: [`resolver/BookController.java`](../src/main/java/com/ankurm/graphqldataloader/resolver/BookController.java)
That's one `SELECT` for the whole list — `select b1_0.id,b1_0.author_id,b1_0.title from book b1_0`,
confirmed in every transcript under `docs/output/` (line 1 of each one). The interesting part is
what happens next, when GraphQL Java has a list of `Book` objects and needs to resolve `author` on
each of them. Two resolver classes answer that question differently, and both are wired into the
same schema and the same entities — only the annotation and the method signature change. They're
kept apart with Spring profiles (`@Profile("naive")` / `@Profile("batched")`) so a single running
instance never has both active at once, and the test suite in docs/02docs/04 boots one profile
per test class to compare them under identical data.
## `application.yml` and `schema.sql`
```yaml
spring:
sql:
init:
mode: always
jpa:
hibernate:
ddl-auto: none
open-in-view: false
graphql:
graphiql:
enabled: true
```
Source: [`src/main/resources/application.yml`](../src/main/resources/application.yml)
`open-in-view: false` is not incidental. With OSIV on, a stray Hibernate session spanning the whole
request can paper over exactly the kind of lazy-loading-triggered-from-the-wrong-place bug this
module is built to make visible; turning it off means any accidental session access outside the
transaction that a resolver runs in fails loudly instead of quietly working by accident.
```sql
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(300) not null,
author_id bigint not null
);
```
Source: [`src/main/resources/schema.sql`](../src/main/resources/schema.sql)
No foreign key constraint on `book.author_id`. That's also deliberate — docs/04 seeds a `Book` row
whose `author_id` matches no `Author` row at all, and a foreign key would make that impossible to
set up in the first place.
@@ -0,0 +1,77 @@
# The naive resolver, and exactly how it becomes N+1
`NaiveAuthorResolver` is the resolver nobody would flag in code review:
```java
@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);
}
}
```
Source: [`resolver/NaiveAuthorResolver.java`](../src/main/java/com/ankurm/graphqldataloader/resolver/NaiveAuthorResolver.java)
One method, one repository call, correct output for any individual `Book`. `@SchemaMapping`
targets a specific field of a specific type (`Book.author`), and Spring GraphQL calls that method
once per `Book` instance GraphQL Java needs to resolve the field for — independently, with no
visibility into what the other invocations are doing. If the query asks for 5 books, this method
runs 5 times; if it asks for 20, it runs 20 times. That's not a framework limitation being worked
around badly, it's simply what `@SchemaMapping` is: a per-object field resolver, full stop.
## The evidence, at two scales
Test class: [`NaiveResolverSqlLogTest`](../src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java),
run with `@ActiveProfiles("naive")`. Every query in these transcripts came from a real HTTP POST to
`/graphql` on a live, randomly-ported embedded Tomcat instance under `@SpringBootTest`, captured by
the JDK dynamic proxy in `StatementLoggingDataSource` — the same mechanism from the sdjpa4-demo
companion project — not from Hibernate's own SQL logging, so what's counted is what actually
reached H2.
**5 books, 5 distinct authors** (`a_fiveBooksFromFiveDistinctAuthorsIssueOneAuthorQueryPerBook`):
```
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
total statements: 6
```
Source: [docs/output/naive-a-five-distinct-authors.txt](output/naive-a-five-distinct-authors.txt)
One `books` query, five `author` queries. That's the "N+1" in its cleanest form: 1 query for the
list, N queries for the thing every item in the list needs.
**20 books, but only 5 distinct authors** (`b_twentyBooksFromFiveAuthorsStillIssuesTwentyAuthorQueries`) —
each author now has 4 books:
```
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
...
21. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
total statements: 21
```
Full transcript: [docs/output/naive-b-twenty-books-five-authors.txt](output/naive-b-twenty-books-five-authors.txt)
This is the case worth sitting with. There are only 5 *distinct* authors involved — a batched
lookup could satisfy the whole request with a single `WHERE id IN (...)` query. The naive resolver
issues 21 statements anyway, because `findById()` inside the per-`Book` callback has no way to know
that the author it's about to fetch for book #6 is the same author it already fetched for book #2.
Repetition in the data doesn't help this resolver at all — it costs exactly as much as 20 books by
20 different authors would have. That's the concrete cost `@BatchMapping` fixes in docs/03, and the
`.distinct()` call that makes the fix worth having.
@@ -0,0 +1,104 @@
# `@BatchMapping`: the same field, one annotation different
`BatchedAuthorResolver` answers the exact same schema field — `Book.author` — with a method that
takes every pending `Book` at once instead of one at a time:
```java
@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;
}
}
```
Source: [`resolver/BatchedAuthorResolver.java`](../src/main/java/com/ankurm/graphqldataloader/resolver/BatchedAuthorResolver.java)
Three things changed relative to `NaiveAuthorResolver`, and only one of them is the annotation:
1. `@BatchMapping` instead of `@SchemaMapping`. Spring GraphQL registers this method as a batch
loading function for `Book.author` and wires it into a per-GraphQL-request `DataLoader`
automatically — there's no `BatchLoaderRegistry` bean to declare for this shortcut form; the
annotation alone is enough. Under the hood, GraphQL Java's execution engine collects every
pending `Book.author` field resolution for the current batch of the current request before
calling this method once with the full list, rather than calling a per-object resolver N times.
2. The method signature takes `List<Book> books`, not a single `Book`, and returns
`Map<Book, Author>` — the DataLoader machinery matches each `Book` key back to its `Author`
value and resolves the individual GraphQL fields from that map.
3. `authorIds` is built with `.distinct()` before it ever reaches the repository. This is the line
that turns "one query instead of N" into "one query for the *distinct* authors involved,"
regardless of how many books repeat the same author. Without it, `findAllById()` would still be
a single query, just one with duplicate IDs in its `IN (...)` list — correct, but not as tight
as it could be.
The returned `Map` also deliberately **omits** an entry for any `Book` whose author lookup misses,
rather than putting an explicit `null` value in for it. That choice doesn't change anything for the
well-formed data in this chapter — it's the setup for docs/04, where a `Book` with no matching
`Author` row shows that Spring GraphQL treats a missing map key exactly the same as an explicit
null.
## The evidence, same two scales as docs/02
Test class: [`BatchedResolverSqlLogTest`](../src/test/java/com/ankurm/graphqldataloader/BatchedResolverSqlLogTest.java),
`@ActiveProfiles("batched")`, same seed helpers, same GraphQL query text, same real-HTTP mechanics
as the naive test class — only the active profile (and therefore which resolver bean is wired to
`Book.author`) differs.
**5 books, 5 distinct authors** (`a_fiveBooksFromFiveDistinctAuthorsIssueOneBatchedAuthorQuery`):
```
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
total statements: 2
```
Source: [docs/output/batched-a-five-distinct-authors.txt](output/batched-a-five-distinct-authors.txt)
Six statements in the naive transcript became two. The books query is identical — same SQL, same
one round trip — and the five separate `author.id=?` lookups collapsed into a single
`author.id in (?,?,?,?,?)`.
**20 books, 5 distinct authors** (`b_twentyBooksFromFiveAuthorsDedupesToOneBatchedQueryForFiveAuthors`):
```
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
total statements: 2
```
Source: [docs/output/batched-b-twenty-books-five-authors.txt](output/batched-b-twenty-books-five-authors.txt)
Still two statements — the same two, in fact, since the `IN` clause still has exactly 5
placeholders (asserted directly in the test by counting `?` characters in the captured SQL). Where
the naive resolver went from 6 statements to 21 as the book count quadrupled with no new authors,
the batched resolver's query count didn't move at all. The `.distinct()` call is doing real work
here: 20 pending DataLoader keys, 5 unique values, 1 query.
| | 5 books / 5 authors | 20 books / 5 authors |
|---|---|---|
| naive (`@SchemaMapping`) | 6 statements | 21 statements |
| batched (`@BatchMapping`) | 2 statements | 2 statements |
That table is the whole argument for `@BatchMapping` in one place: the naive resolver's cost is a
function of how many books are in the result; the batched resolver's cost is a function of how many
*distinct* authors are in the result, which is very often a much smaller, much more slowly growing
number.
@@ -0,0 +1,73 @@
# A dangling foreign key, a non-null field, and the whole response going null
`schema.sql` deliberately puts no foreign key constraint on `book.author_id` (see docs/01), which
makes it possible to insert a `Book` whose `authorId` matches no row in `AUTHOR` at all — a data
integrity problem that a real foreign key would normally prevent, but one that's entirely possible
in systems that don't enforce referential integrity at the database level, or during a migration,
or from a bad import. Both test classes run the same experiment against it:
```java
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));
```
Two books, one with a valid author, one with an `authorId` (`9999999`) that doesn't exist. Then the
same query as docs/02 and docs/03:
```graphql
{ books { title author { name } } }
```
## What happens with the naive resolver
`NaiveAuthorResolver.author()` runs `authorRepository.findById(9_999_999L).orElse(null)` for the
orphan book and gets back `null`. The schema says `author: Author!` — non-null. GraphQL Java's
response, captured verbatim from the real HTTP response body
(`c_danglingAuthorIdNullsTheEntireBooksListViaNonNullPropagation`):
```json
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}},"data":null}
```
Source: [docs/output/naive-c-dangling-foreign-key-null-propagation.txt](output/naive-c-dangling-foreign-key-null-propagation.txt)
Read that error message once, slowly, because it names its own mechanism: a non-null field that
resolves to null doesn't just fail *that* field — the null has to go somewhere, and the GraphQL
specification's rule is that it propagates upward to the nearest field in the response tree that
*is* allowed to be null. Here, the chain is `author` (non-null) inside `Book` (non-null element of
a non-null list, `[Book!]!`) inside `books` (non-null). There is no nullable field anywhere between
the failure and the root, so the null keeps climbing until it reaches the top: the entire `data`
key becomes `null`, and the one broken book takes the four working ones down with it. `errors` is
populated, `data` is not partial — it's entirely absent.
## What happens with the batched resolver
`BatchedAuthorResolver.author()` builds its result map from `authorRepository.findAllById(...)`,
and the orphan book's `authorId` simply never appears as a key in that map — the resolver's own
doc comment calls this out explicitly: a missing map entry, not an explicit null value stored
against the key. The response, from the same experiment run against the `batched` profile:
```json
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}},"data":null}
```
Source: [docs/output/batched-c-dangling-foreign-key-null-propagation.txt](output/batched-c-dangling-foreign-key-null-propagation.txt)
Byte-for-byte the same error, same path, same classification. That's the point of running the
experiment against both profiles rather than just one: Spring GraphQL treats "no entry in the
`Map<Book, Author>` returned from a `@BatchMapping` method" as identical to "no `Author` found from
a per-object `@SchemaMapping` resolver" — the null-propagation behavior lives entirely in GraphQL
Java's execution engine, not in either resolver strategy, and switching from naive to batched buys
better query counts without changing this failure mode at all.
## Why this matters more than it looks like it does
Non-null propagation is a deliberate, spec-mandated safety property — it exists so a client can
trust that a non-null field, if present, is never actually null, which simplifies every consumer of
the API. But it means a single bad row, one dangling foreign key in one book out of however many,
is enough to null out an entire query's `data`, including books whose own data was completely fine.
In a production schema, the practical response to this is usually one of: make the field nullable
if "we don't know the author" is a legitimate state; enforce the foreign key at the database layer
so the dangling reference can't be created in the first place; or have the resolver return a
sentinel "unknown author" object instead of `null`/a missing map entry. This module does none of
those on purpose, specifically so the failure mode is visible and reproducible rather than designed
around.
+100
View File
@@ -0,0 +1,100 @@
# Two Boot 4.1 packaging changes this module ran into directly
Neither of these is about GraphQL or DataLoader batching — they're genuine build-time surprises
from targeting Spring Boot 4.1.1 that are worth recording here because they cost real debugging
time and because searching for the old, Boot-3-era package names will still turn up plenty of
now-stale documentation and Stack Overflow answers.
## `DataSourceAutoConfiguration` moved packages
The very first `mvn clean package` on this module failed with:
```
[ERROR] package org.springframework.boot.autoconfigure.jdbc does not exist
[ERROR] cannot find symbol
symbol: class DataSourceAutoConfiguration
```
`@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)` is used here for the same
reason it's used in the sdjpa4-demo companion project: to stop Boot's own auto-configuration from
building a `DataSource` from `application.yml` properties, so the custom `@Bean DataSource` method
wrapping H2 in `StatementLoggingDataSource` is the only one that ever runs. In Spring Boot 3, that
class lived at `org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration`, inside
the single, large `spring-boot-autoconfigure` artifact.
As of Boot 4.1.1, `spring-boot-autoconfigure` only contains the generic auto-configuration
*infrastructure*`AutoConfigurationImportSelector`, `AutoConfigurations`, and similar — confirmed
directly by listing the jar:
```
$ unzip -l spring-boot-autoconfigure-4.1.1.jar | grep -i jdbc
(no output)
```
The actual per-feature auto-configuration classes now live in their own per-feature artifacts, in
new packages. `DataSourceAutoConfiguration` specifically is in `spring-boot-jdbc-4.1.1.jar`, at
`org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration` — the `jdbc` and
`autoconfigure` segments swapped relative to the old package name. The fix was a one-line import
change:
```diff
-import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
```
The sdjpa4-demo companion project (for the Spring Data JDBC vs. JPA article) already used the
correct new package — this module's `GraphqlDataloaderApplication.java` was written from habit
before that was double-checked here directly.
## Jackson 3 by default
Writing the HTTP-level test assertions for docs/02docs/04 initially used
`com.fasterxml.jackson.databind.{ObjectMapper,JsonNode}`, which failed to compile:
```
[ERROR] package com.fasterxml.jackson.databind does not exist
```
`mvn dependency:tree` on this module shows why:
```
+- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile
| \- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile
| \- tools.jackson.core:jackson-databind:jar:3.1.5:compile
| +- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile
| \- tools.jackson.core:jackson-core:jar:3.1.5:compile
```
Spring Boot 4.1's default Jackson integration is Jackson 3, whose `jackson-core` and
`jackson-databind` modules moved to the `tools.jackson.core` Maven group and the
`tools.jackson.databind` / `tools.jackson.core` Java packages — a deliberate break from the
`com.fasterxml.jackson.*` namespace Jackson 2 used. `jackson-annotations` is the one piece that
stayed on the old `com.fasterxml.jackson.core` group (at 2.21), since annotations are largely
shared/compatible across the 2.x/3.x line. The fix in this module's test classes was the same shape
as the `DataSourceAutoConfiguration` one — swap the import, not the code:
```diff
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
```
`ObjectMapper.writeValueAsString(...)` and `.readTree(...)` behave identically once the import is
fixed — this module didn't need any further changes to its JSON-handling code. Projects still on
`spring-boot-starter-web`/`spring-boot-starter-json` from a Boot 3.x background, or projects that
pull in `com.fasterxml.jackson.core:jackson-databind` directly rather than through a Boot starter,
should expect to hit this the first time they add any code that touches `ObjectMapper` or
`JsonNode` directly after upgrading to Boot 4.1.
## What was, and wasn't, independently verified here
Every SQL statement count and every GraphQL error message in docs/02docs/04 came from this
module's own test runs against real H2 and a real embedded Tomcat instance — nothing in those three
chapters is asserted from documentation or memory. The two packaging changes above were confirmed
directly by inspecting the actual `.jar` contents downloaded into the local Maven repository for
this build (`unzip -l`), not by trusting a changelog summary. What was *not* independently
re-derived from first principles here is the general architectural claim that Spring Boot 4
modularized auto-configuration into per-feature artifacts — that's stated as context, based on what
this module's own dependency tree and jar contents show, not as an exhaustively researched survey
of every module that moved.
@@ -0,0 +1,6 @@
batched profile / 5 books, 5 distinct authors (no repeats to dedupe)
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
total statements: 2
@@ -0,0 +1,6 @@
batched profile / 20 books, 5 distinct authors (4 books per author) — one batched IN query
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
total statements: 2
@@ -0,0 +1,4 @@
batched profile / one Book has authorId=9999999 which matches no Author row
raw HTTP response body from POST /graphql:
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}}],"data":null}
@@ -0,0 +1,10 @@
naive profile / 5 books, 5 distinct authors (no repeats to dedupe)
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
total statements: 6
@@ -0,0 +1,25 @@
naive profile / 20 books, 5 distinct authors (4 books per author) — repeats do not help
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
7. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
8. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
9. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
10. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
11. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
12. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
13. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
14. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
15. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
16. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
17. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
18. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
19. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
20. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
21. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
total statements: 21
@@ -0,0 +1,4 @@
naive profile / one Book has authorId=9999999 which matches no Author row
raw HTTP response body from POST /graphql:
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}}],"data":null}
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>graphql-dataloader</artifactId>
<version>1.0.0</version>
<name>graphql-dataloader</name>
<description>Spring GraphQL 2.0.5 schema-first APIs, DataLoader batching, and killing N+1 on Spring Boot 4.1</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}