Add graphql-dataloader: Spring GraphQL 2.0 DataLoader batching vs naive N+1, and non-null propagation on a dangling FK

- naive @SchemaMapping resolver: 6 statements (5 books/5 authors), 21 statements (20 books/5 authors)
- batched @BatchMapping resolver: flat 2 statements in both cases, via DataLoader + .distinct()
- dangling authorId nulls the entire GraphQL response via non-null propagation, byte-identical under both resolver strategies
- 6-test suite over real HTTP against a live embedded Tomcat instance, SQL captured via a JDK dynamic proxy (StatementLoggingDataSource, reused from sdjpa4-demo)
- docs/05: two Boot 4.1 packaging changes hit along the way (DataSourceAutoConfiguration's new package, Jackson 3 by default)
- root README: add row for graphql-dataloader; fix openapi-versioning's placeholder link now that post 7477 is live
This commit is contained in:
2026-09-17 19:57:41 +00:00
parent 8d0efb0d4b
commit baff043748
29 changed files with 1454 additions and 1 deletions
@@ -0,0 +1,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.