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,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.
|
||||
Reference in New Issue
Block a user