Add hibernate-demo: get() vs load(), merge() vs refresh(), inserting objects (Hibernate 7.4.1.Final + Spring Boot 4.1.0)
This commit is contained in:
108
docs/02-merge-vs-refresh.md
Normal file
108
docs/02-merge-vs-refresh.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 02 — merge() vs refresh()
|
||||
|
||||
[← Previous: 01 — get() vs load()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md)
|
||||
|
||||
Backs [ankurm.com: merge() vs refresh()](https://ankurm.com/mastering-hibernate-7-merging-vs-refreshing-entities-for-robust-data-consistency/).
|
||||
|
||||
Source: [`MergeVsRefreshRunner`](../src/main/java/com/ankurm/hibernatedemo/scenario/MergeVsRefreshRunner.java),
|
||||
entity: [`Book`](../src/main/java/com/ankurm/hibernatedemo/model/Book.java) (note the `@Version`
|
||||
column — this scenario specifically needs a real optimistic-lock field, not just a plain one).
|
||||
Run it yourself with `./scripts/run.sh mergerefresh`; the full transcript is committed at
|
||||
[`docs/output/merge-vs-refresh.txt`](output/merge-vs-refresh.txt).
|
||||
|
||||
## The setup
|
||||
|
||||
1. Seed one `Book` row, `version=0`.
|
||||
2. Load it, then close the session — it is now **detached**, still holding `version=0`.
|
||||
3. A second, independent session loads the same row, edits the title, and commits. The row's
|
||||
`version` in the database is now `1`.
|
||||
4. Back on the original detached instance (still at `version=0`), edit a *different* field
|
||||
(`author`) and call `session.merge(detached)`.
|
||||
5. Separately: load the row fresh into a **managed** session, make an in-memory edit, don't
|
||||
flush it, then call `session.refresh(...)`.
|
||||
|
||||
## What actually happened
|
||||
|
||||
Step 4 does not silently overwrite the title from step 3. It throws:
|
||||
|
||||
```
|
||||
jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another
|
||||
transaction for entity [com.ankurm.hibernatedemo.model.Book with id '1']
|
||||
```
|
||||
|
||||
`merge()` re-fetches the current row, sees the database is at `version=1` while the detached
|
||||
instance is still carrying `version=0`, and refuses the write. The title edited by the "other
|
||||
process" in step 3 survives untouched.
|
||||
|
||||
Step 5 is the opposite outcome for a structurally similar situation. The managed entity has an
|
||||
unflushed local edit (`author` set to a new value that was never sent to the database).
|
||||
`refresh()` re-runs the `SELECT` and overwrites every field with what's in the database right
|
||||
now — including the field with the pending edit. No exception. No warning. The edit is just
|
||||
gone:
|
||||
|
||||
```
|
||||
before refresh(): Book{id=1, title=Clean Code (2nd Edition), author=SOMEONE ELSE ENTIRELY (never flushed), version=1}
|
||||
after refresh(): Book{id=1, title=Clean Code (2nd Edition), author=Robert C. Martin, version=1} -- the local edit is gone, no exception was thrown
|
||||
```
|
||||
|
||||
## What surprised me building this
|
||||
|
||||
Going in, the plan was to show "merge() can silently overwrite concurrent changes" as the
|
||||
headline risk — that's the framing most write-ups of `merge()` use. Running it against a
|
||||
`@Version`-ed entity showed the opposite: with optimistic locking in place, `merge()` is the
|
||||
*safe* one here — it throws rather than clobbering someone else's committed write. `refresh()`
|
||||
is the one that destroys data silently, and it does it to an edit that was never even sent to
|
||||
the database yet. The risk isn't "which method can overwrite the database" — both can, that's
|
||||
their job. It's "which method fails loudly when the state it's holding is stale," and on a
|
||||
versioned entity that is `refresh()`, not `merge()` — precisely backwards from how the pairing
|
||||
usually gets described. Strip the `@Version` column out and this flips: an unversioned `merge()`
|
||||
would apply the stale write in step 4 without a peep. The column is not incidental to the
|
||||
result; it's the whole reason the result is what it is.
|
||||
|
||||
## Full captured transcript
|
||||
|
||||
```console
|
||||
Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional
|
||||
Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional
|
||||
Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id))
|
||||
Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id))
|
||||
Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id))
|
||||
Hibernate: create sequence book_seq start with 1 increment by 50
|
||||
Hibernate: create sequence widget_seq start with 1 increment by 25
|
||||
Hibernate: select next value for book_seq
|
||||
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,title,version,id) values (?,?,?,?)
|
||||
binding parameter (1:VARCHAR) <- [Robert C. Martin]
|
||||
binding parameter (2:VARCHAR) <- [Clean Code]
|
||||
binding parameter (3:BIGINT) <- [0]
|
||||
binding parameter (4:BIGINT) <- [1]
|
||||
SEED: inserted Book{id=1, title=Clean Code, author=Robert C. Martin, version=0}
|
||||
--- Step 1: load the row, then close the session (entity is now detached) ---
|
||||
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
|
||||
binding parameter (1:BIGINT) <- [1]
|
||||
detached instance in hand: Book{id=1, title=Clean Code, author=Robert C. Martin, version=0}
|
||||
--- Step 2: a second, independent session edits the same row and commits ---
|
||||
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
|
||||
binding parameter (1:BIGINT) <- [1]
|
||||
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,title=?,version=? where id=? and version=?
|
||||
binding parameter (1:VARCHAR) <- [Robert C. Martin]
|
||||
binding parameter (2:VARCHAR) <- [Clean Code (2nd Edition)]
|
||||
binding parameter (3:BIGINT) <- [1]
|
||||
binding parameter (4:BIGINT) <- [1]
|
||||
binding parameter (5:BIGINT) <- [0]
|
||||
second session committed: Book{id=1, title=Clean Code (2nd Edition), author=Robert C. Martin, version=1} -- version column has now advanced in the database
|
||||
--- Step 3: mutate the ORIGINAL detached instance (still holding the OLD version) and merge() it ---
|
||||
detached instance before merge (note the version and title are both stale): Book{id=1, title=Clean Code, author=Robert C. Martin (Uncle Bob), version=0}
|
||||
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
|
||||
binding parameter (1:BIGINT) <- [1]
|
||||
merge() threw jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '1']
|
||||
the title change from Step 2 survives untouched -- merge() refused to apply a write built on a stale version
|
||||
--- Step 4: refresh() on a MANAGED entity with an unflushed local edit ---
|
||||
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
|
||||
binding parameter (1:BIGINT) <- [1]
|
||||
before refresh(): Book{id=1, title=Clean Code (2nd Edition), author=SOMEONE ELSE ENTIRELY (never flushed), version=1}
|
||||
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
|
||||
binding parameter (1:BIGINT) <- [1]
|
||||
after refresh(): Book{id=1, title=Clean Code (2nd Edition), author=Robert C. Martin, version=1} -- the local edit is gone, no exception was thrown
|
||||
```
|
||||
|
||||
[← Previous: 01 — get() vs load()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md)
|
||||
Reference in New Issue
Block a user