commit 7b06d653e4318297ab1bc53c5c56d4ba4ecd58b0 Author: Ankur Mhatre Date: Wed Aug 26 17:26:04 2026 +0000 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b13f319 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +*.class +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f243c52 --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/00-versions.md b/docs/00-versions.md new file mode 100644 index 0000000..3dc563c --- /dev/null +++ b/docs/00-versions.md @@ -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 `7.4.1.Final` — +so on Boot 4.1.0, `pom.xml` in this repo does not need to override anything to get 7.4.1.Final; +the `` 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 `` 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) diff --git a/docs/01-get-vs-load.md b/docs/01-get-vs-load.md new file mode 100644 index 0000000..542017a --- /dev/null +++ b/docs/01-get-vs-load.md @@ -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) diff --git a/docs/02-merge-vs-refresh.md b/docs/02-merge-vs-refresh.md new file mode 100644 index 0000000..152e580 --- /dev/null +++ b/docs/02-merge-vs-refresh.md @@ -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) diff --git a/docs/03-inserting-objects.md b/docs/03-inserting-objects.md new file mode 100644 index 0000000..926f452 --- /dev/null +++ b/docs/03-inserting-objects.md @@ -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) diff --git a/docs/output/allocation-and-batch-size-sweeps.txt b/docs/output/allocation-and-batch-size-sweeps.txt new file mode 100644 index 0000000..de09b4e --- /dev/null +++ b/docs/output/allocation-and-batch-size-sweeps.txt @@ -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 diff --git a/docs/output/get-vs-getreference-tests.txt b/docs/output/get-vs-getreference-tests.txt new file mode 100644 index 0000000..013e4dc --- /dev/null +++ b/docs/output/get-vs-getreference-tests.txt @@ -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 diff --git a/docs/output/get-vs-load.txt b/docs/output/get-vs-load.txt new file mode 100644 index 0000000..2938941 --- /dev/null +++ b/docs/output/get-vs-load.txt @@ -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 diff --git a/docs/output/insert-identity.txt b/docs/output/insert-identity.txt new file mode 100644 index 0000000..434dae5 --- /dev/null +++ b/docs/output/insert-identity.txt @@ -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) diff --git a/docs/output/insert-sequence.txt b/docs/output/insert-sequence.txt new file mode 100644 index 0000000..5971a11 --- /dev/null +++ b/docs/output/insert-sequence.txt @@ -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) diff --git a/docs/output/merge-vs-refresh-tests.txt b/docs/output/merge-vs-refresh-tests.txt new file mode 100644 index 0000000..ae9bf9b --- /dev/null +++ b/docs/output/merge-vs-refresh-tests.txt @@ -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] diff --git a/docs/output/merge-vs-refresh.txt b/docs/output/merge-vs-refresh.txt new file mode 100644 index 0000000..60112a0 --- /dev/null +++ b/docs/output/merge-vs-refresh.txt @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a5b7998 --- /dev/null +++ b/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + + com.ankurm + hibernate-demo + 1.0.0 + hibernate-demo + + Companion repository for the ankurm.com Hibernate 7 batch: get() vs load(), merge() vs + refresh(), and inserting objects efficiently. + + + + 25 + + 7.4.1.Final + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/scripts/clean_output.py b/scripts/clean_output.py new file mode 100755 index 0000000..5834a1d --- /dev/null +++ b/scripts/clean_output.py @@ -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") diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..78b1fc9 --- /dev/null +++ b/scripts/run-all.sh @@ -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 diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..bac59f9 --- /dev/null +++ b/scripts/run.sh @@ -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 }" + +cd "$(dirname "$0")/.." +mvn -q -B org.springframework.boot:spring-boot-maven-plugin:run \ + -Dspring-boot.run.profiles="$PROFILE" diff --git a/src/main/java/com/ankurm/hibernatedemo/HibernateDemoApplication.java b/src/main/java/com/ankurm/hibernatedemo/HibernateDemoApplication.java new file mode 100644 index 0000000..ec20c8e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/HibernateDemoApplication.java @@ -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: + * + *
    + *
  • {@code get-vs-load} — docs/01-get-vs-load.md, {@link com.ankurm.hibernatedemo.scenario.GetVsLoadRunner}
  • + *
  • {@code merge-vs-refresh} — docs/02-merge-vs-refresh.md, {@link com.ankurm.hibernatedemo.scenario.MergeVsRefreshRunner}
  • + *
  • {@code insert-identity} / {@code insert-sequence} — docs/03-inserting-objects.md, + * {@link com.ankurm.hibernatedemo.scenario.InsertIdentityRunner} and + * {@link com.ankurm.hibernatedemo.scenario.InsertSequenceRunner}
  • + *
