- 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
5.1 KiB
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:
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:
{ 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):
{"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
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:
{"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
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.