- 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
123 lines
3.9 KiB
Markdown
123 lines
3.9 KiB
Markdown
# 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/02–docs/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.
|