Add jdbc-vs-jpa module: Spring Data JDBC vs JPA on a shared Order aggregate
Same Customer/Order/OrderItem domain modelled with Hibernate/Spring Data JPA and Spring Data JDBC side by side, both running through a shared StatementLoggingDataSource so SQL-statement counts are directly comparable. 11 tests, 11 captured transcripts, 6 doc chapters. Companion repo for the ankurm.com article on when to drop the ORM.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# 01 — The shared domain
|
||||
|
||||
[← README](../README.md) · [next: the JPA side →](02-the-jpa-side.md)
|
||||
|
||||
Both stacks in this module model the same thing: a `Customer` who places `Order`s, each with a
|
||||
list of `OrderItem`s. It is deliberately the smallest domain that has everything worth arguing
|
||||
about — a one-to-many collection, a reference to a second aggregate, and a field a reader will
|
||||
want to mutate in place.
|
||||
|
||||
| Concept | JPA package (`com.ankurm.jdbcvsjpa.jpa`) | JDBC package (`com.ankurm.jdbcvsjpa.jdbc`) |
|
||||
|---|---|---|
|
||||
| Table prefix | `jpa_*` | `JDBC_*` (see the identifier-casing note below) |
|
||||
| Order → Customer | `@ManyToOne(fetch = LAZY)` object reference | `AggregateReference<Customer, Long>` — a typed foreign key, never a loaded object |
|
||||
| Order → items | `@OneToMany(mappedBy, cascade = ALL, orphanRemoval = true)`, `List<OrderItem>` | `@MappedCollection(idColumn, keyColumn)`, `List<OrderItem>` |
|
||||
| Identity of a child row | `@ManyToOne` back-reference to its parent | none — a JDBC `OrderItem` has no idea which Order owns it |
|
||||
| Optimistic locking | `@Version Long version` | `@Version Long version` (same annotation, same package: `org.springframework.data.annotation`) |
|
||||
|
||||
Both sides run against the **same H2 database** through the **same
|
||||
[`StatementLoggingDataSource`](../src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java)**
|
||||
— see [03 — the SQL log](03-the-sql-log.md) for why that matters for the numbers in the article.
|
||||
|
||||
## A casing trap that cost a debugging session
|
||||
|
||||
Spring Data JDBC treats an explicit `@Table("some_name")` or `@Column("some_name")` value as a
|
||||
*literal, quoted* SQL identifier — it is rendered exactly as given, in double quotes. Properties
|
||||
with no explicit annotation go through the H2 dialect's default identifier processing instead,
|
||||
which upper-cases them. H2 itself upper-cases any *unquoted* identifier in DDL. The result: an
|
||||
unquoted `create table jdbc_customer(...)` in `schema.sql` produces a table H2 privately calls
|
||||
`JDBC_CUSTOMER`, but `@Table("jdbc_customer")` generates `INSERT INTO "jdbc_customer" (...)` —
|
||||
quoted, lower-case, and therefore a different identifier as far as H2 is concerned. The fix used
|
||||
throughout this module is to give every explicit `@Table`/`@Column`/`@MappedCollection` value in
|
||||
upper case, matching H2's own folding, so the literal and the folded name agree. See
|
||||
[`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java) for the annotations
|
||||
and [`schema.sql`](../src/main/resources/schema.sql) for the DDL. This is exactly the kind of
|
||||
thing that never shows up in a tutorial that only ever runs one save and eyeballs the console.
|
||||
|
||||
Continue to [02 — the JPA side](02-the-jpa-side.md).
|
||||
@@ -0,0 +1,47 @@
|
||||
# 02 — The JPA side
|
||||
|
||||
[← 01 the shared domain](01-the-shared-domain.md) · [next: the SQL log →](03-the-sql-log.md)
|
||||
|
||||
Source: [`jpa/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jpa/Order.java),
|
||||
[`jpa/OrderItem.java`](../src/main/java/com/ankurm/jdbcvsjpa/jpa/OrderItem.java),
|
||||
[`jpa/Customer.java`](../src/main/java/com/ankurm/jdbcvsjpa/jpa/Customer.java). Tests:
|
||||
[`JpaBehaviorTest.java`](../src/test/java/com/ankurm/jdbcvsjpa/JpaBehaviorTest.java).
|
||||
|
||||
## The five things demonstrated here
|
||||
|
||||
1. **Lazy collections need a live session.** [Scenario 01](output/01-lazy-outside-session.txt)
|
||||
loads an `Order` inside a transaction, returns it, and touches `.getItems()` after that
|
||||
transaction (and the Hibernate session it owned) has closed —
|
||||
`LazyInitializationException: ... (no session)`. `spring.jpa.open-in-view: false` in
|
||||
`application.yml` is what makes the session close at the transaction boundary instead of
|
||||
silently staying open for the rest of the request; open-in-view defaults to `true` in plain
|
||||
Spring Boot and papers over exactly this failure until it happens somewhere without a
|
||||
surrounding web request.
|
||||
2. **The default fetch shape is N+1.** [Scenario 02](output/02-n-plus-one.txt): `findAll()` for
|
||||
3 orders, then `.getItems().size()` on each in a loop, produces 1 + 3 = 4 SELECTs. Nothing
|
||||
about the code looks wrong; it is the ordinary shape of a service method.
|
||||
3. **`@EntityGraph` flattens it to a constant.** [Scenario 03](output/03-entity-graph-fixed-cost.txt)
|
||||
re-runs the same lookup through `findWithItemsAndCustomerById`, an `@EntityGraph` query, for a
|
||||
1-item and a 5-item order — both take exactly 1 statement. This is the fix for scenario 2, and
|
||||
it is also the default (no annotation needed) on the JDBC side; see
|
||||
[05 — the aggregate boundary](05-the-aggregate-boundary.md).
|
||||
4. **Dirty checking writes before you call save().**
|
||||
[Scenario 04](output/04-dirty-checking-autoflush.txt): inside one transaction, mutate a
|
||||
managed item's `quantity` field directly — no `save()` call anywhere — then run an unrelated
|
||||
query. The pending UPDATE appears *before* that second query runs, because Hibernate flushes
|
||||
dirty state ahead of anything that could otherwise see stale data. "I never called save()" is
|
||||
not evidence that nothing was written.
|
||||
5. **`orphanRemoval` is what turns "removed from the list" into a DELETE.**
|
||||
[Scenario 05](output/05-orphan-removal-delete.txt): `order.removeItem(item)` followed by
|
||||
`save()` deletes that row because `orphanRemoval = true` is set on the `@OneToMany`. Drop that
|
||||
attribute and the row survives with a stale `order_id` — the more common real bug report.
|
||||
|
||||
## What most tutorials don't show
|
||||
|
||||
Every one of the five behaviours above is *correct* — this is not a list of JPA bugs. The
|
||||
argument this article makes is narrower: each of these five facts has to be known in advance and
|
||||
opted into (an `@EntityGraph`, an `orphanRemoval = true`, an awareness of the flush timing) for
|
||||
the obvious code to do the obvious thing. Spring Data JDBC, covered next, gets three of these five
|
||||
for free by not having the mechanism that causes them.
|
||||
|
||||
Continue to [03 — the SQL log](03-the-sql-log.md).
|
||||
@@ -0,0 +1,48 @@
|
||||
# 03 — The SQL log
|
||||
|
||||
[← 02 the JPA side](02-the-jpa-side.md) · [next: the JDBC side →](04-the-jdbc-side.md)
|
||||
|
||||
Source: [`support/StatementLoggingDataSource.java`](../src/main/java/com/ankurm/jdbcvsjpa/support/StatementLoggingDataSource.java),
|
||||
[`support/SqlLog.java`](../src/main/java/com/ankurm/jdbcvsjpa/support/SqlLog.java). Endpoint:
|
||||
[`DiagController.java`](../src/main/java/com/ankurm/jdbcvsjpa/DiagController.java) at `/diag/sql-log`.
|
||||
|
||||
Every statement-count claim in this article comes from one mechanism, not from reading
|
||||
Hibernate's `show_sql` output in one format and Spring Data JDBC's `JdbcTemplate` logging in a
|
||||
different one. `StatementLoggingDataSource` wraps the single H2 `DataSource` both stacks share:
|
||||
it hands out a JDK dynamic proxy for every `Connection`, which in turn hands out a proxy for
|
||||
every `Statement`/`PreparedStatement`, and any method starting with `execute` gets its SQL text
|
||||
recorded to `SqlLog` before the call is delegated. This is deliberately *below* both ORMs — it
|
||||
counts what actually reached the database driver, not what each framework's own debug logging
|
||||
chose to print.
|
||||
|
||||
```java
|
||||
@Bean
|
||||
public DataSource dataSource(SqlLog sqlLog) {
|
||||
HikariDataSource real = new HikariDataSource();
|
||||
real.setJdbcUrl("jdbc:h2:mem:jdbcvsjpa;DB_CLOSE_DELAY=-1;MODE=LEGACY");
|
||||
// ...
|
||||
return new StatementLoggingDataSource(real, sqlLog);
|
||||
}
|
||||
```
|
||||
|
||||
Two things this caught that a naive count would have missed:
|
||||
|
||||
- **JDBC batching.** Saving three `OrderItem` rows as part of one aggregate save
|
||||
(see [07](output/07-delete-then-insert.txt)) shows up as *one* `INSERT` line, not three —
|
||||
Spring Data JDBC calls `addBatch()` three times and `executeBatch()` once. Counting SQL text
|
||||
seen by `Statement.executeQuery`/`executeUpdate` would have reported 3 inserts; counting actual
|
||||
`execute*` invocations on the proxy correctly reports 1, because that is the true number of
|
||||
round trips to the database.
|
||||
- **Consistent counting across two completely different SQL-generation paths.** Hibernate's HQL
|
||||
compiler and Spring Data JDBC's `JdbcTemplate`-based query building produce differently
|
||||
formatted SQL for the same logical operation (see the JPA transcripts' lower-case, unquoted
|
||||
style versus the JDBC transcripts' upper-case, quoted style — both are the frameworks' own
|
||||
defaults, untouched). A statement counter that lived inside either framework would only ever
|
||||
see its own side; this one sees both, so [00 — the comparison table](output/00-statement-count-comparison.txt)
|
||||
is a fair, apples-to-apples number.
|
||||
|
||||
`/diag/sql-log` exposes the same log at runtime for manual exploration — hit an endpoint, then
|
||||
`curl localhost:8080/diag/sql-log` to see exactly what ran. Delete this controller before
|
||||
shipping; it has no business existing outside a demo.
|
||||
|
||||
Continue to [04 — the JDBC side](04-the-jdbc-side.md).
|
||||
@@ -0,0 +1,50 @@
|
||||
# 04 — The JDBC side
|
||||
|
||||
[← 03 the SQL log](03-the-sql-log.md) · [next: the aggregate boundary →](05-the-aggregate-boundary.md)
|
||||
|
||||
Source: [`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java),
|
||||
[`jdbc/OrderItem.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/OrderItem.java),
|
||||
[`jdbc/Customer.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Customer.java). Tests:
|
||||
[`JdbcBehaviorTest.java`](../src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java).
|
||||
|
||||
## findById always returns the whole aggregate
|
||||
|
||||
[Scenario 06](output/06-fixed-cost-load.txt): `findById` on a 1-item order and on a 5-item order
|
||||
both take exactly 2 statements (one for the order row, one for its items, via a join-free
|
||||
two-query load — see the actual SQL in the transcript). There is no lazy/eager choice to make
|
||||
because there is no lazy loading. This is not an optimization Spring Data JDBC performs; it is
|
||||
the *only* thing it knows how to do, because an aggregate is, by definition, loaded and saved as
|
||||
one unit.
|
||||
|
||||
## Saving replaces the whole collection — but as one batch
|
||||
|
||||
[Scenario 07](output/07-delete-then-insert.txt): changing the quantity on exactly one of three
|
||||
`OrderItem`s and saving the aggregate produces a `DELETE ... WHERE order_id = ?` for all three
|
||||
existing rows and then a *single* batched `INSERT` (three rows, one JDBC batch execution — see
|
||||
[03](03-the-sql-log.md) for how that's verified). Spring Data JDBC does not diff collections; it
|
||||
does not need to know which row changed, because it never tries to compute a diff. The cost is
|
||||
real (every row's data is retransmitted and reinserted, not just the one that changed) but it is
|
||||
one delete and one batch insert, not `N+1` round trips. For a collection in the tens of rows this
|
||||
is invisible. For a collection in the tens of thousands, see
|
||||
[06 — when this gets expensive](06-when-this-gets-expensive.md).
|
||||
|
||||
## `keyColumn` is what makes list order survive a reload
|
||||
|
||||
[Scenario 10](output/10-list-order-key-column.txt): items saved in the order `[SKU-Z, SKU-A,
|
||||
SKU-M]` come back in that same order after a reload, because `@MappedCollection(idColumn =
|
||||
"ORDER_ID", keyColumn = "ORDER_KEY")` adds an extra integer column that records list position.
|
||||
Omit `keyColumn` and Spring Data JDBC still stores a `List` correctly — it just does not
|
||||
guarantee you the order back. On a fresh H2 table you will likely get insertion order anyway,
|
||||
which is exactly the kind of accident that stops being true the day someone runs a `VACUUM`, or
|
||||
the day the database changes.
|
||||
|
||||
## Optimistic locking works the same way it does under JPA
|
||||
|
||||
[Scenario 09](output/09-optimistic-locking.txt): two callers load the same `version`, the first
|
||||
saves (version increments), the second — still holding the stale version — tries to save and
|
||||
gets `OptimisticLockingFailureException`. `@Version` is the same annotation
|
||||
(`org.springframework.data.annotation.Version`) doing conceptually the same thing: the UPDATE's
|
||||
`WHERE` clause includes the expected version, zero rows match a stale one, and Spring Data JDBC
|
||||
turns "zero rows updated" into an exception rather than silently doing nothing.
|
||||
|
||||
Continue to [05 — the aggregate boundary](05-the-aggregate-boundary.md).
|
||||
@@ -0,0 +1,38 @@
|
||||
# 05 — The aggregate boundary
|
||||
|
||||
[← 04 the JDBC side](04-the-jdbc-side.md) · [next: when this gets expensive →](06-when-this-gets-expensive.md)
|
||||
|
||||
Source: [`jdbc/Order.java`](../src/main/java/com/ankurm/jdbcvsjpa/jdbc/Order.java) (the
|
||||
`AggregateReference<Customer, Long> customer` field). Test:
|
||||
[`JdbcBehaviorTest#c_aggregateReferenceDoesNotLoadTheCustomer`](../src/test/java/com/ankurm/jdbcvsjpa/JdbcBehaviorTest.java),
|
||||
transcript [08](output/08-aggregate-reference-no-join.txt).
|
||||
|
||||
Domain-Driven Design's aggregate pattern says: an aggregate is a cluster of objects saved and
|
||||
loaded as one transactional unit, and a reference to *another* aggregate is held by identity, not
|
||||
by object graph. Spring Data JDBC enforces this at the type level. `Order.customer` is not a
|
||||
`Customer` field — it is an `AggregateReference<Customer, Long>`, a typed wrapper around a
|
||||
foreign-key value. `AggregateReference.getId()` returns that value without touching the database,
|
||||
because the value is already sitting in a column that was read as part of loading the order.
|
||||
|
||||
[Scenario 08](output/08-aggregate-reference-no-join.txt) demonstrates the consequence directly:
|
||||
loading an order and then reading `order.getCustomer().getId()` produces **zero** statements
|
||||
touching the customer table. There is no join, no lazy proxy, no N+1 waiting to happen — because
|
||||
there is no mechanism in Spring Data JDBC that would ever load a second aggregate as a side
|
||||
effect of loading the first one. If you want the `Customer`, you ask a `CustomerRepository` for
|
||||
it, explicitly, and that is a second, deliberate query.
|
||||
|
||||
This is the mechanism that makes [scenario 06](04-the-jdbc-side.md#findbyid-always-returns-the-whole-aggregate)'s
|
||||
"fixed cost regardless of item count" claim scale past one aggregate: the fixed cost is fixed
|
||||
*per aggregate*, and nothing about loading `Order` #1 ever cascades into loading `Order` #2's
|
||||
`Customer`, `Order` #2's `Customer`'s other orders, and so on. JPA's `@ManyToOne(fetch = LAZY)`
|
||||
gives you a proxy that resolves this the moment something touches it — convenient until the thing
|
||||
touching it is a `toString()` in a log statement three services away from the code that loaded
|
||||
the entity.
|
||||
|
||||
The trade is that Spring Data JDBC will not silently fetch related data across an aggregate
|
||||
boundary for you, ever, under any circumstance. If your domain genuinely needs a graph fetch
|
||||
(here's every order together with its customer's other orders), you write that query yourself.
|
||||
Some teams experience that as "more code to write." Others experience the JPA alternative as "a
|
||||
query plan I can no longer predict by reading the entity class."
|
||||
|
||||
Continue to [06 — when this gets expensive](06-when-this-gets-expensive.md).
|
||||
@@ -0,0 +1,53 @@
|
||||
# 06 — When this gets expensive (production checklist)
|
||||
|
||||
[← 05 the aggregate boundary](05-the-aggregate-boundary.md)
|
||||
|
||||
A short, honest checklist for deciding between the two stacks on a real project, expanding on the
|
||||
article's closing "should you even do this" callout.
|
||||
|
||||
## Reach for Spring Data JDBC when
|
||||
|
||||
- Your aggregates are genuinely small (single digits to low tens of child rows). The
|
||||
delete-and-reinsert cost in [07](output/07-delete-then-insert.txt) is proportional to
|
||||
aggregate size on every save, not to what changed.
|
||||
- You have been bitten by `LazyInitializationException`, N+1 queries you didn't predict, or a
|
||||
flush timing surprise (scenarios [01](output/01-lazy-outside-session.txt),
|
||||
[02](output/02-n-plus-one.txt), [04](output/04-dirty-checking-autoflush.txt)) more than once on
|
||||
the same codebase.
|
||||
- You want the query plan for "load this thing" to be readable from the entity class alone, with
|
||||
no session state, no `@EntityGraph` annotations to remember, and no proxy that decides at
|
||||
runtime whether to hit the database.
|
||||
- You are comfortable writing more explicit queries (`@Query`, `JdbcAggregateTemplate`) for
|
||||
anything that crosses an aggregate boundary, because that boundary is enforced rather than
|
||||
advisory.
|
||||
|
||||
## Stay on Spring Data JPA when
|
||||
|
||||
- Collections are large or unbounded (hundreds to thousands of child rows) and updated
|
||||
incrementally — the full delete-and-reinsert becomes real, measurable write amplification.
|
||||
- Your domain genuinely benefits from a persistence-context-managed object graph: complex
|
||||
bidirectional relationships, inheritance hierarchies, or second-level caching.
|
||||
- Your team already has deep Hibernate operational experience (query plan reading, N+1 detection
|
||||
tooling, `@BatchSize` tuning) and the tooling investment already exists.
|
||||
- You need JPQL/Criteria API's richer query composition, or a library that specifically expects
|
||||
`EntityManager`.
|
||||
|
||||
## What migrating does NOT require
|
||||
|
||||
You do not have to choose one stack for a whole application. This module runs both in one Spring
|
||||
context (`@EnableJpaRepositories` scoped to one package, `@EnableJdbcRepositories` scoped to
|
||||
another — see [`JdbcVsJpaApplication.java`](../src/main/java/com/ankurm/jdbcvsjpa/JdbcVsJpaApplication.java)).
|
||||
A pragmatic migration moves the aggregates that are small, hot, and bug-prone under JPA's
|
||||
lazy-loading surprises first, and leaves the genuinely graph-shaped, cache-heavy parts of the
|
||||
domain on JPA.
|
||||
|
||||
## What to measure before deciding, on your own schema
|
||||
|
||||
1. p95 aggregate size (row count) for the collections you're considering moving.
|
||||
2. Write frequency versus read frequency for those aggregates — JDBC's replace-on-save cost is a
|
||||
write-path cost; if reads dominate, it may not matter.
|
||||
3. Whether any code today relies on partial, incremental updates to a large collection
|
||||
(`@Query` bulk updates exist on both sides, but the ORM-managed dirty-checking path some teams
|
||||
lean on for this does not exist under Spring Data JDBC at all).
|
||||
|
||||
[← back to the shared domain](01-the-shared-domain.md) · [README](../README.md)
|
||||
@@ -0,0 +1,16 @@
|
||||
Same logical operation — load every order for a customer and read every item on
|
||||
every order — run against identical H2 data, counted by the same
|
||||
StatementLoggingDataSource, for two stacks.
|
||||
|
||||
orders items/ea JPA (no fetch join) Spring Data JDBC
|
||||
1 1 2 2
|
||||
1 5 2 2
|
||||
5 1 6 6
|
||||
5 5 6 6
|
||||
20 3 21 21
|
||||
|
||||
JPA here uses plain findAll() + lazy .getItems() (scenario 02's shape); adding
|
||||
an @EntityGraph flattens the JPA column to a constant too (scenario 03). The
|
||||
point is not "JDBC beats JPA" in the abstract — it's that JDBC's default gives
|
||||
you the flattened shape for free, while JPA's default gives you the linear one,
|
||||
and the fix for JPA requires the reader to know it's needed.
|
||||
@@ -0,0 +1,8 @@
|
||||
Scenario: load an Order inside a transaction, return it, then touch the lazy
|
||||
items collection AFTER the transaction (and its Hibernate session) has closed.
|
||||
|
||||
order.getItems().size() -> org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.ankurm.jdbcvsjpa.jpa.Order.items' with key '34' (no session)
|
||||
|
||||
This is the LazyInitializationException every JPA tutorial warns about and few
|
||||
show you actually triggering. It is not a bug: the collection genuinely has not
|
||||
been read from the database yet, and the session that could read it is gone.
|
||||
@@ -0,0 +1,13 @@
|
||||
Scenario: findAll() for 3 orders (2 items each), then read .getItems().size()
|
||||
on each in a loop — the pattern that looks completely ordinary in a service
|
||||
method.
|
||||
|
||||
1. select o1_0.id,o1_0.customer_id,o1_0.version from jpa_order o1_0
|
||||
2. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
|
||||
3. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
|
||||
4. select i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku from jpa_order_item i1_0 where i1_0.order_id=?
|
||||
|
||||
total statements: 4
|
||||
|
||||
SELECT statements observed: 4 (1 for the orders themselves + 1 per order for
|
||||
its lazily-loaded items = N+1, here 1 + 3 = 4)
|
||||
@@ -0,0 +1,10 @@
|
||||
Scenario: the same lookup, but through findWithItemsAndCustomerById (an
|
||||
@EntityGraph fetch), for an order with 1 item and an order with 5 items.
|
||||
|
||||
statements for 1-item order: 1
|
||||
statements for 5-item order: 1
|
||||
|
||||
Both are the same number — an entity graph turns the collection fetch into a
|
||||
single outer-join SELECT, so the statement count stops depending on item count.
|
||||
This is the fix for scenario 02, and it is also the shape Spring Data JDBC gives
|
||||
you by default with no annotation at all (see docs/05).
|
||||
@@ -0,0 +1,17 @@
|
||||
Scenario: inside one transaction, load an order, mutate a managed item's
|
||||
quantity field directly (no repository.save() call), then run an unrelated
|
||||
query (customerRepository.count()) in the same transaction.
|
||||
|
||||
1. select o1_0.id,c1_0.id,c1_0.name,i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku,o1_0.version from jpa_order o1_0 left join jpa_customer c1_0 on c1_0.id=o1_0.customer_id left join jpa_order_item i1_0 on o1_0.id=i1_0.order_id where o1_0.id=?
|
||||
2. select count(*) from jpa_customer c1_0
|
||||
3. update jpa_order_item set order_id=?,quantity=?,sku=? where id=?
|
||||
|
||||
total statements: 3
|
||||
|
||||
An UPDATE for jpa_order_item appears BEFORE the transaction ever commits and
|
||||
without save() ever being called, because Hibernate's dirty checking flushes
|
||||
pending changes ahead of any query that could otherwise see stale data. This is
|
||||
correct behaviour, but it means "no save() call" does not mean "no write" — see
|
||||
docs/02-the-jpa-side.md for the case where this surprises people (an outer
|
||||
@Transactional method that throws after this point still writes the row before
|
||||
the eventual rollback un-writes it).
|
||||
@@ -0,0 +1,11 @@
|
||||
Scenario: remove one item from order.items (a plain List.remove, via the
|
||||
addItem/removeItem helpers that keep both sides in sync) and save() the order.
|
||||
|
||||
1. select o1_0.id,c1_0.id,c1_0.name,i1_0.order_id,i1_0.id,i1_0.quantity,i1_0.sku,o1_0.version from jpa_order o1_0 left join jpa_customer c1_0 on c1_0.id=o1_0.customer_id left join jpa_order_item i1_0 on o1_0.id=i1_0.order_id where o1_0.id=?
|
||||
2. delete from jpa_order_item where id=?
|
||||
|
||||
total statements: 2
|
||||
|
||||
orphanRemoval = true on the @OneToMany turns "no longer in the collection" into
|
||||
a DELETE for that row. Without orphanRemoval, the row survives with a dangling
|
||||
(nulled or stale) order_id, which is the more common real-world bug report.
|
||||
@@ -0,0 +1,10 @@
|
||||
Scenario: findById on an order with 1 item, then on an order with 5 items — no
|
||||
@EntityGraph, no fetch annotation, this is the default and only behaviour.
|
||||
|
||||
statements for 1-item order: 2 (items loaded: 1)
|
||||
statements for 5-item order: 2 (items loaded: 5)
|
||||
|
||||
Both counts are the same. There is no lazy vs eager choice to make because
|
||||
Spring Data JDBC has no lazy loading: findById always returns the complete
|
||||
aggregate. Compare to JpaBehaviorTest scenario 02, where the naive path is O(N)
|
||||
in the number of orders touched, not items.
|
||||
@@ -0,0 +1,25 @@
|
||||
Scenario: change the quantity of exactly ONE of 3 order items, then save() the
|
||||
whole aggregate — the idiomatic Spring Data JDBC pattern (see Order#withItems).
|
||||
|
||||
1. UPDATE "JDBC_ORDER" SET "VERSION" = ?, "CUSTOMER_ID" = ? WHERE "JDBC_ORDER"."ID" = ? AND "JDBC_ORDER"."VERSION" = ?
|
||||
2. DELETE FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ?
|
||||
3. INSERT INTO "JDBC_ORDER_ITEM" ("ORDER_ID", "ORDER_KEY", "ID", "QUANTITY", "SKU") VALUES (?, ?, ?, ?, ?)
|
||||
4. SELECT "JDBC_ORDER"."ID" AS "ID", "JDBC_ORDER"."VERSION" AS "VERSION", "JDBC_ORDER"."CUSTOMER_ID" AS "CUSTOMER_ID" FROM "JDBC_ORDER" WHERE "JDBC_ORDER"."ID" = ?
|
||||
5. SELECT "JDBC_ORDER_ITEM"."ID" AS "ID", "JDBC_ORDER_ITEM"."SKU" AS "SKU", "JDBC_ORDER_ITEM"."QUANTITY" AS "QUANTITY", "JDBC_ORDER_ITEM"."ORDER_KEY" AS "ORDER_KEY" FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ? ORDER BY "ORDER_KEY"
|
||||
|
||||
total statements: 5
|
||||
|
||||
DELETE statements: 1
|
||||
INSERT statements: 1 (but the reload shows 3 rows back: [OrderItem[id=13, sku=SKU-1, quantity=1], OrderItem[id=15, sku=SKU-3, quantity=3], OrderItem[id=14, sku=SKU-2, quantity=999]])
|
||||
|
||||
Spring Data JDBC does not diff the collection: saving an aggregate root with a
|
||||
@MappedCollection deletes every existing child row for that parent and reinserts
|
||||
the current collection from scratch, every time — even when only one of three
|
||||
rows actually changed. What it does NOT do is pay for that with N round trips:
|
||||
the three inserts above are one JDBC *batch* — addBatch() three times,
|
||||
executeBatch() once — so this shows as a single "INSERT" line in the log, not
|
||||
three. This is the real trade the "dropping the ORM" article leads with: you
|
||||
give up field-level dirty checking for the aggregate model, but the replace is
|
||||
a delete plus one batched insert, not delete-plus-N-inserts. For a collection
|
||||
with thousands of rows the row churn is still real (every row is rewritten, not
|
||||
just the changed one); see docs/06-when-this-gets-expensive.md.
|
||||
@@ -0,0 +1,17 @@
|
||||
Scenario: load an order, then read its customer's id through the
|
||||
AggregateReference — never call customerRepository.findById for it.
|
||||
|
||||
1. SELECT "JDBC_ORDER"."ID" AS "ID", "JDBC_ORDER"."VERSION" AS "VERSION", "JDBC_ORDER"."CUSTOMER_ID" AS "CUSTOMER_ID" FROM "JDBC_ORDER" WHERE "JDBC_ORDER"."ID" = ?
|
||||
2. SELECT "JDBC_ORDER_ITEM"."ID" AS "ID", "JDBC_ORDER_ITEM"."SKU" AS "SKU", "JDBC_ORDER_ITEM"."QUANTITY" AS "QUANTITY", "JDBC_ORDER_ITEM"."ORDER_KEY" AS "ORDER_KEY" FROM "JDBC_ORDER_ITEM" WHERE "JDBC_ORDER_ITEM"."ORDER_ID" = ? ORDER BY "ORDER_KEY"
|
||||
|
||||
total statements: 2
|
||||
|
||||
statements touching jdbc_customer: 0
|
||||
customer id obtained without a lookup: 3
|
||||
|
||||
AggregateReference<Customer, Long> is a typed foreign key: getId() is free
|
||||
because the id is literally the column value already in hand from loading the
|
||||
order. Nothing about Customer is fetched unless you explicitly ask a
|
||||
CustomerRepository for it. This is the mechanism that keeps a multi-aggregate
|
||||
object graph from becoming N+1 by default — there is no "default" traversal at
|
||||
all across an aggregate boundary.
|
||||
@@ -0,0 +1,9 @@
|
||||
Scenario: two callers load the same order (both see version N), the first saves
|
||||
(version becomes N+1), then the second — still holding version N — tries to save.
|
||||
|
||||
result: org.springframework.dao.OptimisticLockingFailureException: Failed to update versioned entity with id '1' (version '0') in table ["JDBC_ORDER"]; Was the entity updated or deleted concurrently?
|
||||
|
||||
@Version on the JDBC side behaves the same as @Version under JPA: the UPDATE's
|
||||
WHERE clause includes "and version = :expectedVersion", zero rows match, and
|
||||
Spring Data JDBC turns that into OptimisticLockingFailureException rather than
|
||||
silently doing nothing.
|
||||
@@ -0,0 +1,11 @@
|
||||
Scenario: save items in the order [SKU-Z, SKU-A, SKU-M] (deliberately not
|
||||
alphabetical), reload, and read the list back.
|
||||
|
||||
reloaded order: [SKU-Z, SKU-A, SKU-M]
|
||||
|
||||
@MappedCollection(idColumn = "order_id", keyColumn = "order_key") is what makes
|
||||
this deterministic. Without keyColumn, Spring Data JDBC still stores a List
|
||||
correctly, but the reload order is whatever the database happens to return —
|
||||
usually insertion order on a fresh H2 table, but that is an implementation
|
||||
detail, not a guarantee, and it is the kind of thing that breaks quietly on a
|
||||
different database or after a compaction.
|
||||
Reference in New Issue
Block a user