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:
@@ -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/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.
|
||||
@@ -0,0 +1,77 @@
|
||||
# The naive resolver, and exactly how it becomes N+1
|
||||
|
||||
`NaiveAuthorResolver` is the resolver nobody would flag in code review:
|
||||
|
||||
```java
|
||||
@Profile("naive")
|
||||
@Controller
|
||||
public class NaiveAuthorResolver {
|
||||
|
||||
private final AuthorRepository authorRepository;
|
||||
|
||||
public NaiveAuthorResolver(AuthorRepository authorRepository) {
|
||||
this.authorRepository = authorRepository;
|
||||
}
|
||||
|
||||
@SchemaMapping(typeName = "Book", field = "author")
|
||||
public Author author(Book book) {
|
||||
return authorRepository.findById(book.getAuthorId()).orElse(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
Source: [`resolver/NaiveAuthorResolver.java`](../src/main/java/com/ankurm/graphqldataloader/resolver/NaiveAuthorResolver.java)
|
||||
|
||||
One method, one repository call, correct output for any individual `Book`. `@SchemaMapping`
|
||||
targets a specific field of a specific type (`Book.author`), and Spring GraphQL calls that method
|
||||
once per `Book` instance GraphQL Java needs to resolve the field for — independently, with no
|
||||
visibility into what the other invocations are doing. If the query asks for 5 books, this method
|
||||
runs 5 times; if it asks for 20, it runs 20 times. That's not a framework limitation being worked
|
||||
around badly, it's simply what `@SchemaMapping` is: a per-object field resolver, full stop.
|
||||
|
||||
## The evidence, at two scales
|
||||
|
||||
Test class: [`NaiveResolverSqlLogTest`](../src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java),
|
||||
run with `@ActiveProfiles("naive")`. Every query in these transcripts came from a real HTTP POST to
|
||||
`/graphql` on a live, randomly-ported embedded Tomcat instance under `@SpringBootTest`, captured by
|
||||
the JDK dynamic proxy in `StatementLoggingDataSource` — the same mechanism from the sdjpa4-demo
|
||||
companion project — not from Hibernate's own SQL logging, so what's counted is what actually
|
||||
reached H2.
|
||||
|
||||
**5 books, 5 distinct authors** (`a_fiveBooksFromFiveDistinctAuthorsIssueOneAuthorQueryPerBook`):
|
||||
|
||||
```
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
|
||||
total statements: 6
|
||||
```
|
||||
Source: [docs/output/naive-a-five-distinct-authors.txt](output/naive-a-five-distinct-authors.txt)
|
||||
|
||||
One `books` query, five `author` queries. That's the "N+1" in its cleanest form: 1 query for the
|
||||
list, N queries for the thing every item in the list needs.
|
||||
|
||||
**20 books, but only 5 distinct authors** (`b_twentyBooksFromFiveAuthorsStillIssuesTwentyAuthorQueries`) —
|
||||
each author now has 4 books:
|
||||
|
||||
```
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
...
|
||||
21. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
|
||||
total statements: 21
|
||||
```
|
||||
Full transcript: [docs/output/naive-b-twenty-books-five-authors.txt](output/naive-b-twenty-books-five-authors.txt)
|
||||
|
||||
This is the case worth sitting with. There are only 5 *distinct* authors involved — a batched
|
||||
lookup could satisfy the whole request with a single `WHERE id IN (...)` query. The naive resolver
|
||||
issues 21 statements anyway, because `findById()` inside the per-`Book` callback has no way to know
|
||||
that the author it's about to fetch for book #6 is the same author it already fetched for book #2.
|
||||
Repetition in the data doesn't help this resolver at all — it costs exactly as much as 20 books by
|
||||
20 different authors would have. That's the concrete cost `@BatchMapping` fixes in docs/03, and the
|
||||
`.distinct()` call that makes the fix worth having.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `@BatchMapping`: the same field, one annotation different
|
||||
|
||||
`BatchedAuthorResolver` answers the exact same schema field — `Book.author` — with a method that
|
||||
takes every pending `Book` at once instead of one at a time:
|
||||
|
||||
```java
|
||||
@Profile("batched")
|
||||
@Controller
|
||||
public class BatchedAuthorResolver {
|
||||
|
||||
private final AuthorRepository authorRepository;
|
||||
|
||||
public BatchedAuthorResolver(AuthorRepository authorRepository) {
|
||||
this.authorRepository = authorRepository;
|
||||
}
|
||||
|
||||
@BatchMapping(typeName = "Book", field = "author")
|
||||
public Map<Book, Author> author(List<Book> books) {
|
||||
List<Long> authorIds = books.stream().map(Book::getAuthorId).distinct().toList();
|
||||
Map<Long, Author> byId = authorRepository.findAllById(authorIds).stream()
|
||||
.collect(Collectors.toMap(Author::getId, a -> a));
|
||||
|
||||
Map<Book, Author> result = new HashMap<>();
|
||||
for (Book book : books) {
|
||||
Author author = byId.get(book.getAuthorId());
|
||||
if (author != null) {
|
||||
result.put(book, author);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
Source: [`resolver/BatchedAuthorResolver.java`](../src/main/java/com/ankurm/graphqldataloader/resolver/BatchedAuthorResolver.java)
|
||||
|
||||
Three things changed relative to `NaiveAuthorResolver`, and only one of them is the annotation:
|
||||
|
||||
1. `@BatchMapping` instead of `@SchemaMapping`. Spring GraphQL registers this method as a batch
|
||||
loading function for `Book.author` and wires it into a per-GraphQL-request `DataLoader`
|
||||
automatically — there's no `BatchLoaderRegistry` bean to declare for this shortcut form; the
|
||||
annotation alone is enough. Under the hood, GraphQL Java's execution engine collects every
|
||||
pending `Book.author` field resolution for the current batch of the current request before
|
||||
calling this method once with the full list, rather than calling a per-object resolver N times.
|
||||
2. The method signature takes `List<Book> books`, not a single `Book`, and returns
|
||||
`Map<Book, Author>` — the DataLoader machinery matches each `Book` key back to its `Author`
|
||||
value and resolves the individual GraphQL fields from that map.
|
||||
3. `authorIds` is built with `.distinct()` before it ever reaches the repository. This is the line
|
||||
that turns "one query instead of N" into "one query for the *distinct* authors involved,"
|
||||
regardless of how many books repeat the same author. Without it, `findAllById()` would still be
|
||||
a single query, just one with duplicate IDs in its `IN (...)` list — correct, but not as tight
|
||||
as it could be.
|
||||
|
||||
The returned `Map` also deliberately **omits** an entry for any `Book` whose author lookup misses,
|
||||
rather than putting an explicit `null` value in for it. That choice doesn't change anything for the
|
||||
well-formed data in this chapter — it's the setup for docs/04, where a `Book` with no matching
|
||||
`Author` row shows that Spring GraphQL treats a missing map key exactly the same as an explicit
|
||||
null.
|
||||
|
||||
## The evidence, same two scales as docs/02
|
||||
|
||||
Test class: [`BatchedResolverSqlLogTest`](../src/test/java/com/ankurm/graphqldataloader/BatchedResolverSqlLogTest.java),
|
||||
`@ActiveProfiles("batched")`, same seed helpers, same GraphQL query text, same real-HTTP mechanics
|
||||
as the naive test class — only the active profile (and therefore which resolver bean is wired to
|
||||
`Book.author`) differs.
|
||||
|
||||
**5 books, 5 distinct authors** (`a_fiveBooksFromFiveDistinctAuthorsIssueOneBatchedAuthorQuery`):
|
||||
|
||||
```
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
|
||||
|
||||
total statements: 2
|
||||
```
|
||||
Source: [docs/output/batched-a-five-distinct-authors.txt](output/batched-a-five-distinct-authors.txt)
|
||||
|
||||
Six statements in the naive transcript became two. The books query is identical — same SQL, same
|
||||
one round trip — and the five separate `author.id=?` lookups collapsed into a single
|
||||
`author.id in (?,?,?,?,?)`.
|
||||
|
||||
**20 books, 5 distinct authors** (`b_twentyBooksFromFiveAuthorsDedupesToOneBatchedQueryForFiveAuthors`):
|
||||
|
||||
```
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
|
||||
|
||||
total statements: 2
|
||||
```
|
||||
Source: [docs/output/batched-b-twenty-books-five-authors.txt](output/batched-b-twenty-books-five-authors.txt)
|
||||
|
||||
Still two statements — the same two, in fact, since the `IN` clause still has exactly 5
|
||||
placeholders (asserted directly in the test by counting `?` characters in the captured SQL). Where
|
||||
the naive resolver went from 6 statements to 21 as the book count quadrupled with no new authors,
|
||||
the batched resolver's query count didn't move at all. The `.distinct()` call is doing real work
|
||||
here: 20 pending DataLoader keys, 5 unique values, 1 query.
|
||||
|
||||
| | 5 books / 5 authors | 20 books / 5 authors |
|
||||
|---|---|---|
|
||||
| naive (`@SchemaMapping`) | 6 statements | 21 statements |
|
||||
| batched (`@BatchMapping`) | 2 statements | 2 statements |
|
||||
|
||||
That table is the whole argument for `@BatchMapping` in one place: the naive resolver's cost is a
|
||||
function of how many books are in the result; the batched resolver's cost is a function of how many
|
||||
*distinct* authors are in the result, which is very often a much smaller, much more slowly growing
|
||||
number.
|
||||
@@ -0,0 +1,73 @@
|
||||
# A dangling foreign key, a non-null field, and the whole response going null
|
||||
|
||||
`schema.sql` deliberately puts no foreign key constraint on `book.author_id` (see docs/01), which
|
||||
makes it possible to insert a `Book` whose `authorId` matches no row in `AUTHOR` at all — a data
|
||||
integrity problem that a real foreign key would normally prevent, but one that's entirely possible
|
||||
in systems that don't enforce referential integrity at the database level, or during a migration,
|
||||
or from a bad import. Both test classes run the same experiment against it:
|
||||
|
||||
```java
|
||||
Author real = authorRepository.save(new Author("Ursula K. Le Guin"));
|
||||
bookRepository.save(new Book("The Dispossessed", real.getId()));
|
||||
bookRepository.save(new Book("Orphan Book With No Author Row", 9_999_999L));
|
||||
```
|
||||
|
||||
Two books, one with a valid author, one with an `authorId` (`9999999`) that doesn't exist. Then the
|
||||
same query as docs/02 and docs/03:
|
||||
|
||||
```graphql
|
||||
{ books { title author { name } } }
|
||||
```
|
||||
|
||||
## What happens with the naive resolver
|
||||
|
||||
`NaiveAuthorResolver.author()` runs `authorRepository.findById(9_999_999L).orElse(null)` for the
|
||||
orphan book and gets back `null`. The schema says `author: Author!` — non-null. GraphQL Java's
|
||||
response, captured verbatim from the real HTTP response body
|
||||
(`c_danglingAuthorIdNullsTheEntireBooksListViaNonNullPropagation`):
|
||||
|
||||
```json
|
||||
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}},"data":null}
|
||||
```
|
||||
Source: [docs/output/naive-c-dangling-foreign-key-null-propagation.txt](output/naive-c-dangling-foreign-key-null-propagation.txt)
|
||||
|
||||
Read that error message once, slowly, because it names its own mechanism: a non-null field that
|
||||
resolves to null doesn't just fail *that* field — the null has to go somewhere, and the GraphQL
|
||||
specification's rule is that it propagates upward to the nearest field in the response tree that
|
||||
*is* allowed to be null. Here, the chain is `author` (non-null) inside `Book` (non-null element of
|
||||
a non-null list, `[Book!]!`) inside `books` (non-null). There is no nullable field anywhere between
|
||||
the failure and the root, so the null keeps climbing until it reaches the top: the entire `data`
|
||||
key becomes `null`, and the one broken book takes the four working ones down with it. `errors` is
|
||||
populated, `data` is not partial — it's entirely absent.
|
||||
|
||||
## What happens with the batched resolver
|
||||
|
||||
`BatchedAuthorResolver.author()` builds its result map from `authorRepository.findAllById(...)`,
|
||||
and the orphan book's `authorId` simply never appears as a key in that map — the resolver's own
|
||||
doc comment calls this out explicitly: a missing map entry, not an explicit null value stored
|
||||
against the key. The response, from the same experiment run against the `batched` profile:
|
||||
|
||||
```json
|
||||
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}},"data":null}
|
||||
```
|
||||
Source: [docs/output/batched-c-dangling-foreign-key-null-propagation.txt](output/batched-c-dangling-foreign-key-null-propagation.txt)
|
||||
|
||||
Byte-for-byte the same error, same path, same classification. That's the point of running the
|
||||
experiment against both profiles rather than just one: Spring GraphQL treats "no entry in the
|
||||
`Map<Book, Author>` returned from a `@BatchMapping` method" as identical to "no `Author` found from
|
||||
a per-object `@SchemaMapping` resolver" — the null-propagation behavior lives entirely in GraphQL
|
||||
Java's execution engine, not in either resolver strategy, and switching from naive to batched buys
|
||||
better query counts without changing this failure mode at all.
|
||||
|
||||
## Why this matters more than it looks like it does
|
||||
|
||||
Non-null propagation is a deliberate, spec-mandated safety property — it exists so a client can
|
||||
trust that a non-null field, if present, is never actually null, which simplifies every consumer of
|
||||
the API. But it means a single bad row, one dangling foreign key in one book out of however many,
|
||||
is enough to null out an entire query's `data`, including books whose own data was completely fine.
|
||||
In a production schema, the practical response to this is usually one of: make the field nullable
|
||||
if "we don't know the author" is a legitimate state; enforce the foreign key at the database layer
|
||||
so the dangling reference can't be created in the first place; or have the resolver return a
|
||||
sentinel "unknown author" object instead of `null`/a missing map entry. This module does none of
|
||||
those on purpose, specifically so the failure mode is visible and reproducible rather than designed
|
||||
around.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Two Boot 4.1 packaging changes this module ran into directly
|
||||
|
||||
Neither of these is about GraphQL or DataLoader batching — they're genuine build-time surprises
|
||||
from targeting Spring Boot 4.1.1 that are worth recording here because they cost real debugging
|
||||
time and because searching for the old, Boot-3-era package names will still turn up plenty of
|
||||
now-stale documentation and Stack Overflow answers.
|
||||
|
||||
## `DataSourceAutoConfiguration` moved packages
|
||||
|
||||
The very first `mvn clean package` on this module failed with:
|
||||
|
||||
```
|
||||
[ERROR] package org.springframework.boot.autoconfigure.jdbc does not exist
|
||||
[ERROR] cannot find symbol
|
||||
symbol: class DataSourceAutoConfiguration
|
||||
```
|
||||
|
||||
`@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)` is used here for the same
|
||||
reason it's used in the sdjpa4-demo companion project: to stop Boot's own auto-configuration from
|
||||
building a `DataSource` from `application.yml` properties, so the custom `@Bean DataSource` method
|
||||
wrapping H2 in `StatementLoggingDataSource` is the only one that ever runs. In Spring Boot 3, that
|
||||
class lived at `org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration`, inside
|
||||
the single, large `spring-boot-autoconfigure` artifact.
|
||||
|
||||
As of Boot 4.1.1, `spring-boot-autoconfigure` only contains the generic auto-configuration
|
||||
*infrastructure* — `AutoConfigurationImportSelector`, `AutoConfigurations`, and similar — confirmed
|
||||
directly by listing the jar:
|
||||
|
||||
```
|
||||
$ unzip -l spring-boot-autoconfigure-4.1.1.jar | grep -i jdbc
|
||||
(no output)
|
||||
```
|
||||
|
||||
The actual per-feature auto-configuration classes now live in their own per-feature artifacts, in
|
||||
new packages. `DataSourceAutoConfiguration` specifically is in `spring-boot-jdbc-4.1.1.jar`, at
|
||||
`org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration` — the `jdbc` and
|
||||
`autoconfigure` segments swapped relative to the old package name. The fix was a one-line import
|
||||
change:
|
||||
|
||||
```diff
|
||||
-import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
+import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
```
|
||||
|
||||
The sdjpa4-demo companion project (for the Spring Data JDBC vs. JPA article) already used the
|
||||
correct new package — this module's `GraphqlDataloaderApplication.java` was written from habit
|
||||
before that was double-checked here directly.
|
||||
|
||||
## Jackson 3 by default
|
||||
|
||||
Writing the HTTP-level test assertions for docs/02–docs/04 initially used
|
||||
`com.fasterxml.jackson.databind.{ObjectMapper,JsonNode}`, which failed to compile:
|
||||
|
||||
```
|
||||
[ERROR] package com.fasterxml.jackson.databind does not exist
|
||||
```
|
||||
|
||||
`mvn dependency:tree` on this module shows why:
|
||||
|
||||
```
|
||||
+- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile
|
||||
| \- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile
|
||||
| \- tools.jackson.core:jackson-databind:jar:3.1.5:compile
|
||||
| +- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile
|
||||
| \- tools.jackson.core:jackson-core:jar:3.1.5:compile
|
||||
```
|
||||
|
||||
Spring Boot 4.1's default Jackson integration is Jackson 3, whose `jackson-core` and
|
||||
`jackson-databind` modules moved to the `tools.jackson.core` Maven group and the
|
||||
`tools.jackson.databind` / `tools.jackson.core` Java packages — a deliberate break from the
|
||||
`com.fasterxml.jackson.*` namespace Jackson 2 used. `jackson-annotations` is the one piece that
|
||||
stayed on the old `com.fasterxml.jackson.core` group (at 2.21), since annotations are largely
|
||||
shared/compatible across the 2.x/3.x line. The fix in this module's test classes was the same shape
|
||||
as the `DataSourceAutoConfiguration` one — swap the import, not the code:
|
||||
|
||||
```diff
|
||||
-import com.fasterxml.jackson.databind.JsonNode;
|
||||
-import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
+import tools.jackson.databind.JsonNode;
|
||||
+import tools.jackson.databind.ObjectMapper;
|
||||
```
|
||||
|
||||
`ObjectMapper.writeValueAsString(...)` and `.readTree(...)` behave identically once the import is
|
||||
fixed — this module didn't need any further changes to its JSON-handling code. Projects still on
|
||||
`spring-boot-starter-web`/`spring-boot-starter-json` from a Boot 3.x background, or projects that
|
||||
pull in `com.fasterxml.jackson.core:jackson-databind` directly rather than through a Boot starter,
|
||||
should expect to hit this the first time they add any code that touches `ObjectMapper` or
|
||||
`JsonNode` directly after upgrading to Boot 4.1.
|
||||
|
||||
## What was, and wasn't, independently verified here
|
||||
|
||||
Every SQL statement count and every GraphQL error message in docs/02–docs/04 came from this
|
||||
module's own test runs against real H2 and a real embedded Tomcat instance — nothing in those three
|
||||
chapters is asserted from documentation or memory. The two packaging changes above were confirmed
|
||||
directly by inspecting the actual `.jar` contents downloaded into the local Maven repository for
|
||||
this build (`unzip -l`), not by trusting a changelog summary. What was *not* independently
|
||||
re-derived from first principles here is the general architectural claim that Spring Boot 4
|
||||
modularized auto-configuration into per-feature artifacts — that's stated as context, based on what
|
||||
this module's own dependency tree and jar contents show, not as an exhaustively researched survey
|
||||
of every module that moved.
|
||||
@@ -0,0 +1,6 @@
|
||||
batched profile / 5 books, 5 distinct authors (no repeats to dedupe)
|
||||
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
|
||||
|
||||
total statements: 2
|
||||
@@ -0,0 +1,6 @@
|
||||
batched profile / 20 books, 5 distinct authors (4 books per author) — one batched IN query
|
||||
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
|
||||
|
||||
total statements: 2
|
||||
@@ -0,0 +1,4 @@
|
||||
batched profile / one Book has authorId=9999999 which matches no Author row
|
||||
raw HTTP response body from POST /graphql:
|
||||
|
||||
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}}],"data":null}
|
||||
@@ -0,0 +1,10 @@
|
||||
naive profile / 5 books, 5 distinct authors (no repeats to dedupe)
|
||||
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
|
||||
total statements: 6
|
||||
@@ -0,0 +1,25 @@
|
||||
naive profile / 20 books, 5 distinct authors (4 books per author) — repeats do not help
|
||||
|
||||
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
|
||||
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
7. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
8. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
9. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
10. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
11. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
12. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
13. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
14. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
15. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
16. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
17. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
18. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
19. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
20. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
21. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
|
||||
|
||||
total statements: 21
|
||||
@@ -0,0 +1,4 @@
|
||||
naive profile / one Book has authorId=9999999 which matches no Author row
|
||||
raw HTTP response body from POST /graphql:
|
||||
|
||||
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}}],"data":null}
|
||||
Reference in New Issue
Block a user