+ * + * Each scenario is a profile-gated {@link org.springframework.boot.CommandLineRunner} that runs + * once against an in-memory H2 database and exits — there is no web server to keep alive, + * so {@code scripts/run.sh <profile>} 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); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/Book.java b/src/main/java/com/ankurm/hibernatedemo/model/Book.java new file mode 100644 index 0000000..64c1692 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/Book.java @@ -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}. + * + *

Docs: docs/01-get-vs-load.md, docs/02-merge-vs-refresh.md. + * + *

Carries a {@code @Version} column on purpose — 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 + * "USER_EDIT" vs "ADMIN_EDIT" scenario in docs/02-merge-vs-refresh.md. The + * {@code notes} collection is LAZY and cascades MERGE only — 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 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 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); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/Note.java b/src/main/java/com/ankurm/hibernatedemo/model/Note.java new file mode 100644 index 0000000..a30f099 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/Note.java @@ -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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc1.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc1.java new file mode 100644 index 0000000..d772a4a --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc1.java @@ -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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc10.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc10.java new file mode 100644 index 0000000..cecd681 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc10.java @@ -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} — 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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc25.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc25.java new file mode 100644 index 0000000..18f2fa5 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc25.java @@ -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} — 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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc50.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc50.java new file mode 100644 index 0000000..4efc6e5 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc50.java @@ -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} — 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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep1.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep1.java new file mode 100644 index 0000000..019827d --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep1.java @@ -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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep10.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep10.java new file mode 100644 index 0000000..6c80d9f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep10.java @@ -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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep25.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep25.java new file mode 100644 index 0000000..e5d2355 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep25.java @@ -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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep50.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep50.java new file mode 100644 index 0000000..fb96965 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep50.java @@ -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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java new file mode 100644 index 0000000..650d4dc --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java @@ -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. + * + *

{@code IDENTITY} requires the database to hand back the generated key on every single + * insert, which is exactly why it defeats JDBC batching — 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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java new file mode 100644 index 0000000..9cbaf30 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java @@ -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. + * + *

{@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; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/scenario/GetVsLoadRunner.java b/src/main/java/com/ankurm/hibernatedemo/scenario/GetVsLoadRunner.java new file mode 100644 index 0000000..e9de341 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/scenario/GetVsLoadRunner.java @@ -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}. + * + *

Each step opens its own {@link EntityManager} deliberately, so the SQL log lines that + * bracket a step are unambiguously that step's own traffic — 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(); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/scenario/InsertIdentityRunner.java b/src/main/java/com/ankurm/hibernatedemo/scenario/InsertIdentityRunner.java new file mode 100644 index 0000000..91e05a6 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/scenario/InsertIdentityRunner.java @@ -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}. + * + *

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)"); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/scenario/InsertSequenceRunner.java b/src/main/java/com/ankurm/hibernatedemo/scenario/InsertSequenceRunner.java new file mode 100644 index 0000000..f0c07c9 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/scenario/InsertSequenceRunner.java @@ -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}. + * + *

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)"); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/scenario/MergeVsRefreshRunner.java b/src/main/java/com/ankurm/hibernatedemo/scenario/MergeVsRefreshRunner.java new file mode 100644 index 0000000..0e32e49 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/scenario/MergeVsRefreshRunner.java @@ -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}. + * + *

{@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 — 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(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..cee1e41 --- /dev/null +++ b/src/main/resources/application.yml @@ -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" diff --git a/src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java b/src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java new file mode 100644 index 0000000..faf774e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java @@ -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}. + * + *

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 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); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java b/src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java new file mode 100644 index 0000000..b3885b0 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java @@ -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}. + * + *

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. + * + *

Each sweep point uses its own dedicated entity and sequence + * ({@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 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); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java b/src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java new file mode 100644 index 0000000..67feac8 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java @@ -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. + * + *

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 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); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java b/src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java new file mode 100644 index 0000000..dc2b072 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java @@ -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); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java b/src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java new file mode 100644 index 0000000..fe4a57f --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java @@ -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}. + * + *

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(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java b/src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java new file mode 100644 index 0000000..874d61e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java @@ -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}. + * + *

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(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/SequenceBatchTest.java b/src/test/java/com/ankurm/hibernatedemo/SequenceBatchTest.java new file mode 100644 index 0000000..4b203de --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/SequenceBatchTest.java @@ -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}. + * + *

{@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); + } +}