1
0

Add hibernate-demo: get() vs load(), merge() vs refresh(), inserting objects (Hibernate 7.4.1.Final + Spring Boot 4.1.0)

Adds a JUnit test suite (GetVsGetReferenceTest, MergeRefreshTest, OptimisticLockTest,
IdentityBatchTest, SequenceBatchTest, AllocationSizeSweepTest, BatchSizeSweepTest) so every
surprising behavior described in the three companion posts has a reproducible test, alongside
the original CommandLineRunner scenarios. Rewrites all three doc chapters and the README around
the new experiments: the get()/getReference() same-session matrix, the merge()/refresh()
experiments (including exactly when OptimisticLockException surfaces and a corrected LAZY-plus-
cascade merge() result), and two new sweeps (allocationSize, batch_size) for batch inserts.
This commit is contained in:
2026-08-26 17:26:04 +00:00
commit 7b06d653e4
43 changed files with 3053 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
target/
*.class
.idea/
*.iml
.vscode/
.DS_Store

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# hibernate-demo
Companion repository for three ankurm.com posts on Hibernate 7's persistence-context APIs:
`get()` vs `getReference()`, `merge()` vs `refresh()`, and batch inserts. Every claim in those
posts that comes from this repo traces to a named JUnit test here and a captured transcript in
`docs/output/` — nothing is asserted that wasn't actually run. Every surprising behavior
described in the three posts has a reproducible test backing it; the table below maps each one.
## Versions
| Component | Version |
|---|---|
| Hibernate ORM | `7.4.1.Final` (GA 2026-06-09) |
| Spring Boot | `4.1.0` (GA 2026-06-10) |
| Java | `25` (LTS) |
| H2 | in-memory, managed by Spring Boot |
See [`docs/00-versions.md`](docs/00-versions.md) for how these were verified and a trap worth
knowing about if you bump the Spring Boot version.
## Quickstart
```bash
git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
cd hibernate-demo
./mvnw test
```
Requires JDK 25 and a network connection the first time (Maven needs to fetch plugins online
before `-o` offline mode works for subsequent runs).
## Every surprising claim, mapped to a test
Each row is one `./mvnw -Dtest=ClassName test` away from reproducing itself. This is the
reproduction path referenced throughout all three posts and all three doc chapters below.
| Test class | Backs post | Proves |
|---|---|---|
| [`GetVsGetReferenceTest`](src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java) | 4859 (get vs getReference) | The 4-calls-4-outcomes table, the same-session matrix, proxy identity vs `equals()` |
| [`MergeRefreshTest`](src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java) | 4860 (merge vs refresh) | `merge()` returns the pre-existing managed instance; `merge()` initializes a cascaded LAZY collection; `refresh()` silently discards an unflushed edit |
| [`OptimisticLockTest`](src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java) | 4860 (merge vs refresh) | Exactly when `OptimisticLockException` surfaces relative to `merge()`/commit |
| [`IdentityBatchTest`](src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java) | 4861 (batch inserts) | `GenerationType.IDENTITY` disables batching entirely |
| [`SequenceBatchTest`](src/test/java/com/ankurm/hibernatedemo/SequenceBatchTest.java) | 4861 (batch inserts) | `GenerationType.SEQUENCE` allows real batching |
| [`AllocationSizeSweepTest`](src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java) | 4861 (batch inserts) | `allocationSize` sweep (1, 10, 25, 50) at fixed `batch_size=25` |
| [`BatchSizeSweepTest`](src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java) | 4861 (batch inserts) | `batch_size` sweep (1, 10, 25, 50) at fixed `allocationSize=50` |
```bash
./mvnw -Dtest=GetVsGetReferenceTest test
./mvnw -Dtest=MergeRefreshTest,OptimisticLockTest test
./mvnw -Dtest=IdentityBatchTest,SequenceBatchTest test
./mvnw -Dtest=AllocationSizeSweepTest test
./mvnw -Dtest=BatchSizeSweepTest test
./mvnw test # the whole suite, 24 tests, 0 failures as of the last commit
```
## CommandLineRunner scenarios
The original narrative scenarios are still here, unchanged, for anyone who wants to read a
straight-line script instead of a test class:
| Profile | Runs | Chapter |
|---|---|---|
| `getvsload` | `session.get()` vs `session.getReference()`, proxies, `LazyInitializationException` | [docs/01-get-vs-load.md](docs/01-get-vs-load.md) |
| `mergerefresh` | `merge()` vs `refresh()` against a `@Version`-ed entity | [docs/02-merge-vs-refresh.md](docs/02-merge-vs-refresh.md) |
| `insert-identity` | Batch insert attempt with `GenerationType.IDENTITY` | [docs/03-inserting-objects.md](docs/03-inserting-objects.md) |
| `insert-sequence` | The same insert, with `GenerationType.SEQUENCE` | [docs/03-inserting-objects.md](docs/03-inserting-objects.md) |
```bash
./scripts/run.sh getvsload
./scripts/run.sh mergerefresh
./scripts/run.sh insert-identity
./scripts/run.sh insert-sequence
```
## Regenerating captured output
```bash
./scripts/run-all.sh
```
Regenerates the `CommandLineRunner`-scenario files in `docs/output/`. `scripts/clean_output.py`
strips JVM noise and a harmless duplicate SQL echo line so the committed transcripts stay
readable — nothing else is edited by hand. The test-suite transcripts in `docs/output/` (the
session matrix, the sweeps) were captured the same way, from `./mvnw -Dtest=... test` piped
through the same script.
## Documentation index
| Chapter | Covers |
|---|---|
| [00 — Versions](docs/00-versions.md) | Verified version pins, and the Spring Boot patch that silently changes which Hibernate patch you get |
| [01 — get() vs getReference()](docs/01-get-vs-load.md) | The 4-calls-4-outcomes table, the same-session matrix, proxy identity vs `equals()` |
| [02 — merge() vs refresh()](docs/02-merge-vs-refresh.md) | Three named experiments: which method fails loudly vs silently, exactly when the optimistic-lock check fires, and what a cascaded LAZY collection does under merge() |
| [03 — Hibernate 7 batch inserts](docs/03-inserting-objects.md) | `IDENTITY` vs `SEQUENCE`, the allocationSize and batch_size sweeps, `Session` vs `StatelessSession`, with `hibernate.generate_statistics` as evidence throughout |
## Captured output index
| File | Source |
|---|---|
| [docs/output/get-vs-load.txt](docs/output/get-vs-load.txt) | `./scripts/run.sh getvsload` (CommandLineRunner) |
| [docs/output/get-vs-getreference-tests.txt](docs/output/get-vs-getreference-tests.txt) | `./mvnw -Dtest=GetVsGetReferenceTest test` |
| [docs/output/merge-vs-refresh.txt](docs/output/merge-vs-refresh.txt) | `./scripts/run.sh mergerefresh` (CommandLineRunner) |
| [docs/output/merge-vs-refresh-tests.txt](docs/output/merge-vs-refresh-tests.txt) | `./mvnw -Dtest=MergeRefreshTest,OptimisticLockTest test` |
| [docs/output/insert-identity.txt](docs/output/insert-identity.txt) | `./scripts/run.sh insert-identity` (also matches `IdentityBatchTest`) |
| [docs/output/insert-sequence.txt](docs/output/insert-sequence.txt) | `./scripts/run.sh insert-sequence` (also matches `SequenceBatchTest`) |
| [docs/output/allocation-and-batch-size-sweeps.txt](docs/output/allocation-and-batch-size-sweeps.txt) | `./mvnw -Dtest=AllocationSizeSweepTest,BatchSizeSweepTest test` |
## Layout
```
hibernate-demo/
├── pom.xml
├── LICENSE
├── scripts/
│ ├── run.sh start one CommandLineRunner profile, run it, exit
│ ├── run-all.sh regenerate the CommandLineRunner docs/output/ files
│ └── clean_output.py strip JVM noise + a duplicate SQL echo line from a raw capture
├── src/main/java/com/ankurm/hibernatedemo/
│ ├── HibernateDemoApplication.java
│ ├── model/ Book, Note, WidgetIdentity, WidgetSequence,
│ │ WidgetAlloc1/10/25/50, WidgetBatchSweep1/10/25/50
│ └── scenario/ one CommandLineRunner per profile
├── src/test/java/com/ankurm/hibernatedemo/
│ ├── GetVsGetReferenceTest.java
│ ├── MergeRefreshTest.java, OptimisticLockTest.java
│ ├── IdentityBatchTest.java, SequenceBatchTest.java
│ └── AllocationSizeSweepTest.java, BatchSizeSweepTest.java
└── docs/
├── 00-versions.md .. 03-inserting-objects.md
└── output/*.txt captured, unedited console transcripts (both runner and test output)
```
## License
MIT — see [LICENSE](LICENSE).

45
docs/00-versions.md Normal file
View File

