diff --git a/README.md b/README.md index 3ce7c3f..923cb79 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ files. | [`spring-batch-partitioning/`](spring-batch-partitioning) | [Spring Batch Partitioning and Parallel Steps: Scaling a 10-Million-Row Job](https://ankurm.com/) | the real grid-size sweep at 10M and 300K rows (best speedup 1.42x, on 2 cores), `MultiResourcePartitioner` ignoring gridSize entirely, a rejected partition's `StepExecution` stuck at `STARTING` forever, and Spring Batch 6.0's new `JobOperator#recover` unsticking it | | [`db-migrations-flyway-liquibase/`](db-migrations-flyway-liquibase) | [Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and Baselines](https://ankurm.com/flyway-vs-liquibase-spring-boot-4-migrations-rollbacks-baselines/) | Flyway Community's `undo` throwing `FlywayRedgateEditionRequiredException` at runtime, a real Liquibase 5.0.3 filename-caching defect that produces a phantom successful run, Liquibase's 10-second default lock-poll rate versus Flyway's near-instant row lock, the FSL license change and its ASF/Keycloak fallout, and what actually happens when both tools are enabled against one database | | [`db-migrations-expand-contract/`](db-migrations-expand-contract) | [Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot](https://ankurm.com/zero-downtime-database-migrations-expand-contract-spring-boot/) | a real 4-deploy rolling sequence against two live replicas with a load generator proving 99.98% success, H2's `AUTO_SERVER` single-point-of-failure trap, a `NOT NULL` constraint that fails every Stage 4 insert, and `ALTER TABLE` silently dropping a concurrently committed row with no exception thrown | -| [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions | +| [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/springdoc-openapi-spring-boot-4-1-versioning/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions | +| [`graphql-dataloader/`](graphql-dataloader) | [Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1](https://ankurm.com/) | a naive `@SchemaMapping` resolver measured at 21 SQL statements for 20 books versus a `@BatchMapping` resolver's flat 2, a dangling foreign key nulling an entire GraphQL response via non-null propagation identically under both resolver strategies, and two Spring Boot 4.1 packaging changes (`DataSourceAutoConfiguration`'s new package, Jackson 3 by default) hit along the way | Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/graphql-dataloader/README.md b/graphql-dataloader/README.md new file mode 100644 index 0000000..67a18ce --- /dev/null +++ b/graphql-dataloader/README.md @@ -0,0 +1,63 @@ +# graphql-dataloader + +Companion code for **Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1** +on [ankurm.com](https://ankurm.com). + +One schema, one `Book.author` field, two resolver implementations behind Spring profiles — a naive +`@SchemaMapping` and a `@BatchMapping` — with a JDK dynamic proxy in front of the JDBC driver +counting every SQL statement each one actually sends to the database, and a deliberately dangling +foreign key to observe GraphQL's non-null propagation up close. + +**Tested with:** Spring Boot 4.1.1 / Spring Framework 7.0.9 / Spring GraphQL 2.0.5 / GraphQL Java +25.0 / java-dataloader 6.0.0 / JDK 25 (Temurin 25.0.4.1+1). + +## Quickstart + +```bash +mvn test # runs everything, regenerates docs/output/ + +mvn spring-boot:run -Dspring-boot.run.profiles=naive # then, in another shell: +curl -s -X POST http://localhost:8080/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ books { title author { name } } }"}' + +mvn spring-boot:run -Dspring-boot.run.profiles=batched # same query, batched resolver +open http://localhost:8080/graphiql # interactive GraphiQL, either profile +``` + +## Where things are + +| | | +|---|---| +| Schema, entities, and why there's no `@ManyToOne` | [docs/01-schema-and-resolvers.md](docs/01-schema-and-resolvers.md) | +| The naive resolver, and exactly how it becomes N+1 | [docs/02-the-n-plus-one-problem.md](docs/02-the-n-plus-one-problem.md) | +| `@BatchMapping`: the same field, one annotation different | [docs/03-the-batchmapping-fix.md](docs/03-the-batchmapping-fix.md) | +| A dangling foreign key and non-null propagation | [docs/04-the-null-propagation-trap.md](docs/04-the-null-propagation-trap.md) | +| Two Boot 4.1 packaging changes this module ran into | [docs/05-known-issues.md](docs/05-known-issues.md) | + +## Captured output + +Every SQL statement count and every GraphQL error message quoted in the article is one of these +files, regenerated by `mvn test`: + +| File | What it shows | +|---|---| +| [docs/output/naive-a-five-distinct-authors.txt](docs/output/naive-a-five-distinct-authors.txt) | naive profile, 5 books / 5 authors — 6 statements | +| [docs/output/naive-b-twenty-books-five-authors.txt](docs/output/naive-b-twenty-books-five-authors.txt) | naive profile, 20 books / 5 authors — 21 statements | +| [docs/output/naive-c-dangling-foreign-key-null-propagation.txt](docs/output/naive-c-dangling-foreign-key-null-propagation.txt) | naive profile, orphan `authorId` — `data: null` | +| [docs/output/batched-a-five-distinct-authors.txt](docs/output/batched-a-five-distinct-authors.txt) | batched profile, 5 books / 5 authors — 2 statements | +| [docs/output/batched-b-twenty-books-five-authors.txt](docs/output/batched-b-twenty-books-five-authors.txt) | batched profile, 20 books / 5 authors — 2 statements | +| [docs/output/batched-c-dangling-foreign-key-null-propagation.txt](docs/output/batched-c-dangling-foreign-key-null-propagation.txt) | batched profile, orphan `authorId` — identical `data: null` | + +## Test suite + +| Class | What it covers | +|---|---| +| [`NaiveResolverSqlLogTest`](src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java) | `@ActiveProfiles("naive")` — the three experiments above, against `NaiveAuthorResolver` | +| [`BatchedResolverSqlLogTest`](src/test/java/com/ankurm/graphqldataloader/BatchedResolverSqlLogTest.java) | `@ActiveProfiles("batched")` — the same three experiments, against `BatchedAuthorResolver` | + +Both run real HTTP POSTs (`java.net.http.HttpClient`) against a live, randomly-ported embedded +Tomcat instance under `@SpringBootTest`, and read SQL statement counts from +[`StatementLoggingDataSource`](src/main/java/com/ankurm/graphqldataloader/support/StatementLoggingDataSource.java) — +a JDK dynamic proxy over the JDBC driver, reused from the sdjpa4-demo companion project, that +counts what actually reaches H2 rather than trusting Hibernate's own SQL logging. diff --git a/graphql-dataloader/docs/01-schema-and-resolvers.md b/graphql-dataloader/docs/01-schema-and-resolvers.md new file mode 100644 index 0000000..b45fc6a --- /dev/null +++ b/graphql-dataloader/docs/01-schema-and-resolvers.md @@ -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 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. diff --git a/graphql-dataloader/docs/02-the-n-plus-one-problem.md b/graphql-dataloader/docs/02-the-n-plus-one-problem.md new file mode 100644 index 0000000..177bc60 --- /dev/null +++ b/graphql-dataloader/docs/02-the-n-plus-one-problem.md @@ -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. diff --git a/graphql-dataloader/docs/03-the-batchmapping-fix.md b/graphql-dataloader/docs/03-the-batchmapping-fix.md new file mode 100644 index 0000000..024926b --- /dev/null +++ b/graphql-dataloader/docs/03-the-batchmapping-fix.md @@ -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 author(List books) { + List authorIds = books.stream().map(Book::getAuthorId).distinct().toList(); + Map byId = authorRepository.findAllById(authorIds).stream() + .collect(Collectors.toMap(Author::getId, a -> a)); + + Map 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 books`, not a single `Book`, and returns + `Map` — 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. diff --git a/graphql-dataloader/docs/04-the-null-propagation-trap.md b/graphql-dataloader/docs/04-the-null-propagation-trap.md new file mode 100644 index 0000000..ffbdea0 --- /dev/null +++ b/graphql-dataloader/docs/04-the-null-propagation-trap.md @@ -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` 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. diff --git a/graphql-dataloader/docs/05-known-issues.md b/graphql-dataloader/docs/05-known-issues.md new file mode 100644 index 0000000..3cd0202 --- /dev/null +++ b/graphql-dataloader/docs/05-known-issues.md @@ -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. diff --git a/graphql-dataloader/docs/output/batched-a-five-distinct-authors.txt b/graphql-dataloader/docs/output/batched-a-five-distinct-authors.txt new file mode 100644 index 0000000..67a1c6f --- /dev/null +++ b/graphql-dataloader/docs/output/batched-a-five-distinct-authors.txt @@ -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 diff --git a/graphql-dataloader/docs/output/batched-b-twenty-books-five-authors.txt b/graphql-dataloader/docs/output/batched-b-twenty-books-five-authors.txt new file mode 100644 index 0000000..5fc0ecf --- /dev/null +++ b/graphql-dataloader/docs/output/batched-b-twenty-books-five-authors.txt @@ -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 diff --git a/graphql-dataloader/docs/output/batched-c-dangling-foreign-key-null-propagation.txt b/graphql-dataloader/docs/output/batched-c-dangling-foreign-key-null-propagation.txt new file mode 100644 index 0000000..a3be4a1 --- /dev/null +++ b/graphql-dataloader/docs/output/batched-c-dangling-foreign-key-null-propagation.txt @@ -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} diff --git a/graphql-dataloader/docs/output/naive-a-five-distinct-authors.txt b/graphql-dataloader/docs/output/naive-a-five-distinct-authors.txt new file mode 100644 index 0000000..a3d6326 --- /dev/null +++ b/graphql-dataloader/docs/output/naive-a-five-distinct-authors.txt @@ -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 diff --git a/graphql-dataloader/docs/output/naive-b-twenty-books-five-authors.txt b/graphql-dataloader/docs/output/naive-b-twenty-books-five-authors.txt new file mode 100644 index 0000000..851478b --- /dev/null +++ b/graphql-dataloader/docs/output/naive-b-twenty-books-five-authors.txt @@ -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 diff --git a/graphql-dataloader/docs/output/naive-c-dangling-foreign-key-null-propagation.txt b/graphql-dataloader/docs/output/naive-c-dangling-foreign-key-null-propagation.txt new file mode 100644 index 0000000..e7f862d --- /dev/null +++ b/graphql-dataloader/docs/output/naive-c-dangling-foreign-key-null-propagation.txt @@ -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} diff --git a/graphql-dataloader/pom.xml b/graphql-dataloader/pom.xml new file mode 100644 index 0000000..e9e8c28 --- /dev/null +++ b/graphql-dataloader/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + graphql-dataloader + 1.0.0 + graphql-dataloader + Spring GraphQL 2.0.5 schema-first APIs, DataLoader batching, and killing N+1 on Spring Boot 4.1 + + + 25 + + + + + org.springframework.boot + spring-boot-starter-graphql + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-graphql-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/GraphqlDataloaderApplication.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/GraphqlDataloaderApplication.java new file mode 100644 index 0000000..d92279e --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/GraphqlDataloaderApplication.java @@ -0,0 +1,42 @@ +package com.ankurm.graphqldataloader; + +import com.ankurm.graphqldataloader.support.SqlLog; +import com.ankurm.graphqldataloader.support.StatementLoggingDataSource; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; + +import javax.sql.DataSource; + +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +public class GraphqlDataloaderApplication { + + public static void main(String[] args) { + SpringApplication.run(GraphqlDataloaderApplication.class, args); + } + + @Bean + public SqlLog sqlLog() { + return new SqlLog(); + } + + @Bean + public DataSource dataSource(SqlLog sqlLog) { + // A random suffix per context, not a fixed name: DB_CLOSE_DELAY=-1 keeps an H2 in-memory + // database alive for as long as this JVM runs, so a fixed name would leak across separate + // Spring ApplicationContexts started in the same test JVM (e.g. the "naive" and "batched" + // profile test classes each boot their own context) and the second one to run schema.sql + // would fail with "Table already exists" against the first one's still-open database. + String dbName = "graphqldataloader-" + java.util.UUID.randomUUID(); + HikariDataSource real = DataSourceBuilder.create() + .url("jdbc:h2:mem:" + dbName + ";DB_CLOSE_DELAY=-1") + .username("sa") + .password("") + .type(HikariDataSource.class) + .build(); + return StatementLoggingDataSource.wrap(real, sqlLog); + } +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/Author.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/Author.java new file mode 100644 index 0000000..9b116be --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/Author.java @@ -0,0 +1,33 @@ +package com.ankurm.graphqldataloader.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "AUTHOR") +public class Author { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + protected Author() { + } + + public Author(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/AuthorRepository.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/AuthorRepository.java new file mode 100644 index 0000000..d8c66f8 --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/AuthorRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.graphqldataloader.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuthorRepository extends JpaRepository { +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/Book.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/Book.java new file mode 100644 index 0000000..60fb537 --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/Book.java @@ -0,0 +1,46 @@ +package com.ankurm.graphqldataloader.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Deliberately NOT a JPA {@code @ManyToOne} to {@link Author} — {@code authorId} is a plain + * column. The whole point of this module is controlling, and observing, exactly how and when + * the Author for a Book gets loaded from a GraphQL resolver, not letting Hibernate's own lazy + * loading decide that for us underneath a completely different mechanism. + */ +@Entity +@Table(name = "BOOK") +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private Long authorId; + + protected Book() { + } + + public Book(String title, Long authorId) { + this.title = title; + this.authorId = authorId; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public Long getAuthorId() { + return authorId; + } +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/BookRepository.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/BookRepository.java new file mode 100644 index 0000000..e91b5b2 --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/domain/BookRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.graphqldataloader.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookRepository extends JpaRepository { +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/BatchedAuthorResolver.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/BatchedAuthorResolver.java new file mode 100644 index 0000000..ce3ccc2 --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/BatchedAuthorResolver.java @@ -0,0 +1,52 @@ +package com.ankurm.graphqldataloader.resolver; + +import com.ankurm.graphqldataloader.domain.Author; +import com.ankurm.graphqldataloader.domain.AuthorRepository; +import com.ankurm.graphqldataloader.domain.Book; +import org.springframework.context.annotation.Profile; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Same field, same schema, one annotation different: {@code @BatchMapping} instead of + * {@code @SchemaMapping}, and the method signature takes every {@code Book} GraphQL Java is about + * to resolve {@code author} for in this batch, not one. Spring GraphQL wires this into a + * per-request {@code DataLoader} automatically — no {@code BatchLoaderRegistry} bean needed for + * this shortcut form. See docs/03-the-batchmapping-fix.md. + * + *

The returned {@code Map} deliberately omits an entry for any {@code Book} whose author + * lookup misses (a dangling {@code authorId}) rather than putting a null value in — Spring + * GraphQL treats a missing key the same as an explicit null for that source object, which is + * what docs/05-the-null-propagation-trap.md is built around. + */ +@Profile("batched") +@Controller +public class BatchedAuthorResolver { + + private final AuthorRepository authorRepository; + + public BatchedAuthorResolver(AuthorRepository authorRepository) { + this.authorRepository = authorRepository; + } + + @BatchMapping(typeName = "Book", field = "author") + public Map author(List books) { + List authorIds = books.stream().map(Book::getAuthorId).distinct().toList(); + Map byId = authorRepository.findAllById(authorIds).stream() + .collect(Collectors.toMap(Author::getId, a -> a)); + + Map result = new HashMap<>(); + for (Book book : books) { + Author author = byId.get(book.getAuthorId()); + if (author != null) { + result.put(book, author); + } + } + return result; + } +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/BookController.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/BookController.java new file mode 100644 index 0000000..ce57299 --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/BookController.java @@ -0,0 +1,23 @@ +package com.ankurm.graphqldataloader.resolver; + +import com.ankurm.graphqldataloader.domain.Book; +import com.ankurm.graphqldataloader.domain.BookRepository; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; + +@Controller +public class BookController { + + private final BookRepository bookRepository; + + public BookController(BookRepository bookRepository) { + this.bookRepository = bookRepository; + } + + @QueryMapping + public List books() { + return bookRepository.findAll(); + } +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/NaiveAuthorResolver.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/NaiveAuthorResolver.java new file mode 100644 index 0000000..258d90f --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/resolver/NaiveAuthorResolver.java @@ -0,0 +1,29 @@ +package com.ankurm.graphqldataloader.resolver; + +import com.ankurm.graphqldataloader.domain.Author; +import com.ankurm.graphqldataloader.domain.AuthorRepository; +import com.ankurm.graphqldataloader.domain.Book; +import org.springframework.context.annotation.Profile; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; + +/** + * The resolver nobody would flag in review: one {@code @SchemaMapping} method, one repository + * call, correct output. GraphQL Java calls it once per {@code Book} in the result, independently, + * which is exactly how it becomes N+1 — see docs/02-the-n-plus-one-problem.md. + */ +@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); + } +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/support/SqlLog.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/support/SqlLog.java new file mode 100644 index 0000000..ff4a7ac --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/support/SqlLog.java @@ -0,0 +1,48 @@ +package com.ankurm.graphqldataloader.support; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +/** Thread-safe record of every SQL statement actually executed, since the last {@link #reset()}. */ +public class SqlLog { + + private final List statements = new CopyOnWriteArrayList<>(); + + public void record(String sql) { + statements.add(sql); + } + + public List all() { + return Collections.unmodifiableList(statements); + } + + public int count() { + return statements.size(); + } + + public long countContaining(String needle) { + return statements.stream().filter(s -> s.contains(needle)).count(); + } + + public void reset() { + statements.clear(); + } + + public String render() { + StringBuilder sb = new StringBuilder(); + int i = 1; + for (String s : statements) { + sb.append(i++).append(". ").append(s).append('\n'); + } + sb.append("\ntotal statements: ").append(statements.size()); + return sb.toString(); + } + + public String renderNumbered() { + return statements.stream() + .map(s -> (statements.indexOf(s) + 1) + ". " + s) + .collect(Collectors.joining("\n")); + } +} diff --git a/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/support/StatementLoggingDataSource.java b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/support/StatementLoggingDataSource.java new file mode 100644 index 0000000..82597b8 --- /dev/null +++ b/graphql-dataloader/src/main/java/com/ankurm/graphqldataloader/support/StatementLoggingDataSource.java @@ -0,0 +1,106 @@ +package com.ankurm.graphqldataloader.support; + +import javax.sql.DataSource; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * A {@link DataSource} decorator that logs every SQL statement actually sent to the driver, via + * a JDK dynamic proxy over {@link Connection} and {@link Statement}/{@link PreparedStatement} — + * so counts reflect what really reached the database, not what Hibernate's own SQL logging + * chooses to print (which, notably, prints one line per {@code addBatch()} call rather than + * counting {@code executeBatch()} as the single round trip it is). Same mechanism used in the + * sdjpa4-demo companion project for "Spring Data JDBC vs Spring Data JPA in 2026". + */ +public class StatementLoggingDataSource implements InvocationHandler { + + private final DataSource delegate; + private final SqlLog log; + + private StatementLoggingDataSource(DataSource delegate, SqlLog log) { + this.delegate = delegate; + this.log = log; + } + + public static DataSource wrap(DataSource delegate, SqlLog log) { + return (DataSource) Proxy.newProxyInstance( + DataSource.class.getClassLoader(), + new Class[]{DataSource.class}, + new StatementLoggingDataSource(delegate, log)); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + Object result; + try { + result = method.invoke(delegate, args); + } catch (java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + if ("getConnection".equals(method.getName()) && result instanceof Connection connection) { + return wrapConnection(connection); + } + return result; + } + + private Connection wrapConnection(Connection connection) { + return (Connection) Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[]{Connection.class}, + (proxy, method, args) -> { + Object result; + try { + result = method.invoke(connection, args); + } catch (java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + String name = method.getName(); + if ("prepareStatement".equals(name) && args != null && args.length > 0 && result instanceof PreparedStatement ps) { + return wrapPreparedStatement(ps, (String) args[0]); + } + if ("createStatement".equals(name) && result instanceof Statement st) { + return wrapStatement(st); + } + return result; + }); + } + + private PreparedStatement wrapPreparedStatement(PreparedStatement ps, String sql) { + return (PreparedStatement) Proxy.newProxyInstance( + PreparedStatement.class.getClassLoader(), + new Class[]{PreparedStatement.class}, + (proxy, method, args) -> { + String name = method.getName(); + if (name.startsWith("execute")) { + log.record(sql.trim()); + } + try { + return method.invoke(ps, args); + } catch (java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + }); + } + + private Statement wrapStatement(Statement st) { + return (Statement) Proxy.newProxyInstance( + Statement.class.getClassLoader(), + new Class[]{Statement.class}, + (proxy, method, args) -> { + String name = method.getName(); + if (name.startsWith("execute") && args != null && args.length > 0 && args[0] instanceof String sql) { + log.record(sql.trim()); + } + try { + return method.invoke(st, args); + } catch (java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + }); + } +} diff --git a/graphql-dataloader/src/main/resources/application.yml b/graphql-dataloader/src/main/resources/application.yml new file mode 100644 index 0000000..61dd6f6 --- /dev/null +++ b/graphql-dataloader/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + application: + name: graphql-dataloader + sql: + init: + mode: always + jpa: + hibernate: + ddl-auto: none + open-in-view: false + properties: + hibernate: + show_sql: false # real statements come from StatementLoggingDataSource instead + graphql: + graphiql: + enabled: true + +logging: + level: + root: WARN + com.ankurm.graphqldataloader: INFO diff --git a/graphql-dataloader/src/main/resources/graphql/schema.graphqls b/graphql-dataloader/src/main/resources/graphql/schema.graphqls new file mode 100644 index 0000000..a3bb84c --- /dev/null +++ b/graphql-dataloader/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,14 @@ +type Query { + books: [Book!]! +} + +type Book { + id: ID! + title: String! + author: Author! +} + +type Author { + id: ID! + name: String! +} diff --git a/graphql-dataloader/src/main/resources/schema.sql b/graphql-dataloader/src/main/resources/schema.sql new file mode 100644 index 0000000..9324a90 --- /dev/null +++ b/graphql-dataloader/src/main/resources/schema.sql @@ -0,0 +1,10 @@ +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(200) not null, + author_id bigint +); diff --git a/graphql-dataloader/src/test/java/com/ankurm/graphqldataloader/BatchedResolverSqlLogTest.java b/graphql-dataloader/src/test/java/com/ankurm/graphqldataloader/BatchedResolverSqlLogTest.java new file mode 100644 index 0000000..f80e68f --- /dev/null +++ b/graphql-dataloader/src/test/java/com/ankurm/graphqldataloader/BatchedResolverSqlLogTest.java @@ -0,0 +1,184 @@ +package com.ankurm.graphqldataloader; + +import com.ankurm.graphqldataloader.domain.Author; +import com.ankurm.graphqldataloader.domain.AuthorRepository; +import com.ankurm.graphqldataloader.domain.Book; +import com.ankurm.graphqldataloader.domain.BookRepository; +import com.ankurm.graphqldataloader.support.SqlLog; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Same experiments as {@link NaiveResolverSqlLogTest}, run against the {@code batched} profile + * ({@link com.ankurm.graphqldataloader.resolver.BatchedAuthorResolver}) instead — same schema, + * same seed helpers, same GraphQL query text, only the resolver wiring differs. The SQL statement + * counts are what the naive-vs-batched comparison in the article is built on. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("batched") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class BatchedResolverSqlLogTest { + + @LocalServerPort + int port; + + @Autowired + AuthorRepository authorRepository; + + @Autowired + BookRepository bookRepository; + + @Autowired + SqlLog sqlLog; + + private final HttpClient http = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void cleanDatabase() { + bookRepository.deleteAll(); + authorRepository.deleteAll(); + } + + @Test + @Order(1) + void a_fiveBooksFromFiveDistinctAuthorsIssueOneBatchedAuthorQuery() throws Exception { + seedDistinctAuthorPerBook(5); + sqlLog.reset(); + + JsonNode data = postGraphql("{ books { title author { name } } }"); + + assertThat(data.get("books")).hasSize(5); + for (JsonNode book : data.get("books")) { + assertThat(book.get("author").get("name").asText()).isNotBlank(); + } + // 1 SELECT for books.findAll() + 1 SELECT ... WHERE id IN (...) from the batched + // authorRepository.findAllById() call — GraphQL Java's DataLoader collected all 5 pending + // author lookups into a single batch before BatchedAuthorResolver.author() ever ran. + assertThat(sqlLog.count()).isEqualTo(2); + assertThat(sqlLog.all().get(1)).containsIgnoringCase(" in ("); + + writeTranscript("batched-a-five-distinct-authors", + "batched profile / 5 books, 5 distinct authors (no repeats to dedupe)"); + } + + @Test + @Order(2) + void b_twentyBooksFromFiveAuthorsDedupesToOneBatchedQueryForFiveAuthors() throws Exception { + seedRoundRobinAuthors(5, 20); + sqlLog.reset(); + + JsonNode data = postGraphql("{ books { title author { name } } }"); + + assertThat(data.get("books")).hasSize(20); + // Still exactly 2 statements: 1 for books.findAll(), 1 for the batched author lookup — the + // resolver's `.distinct()` call collapses the 20 pending DataLoader keys down to the 5 + // unique authorIds actually present before the repository is ever asked. + assertThat(sqlLog.count()).isEqualTo(2); + + long placeholders = sqlLog.all().get(1).chars().filter(c -> c == '?').count(); + assertThat(placeholders).isEqualTo(5); + + writeTranscript("batched-b-twenty-books-five-authors", + "batched profile / 20 books, 5 distinct authors (4 books per author) — one batched IN query"); + } + + @Test + @Order(3) + void c_danglingAuthorIdNullsTheEntireBooksListViaNonNullPropagationSameAsNaive() throws Exception { + 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)); + sqlLog.reset(); + + String responseBody = postGraphqlRaw("{ books { title author { name } } }"); + JsonNode root = mapper.readTree(responseBody); + + // Same outcome as the naive profile: BatchedAuthorResolver.author() simply omits the map + // entry for the orphan Book, and Spring GraphQL treats a missing DataLoader/batch result + // the same as an explicit null for that source object — the non-null `author: Author!` + // field then propagates null up through the non-null `[Book!]!` list to the whole response. + assertThat(root.get("data").isNull()).isTrue(); + assertThat(root.get("errors")).isNotEmpty(); + assertThat(root.get("errors").get(0).get("message").asText()) + .contains("non-null"); + + Files.writeString( + outputPath("batched-c-dangling-foreign-key-null-propagation.txt"), + "batched profile / one Book has authorId=9999999 which matches no Author row\n" + + "raw HTTP response body from POST /graphql:\n\n" + responseBody + "\n", + StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + private void seedDistinctAuthorPerBook(int n) { + for (int i = 1; i <= n; i++) { + Author author = authorRepository.save(new Author("Author " + i)); + bookRepository.save(new Book("Book " + i, author.getId())); + } + } + + private void seedRoundRobinAuthors(int authorCount, int bookCount) { + List authorIds = new ArrayList<>(); + for (int i = 1; i <= authorCount; i++) { + authorIds.add(authorRepository.save(new Author("Author " + i)).getId()); + } + for (int i = 1; i <= bookCount; i++) { + Long authorId = authorIds.get((i - 1) % authorCount); + bookRepository.save(new Book("Book " + i, authorId)); + } + } + + private JsonNode postGraphql(String query) throws Exception { + String body = postGraphqlRaw(query); + JsonNode root = mapper.readTree(body); + assertThat(root.has("errors")).isFalse(); + return root.get("data"); + } + + private String postGraphqlRaw(String query) throws Exception { + String requestBody = mapper.writeValueAsString(java.util.Map.of("query", query)); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/graphql")) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + return response.body(); + } + + private void writeTranscript(String fileNameStem, String heading) throws Exception { + String content = heading + "\n\n" + sqlLog.render() + "\n"; + Files.writeString(outputPath(fileNameStem + ".txt"), content, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + private Path outputPath(String fileName) throws Exception { + Path dir = Path.of(System.getProperty("user.dir"), "docs", "output"); + Files.createDirectories(dir); + return dir.resolve(fileName); + } +} diff --git a/graphql-dataloader/src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java b/graphql-dataloader/src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java new file mode 100644 index 0000000..f11bbcf --- /dev/null +++ b/graphql-dataloader/src/test/java/com/ankurm/graphqldataloader/NaiveResolverSqlLogTest.java @@ -0,0 +1,175 @@ +package com.ankurm.graphqldataloader; + +import com.ankurm.graphqldataloader.domain.Author; +import com.ankurm.graphqldataloader.domain.AuthorRepository; +import com.ankurm.graphqldataloader.domain.Book; +import com.ankurm.graphqldataloader.domain.BookRepository; +import com.ankurm.graphqldataloader.support.SqlLog; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Runs the {@code naive} profile ({@link com.ankurm.graphqldataloader.resolver.NaiveAuthorResolver}) + * against a real, running server over real HTTP, and counts the SQL statements that actually hit + * H2 via {@link com.ankurm.graphqldataloader.support.StatementLoggingDataSource}. Every number + * asserted here is also written to a transcript file under docs/output/ so the article can quote + * it verbatim instead of restating a claim from memory. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("naive") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class NaiveResolverSqlLogTest { + + @LocalServerPort + int port; + + @Autowired + AuthorRepository authorRepository; + + @Autowired + BookRepository bookRepository; + + @Autowired + SqlLog sqlLog; + + private final HttpClient http = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void cleanDatabase() { + bookRepository.deleteAll(); + authorRepository.deleteAll(); + } + + @Test + @Order(1) + void a_fiveBooksFromFiveDistinctAuthorsIssueOneAuthorQueryPerBook() throws Exception { + seedDistinctAuthorPerBook(5); + sqlLog.reset(); + + JsonNode data = postGraphql("{ books { title author { name } } }"); + + assertThat(data.get("books")).hasSize(5); + for (JsonNode book : data.get("books")) { + assertThat(book.get("author").get("name").asText()).isNotBlank(); + } + // 1 SELECT for books.findAll() + 5 SELECTs, one per book, from author.findById() + assertThat(sqlLog.count()).isEqualTo(6); + + writeTranscript("naive-a-five-distinct-authors", + "naive profile / 5 books, 5 distinct authors (no repeats to dedupe)"); + } + + @Test + @Order(2) + void b_twentyBooksFromFiveAuthorsStillIssuesTwentyAuthorQueries() throws Exception { + seedRoundRobinAuthors(5, 20); + sqlLog.reset(); + + JsonNode data = postGraphql("{ books { title author { name } } }"); + + assertThat(data.get("books")).hasSize(20); + // 1 SELECT for books.findAll() + 20 SELECTs — the naive resolver calls findById() once per + // Book with no awareness that 4 of those books share the same authorId, so the 5-author, + // 20-book case is exactly as expensive as 20 distinct authors would have been. + assertThat(sqlLog.count()).isEqualTo(21); + + writeTranscript("naive-b-twenty-books-five-authors", + "naive profile / 20 books, 5 distinct authors (4 books per author) — repeats do not help"); + } + + @Test + @Order(3) + void c_danglingAuthorIdNullsTheEntireBooksListViaNonNullPropagation() throws Exception { + 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)); + sqlLog.reset(); + + String responseBody = postGraphqlRaw("{ books { title author { name } } }"); + JsonNode root = mapper.readTree(responseBody); + + assertThat(root.get("data").isNull()).isTrue(); + assertThat(root.get("errors")).isNotEmpty(); + assertThat(root.get("errors").get(0).get("message").asText()) + .contains("non-null"); + + Files.writeString( + outputPath("naive-c-dangling-foreign-key-null-propagation.txt"), + "naive profile / one Book has authorId=9999999 which matches no Author row\n" + + "raw HTTP response body from POST /graphql:\n\n" + responseBody + "\n", + StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + private void seedDistinctAuthorPerBook(int n) { + for (int i = 1; i <= n; i++) { + Author author = authorRepository.save(new Author("Author " + i)); + bookRepository.save(new Book("Book " + i, author.getId())); + } + } + + private void seedRoundRobinAuthors(int authorCount, int bookCount) { + List authorIds = new ArrayList<>(); + for (int i = 1; i <= authorCount; i++) { + authorIds.add(authorRepository.save(new Author("Author " + i)).getId()); + } + for (int i = 1; i <= bookCount; i++) { + Long authorId = authorIds.get((i - 1) % authorCount); + bookRepository.save(new Book("Book " + i, authorId)); + } + } + + private JsonNode postGraphql(String query) throws Exception { + String body = postGraphqlRaw(query); + JsonNode root = mapper.readTree(body); + assertThat(root.has("errors")).isFalse(); + return root.get("data"); + } + + private String postGraphqlRaw(String query) throws Exception { + String requestBody = mapper.writeValueAsString(java.util.Map.of("query", query)); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/graphql")) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + return response.body(); + } + + private void writeTranscript(String fileNameStem, String heading) throws Exception { + String content = heading + "\n\n" + sqlLog.render() + "\n"; + Files.writeString(outputPath(fileNameStem + ".txt"), content, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + private Path outputPath(String fileName) throws Exception { + Path dir = Path.of(System.getProperty("user.dir"), "docs", "output"); + Files.createDirectories(dir); + return dir.resolve(fileName); + } +}