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

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.