@@ -0,0 +1,45 @@
# 00 — Versions
[Next: 01 — get() vs load() →](01-get-vs-load.md)
This repository is pinned to:
| Component | Version | GA date | Source |
|---|---|---|---|
| Hibernate ORM | `7.4.1.Final` | 2026-06-09 | [hibernate.org/orm/releases/7.4](https://hibernate.org/orm/releases/7.4/) |
| Spring Boot | `4.1.0` | 2026-06-10 | [spring.io/blog/2026/06/10/spring-boot-4](https://spring.io/blog/2026/06/10/spring-boot-4/) |
| Java | `25` (LTS) | 2025-09 | latest LTS at the time this repo was built |
| H2 | managed by Spring Boot 4.1.0 | — | in-memory, `DB_CLOSE_DELAY=-1` |
## The pin lines up, but check before you assume it always will
`hibernate-core`'s own `maven-metadata.xml` on Maven Central lists `7.4.6.Final` as the newest
GA release at the time this was written — several patches ahead of `7.4.1.Final`. This repo
pins to `7.4.1.Final` deliberately, because that is the version this batch of posts was written
and run against, and because it is *exactly* the version Spring Boot 4.1.0 resolves on its own.
That last part is not a coincidence to take for granted, though. Checking
`spring-boot-dependencies-4.1.0.pom` directly shows `<hibernate.version>7.4.1.Final</hibernate.version>`
so on Boot 4.1.0, `pom.xml` in this repo does not need to override anything to get 7.4.1.Final;
the `<hibernate.version>` property declared here is redundant with what Boot already resolves,
kept only so the pin is visible without cracking open Boot's own POM.
That stops being true one patch release later. `spring-boot-dependencies-4.1.1.pom` resolves
`hibernate.version` to `7.4.5.Final` — a different Hibernate patch from the same Spring Boot
minor version, four Hibernate patch releases apart. If you bump this repo's parent to `4.1.1`
without touching the `<hibernate.version>` property, you get `7.4.1.Final` back (the explicit
property now *does* override Boot's own management) rather than the `7.4.5.Final` Boot intended
you to get — which is a more useful trap to know about than to fall into.
| Spring Boot version | Hibernate version Boot resolves |
|---|---|
| `4.0.8` | `7.2.24.Final` |
| `4.1.0` | `7.4.1.Final` |
| `4.1.1` | `7.4.5.Final` |
Verified against `maven-metadata.xml` on `repo1.maven.org`, not against Maven Central's
`solrsearch` API — that index has been observed stale from this kind of sandboxed build
environment (it reported an old Spring Boot release as newest well after a later one had
shipped), so it should not be trusted for currency checks.
[Next: 01 — get() vs load() →](01-get-vs-load.md)

134
docs/01-get-vs-load.md Normal file
View File

@@ -0,0 +1,134 @@
# 01 — get() vs getReference()
[← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md)
Backs [ankurm.com: Hibernate 7 — get() vs load()](https://ankurm.com/hibernate-7-get-vs-load-which-one-should-you-actually-use/).
Test class: [`GetVsGetReferenceTest`](../src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java).
Run it yourself:
```bash
git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
cd hibernate-demo
./mvnw -Dtest=GetVsGetReferenceTest test
```
Every number and exception class name below came from that command, not from documentation or
memory. Raw captured output: [`docs/output/get-vs-getreference-tests.txt`](output/get-vs-getreference-tests.txt).
## Contract vs observation
The JPA/Hibernate contract for these two methods is short: `get()` fetches now and may return
`null`; `getReference()` defers and may throw once accessed. That contract is real and both
methods honor it. What it doesn't tell you is what happens once the *same id* has already been
touched once in the *same session* — and that's where the interesting behavior lives, because it's
governed by the persistence context, not by the method you happen to call second.
## Mental model
Stop thinking of `get()` vs `getReference()` as "eager vs lazy." Think of it as what you're telling
Hibernate you need:
- `get()` says **"I need the entity."** Hibernate will do whatever it takes — including firing a
`SELECT` against an id it already has a reference for — to hand you something with real data
behind it.
- `getReference()` says **"I need a reference."** Hibernate will hand you the cheapest possible
object that satisfies that and defers everything else, including telling you the row doesn't
exist.
That framing predicts the session-matrix results in the next section better than "eager vs lazy"
does — see the `getReference()``get()` row in particular.
## Four calls, four outcomes
| # | Call | Fires a `SELECT` at the call site? | Row missing | Row exists |
|---|---|---|---|---|
| 1 | `session.get(Book.class, id)` | Yes, immediately | returns `null` | returns the real entity |
| 2 | `session.get(Book.class, id)` (missing id) | Yes, immediately | returns `null` | — |
| 3 | `session.getReference(Book.class, id)` | No — deferred to first non-id accessor | proxy returned, no error yet | proxy returned, no `SELECT` yet |
| 4 | `session.getReference(Book.class, id)` (missing id), then `.getTitle()` | Yes, on first accessor call | throws `jakarta.persistence.EntityNotFoundException` on access | — |
Row 4 is worth being precise about: the exception class is `jakarta.persistence.EntityNotFoundException`,
not `org.hibernate.ObjectNotFoundException` — the name still used in a lot of older Hibernate
discussion. Running it against 7.4.1.Final settles which one this version actually throws.
## Same-session matrix
Four combinations, both calls against the *same id* in the *same session*, each with statistics
cleared right before the second call so `prepareStatementCount` reflects only that call:
| First call | Second call | `prepareStatementCount` for 2nd call | 2nd call returns |
|---|---|---|---|
| `get()` | `get()` | **0** | the same instance (L1 cache hit) |
| `getReference()` | `getReference()` | **0** | the same proxy instance |
| `get()` | `getReference()` | **0** | the same, already-real instance — not a new proxy |
| `getReference()` | `get()` | **1** | the same instance, now initialized |
The last row is the one that doesn't follow from "it's already in the L1 cache, so nothing
happens." It does happen: calling `get()` against an id that already has an *uninitialized* proxy
sitting in the persistence context still fires a `SELECT`. `get()`'s contract is "hand back a real,
usable entity" — an uninitialized proxy doesn't satisfy that, so Hibernate initializes it in place
and returns the same object reference, now with real data behind it. The reverse direction
(`get()` then `getReference()`) needs nothing further, because a real, fully-loaded instance
already satisfies whatever `getReference()` was going to ask for.
This wasn't something I went looking for — it fell out of writing the fourth row of the matrix
and reading the log, which is the actual argument for building the matrix instead of reasoning
about two of the four cells and assuming the rest.
## Proxy identity experiment
Six checks against the same proxy, all in one test:
```java
assertThat(proxy).isInstanceOf(Book.class); // true
assertThat(Hibernate.getClass(proxy)).isEqualTo(Book.class); // true -- the REAL class
assertThat(proxy.getClass()).isNotEqualTo(Book.class); // true -- proxy.getClass() is Book$HibernateProxy
assertThat(real.equals(proxy)).isFalse(); // false
assertThat(proxy.equals(real)).isFalse(); // false, both directions
assertThat(new HashSet<>(List.of(real)).contains(proxy)).isFalse();// a HashSet can't see they're the same row
```
`instanceof` and `Hibernate.getClass()` both see through the proxy to the real type. `getClass()`
does not — a Hibernate proxy's runtime class is a generated `Book$HibernateProxy`, never `Book`
itself, which is why `Hibernate.getClass()` exists as the "give me the real entity class" escape
hatch. `equals()` breaks in both directions because `Book` never overrides it, so Java's default
falls back to reference identity — this is not a Hibernate quirk, it's plain Java doing exactly
what an un-overridden `equals()` always does once two different objects (a proxy and a loaded
instance) represent the same row. The `HashSet` check is the concrete cost of that: a collection
built on default `equals()`/`hashCode()` cannot recognize the proxy and the real instance as the
same database row, silently.
A seventh check, in a separate test, confirms the other well-known proxy trap: a proxy that
outlives the session that created it throws `org.hibernate.LazyInitializationException` the
moment a non-id accessor is called on it — a different failure from `EntityNotFoundException`,
worth not confusing with it.
## What surprised me building this
Two things, not one.
The proxy-equals-breaking result was expected going in, just not in its full shape — I expected
`equals()` to be asymmetric or to depend on which side calls it. It doesn't; it fails identically
in both directions, which is simpler and worse than a half-remembered version of this story
usually gets described.
The one I didn't expect at all was the `getReference()``get()` row of the session matrix. The
intuitive prediction — "the id is already in the L1 cache, so the second call is free" — is true
for three of the four matrix combinations and wrong for exactly this one, because `get()`'s
contract requires more than presence in the cache; it requires the object behind that cache entry
to actually be usable as loaded data. Predicting three cells right and getting the fourth wrong
in a way that only shows up by actually building all four is the whole argument for running the
matrix instead of describing two of its cells from memory.
## Decision table
| You have | You need | Call |
|---|---|---|
| An id, unsure if the row exists | The actual data, or a safe existence check | `get()` |
| An id, certain the row exists | Only a reference to set a foreign key | `getReference()` |
| An id already fetched once this session | Anything | Whatever's already loaded is reused — see the matrix above for exactly when a `SELECT` still fires anyway |
| A proxy that might outlive this session | Safe access later | Initialize it now (`Hibernate.initialize(proxy)`), or don't let it leave the session |
| Two references to the same row from mixed `get()`/`getReference()` calls, going into a `Set` or `equals()`-based comparison | Correct identity behavior | Override `equals()`/`hashCode()` on the id — the un-overridden default will not survive the proxy boundary |
[← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md)

170
docs/02-merge-vs-refresh.md Normal file
View File

@@ -0,0 +1,170 @@
# 02 — merge() vs refresh()
[← Previous: 01 — get() vs getReference()](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/).
Test classes: [`MergeRefreshTest`](../src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java),
[`OptimisticLockTest`](../src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java).
Entity: [`Book`](../src/main/java/com/ankurm/hibernatedemo/model/Book.java) — note the real
`@Version` column and the `notes` LAZY collection, both load-bearing for the experiments below.
```bash
./mvnw -Dtest=MergeRefreshTest,OptimisticLockTest test
```
Raw captured output: [`docs/output/merge-vs-refresh-tests.txt`](output/merge-vs-refresh-tests.txt).
(The `docs/output/merge-vs-refresh.txt` file, unchanged, is the older `CommandLineRunner`
transcript behind the `mergerefresh` profile mentioned in the README — a different, narrower
scenario than the three experiments below.)
## Contract vs observation
The textbook framing is "`merge()` pushes Java state to the database, `refresh()` pulls database
state into Java — opposite directions of the same kind of operation." That's accurate as a
description of data flow and useless as a guide to which one is dangerous. The three experiments
below are about the part the contract doesn't specify: what each method does when the state it's
holding is already stale, which is precisely the situation both exist to handle.
## Reframing: this is about entity state, not "data consistency"
`merge()` and `refresh()` don't care about your application's notion of consistency. They care
about exactly one thing: what identity state (`@Version` value, or an unflushed field on a managed
instance) the object handed to them holds at the moment they're called. Everything below follows
from that, entity-state mechanics, not from anything data-consistency-flavored.
## Experiment 1 — merge() of a detached instance
**Config:** a `Book` row exists in the database. A session already holds its *own* managed
instance of that row (via `get()`), before `merge()` is ever called on a separately-detached copy
of the same row.
**Expected:** `merge()` returns some object representing the updated state.
**Observed:** `merge()` returns the exact, identity-equal managed instance the session already had
— not a new object, and not the detached instance passed in:
```java
Book result = session.merge(detached);
assertThat(result).isSameAs(managed); // true
assertThat(result).isNotSameAs(detached); // true
```
This is the precise version of "`merge()` returns a managed copy" — it's not just *a* managed
copy, it's *the* one instance this persistence context has already committed to tracking for this
row, reused rather than replaced.
## Experiment 2 — optimistic-lock conflict: WHEN does it surface?
**Config:** a detached `Book` instance holds `version=0`. A second, independent session has since
updated the same row and committed, advancing the database to `version=1`. The stale detached
instance is then edited and merged.
**Expected (the common but imprecise claim):** "`merge()` throws `OptimisticLockException`."
**Observed, precisely:** it does — and specifically **at the `merge()` call itself**, not at
`flush()` and not at `tx.commit()`:
```
OptimisticLockException surfaced directly from the merge() call.
exception: jakarta.persistence.OptimisticLockException: Row was already updated or deleted by
another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '4']
```
The test doesn't assume this — it wraps `session.merge(detached)` in a `try/catch` first and only
falls through to asserting the exception at `commit()` if `merge()` itself didn't throw, logging
which path actually happened. On 7.4.1.Final, `merge()` re-selects the row as part of copying
state and compares versions right there, before any flush is even scheduled — so the failure is as
early as it can possibly be. This matters in code that wraps `merge()` calls expecting the
exception only at commit time: on this version, it never gets that far.
## Experiment 3 — merge() with a LAZY collection under CascadeType.MERGE
**Config:** `Book.notes` is `FetchType.LAZY` and cascades `MERGE`. A `Book` is loaded and detached
*without ever touching* `.getNotes()` — the collection proxy is confirmed uninitialized before
detachment. The detached instance is edited and merged.
**Expected (the naive, plausible-sounding claim):** "an unfetched LAZY collection is never
touched by `merge()`, since it was never loaded in the first place."
**Observed:** the opposite. `merge()` initializes the collection anyway:
```java
Book merged = session.merge(detached);
assertThat(Hibernate.isInitialized(merged.getNotes())).isTrue(); // true -- NOT false
```
The reason is cascading itself: `CascadeType.MERGE` on `notes` means merging the parent requires
merging each element of that collection too, and Hibernate cannot cascade to elements it hasn't
loaded — so it loads them first. Remove `cascade = CascadeType.MERGE` from `Book.notes` and
re-run this exact test and the result flips: the collection stays uninitialized, because nothing
requires Hibernate to look at it. Cascading, not laziness, decides whether `merge()` touches an
unfetched collection.
## Experiment 4 — refresh() silently discards an unflushed edit
**Config:** the `USER_EDIT` / `ADMIN_EDIT` scenario. A `Book.status` row starts at `USER_EDIT`. A
separate admin process loads the row, sets `status=ADMIN_EDIT`, and commits. A second session then
loads the row (now `ADMIN_EDIT` in the database), makes a local, unflushed edit back to
`USER_EDIT`, and calls `session.refresh()` on the managed instance.
**Observed:**
```java
managed.setStatus("USER_EDIT"); // local edit, never sent to the database
session.refresh(managed);
assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT"); // the local edit is gone
```
No exception. No warning. `refresh()` re-runs the `SELECT` and overwrites every field on the
managed instance with what the database currently holds — including the field holding the pending
edit that was never flushed. The edit isn't rejected; it's erased.
## What surprised me building this
Going in, the plan was to demonstrate "`merge()` can silently overwrite concurrent changes" as
the headline risk — that's the framing most write-ups use, and it's the one the article originally
carried. Running Experiments 2 and 4 back to back showed the opposite. With a real `@Version`
column in place, `merge()` is the one that refuses to write a stale change — loudly, at the
earliest possible point. `refresh()` is the one that destroys data without a sound, and it does it
to an edit that was never even sent to the database. The risk isn't "which method can overwrite
the database" — both can, that's their job. It's "which one fails loudly when the state it's
holding is stale," and on a versioned entity that's `refresh()`, not `merge()` — backwards from
how the pairing is usually described. Strip the `@Version` column out and Experiment 2 flips: an
unversioned `merge()` would apply the stale write without complaint. The column isn't incidental
to the result; it's the entire reason the result comes out this way.
The LAZY-collection result (Experiment 3) was the other correction: the plan going in was to
show that unfetched LAZY state is never touched by `merge()`. It is, specifically because of the
cascade — a fact only visible by running the on/off comparison rather than asserting the more
intuitive-sounding half of it.
## Decision tree
```
Do you have a DETACHED instance you want written to the database?
├─ Yes → merge()
│ Does the entity carry @Version?
│ ├─ Yes → a stale write throws OptimisticLockException AT THE merge() CALL — loud, safe
│ └─ No → a stale write silently overwrites the current row — no different from update()
└─ No, you have a MANAGED instance and want it to reflect the current database row
→ refresh()
Does it have an unflushed local edit?
├─ Yes → that edit is silently discarded, no exception — refresh() is NOT reversible
└─ No → refresh() is a safe, ordinary re-read
```
## Pessimistic locking, briefly
Pessimistic locking (`LockModeType.PESSIMISTIC_WRITE`, issuing `SELECT ... FOR UPDATE`) is the
other tool for the same underlying problem — it prevents the conflict from ever existing rather
than detecting it after the fact. Reach for it only when the retry cost of an
`OptimisticLockException` is genuinely unacceptable (real-time seat/ticket reservation, high
per-row contention); it holds a database lock for the duration of the transaction, which is the
wrong tradeoff for the common case of a REST API with real user think-time between load and save.
`@Version` optimistic locking is the sane default; this repo doesn't carry a dedicated scenario for
pessimistic locking because there's no surprising runtime behavior to verify here beyond "the lock
is held until commit," which the database's own documentation already states correctly.
[← Previous: 01 — get() vs getReference()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md)

View File

@@ -0,0 +1,198 @@
# 03 — Hibernate 7 batch inserts: proving batching is working
[← Previous: 02 — merge() vs refresh()](02-merge-vs-refresh.md) | [Back to README →](../README.md)
Backs [ankurm.com: Hibernate 7 batch inserts](https://ankurm.com/mastering-hibernate-7-the-ultimate-guide-to-inserting-objects-efficiently/).
Test classes: [`IdentityBatchTest`](../src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java),
[`SequenceBatchTest`](../src/test/java/com/ankurm/hibernatedemo/SequenceBatchTest.java),
[`AllocationSizeSweepTest`](../src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java),
[`BatchSizeSweepTest`](../src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java).
```bash
./mvnw -Dtest=IdentityBatchTest,SequenceBatchTest test
./mvnw -Dtest=AllocationSizeSweepTest test
./mvnw -Dtest=BatchSizeSweepTest test
```
Raw captured output: [`docs/output/insert-identity.txt`](output/insert-identity.txt),
[`docs/output/insert-sequence.txt`](output/insert-sequence.txt),
[`docs/output/allocation-and-batch-size-sweeps.txt`](output/allocation-and-batch-size-sweeps.txt).
## The discovery that opens this chapter: IDENTITY silently disables batching
Two entities, [`WidgetIdentity`](../src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java)
and [`WidgetSequence`](../src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java), differ
in exactly one line — the `@GeneratedValue` strategy — and are otherwise inserted with identical
settings: `hibernate.jdbc.batch_size=25`, `hibernate.order_inserts=true`. 30 rows each, same
transaction shape, `hibernate.generate_statistics=true` reading the real counts.
## Results, up front
| | `entityInsertCount` | `prepareStatementCount` | Batching actually happening? |
|---|---|---|---|
| `WidgetIdentity` (`GenerationType.IDENTITY`) | 30 | **30** | No — one round trip per row |
| `WidgetSequence` (`GenerationType.SEQUENCE`) | 30 | **4** | Yes |
`batch_size=25` is configured identically for both entities. It does nothing at all for
`IDENTITY``prepareStatementCount` equals `entityInsertCount` exactly, meaning every insert is
its own round trip. The reason: with `IDENTITY`, the database generates the primary key value
*during* the `INSERT`, and Hibernate has no way to know what id a row got without that insert
actually executing — so there's nothing left to batch. `SEQUENCE` inverts this: Hibernate gets the
id from the sequence *before* building the insert, so it can queue several inserts and hand them
to the JDBC driver as one `executeBatch()` call.
## Why SEQUENCE's count is 4, not 2
The naive prediction is "30 rows at batch_size=25 is two insert batches (25 + 5), so
`prepareStatementCount` should be 2." Measured, it's 4 — because `prepareStatementCount` also
counts calls to pull the next block of ids from the sequence, and that's governed by a second,
independent setting: the generator's `allocationSize`. `WidgetSequence` sets `allocationSize=25`
to match `batch_size`, so the first 25 ids come from one sequence call and the remaining 5 force a
second — 2 insert batches + 2 sequence calls = 4. `allocationSize` and `batch_size` are separate
knobs governing separate things, and the two sweeps below exist because "separate knobs" doesn't
tell you what happens when they're set to different values — that has to be run.
## Sweep 1 — allocationSize, batch_size fixed at 25
Four otherwise-identical entities (`WidgetAlloc1/10/25/50`, each with its own dedicated sequence),
30 rows each, `batch_size=25` fixed:
| `allocationSize` | `prepareStatementCount` | Naive prediction | Matched? |
|---|---|---|---|
| 1 | **31** | 32 (2 batches + 30 sequence calls) | No |
| 10 | **5** | 5 (2 batches + 3 sequence calls) | Yes |
| 25 | **4** | 4 (2 batches + 2 sequence calls) | Yes |
| 50 | **3** | 3 (2 batches + 1 sequence call) | Yes |
Three of the four match paper arithmetic exactly. `allocationSize=1` doesn't — the naive "one
sequence call per row" count is 30, plus 2 insert batches, predicting 32; the measured number is
31. The off-by-one isn't a fluke of this run: it reproduced identically across two separate full
suite executions. It's disclosed here rather than smoothed over, because "run the numbers rather
than predicting them" only means something if a number that doesn't match the prediction gets
published anyway.
## Sweep 2 — batch_size, allocationSize fixed at 50
Four fully isolated entities (`WidgetBatchSweep1/10/25/50`, each `allocationSize=50`), 30 rows
each, as four separate `@SpringBootTest` configurations so each genuinely boots its own Hibernate
configuration rather than one mutated at runtime:
| `batch_size` | `prepareStatementCount` |
|---|---|
| 1 | **32** |
| 10 | **2** |
| 25 | **2** |
| 50 | **2** |
`batch_size=1` is effectively "no batching" — close to one prepared statement per row, plus the
sequence traffic. The moment batching is enabled at all, the insert-side contribution to
`prepareStatementCount` collapses to the same small constant regardless of the exact `batch_size`
value — 10, 25, and 50 all measured identically. That does **not** mean `batch_size` stops
mattering: it still governs how many rows go into each `executeBatch()` call at the JDBC driver
level, which is real and documented — it just isn't a distinction this particular Hibernate
statistic can see once batching is switched on at all. Reading `prepareStatementCount` answers
"is batching happening," not "how big are the batches."
**An open, disclosed caveat:** running `BatchSizeSweepTest` in isolation
(`-Dtest=BatchSizeSweepTest#batchSizeTen`) measured 3 for `batch_size=10/25/50`, not the 2 shown
above — the table above reflects the full `mvn test` run, which is the literal reproduction
command given in this repo and the number treated as canonical. The two runs disagree by exactly
one prepared statement, reproducibly, and the most likely explanation is some one-time cost on
the first Hibernate `SessionFactory` bootstrapped in a JVM process — but that mechanism was not
traced into Hibernate's own source to confirm, and it would be dishonest to assert it as fact. Run
both ways yourself; they're one flag apart.
## The off-by-one bug this article used to ship with
An earlier version of the flush/clear loop in this article's own code sample read:
```java
if (i > 0 && i % 50 == 0) {
session.flush();
session.clear();
}
```
`i` is the 0-based loop index (`for (int i = 0; i < users.size(); i++)`), and `persist()` for row
`i` has already run by the time this check executes. `i > 0 && i % 50 == 0` is true at
`i = 50, 100, 150, ...` — but by the time `i` reaches 50, rows at index `0` through `50` have
already been persisted, which is **51 rows**, not 50. Every batch boundary holds one extra row in
memory beyond the intended checkpoint, every cycle — the flush is consistently a row late. The
fix:
```java
if ((i + 1) % 50 == 0) {
session.flush();
session.clear();
}
```
`(i + 1)` counts rows processed so far (1-based) rather than the 0-based loop index, so the flush
fires after exactly the 50th, 100th, 150th row every time, with no off-by-one drift regardless of
where the loop starts counting. This repo's own `InsertIdentityRunner` / `InsertSequenceRunner`
don't contain this flush/clear loop at all — they persist a small enough batch in one transaction
without needing a periodic clear — so the bug lived only in the article's illustrative code sample
and has been fixed there, not here.
## Session vs StatelessSession
Not run in this repo — a `StatelessSession` scenario needs a dedicated build to demonstrate its
actual failure modes (cascades that silently don't fire, no dirty checking, no first-level cache)
rather than a profile bolted onto this one. The comparison below is sourced from the Hibernate
User Guide's documented contract, not a captured run — treat it as contract, not observation:
| | `Session` | `StatelessSession` |
|---|---|---|
| First-level cache | Yes | No |
| Dirty checking | Yes | No — every change needs an explicit `update()` |
| Cascading | Yes, per `CascadeType` | No — you must persist each entity yourself |
| Lifecycle callbacks (`@PrePersist`, etc.) | Yes | No |
| Batching behavior | Governed by `hibernate.jdbc.batch_size` as measured above | Its own `insert()`/`insertMultiple()` path, generally lower per-row overhead |
| Best fit | Ordinary application code | ETL, bulk import/migration, seed scripts |
The tradeoff is overhead for correctness: `StatelessSession` skips the machinery that makes
ordinary Hibernate usage convenient (cascades, dirty checking, the L1 cache), which is exactly why
it's faster for a one-shot bulk load and exactly why it's the wrong tool for ordinary
request-scoped persistence code.
## Corrected wording: what batching actually sends
Batching does not send "multiple SQL statements in a single network packet" — that description
conflates two different things. What actually happens: JDBC's `PreparedStatement.addBatch()` /
`executeBatch()` groups several parameter sets for the *same* prepared statement and sends them in
one client-to-server exchange, avoiding a full round-trip per row. Whether that exchange spans one
TCP packet or several is a driver- and network-layer detail with no fixed answer — it depends on
row size, driver buffering, and the network path, none of which this repo's numbers speak to. The
correct claim is about round trips avoided, not about network packet counts.
## Constraint exceptions: two different failures with similar names
**`jakarta.validation.ConstraintViolationException`** (Bean Validation, `jakarta.validation`
package) is thrown *before* any SQL runs, when an entity fails an annotation like `@NotNull` or
`@Size` during Hibernate Validator's pre-flush validation pass. No database round trip happens at
all in this case.
**`org.hibernate.exception.ConstraintViolationException`** (Hibernate's own, wrapped inside a
`jakarta.persistence.PersistenceException`) is thrown *after* SQL runs and the database itself
rejects the statement — a unique index, a foreign key, or a check constraint failing at the
database level.
Same short name, different packages, different failure points, and code that catches one by name
without checking the fully-qualified type will silently fail to catch the other. Catching
`jakarta.persistence.PersistenceException` and inspecting `getCause()` handles the database-level
case; a Bean Validation failure needs its own catch block for `jakarta.validation.ConstraintViolationException`
before that.
## What surprised me building this
The identity-vs-sequence result itself wasn't the surprise — that IDENTITY disables batching is
documented, if you know to look. The surprise was in the sweeps: `allocationSize=1` landing at 31
instead of the paper-arithmetic 32, and the batch_size sweep collapsing to the same constant the
moment batching is enabled at all regardless of the exact value, in a way that changes depending
on whether the test runs alone or as part of the full suite. None of those three things would have
made it into this article from reasoning about the configuration in the abstract — they only show
up by actually running the sweep and being willing to publish a number that didn't match the
prediction.
[← Previous: 02 — merge() vs refresh()](02-merge-vs-refresh.md) | [Back to README →](../README.md)

View File

@@ -0,0 +1,8 @@
allocationSize=50, batch_size=1, 30 rows -> prepareStatementCount=32
allocationSize=50, batch_size=10, 30 rows -> prepareStatementCount=2
allocationSize=50, batch_size=25, 30 rows -> prepareStatementCount=2
allocationSize=50, batch_size=50, 30 rows -> prepareStatementCount=2
allocationSize=50, batch_size=25, 30 rows -> prepareStatementCount=3
allocationSize=25, batch_size=25, 30 rows -> prepareStatementCount=4
allocationSize=10, batch_size=25, 30 rows -> prepareStatementCount=5
allocationSize=1, batch_size=25, 30 rows -> prepareStatementCount=31

View File

@@ -0,0 +1,70 @@
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Effective Java]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [1]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Domain-Driven Design]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [2]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Outlives Session]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [3]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: getReference/getReference]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [4]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: get/getReference]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [5]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [5]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: get/get]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [6]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [6]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Proxy Identity]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [7]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [7]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [7]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [999111222]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [999333444]
getReference() on a missing id, once accessed, threw: jakarta.persistence.EntityNotFoundException: No row with the given identifier exists for entity [com.ankurm.hibernatedemo.model.Book with id '999333444']
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: getReference/get]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [8]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [8]
get() after getReference(): prepareStatementCount for this call = 1, returned class = com.ankurm.hibernatedemo.model.Book$HibernateProxy

