- 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
5.1 KiB
@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:
@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
Three things changed relative to NaiveAuthorResolver, and only one of them is the annotation:
@BatchMappinginstead of@SchemaMapping. Spring GraphQL registers this method as a batch loading function forBook.authorand wires it into a per-GraphQL-requestDataLoaderautomatically — there's noBatchLoaderRegistrybean to declare for this shortcut form; the annotation alone is enough. Under the hood, GraphQL Java's execution engine collects every pendingBook.authorfield 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.- The method signature takes
List<Book> books, not a singleBook, and returnsMap<Book, Author>— the DataLoader machinery matches eachBookkey back to itsAuthorvalue and resolves the individual GraphQL fields from that map. authorIdsis 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 itsIN (...)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,
@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
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
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.