- 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
3.9 KiB
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/02–docs/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.