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

3.9 KiB
Raw Permalink Blame History

Schema, entities, and why there's no @ManyToOne

The schema is deliberately small:

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:

@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:

@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

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

spring:
  sql:
    init:
      mode: always
  jpa:
    hibernate:
      ddl-auto: none
    open-in-view: false
  graphql:
    graphiql:
      enabled: true

Source: 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.

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

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.