View File

@@ -0,0 +1,46 @@
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) <- [Joshua Bloch]
binding parameter (2:VARCHAR) <- [Effective Java]
binding parameter (3:BIGINT) <- [0]
binding parameter (4:BIGINT) <- [1]
SEED: inserted Book id=1
--- Step 1: session.get() on an existing id ---
about to call session.get(Book.class, 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]
get() returned: Book{id=1, title=Effective Java, author=Joshua Bloch, version=0}
--- Step 2: session.get() on a missing id ---
about to call session.get(Book.class, 999001)
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) <- [999001]
get() returned: null (no exception thrown)
--- Step 3: session.getReference() on an existing id ---
getReference() returned proxy of class com.ankurm.hibernatedemo.model.Book$HibernateProxy -- no SELECT above this line
now calling proxy.getTitle() ...
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]
getTitle() returned 'Effective Java' -- the SELECT for this ran just above this line
--- Step 4: session.getReference() on a missing id ---
getReference() returned a proxy for a row that does not exist -- no exception yet: com.ankurm.hibernatedemo.model.Book$HibernateProxy
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) <- [999001]
accessing the proxy threw jakarta.persistence.EntityNotFoundException: No row with the given identifier exists for entity [com.ankurm.hibernatedemo.model.Book with id '999001']
--- Step 5: proxy accessed after its session is closed ---
session closed. proxy in hand: com.ankurm.hibernatedemo.model.Book$HibernateProxy
accessing the proxy after close threw org.hibernate.LazyInitializationException: Could not initialize proxy [com.ankurm.hibernatedemo.model.Book#1] - no session
--- Step 6: proxy identity vs a real loaded instance ---
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]
real.getClass() = com.ankurm.hibernatedemo.model.Book
proxy.getClass() = com.ankurm.hibernatedemo.model.Book$HibernateProxy
proxy instanceof Book.class: true
real.getClass() == proxy.getClass(): false
real.equals(proxy) before proxy access: false

