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

74 lines
5.1 KiB
Markdown

# 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.