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:
@@ -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.
|
||||
Reference in New Issue
Block a user