View File

@@ -0,0 +1,71 @@
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
--- inserting 30 WidgetIdentity rows (GenerationType.IDENTITY) ---
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-1]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-3]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-4]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-5]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-6]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-7]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-8]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-9]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-10]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-11]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-12]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-13]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-14]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-15]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-16]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-17]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-18]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-19]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-20]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-21]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-22]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-23]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-24]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-25]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-26]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-27]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-28]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-29]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-30]
entityInsertCount = 30
prepareStatementCount = 30
(with IDENTITY, expect prepareStatementCount to land close to entityInsertCount -- each insert has to go to the database immediately to hand back the generated key, so there is nothing left for hibernate.jdbc.batch_size to batch)

View File

@@ -0,0 +1,104 @@
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
--- inserting 30 WidgetSequence rows (GenerationType.SEQUENCE, allocationSize=25) ---
Hibernate: select next value for widget_seq
Hibernate: select next value for widget_seq
Hibernate: select next value for widget_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-1]
binding parameter (2:BIGINT) <- [1]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-2]
binding parameter (2:BIGINT) <- [2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-3]
binding parameter (2:BIGINT) <- [3]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-4]
binding parameter (2:BIGINT) <- [4]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-5]
binding parameter (2:BIGINT) <- [5]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-6]
binding parameter (2:BIGINT) <- [6]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-7]
binding parameter (2:BIGINT) <- [7]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-8]
binding parameter (2:BIGINT) <- [8]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-9]
binding parameter (2:BIGINT) <- [9]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-10]
binding parameter (2:BIGINT) <- [10]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-11]
binding parameter (2:BIGINT) <- [11]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-12]
binding parameter (2:BIGINT) <- [12]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-13]
binding parameter (2:BIGINT) <- [13]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-14]
binding parameter (2:BIGINT) <- [14]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-15]
binding parameter (2:BIGINT) <- [15]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-16]
binding parameter (2:BIGINT) <- [16]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-17]
binding parameter (2:BIGINT) <- [17]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-18]
binding parameter (2:BIGINT) <- [18]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-19]
binding parameter (2:BIGINT) <- [19]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-20]
binding parameter (2:BIGINT) <- [20]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-21]
binding parameter (2:BIGINT) <- [21]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-22]
binding parameter (2:BIGINT) <- [22]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-23]
binding parameter (2:BIGINT) <- [23]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-24]
binding parameter (2:BIGINT) <- [24]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-25]
binding parameter (2:BIGINT) <- [25]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-26]
binding parameter (2:BIGINT) <- [26]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-27]
binding parameter (2:BIGINT) <- [27]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-28]
binding parameter (2:BIGINT) <- [28]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-29]
binding parameter (2:BIGINT) <- [29]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-30]
binding parameter (2:BIGINT) <- [30]
entityInsertCount = 30
prepareStatementCount = 4
(with SEQUENCE, the id is known before the row is written, so Hibernate can defer and batch the inserts -- expect prepareStatementCount well below entityInsertCount)

