Files
asmhatre baff043748 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
2026-09-17 19:57:41 +00:00

123 lines
3.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.