- 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
105 lines
5.1 KiB
Markdown
105 lines
5.1 KiB
Markdown
# `@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.
|