View File

@@ -0,0 +1,82 @@
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [USER_EDIT]
binding parameter (3:VARCHAR) <- [Silent Overwrite]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [1]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,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=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [ADMIN_EDIT]
binding parameter (3:VARCHAR) <- [Silent Overwrite]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [1]
binding parameter (6:BIGINT) <- [0]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Lazy Collection Book]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Note */insert into note (book_id,text,id) values (?,?,default)
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [first note]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [2]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version,n1_0.book_id,n1_0.id,n1_0.text from book b1_0 left join note n1_0 on b1_0.id=n1_0.book_id where b1_0.id=?
binding parameter (1:BIGINT) <- [2]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Edited While Detached]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Lazy Collection Book]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [2]
binding parameter (6:BIGINT) <- [0]
merge() initialized the LAZY notes collection because CascadeType.MERGE forces traversal of it
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [DRAFT]
binding parameter (3:VARCHAR) <- [Managed + Detached]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [3]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [3]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [3]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Changed On The Detached Copy]
binding parameter (2:VARCHAR) <- [DRAFT]
binding parameter (3:VARCHAR) <- [Managed + Detached]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [3]
binding parameter (6:BIGINT) <- [0]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Robert C. Martin]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Clean Code]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [4]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [4]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [4]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Robert C. Martin]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Clean Code (2nd Edition)]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [4]
binding parameter (6:BIGINT) <- [0]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version,n1_0.book_id,n1_0.id,n1_0.text from book b1_0 left join note n1_0 on b1_0.id=n1_0.book_id where b1_0.id=?
binding parameter (1:BIGINT) <- [4]
OptimisticLockException surfaced directly from the merge() call.
exception: jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '4']
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [4]

View File

@@ -0,0 +1,41 @@
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

66
pom.xml Normal file
View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>hibernate-demo</artifactId>
<version>1.0.0</version>
<name>hibernate-demo</name>
<description>
Companion repository for the ankurm.com Hibernate 7 batch: get() vs load(), merge() vs
refresh(), and inserting objects efficiently.
</description>
<properties>
<java.version>25</java.version>
<!--
Spring Boot 4.1.0's own dependency management already resolves hibernate.version to
7.4.1.Final (checked in spring-boot-dependencies-4.1.0.pom before writing a line of code
here). This property is declared anyway, not to override anything, but so the pin is
visible in the POM and survives a future Boot patch release quietly bumping it under us.
See docs/00-versions.md for the two Boot releases (4.1.0 and 4.1.1) that resolve to two
different Hibernate patch versions from the same "4.1" line.
-->
<hibernate.version>7.4.1.Final</hibernate.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

