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,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.