1
0

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:
2026-08-26 17:26:04 +00:00
commit 5533c6c8fe
24 changed files with 1653 additions and 0 deletions

108
docs/02-merge-vs-refresh.md Normal file
View 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)