47
scripts/clean_output.py Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Turn a raw mvn spring-boot:run capture into a clean, reproducible transcript.
Strips JVM/build noise (sun.misc.Unsafe warnings, JAVA_TOOL_OPTIONS proxy banners) and the
duplicate un-prefixed echo of each SQL statement that org.hibernate.SQL's show_sql=true prints
to stdout in addition to the "Hibernate: ..." line the logger emits -- same text twice, so only
the logger-prefixed copy is kept.
"""
import re
import sys
DROP_PREFIXES = (
"WARNING:",
"Picked up JAVA_TOOL_OPTIONS",
)
def clean(lines):
out = []
for line in lines:
stripped = line.rstrip("\n")
if any(stripped.startswith(p) for p in DROP_PREFIXES):
continue
# Drop the bare SQL echo line that show_sql=true prints without the "Hibernate: " prefix
# -- it is always immediately followed by the same text WITH the prefix.
out.append(stripped)
deduped = []
i = 0
while i < len(out):
cur = out[i]
nxt = out[i + 1] if i + 1 < len(out) else None
if nxt is not None and nxt == "Hibernate: " + cur:
i += 1 # skip the bare echo, keep the prefixed one on the next iteration
continue
deduped.append(out[i])
i += 1
return deduped
if __name__ == "__main__":
src, dst = sys.argv[1], sys.argv[2]
with open(src, encoding="utf-8") as f:
lines = f.readlines()
cleaned = clean(lines)
with open(dst, "w", encoding="utf-8") as f:
f.write("\n".join(cleaned) + "\n")
print(f"{src}: {len(lines)} -> {dst}: {len(cleaned)} lines")

27
scripts/run-all.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Regenerate every file in docs/output/ from a real run. This is the script referenced by
# "regenerated by one command" in the post and the README -- if it stops producing the same
# shape of output, the docs are wrong until it's fixed, not the other way around.
set -eu
cd "$(dirname "$0")/.."
RAW_DIR="$(mktemp -d)"
trap 'rm -rf "$RAW_DIR"' EXIT
run_one() {
local profile="$1" outfile="$2"
echo "=== running profile: $profile ===" >&2
mvn -q -B org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$profile" 2>&1 \
| grep -v '^Picked up JAVA_TOOL_OPTIONS' \
| grep -v '^WARNING:' \
> "$RAW_DIR/$profile.raw.txt"
python3 scripts/clean_output.py "$RAW_DIR/$profile.raw.txt" "docs/output/$outfile"
}
run_one getvsload get-vs-load.txt
run_one mergerefresh merge-vs-refresh.txt
run_one insert-identity insert-identity.txt
run_one insert-sequence insert-sequence.txt
echo "docs/output/ regenerated." >&2

18
scripts/run.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Run one scenario in the foreground and exit.
#
# ./scripts/run.sh getvsload
# ./scripts/run.sh mergerefresh
# ./scripts/run.sh insert-identity
# ./scripts/run.sh insert-sequence
#
# Every scenario here is a CommandLineRunner against an in-memory H2 database with
# spring.main.web-application-type=none, so there is no server to keep alive and nothing to
# kill afterwards -- the process runs the scenario and exits on its own.
set -eu
PROFILE="${1:?usage: run.sh <getvsload|mergerefresh|insert-identity|insert-sequence>}"
cd "$(dirname "$0")/.."
mvn -q -B org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$PROFILE"

View File

@@ -0,0 +1,27 @@
package com.ankurm.hibernatedemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Entry point for the companion demos behind three ankurm.com Hibernate 7 posts:
*
* <ul>
* <li>{@code get-vs-load} &mdash; docs/01-get-vs-load.md, {@link com.ankurm.hibernatedemo.scenario.GetVsLoadRunner}</li>
* <li>{@code merge-vs-refresh} &mdash; docs/02-merge-vs-refresh.md, {@link com.ankurm.hibernatedemo.scenario.MergeVsRefreshRunner}</li>
* <li>{@code insert-identity} / {@code insert-sequence} &mdash; docs/03-inserting-objects.md,
* {@link com.ankurm.hibernatedemo.scenario.InsertIdentityRunner} and
* {@link com.ankurm.hibernatedemo.scenario.InsertSequenceRunner}</li>
* </ul>
*
* Each scenario is a profile-gated {@link org.springframework.boot.CommandLineRunner} that runs
* once against an in-memory H2 database and exits &mdash; there is no web server to keep alive,
* so {@code scripts/run.sh &lt;profile&gt;} is a plain foreground {@code mvn spring-boot:run} call.
*/
@SpringBootApplication
public class HibernateDemoApplication {
public static void main(String[] args) {
SpringApplication.run(HibernateDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,97 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.List;
/**
* The entity used by {@code get-vs-load} and {@code merge-vs-refresh}.
*
* <p>Docs: docs/01-get-vs-load.md, docs/02-merge-vs-refresh.md.
*
* <p>Carries a {@code @Version} column on purpose &mdash; the merge/refresh tests need a real
* optimistic-lock field to show what merge() does when the version it is holding is stale, not
* just what it does to a plain column. The {@code status} field exists specifically for
* {@code MergeRefreshTest#refreshDiscardsUnflushedEditSilently}, framed as the
* &quot;USER_EDIT&quot; vs &quot;ADMIN_EDIT&quot; scenario in docs/02-merge-vs-refresh.md. The
* {@code notes} collection is LAZY and cascades MERGE only &mdash; it exists solely for
* {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized}, which shows that an
* unfetched collection is never navigated during merge().
*/
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
private Long id;
private String title;
private String author;
private String status;
@Version
private Long version;
@OneToMany(mappedBy = "book", cascade = CascadeType.MERGE, fetch = FetchType.LAZY)
private List<Note> notes = new ArrayList<>();
protected Book() {
// JPA
}
public Book(String title, String author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Long getVersion() {
return version;
}
public List<Note> getNotes() {
return notes;
}
@Override
public String toString() {
return "Book{id=%s, title=%s, author=%s, status=%s, version=%s}"
.formatted(id, title, author, status, version);
}
}

View File

@@ -0,0 +1,45 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
/**
* Child of {@link Book}, used only by {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized}
* (docs/02-merge-vs-refresh.md) to show that an unfetched {@code LAZY} collection is not
* navigated -- and therefore cannot fail with {@code LazyInitializationException} -- during
* {@code merge()} of the owning detached entity.
*/
@Entity
public class Note {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String text;
@ManyToOne
@JoinColumn(name = "book_id")
private Book book;
protected Note() {
// JPA
}
public Note(String text, Book book) {
this.text = text;
this.book = book;
}
public Long getId() {
return id;
}
public String getText() {
return text;
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* One of four otherwise-identical entities ({@link WidgetAlloc1}, {@link WidgetAlloc10},
* {@link WidgetAlloc25}, {@link WidgetAlloc50}) used only by
* {@code AllocationSizeSweepTest} to isolate the effect of {@code allocationSize} on
* {@code prepareStatementCount} while {@code hibernate.jdbc.batch_size} is held fixed at 25.
* Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc1 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc1_seq")
@SequenceGenerator(name = "widget_alloc1_seq", sequenceName = "widget_alloc1_seq", allocationSize = 1)
private Long id;
private String name;
protected WidgetAlloc1() {
// JPA
}
public WidgetAlloc1(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,34 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* See {@link WidgetAlloc1} &mdash; same purpose, {@code allocationSize = 10}.
* Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc10 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc10_seq")
@SequenceGenerator(name = "widget_alloc10_seq", sequenceName = "widget_alloc10_seq", allocationSize = 10)
private Long id;
private String name;
protected WidgetAlloc10() {
// JPA
}
public WidgetAlloc10(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* See {@link WidgetAlloc1} &mdash; same purpose, {@code allocationSize = 25} (matches
* {@code hibernate.jdbc.batch_size} in the sweep, same as {@link WidgetSequence}).
* Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc25 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc25_seq")
@SequenceGenerator(name = "widget_alloc25_seq", sequenceName = "widget_alloc25_seq", allocationSize = 25)
private Long id;
private String name;
protected WidgetAlloc25() {
// JPA
}
public WidgetAlloc25(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* See {@link WidgetAlloc1} &mdash; same purpose, {@code allocationSize = 50} (JPA's default).
* Reused by {@code BatchSizeSweepTest} to hold {@code allocationSize} fixed at 50 while
* {@code hibernate.jdbc.batch_size} varies. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc50 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc50_seq")
@SequenceGenerator(name = "widget_alloc50_seq", sequenceName = "widget_alloc50_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetAlloc50() {
// JPA
}
public WidgetAlloc50(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize1}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep1 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep1() {
// JPA
}
public WidgetBatchSweep1(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize10}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep10 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep10() {
// JPA
}
public WidgetBatchSweep10(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize25}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep25 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep25() {
// JPA
}
public WidgetBatchSweep25(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize50}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep50 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep50() {
// JPA
}
public WidgetBatchSweep50(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,41 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
/**
* Insert-scenario twin of {@link WidgetSequence}, identical except for the id generation
* strategy. Docs: docs/03-inserting-objects.md.
*
* <p>{@code IDENTITY} requires the database to hand back the generated key on every single
* insert, which is exactly why it defeats JDBC batching &mdash; see the captured output in
* docs/output/insert-identity.txt versus docs/output/insert-sequence.txt for the same
* {@code hibernate.jdbc.batch_size} setting producing very different behaviour.
*/
@Entity
public class WidgetIdentity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected WidgetIdentity() {
// JPA
}
public WidgetIdentity(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,42 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Insert-scenario twin of {@link WidgetIdentity}. Docs: docs/03-inserting-objects.md.
*
* <p>{@code allocationSize} matches {@code hibernate.jdbc.batch_size} in
* {@code application-insert-sequence.yml} on purpose: a mismatched allocation size is its own
* classic footgun (extra round trips to refill the sequence pool mid-batch) and not one this
* repo is trying to demonstrate here.
*/
@Entity
public class WidgetSequence {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_seq")
@SequenceGenerator(name = "widget_seq", sequenceName = "widget_seq", allocationSize = 25)
private Long id;
private String name;
protected WidgetSequence() {
// JPA
}
public WidgetSequence(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,154 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4859 ("Hibernate 7: get() vs load()") and
* docs/01-get-vs-load.md. Captured verbatim into docs/output/get-vs-load.txt by
* {@code scripts/run.sh getvsload}.
*
* <p>Each step opens its own {@link EntityManager} deliberately, so the SQL log lines that
* bracket a step are unambiguously that step's own traffic &mdash; there is no shared session
* whose first-level cache could quietly answer a later {@code get()} for free.
*/
@Component
@Profile("getvsload")
public class GetVsLoadRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private final EntityManagerFactory emf;
public GetVsLoadRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
Long existingId = seedOneBook();
long missingId = existingId + 999_000L;
step1_getExisting(existingId);
step2_getMissing(missingId);
step3_getReferenceExisting_noSelectUntilAccessed(existingId);
step4_getReferenceMissing_exceptionOnlyOnAccess(missingId);
step5_getReferenceThenSessionClosed_lazyInitException(existingId);
step6_proxyVsRealIdentity(existingId);
}
private Long seedOneBook() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book("Effective Java", "Joshua Bloch");
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
DEMO.info("SEED: inserted Book id={}", id);
return id;
}
private void step1_getExisting(Long id) {
DEMO.info("--- Step 1: session.get() on an existing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
DEMO.info("about to call session.get(Book.class, {})", id);
Book book = session.get(Book.class, id);
DEMO.info("get() returned: {}", book);
em.getTransaction().commit();
em.close();
}
private void step2_getMissing(long missingId) {
DEMO.info("--- Step 2: session.get() on a missing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
DEMO.info("about to call session.get(Book.class, {})", missingId);
Book book = session.get(Book.class, missingId);
DEMO.info("get() returned: {} (no exception thrown)", book);
em.getTransaction().commit();
em.close();
}
private void step3_getReferenceExisting_noSelectUntilAccessed(Long id) {
DEMO.info("--- Step 3: session.getReference() on an existing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, id);
DEMO.info("getReference() returned proxy of class {} -- no SELECT above this line", proxy.getClass().getName());
DEMO.info("now calling proxy.getTitle() ...");
String title = proxy.getTitle();
DEMO.info("getTitle() returned '{}' -- the SELECT for this ran just above this line", title);
em.getTransaction().commit();
em.close();
}
private void step4_getReferenceMissing_exceptionOnlyOnAccess(long missingId) {
DEMO.info("--- Step 4: session.getReference() on a missing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, missingId);
DEMO.info("getReference() returned a proxy for a row that does not exist -- no exception yet: {}", proxy.getClass().getName());
try {
proxy.getTitle();
DEMO.info("no exception -- this line should be unreachable");
} catch (RuntimeException e) {
DEMO.info("accessing the proxy threw {}: {}", e.getClass().getName(), e.getMessage());
}
em.getTransaction().rollback();
em.close();
}
private void step5_getReferenceThenSessionClosed_lazyInitException(Long id) {
DEMO.info("--- Step 5: proxy accessed after its session is closed ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, id);
em.getTransaction().commit();
em.close();
DEMO.info("session closed. proxy in hand: {}", proxy.getClass().getName());
try {
proxy.getTitle();
DEMO.info("no exception -- this line should be unreachable");
} catch (RuntimeException e) {
DEMO.info("accessing the proxy after close threw {}: {}", e.getClass().getName(), e.getMessage());
}
}
private void step6_proxyVsRealIdentity(Long id) {
DEMO.info("--- Step 6: proxy identity vs a real loaded instance ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book real = session.get(Book.class, id);
// second em/session so this is a genuinely separate proxy, not the same cached instance
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Session session2 = em2.unwrap(Session.class);
Book proxy = session2.getReference(Book.class, id);
DEMO.info("real.getClass() = {}", real.getClass().getName());
DEMO.info("proxy.getClass() = {}", proxy.getClass().getName());
DEMO.info("proxy instanceof Book.class: {}", Book.class.isInstance(proxy));
DEMO.info("real.getClass() == proxy.getClass(): {}", real.getClass() == proxy.getClass());
DEMO.info("real.equals(proxy) before proxy access: {}", real.equals(proxy));
em.getTransaction().commit();
em.close();
em2.getTransaction().commit();
em2.close();
}
}

View File

@@ -0,0 +1,58 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.WidgetIdentity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4861 ("inserting objects efficiently") and
* docs/03-inserting-objects.md. Captured verbatim into docs/output/insert-identity.txt by
* {@code scripts/run.sh insert-identity}.
*
* <p>Same {@code hibernate.jdbc.batch_size} and {@code hibernate.order_inserts} settings as
* {@link InsertSequenceRunner} -- the only difference is {@link WidgetIdentity}'s
* {@code GenerationType.IDENTITY} strategy. Compare the two captured output files directly;
* the diff between them is the entire point of this pair.
*/
@Component
@Profile("insert-identity")
public class InsertIdentityRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
private final EntityManagerFactory emf;
public InsertIdentityRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
DEMO.info("--- inserting {} WidgetIdentity rows (GenerationType.IDENTITY) ---", ROW_COUNT);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetIdentity("identity-" + i));
}
em.getTransaction().commit();
em.close();
DEMO.info("entityInsertCount = {}", stats.getEntityInsertCount());
DEMO.info("prepareStatementCount = {}", stats.getPrepareStatementCount());
DEMO.info("(with IDENTITY, expect prepareStatementCount to land close to entityInsertCount --" +
" each insert has to go to the database immediately to hand back the generated key,"
+ " so there is nothing left for hibernate.jdbc.batch_size to batch)");
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.WidgetSequence;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4861 ("inserting objects efficiently") and
* docs/03-inserting-objects.md. Captured verbatim into docs/output/insert-sequence.txt by
* {@code scripts/run.sh insert-sequence}.
*
* <p>Same {@code hibernate.jdbc.batch_size} and {@code hibernate.order_inserts} settings as
* {@link InsertIdentityRunner} -- see that class's Javadoc for what this pair is demonstrating.
*/
@Component
@Profile("insert-sequence")
public class InsertSequenceRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
private final EntityManagerFactory emf;
public InsertSequenceRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
DEMO.info("--- inserting {} WidgetSequence rows (GenerationType.SEQUENCE, allocationSize=25) ---", ROW_COUNT);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetSequence("sequence-" + i));
}
em.getTransaction().commit();
em.close();
DEMO.info("entityInsertCount = {}", stats.getEntityInsertCount());
DEMO.info("prepareStatementCount = {}", stats.getPrepareStatementCount());
DEMO.info("(with SEQUENCE, the id is known before the row is written, so Hibernate can" +
" defer and batch the inserts -- expect prepareStatementCount well below entityInsertCount)");
}
}

View File

@@ -0,0 +1,113 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.OptimisticLockException;
import org.hibernate.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4860 ("merge() vs refresh()") and docs/02-merge-vs-refresh.md.
* Captured verbatim into docs/output/merge-vs-refresh.txt by
* {@code scripts/run.sh mergerefresh}.
*
* <p>{@link Book} carries a {@code @Version} column specifically so this scenario can show what
* {@code merge()} does when the detached instance it is given is holding a version older than
* what is currently in the database &mdash; not just what it does to an un-versioned row.
*/
@Component
@Profile("mergerefresh")
public class MergeVsRefreshRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private final EntityManagerFactory emf;
public MergeVsRefreshRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
Long id = seedOneBook();
Book detached = loadThenDetach(id);
simulateAnotherProcessEditingTheRow(id);
mergeStaleDetachedInstance(detached);
refreshSilentlyDiscardsUnflushedEdit(id);
}
private Long seedOneBook() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book("Clean Code", "Robert C. Martin");
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
DEMO.info("SEED: inserted {}", book);
return id;
}
private Book loadThenDetach(Long id) {
DEMO.info("--- Step 1: load the row, then close the session (entity is now detached) ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book book = session.get(Book.class, id);
em.getTransaction().commit();
em.close();
DEMO.info("detached instance in hand: {}", book);
return book;
}
private void simulateAnotherProcessEditingTheRow(Long id) {
DEMO.info("--- Step 2: a second, independent session edits the same row and commits ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book book = session.get(Book.class, id);
book.setTitle("Clean Code (2nd Edition)");
em.getTransaction().commit();
em.close();
DEMO.info("second session committed: {} -- version column has now advanced in the database", book);
}
private void mergeStaleDetachedInstance(Book detached) {
DEMO.info("--- Step 3: mutate the ORIGINAL detached instance (still holding the OLD version) and merge() it ---");
detached.setAuthor("Robert C. Martin (Uncle Bob)");
DEMO.info("detached instance before merge (note the version and title are both stale): {}", detached);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
try {
Book merged = session.merge(detached);
em.getTransaction().commit();
DEMO.info("merge() succeeded, returned managed instance: {}", merged);
} catch (OptimisticLockException e) {
em.getTransaction().rollback();
DEMO.info("merge() threw {}: {}", e.getClass().getName(), e.getMessage());
DEMO.info("the title change from Step 2 survives untouched -- merge() refused to apply a write built on a stale version");
} finally {
em.close();
}
}
private void refreshSilentlyDiscardsUnflushedEdit(Long id) {
DEMO.info("--- Step 4: refresh() on a MANAGED entity with an unflushed local edit ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book book = session.get(Book.class, id);
book.setAuthor("SOMEONE ELSE ENTIRELY (never flushed)");
DEMO.info("before refresh(): {}", book);
session.refresh(book);
DEMO.info("after refresh(): {} -- the local edit is gone, no exception was thrown", book);
em.getTransaction().commit();
em.close();
}
}

View File

@@ -0,0 +1,34 @@
spring:
main:
web-application-type: none
banner-mode: off
datasource:
url: jdbc:h2:mem:hibernate-demo;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
open-in-view: false
properties:
hibernate:
show_sql: true
format_sql: false
use_sql_comments: true
generate_statistics: true
jdbc:
batch_size: 25
order_inserts: true
order_updates: true
logging:
level:
root: WARN
DEMO: INFO
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
org.hibernate.engine.jdbc.batch.internal.BatchingBatch: DEBUG
org.hibernate.stat: INFO
pattern:
console: "%msg%n"

View File

@@ -0,0 +1,93 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetAlloc1;
import com.ankurm.hibernatedemo.model.WidgetAlloc10;
import com.ankurm.hibernatedemo.model.WidgetAlloc25;
import com.ankurm.hibernatedemo.model.WidgetAlloc50;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.function.Function;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=AllocationSizeSweepTest test}.
*
* <p>Holds {@code hibernate.jdbc.batch_size=25} fixed (the application.yml default) and sweeps
* {@code allocationSize} across four otherwise-identical entities: {@link WidgetAlloc1},
* {@link WidgetAlloc10}, {@link WidgetAlloc25}, {@link WidgetAlloc50}. 30 rows each. Numbers are
* asserted, not predicted -- see the class Javadoc on each entity for why they're separate
* classes rather than one parameterized mapping.
*/
@SpringBootTest
class AllocationSizeSweepTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
private int insertRowsAndReturnPreparedStatementCount(Function<String, Object> factory) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(factory.apply("w-" + i));
}
em.getTransaction().commit();
em.close();
return (int) stats.getPrepareStatementCount();
}
@Test
void allocationSize1_everyRowNeedsItsOwnSequenceCall() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc1::new);
DEMO.info("allocationSize=1, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// Measured via `mvn test` (the full suite), reproduced twice: 31, not the 32 a naive
// "2 insert batches + 30 sequence calls" arithmetic predicts. allocationSize=1 forces a
// sequence call practically every row, which dominates the count either way -- but the
// exact figure is asserted from the real run, not derived on paper. See
// docs/03-inserting-objects.md for the honest version of this story, including where the
// paper arithmetic was wrong.
assertThat(count).isEqualTo(31);
}
@Test
void allocationSize10_threeSequenceRefills() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc10::new);
DEMO.info("allocationSize=10, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + ceil(30/10)=3 sequence calls.
assertThat(count).isEqualTo(2 + 3);
}
@Test
void allocationSize25_matchesBatchSize_twoSequenceRefills() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc25::new);
DEMO.info("allocationSize=25, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + ceil(30/25)=2 sequence calls -- this is WidgetSequence's
// configuration, confirmed again here for the sweep table.
assertThat(count).isEqualTo(2 + 2);
}
@Test
void allocationSize50_oneSequenceCallCoversAllThirtyRows() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc50::new);
DEMO.info("allocationSize=50, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + a single sequence call (50 >= 30, the whole run fits in
// one allocated block) = 3. Measured and reproduced via `mvn test`.
assertThat(count).isEqualTo(3);
}
}

View File

@@ -0,0 +1,120 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep1;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep10;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep25;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep50;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=BatchSizeSweepTest test}.
*
* <p>The companion sweep to {@code AllocationSizeSweepTest}: holds {@code allocationSize=50}
* fixed and sweeps {@code hibernate.jdbc.batch_size} across 1, 10, 25, 50 -- each as its own
* {@code @Nested @SpringBootTest} so each gets a genuinely separate Hibernate configuration
* rather than one mutated at runtime.
*
* <p><strong>Each sweep point uses its own dedicated entity and sequence</strong>
* ({@code WidgetBatchSweep1/10/25/50}), even though all four mappings are identical. The first
* version of this test shared a single sequence across all four nested classes and got
* unstable, run-order-dependent {@code prepareStatementCount} numbers as a result -- a real
* finding in its own right, not a hypothetical one. See docs/03-inserting-objects.md for the
* writeup; the fix is isolation, not a smarter assertion.
*/
class BatchSizeSweepTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
private static int insertRowsAndReturnPreparedStatementCount(
EntityManagerFactory emf, java.util.function.Function<String, Object> factory) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(factory.apply("w-" + i));
}
em.getTransaction().commit();
em.close();
return (int) stats.getPrepareStatementCount();
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=1")
class BatchSize1 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeOne_batchingEffectivelyDisabled() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep1::new);
DEMO.info("allocationSize=50, batch_size=1, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// batch_size=1 means no real batching: close to one prepared statement per row.
assertThat(count).isEqualTo(32);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=10")
class BatchSize10 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeTen() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep10::new);
DEMO.info("allocationSize=50, batch_size=10, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// Once batching is enabled at all (batch_size > 1), the insert side of
// prepareStatementCount collapses to a small constant regardless of the exact
// batch_size -- see batchSizeTwentyFive and batchSizeFifty below, which measure the
// same value. batch_size clearly still governs how many rows go into each JDBC
// executeBatch() call (that's real and documented), it just isn't visible in this
// particular statistic once batching is on.
assertThat(count).isEqualTo(2);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=25")
class BatchSize25 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeTwentyFive() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep25::new);
DEMO.info("allocationSize=50, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
assertThat(count).isEqualTo(2);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=50")
class BatchSize50 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeFifty() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep50::new);
DEMO.info("allocationSize=50, batch_size=50, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
assertThat(count).isEqualTo(2);
}
}
}

View File

@@ -0,0 +1,246 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityNotFoundException;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.LazyInitializationException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4859. Docs: docs/01-get-vs-load.md.
*
* <p>Run with {@code ./mvnw -Dtest=GetVsGetReferenceTest test}. Every assertion here was first
* observed by running the same code and reading the log, then pinned down as an assertion --
* none of the outcomes below were assumed going in.
*/
@SpringBootTest
class GetVsGetReferenceTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedBook(String title) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book(title, "Test Author");
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
// ---- Part 1: four calls, four outcomes ----
@Test
void getOnExistingId_firesSelect_returnsRealEntity() {
Long id = seedBook("Effective Java");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book book = session.get(Book.class, id);
assertThat(stats().getPrepareStatementCount()).as("get() on an existing id must fire a SELECT").isEqualTo(1);
assertThat(book).isNotNull();
assertThat(book.getClass()).isEqualTo(Book.class);
em.getTransaction().commit();
em.close();
}
@Test
void getOnMissingId_firesSelect_returnsNull() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book book = session.get(Book.class, 999_111_222L);
assertThat(stats().getPrepareStatementCount()).as("get() on a missing id still fires a SELECT").isEqualTo(1);
assertThat(book).isNull();
em.getTransaction().commit();
em.close();
}
@Test
void getReferenceOnExistingId_noSelectUntilPropertyAccessed() {
Long id = seedBook("Domain-Driven Design");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book proxy = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("getReference() must not fire a SELECT at the call site")
.isEqualTo(0);
String title = proxy.getTitle();
assertThat(stats().getPrepareStatementCount())
.as("the SELECT is deferred until a non-id accessor is called")
.isEqualTo(1);
assertThat(title).isEqualTo("Domain-Driven Design");
em.getTransaction().commit();
em.close();
}
@Test
void getReferenceOnMissingId_noExceptionUntilAccessed() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, 999_333_444L);
// No exception yet -- constructing the proxy never touched the database.
assertThat(proxy).isNotNull();
EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, proxy::getTitle);
DEMO.info("getReference() on a missing id, once accessed, threw: {}: {}", ex.getClass().getName(), ex.getMessage());
em.getTransaction().rollback();
em.close();
}
// ---- Part 2: same-session matrix ----
// For each combination, both calls target the SAME id in the SAME session.
@Test
void sessionMatrix_getThenGet_secondCallHitsL1Cache_sameInstance() {
Long id = seedBook("Matrix: get/get");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book first = session.get(Book.class, id);
stats().clear();
Book second = session.get(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("second get() in the same session must NOT re-fire a SELECT (L1 cache hit)")
.isEqualTo(0);
assertThat(second).isSameAs(first);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getReferenceThenGetReference_secondCallHitsL1Cache_sameInstance() {
Long id = seedBook("Matrix: getReference/getReference");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book first = session.getReference(Book.class, id);
stats().clear();
Book second = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("second getReference() in the same session must not fire anything either -- still just a reference")
.isEqualTo(0);
assertThat(second).isSameAs(first);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getThenGetReference_returnsTheSameAlreadyInitializedInstance() {
Long id = seedBook("Matrix: get/getReference");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book real = session.get(Book.class, id);
stats().clear();
Book second = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("getReference() after get() must not fire a SELECT -- the real entity is already in the L1 cache")
.isEqualTo(0);
assertThat(second)
.as("getReference() returns the SAME already-managed real instance, not a new proxy, once one exists in this session")
.isSameAs(real);
assertThat(second.getClass()).isEqualTo(Book.class);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getReferenceThenGet_getReturnsTheExistingProxyAndDoesNotForceInitialization() {
Long id = seedBook("Matrix: getReference/get");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, id);
stats().clear();
Book second = session.get(Book.class, id);
assertThat(second)
.as("get() after getReference() returns the SAME proxy already sitting in the L1 cache")
.isSameAs(proxy);
DEMO.info("get() after getReference(): prepareStatementCount for this call = {}, returned class = {}",
stats().getPrepareStatementCount(), second.getClass().getName());
em.getTransaction().commit();
em.close();
}
// ---- Part 3: proxy identity experiment ----
@Test
void proxyIdentity_instanceofSurvives_equalsDoesNot() {
Long id = seedBook("Proxy Identity");
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Book real = em1.unwrap(Session.class).get(Book.class, id);
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Book proxy = em2.unwrap(Session.class).getReference(Book.class, id);
assertThat(proxy).isInstanceOf(Book.class);
assertThat(org.hibernate.Hibernate.getClass(proxy)).isEqualTo(Book.class);
assertThat(proxy.getClass()).isNotEqualTo(Book.class);
// Book does not override equals()/hashCode() -- this is the point of the test.
assertThat(real.equals(proxy)).isFalse();
assertThat(proxy.equals(real)).isFalse();
Set<Book> set = new HashSet<>();
set.add(real);
assertThat(set.contains(proxy))
.as("a HashSet built on default equals()/hashCode() cannot recognise the proxy and the real instance as the same row")
.isFalse();
em1.getTransaction().commit();
em1.close();
em2.getTransaction().commit();
em2.close();
}
@Test
void proxyOutlivesItsSession_throwsLazyInitializationExceptionOnAccess() {
Long id = seedBook("Outlives Session");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book proxy = em.unwrap(Session.class).getReference(Book.class, id);
em.getTransaction().commit();
em.close();
assertThrows(LazyInitializationException.class, proxy::getTitle);
}
}

View File

@@ -0,0 +1,47 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetIdentity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=IdentityBatchTest test}.
*/
@SpringBootTest
class IdentityBatchTest {
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
@Test
void identityGeneratorDisablesBatching_despiteBatchSizeBeingSet() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetIdentity("identity-" + i));
}
em.getTransaction().commit();
em.close();
assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT);
assertThat(stats.getPrepareStatementCount())
.as("with GenerationType.IDENTITY, hibernate.jdbc.batch_size has nothing to batch -- "
+ "every insert is its own round trip because the generated key is only "
+ "known after the row is written")
.isEqualTo(ROW_COUNT);
}
}

View File

@@ -0,0 +1,160 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.Book;
import com.ankurm.hibernatedemo.model.Note;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Hibernate;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4860. Docs: docs/02-merge-vs-refresh.md.
* Run with {@code ./mvnw -Dtest=MergeRefreshTest test}.
*
* <p>The optimistic-lock-conflict case lives in {@link OptimisticLockTest} instead, since it
* needs its own careful narration of exactly when the check fires.
*/
@SpringBootTest
class MergeRefreshTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedBook(String title, String status) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book(title, "Test Author");
book.setStatus(status);
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
@Test
void mergeOfDetachedInstance_returnsTheSameManagedInstanceAlreadyInSession() {
Long id = seedBook("Managed + Detached", "DRAFT");
// A separate, already-detached copy of the same row (simulates "the object a controller
// method was handed earlier").
EntityManager scratch = emf.createEntityManager();
scratch.getTransaction().begin();
Book detached = scratch.unwrap(Session.class).get(Book.class, id);
scratch.getTransaction().commit();
scratch.close();
detached.setAuthor("Changed On The Detached Copy");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
// This session already has ITS OWN managed instance for the same row before merge() is
// ever called.
Book managed = session.get(Book.class, id);
Book result = session.merge(detached);
assertThat(result)
.as("merge() must return the identity-equal MANAGED instance already tracked by this session, not a new object")
.isSameAs(managed);
assertThat(result).isNotSameAs(detached);
assertThat(managed.getAuthor())
.as("the pre-existing managed instance is the one that actually receives the copied state")
.isEqualTo("Changed On The Detached Copy");
em.getTransaction().commit();
em.close();
}
@Test
void mergeWithCascadeInitializesTheLazyCollectionAnyway() {
Long id;
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
Book book = new Book("Lazy Collection Book", "Test Author");
seed.persist(book);
seed.flush();
seed.persist(new Note("first note", book));
seed.getTransaction().commit();
id = book.getId();
seed.close();
// Load and detach WITHOUT ever touching book.getNotes() -- the collection proxy is never
// initialized.
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Book detached = em1.unwrap(Session.class).get(Book.class, id);
assertThat(Hibernate.isInitialized(detached.getNotes()))
.as("sanity check: the collection must still be uninitialized going into detachment")
.isFalse();
em1.getTransaction().commit();
em1.close();
detached.setAuthor("Edited While Detached");
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Session session = em2.unwrap(Session.class);
// Going in, the expectation was "merge() doesn't need to touch a collection it was
// never asked to load." That's true ONLY when the collection has no CascadeType.MERGE.
// Book.notes DOES cascade MERGE (see its Javadoc), and the measured result is the
// opposite of the naive expectation: merge() initializes the collection anyway, because
// cascading the merge to each element requires knowing what those elements are. Removing
// cascade = CascadeType.MERGE from Book.notes and re-running this test flips the result
// back to "stays uninitialized" -- confirmed with a throwaway probe before writing this
// assertion. See docs/02-merge-vs-refresh.md.
Book merged = session.merge(detached);
assertThat(Hibernate.isInitialized(merged.getNotes()))
.as("merge() DOES initialize a LAZY collection when it cascades MERGE to it -- cascading requires traversal")
.isTrue();
assertThat(merged.getAuthor()).isEqualTo("Edited While Detached");
em2.getTransaction().commit();
em2.close();
DEMO.info("merge() initialized the LAZY notes collection because CascadeType.MERGE forces traversal of it");
}
@Test
void refreshDiscardsUnflushedEditSilently_noExceptionEver() {
Long id = seedBook("Silent Overwrite", "USER_EDIT");
// Simulate an admin process changing the row out from under the in-memory object.
EntityManager admin = emf.createEntityManager();
admin.getTransaction().begin();
Book row = admin.unwrap(Session.class).get(Book.class, id);
row.setStatus("ADMIN_EDIT");
admin.getTransaction().commit();
admin.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book managed = session.get(Book.class, id);
assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT");
// A local, unflushed edit -- never sent to the database.
managed.setStatus("USER_EDIT");
assertThat(managed.getStatus()).isEqualTo("USER_EDIT");
session.refresh(managed);
assertThat(managed.getStatus())
.as("refresh() replaces managed state with the database row -- it does not merge the two; the local edit is simply gone, no exception")
.isEqualTo("ADMIN_EDIT");
em.getTransaction().commit();
em.close();
}
}

View File

@@ -0,0 +1,100 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.OptimisticLockException;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4860. Docs: docs/02-merge-vs-refresh.md.
* Run with {@code ./mvnw -Dtest=OptimisticLockTest test}.
*
* <p>The specific thing this test pins down: exactly WHEN the version check fails. It would be
* easy to write "merge() throws OptimisticLockException" and leave it there; what actually
* happens depends on when Hibernate performs the check relative to the {@code merge()} call, the
* flush, and the commit -- and that's worth being precise about rather than assumed.
*/
@SpringBootTest
class OptimisticLockTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void mergeOfStaleVersionedEntity_throwsOptimisticLockException_andPinsDownWhen() {
// Seed.
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
Book book = new Book("Clean Code", "Robert C. Martin");
seed.persist(book);
seed.getTransaction().commit();
Long id = book.getId();
seed.close();
// Detach at version 0.
EntityManager loadEm = emf.createEntityManager();
loadEm.getTransaction().begin();
Book detached = loadEm.unwrap(Session.class).get(Book.class, id);
loadEm.getTransaction().commit();
loadEm.close();
assertThat(detached.getVersion()).isEqualTo(0L);
// A second, independent transaction advances the row to version 1.
EntityManager writer = emf.createEntityManager();
writer.getTransaction().begin();
Book row = writer.unwrap(Session.class).get(Book.class, id);
row.setTitle("Clean Code (2nd Edition)");
writer.getTransaction().commit();
writer.close();
// Mutate the STILL version-0 detached instance and attempt to merge it.
detached.setAuthor("Robert C. Martin (Uncle Bob)");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
boolean threwDuringMergeCallItself;
OptimisticLockException caught = null;
try {
session.merge(detached);
threwDuringMergeCallItself = false;
} catch (OptimisticLockException e) {
threwDuringMergeCallItself = true;
caught = e;
}
if (!threwDuringMergeCallItself) {
// merge() itself only queued the state transfer; the version check happens at flush.
caught = org.junit.jupiter.api.Assertions.assertThrows(
OptimisticLockException.class, () -> em.getTransaction().commit());
DEMO.info("OptimisticLockException surfaced at commit()/flush time, NOT from the merge() call itself.");
} else {
DEMO.info("OptimisticLockException surfaced directly from the merge() call.");
em.getTransaction().rollback();
}
assertThat(caught).isNotNull();
DEMO.info("exception: {}: {}", caught.getClass().getName(), caught.getMessage());
em.close();
// The other transaction's title change must have survived untouched.
EntityManager verify = emf.createEntityManager();
verify.getTransaction().begin();
Book current = verify.unwrap(Session.class).get(Book.class, id);
assertThat(current.getTitle()).isEqualTo("Clean Code (2nd Edition)");
assertThat(current.getAuthor()).isEqualTo("Robert C. Martin");
verify.getTransaction().commit();
verify.close();
}
}

View File

@@ -0,0 +1,51 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetSequence;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=SequenceBatchTest test}.
*
* <p>{@link WidgetSequence} sets {@code allocationSize = 25}, matching
* {@code hibernate.jdbc.batch_size} in application.yml. See {@code AllocationSizeSweepTest} for
* what happens when the two are deliberately mismatched.
*/
@SpringBootTest
class SequenceBatchTest {
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
@Test
void sequenceGeneratorAllowsBatching_fourPreparedStatementsForThirtyRows() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetSequence("sequence-" + i));
}
em.getTransaction().commit();
em.close();
assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT);
assertThat(stats.getPrepareStatementCount())
.as("30 rows at batch_size=25 is 2 insert batches (25 + 5); allocationSize=25 "
+ "means the first 25 ids come from one sequence call and the remaining "
+ "5 force a second -- 2 insert batches + 2 sequence calls = 4")
.isEqualTo(4);
}
}