Add Hibernate 7 batches 2-6, batch 7, and batch 8: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, Hibernate Validator CDI integration, aggregate functions, sorting, pagination, interceptors, and Hibernate Search 8 (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)
This commit is contained in:
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
target/
|
||||||
|
*.class
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
# hibernate-demo
|
||||||
|
|
||||||
|
Companion repository for a batch of twenty-six ankurm.com chapters on Hibernate 7: the
|
||||||
|
persistence-context APIs (`get()` vs `getReference()`, `merge()` vs `refresh()`, batch inserts),
|
||||||
|
mapping styles (annotations vs. XML, JPA persistence annotations, natural ids, immutable
|
||||||
|
entities), a set of deeper operational topics (stored procedures, in-memory test databases,
|
||||||
|
mocking JNDI datasources, proxies and lazy initialization, association mappings, date/time
|
||||||
|
mapping, and named queries), the query layer (HQL, the Criteria API with a real generated
|
||||||
|
metamodel, and bootstrapping `EntityManagerFactory` outside Spring entirely), second-level
|
||||||
|
cache configuration with Ehcache 3 (entity caching, query caching, bulk-mutation cache
|
||||||
|
invalidation, and JCache provider setup), HikariCP connection pooling (Spring Boot's default
|
||||||
|
pool, raw non-Spring bootstrap, pool exhaustion, and leak detection), Jakarta Bean
|
||||||
|
Validation's CDI integration (constraint validator dependency injection, with and without a
|
||||||
|
running CDI container), aggregate functions (empty-result-set behavior, `select new` records,
|
||||||
|
and HQL window functions), sorting (`@OrderBy`, `@SortNatural`/`@SortComparator`, dynamic-sort
|
||||||
|
injection, and null precedence via `jakarta.persistence.criteria.Nulls`), pagination
|
||||||
|
(`setFirstResult`/`setMaxResults`, `ScrollableResults`, when a `join fetch` actually falls back
|
||||||
|
to in-memory pagination, and keyset pagination), interceptors (implementing `Interceptor`
|
||||||
|
directly now that every method is a default method, session-scoped vs. globally-registered
|
||||||
|
interceptors, and bulk updates bypassing them), and Hibernate Search 8 (full-text and keyword
|
||||||
|
fields, fuzzy matching, `@IndexedEmbedded`, and `MassIndexer`). Every claim in
|
||||||
|
those chapters 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.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
| Component | Version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Spring Boot | `4.1.1` | parent POM |
|
||||||
|
| Hibernate ORM | `7.4.5.Final` | what Boot 4.1.1 manages — see [`docs/00-versions.md`](docs/00-versions.md) for why this is *not* the same patch Boot 4.1.0 resolves |
|
||||||
|
| Spring Framework | `7.0.9` | pulled in transitively by Boot 4.1.1 |
|
||||||
|
| Jakarta Persistence | `3.2.0` | `jakarta.persistence-api` |
|
||||||
|
| Java | `25` (Temurin 25.0.4.1 LTS) | |
|
||||||
|
| H2 | `2.4.240` | in-memory, `DB_CLOSE_DELAY=-1` |
|
||||||
|
| HSQLDB | `2.7.3` | in-memory |
|
||||||
|
| Apache Derby | `10.16.1.1` | in-memory; needs `hibernate-community-dialects` (see below) |
|
||||||
|
| simple-jndi | `0.25.0` | `com.github.h-thurow:simple-jndi`, test scope — **not** the abandoned `simple-jndi:simple-jndi` groupId |
|
||||||
|
| JUnit Jupiter | `6.0.3` | via `spring-boot-starter-test` |
|
||||||
|
| HikariCP | `7.0.2` | pulled in transitively by `spring-boot-starter-data-jpa`; no explicit dependency needed for the Spring-managed chapters |
|
||||||
|
| `hibernate-hikaricp` | `7.4.5.Final` | added test-scope for chapter 19's raw, non-Spring `StandardServiceRegistry` bootstrap only |
|
||||||
|
| Hibernate Validator | `9.1.3.Final` | what Boot 4.1.1's own BOM manages — confirmed against `spring-boot-dependencies-4.1.1.pom` |
|
||||||
|
| `hibernate-validator-cdi` | `9.1.3.Final` | chapter 20 only; not in Boot's BOM, pinned to match |
|
||||||
|
| `weld-se-core` | `6.0.4.Final` | chapter 20 only — resolves `jakarta.enterprise.cdi-api:4.1.0` transitively, verified with `mvn dependency:tree` before use |
|
||||||
|
| `org.glassfish.expressly` | `6.0.0` | chapter 20 only — the EL implementation matching the `jakarta.el-api:6.0.1` Weld pulls in |
|
||||||
|
| `hibernate-search-mapper-orm` | `8.4.0.Final` | chapter 25 — verified against `maven-metadata.xml` as the current GA line, not the `7.3.2.Final` this repo's own earlier post claimed; depends on `hibernate-core:7.4.0.Final`, compatible with this repo's `7.4.5.Final` pin |
|
||||||
|
| `hibernate-search-backend-lucene` | `8.4.0.Final` | chapter 25 — the embedded, local-filesystem search backend; pulls in `lucene-core:9.12.3` transitively |
|
||||||
|
|
||||||
|
**This repo was bumped this session from Boot `4.1.0` / Hibernate `7.4.1.Final` to Boot `4.1.1` /
|
||||||
|
Hibernate `7.4.5.Final`.** If you see either of those older numbers anywhere outside
|
||||||
|
`docs/00-versions.md`'s own explanation of the bump, that's stale and should be corrected — the
|
||||||
|
live `pom.xml` and every chapter added or touched this session already reflect `4.1.1` /
|
||||||
|
`7.4.5.Final`.
|
||||||
|
|
||||||
|
See [`docs/00-versions.md`](docs/00-versions.md) for how these were verified (against
|
||||||
|
`maven-metadata.xml`, not Maven Central's search index) and the trap worth knowing about if you
|
||||||
|
bump the Spring Boot version again: two different Boot 4.1.x patch releases can resolve two
|
||||||
|
different Hibernate patch releases from the same "4.1" line.
|
||||||
|
|
||||||
|
## 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). `mvn test` runs the full suite — **209
|
||||||
|
tests as of the last commit, 2 known errors** (see "Known issues" below; both predate this
|
||||||
|
batch) — and takes a couple of minutes, since it boots Hibernate against H2, HSQLDB and Derby
|
||||||
|
repeatedly, spins up a real embedded Tomcat for the OSIV chapter, boots a standalone Weld SE CDI
|
||||||
|
container for the validation chapter, boots a Lucene-backed Hibernate Search index for every
|
||||||
|
single test in the suite (chapter 25's `@Indexed` entity lives in the normally-scanned package,
|
||||||
|
so its backend starts once per JVM alongside everything else), and runs a 5,500-iteration timing
|
||||||
|
comparison for the
|
||||||
|
named-query chapter.
|
||||||
|
|
||||||
|
Three configuration decisions in this repo are easy to trip over if you're extending it rather
|
||||||
|
than just reading it:
|
||||||
|
|
||||||
|
- **`hibernate.cache.use_second_level_cache: false`** is pinned explicitly in
|
||||||
|
[`application.yml`](src/main/resources/application.yml), on purpose. `hibernate-jcache` is on
|
||||||
|
this project's classpath (chapter 06's natural-id L2 measurements need it), and Hibernate
|
||||||
|
7.4.5 turns the second-level cache **on by itself**, for every context in the repo, the moment
|
||||||
|
it finds a `RegionFactory` via the service loader — with nothing configured anywhere. Before
|
||||||
|
this was pinned, that silently broke an unrelated test (see
|
||||||
|
[chapter 09](docs/09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not)
|
||||||
|
for the verbatim failure and the `JCacheOnClasspathAutoEnablesL2Test` that pins it down). If
|
||||||
|
you add a JCache/Ehcache/Infinispan dependency to a Hibernate project for one narrow purpose,
|
||||||
|
assume it just turned L2 caching on globally unless you pin this back down yourself.
|
||||||
|
- **`hibernate-community-dialects` is a required runtime dependency**, not an optional add-on,
|
||||||
|
because `org.hibernate.dialect.DerbyDialect` no longer exists in `hibernate-core` as of
|
||||||
|
Hibernate 6.2+. Derby's dialect moved to a different package,
|
||||||
|
`org.hibernate.community.dialect.DerbyDialect`, in this separate artifact — see
|
||||||
|
[chapter 09](docs/09-testing-in-memory-databases.md#headline-correction-derby-has-no-dialect-in-hibernate-7-without-an-extra-dependency)
|
||||||
|
for both failure messages this produces if the dependency or the dialect setting is missing.
|
||||||
|
- **`hibernate.search.backend.directory.root: target/lucene-indexes`** is pinned explicitly
|
||||||
|
because chapter 25's `@Indexed` entity lives in this repo's normally-scanned package tree, so
|
||||||
|
Hibernate Search bootstraps for **every** `@SpringBootTest` in the whole suite, not only
|
||||||
|
chapter 25's own tests — confirmed the hard way, by watching an already-passing, unrelated test
|
||||||
|
fail until this was configured. Adding an `@Indexed` entity to a Spring Boot app means
|
||||||
|
configuring a search backend for the whole application, whether or not most of it ever searches
|
||||||
|
anything. See [chapter 25](docs/25-hibernate-search.md#two-configuration-traps-that-break-the-whole-application-not-just-this-chapter).
|
||||||
|
|
||||||
|
## Three databases, and why
|
||||||
|
|
||||||
|
H2, HSQLDB and Derby are all exercised side by side (chapter 09 in particular boots all three
|
||||||
|
against the identical entity mapping), specifically to find where their dialect emulation
|
||||||
|
actually diverges rather than repeating folklore about it — the reserved-word survey, the
|
||||||
|
CHAR-padding behavior, and the Derby dialect relocation above are all things that only showed up
|
||||||
|
by running all three, not by reading about one of them. **Testcontainers is discussed but not
|
||||||
|
runnable in the sandbox this repo was built in** — there is no Docker binary and no
|
||||||
|
`/var/run/docker.sock` available, so nothing Testcontainers-based was attempted or claimed to
|
||||||
|
have been run. The in-memory engines cover fast, unit-level ORM testing; a real production
|
||||||
|
database via Testcontainers is a separate, slower integration stage this repo doesn't attempt to
|
||||||
|
substitute for.
|
||||||
|
|
||||||
|
## Chapter 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) | 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` |
|
||||||
|
| [04 — Annotations vs. XML mappings](docs/04-annotations-vs-xml.md) | `hbm.xml` still works (WARN, not broken), `orm.xml` can define a whole entity, XML always wins on conflict, and the third XML dialect (`mapping.xml`) neither older article mentions |
|
||||||
|
| [05 — JPA persistence annotations](docs/05-jpa-persistence-annotations.md) | `@Temporal`'s formal deprecation, the ORDINAL enum reorder failure reproduced, `@JdbcTypeCode(JSON)`'s hidden Jackson dependency, the surrogate-id `HashSet` trap, mixed access-type gotchas, and what's actually new in Jakarta Persistence 3.2 |
|
||||||
|
| [06 — Natural IDs](docs/06-natural-ids.md) | `bySimpleNaturalId`, L1 vs L2 cache behavior measured with real query counts, the "L2 populates on INSERT, not first lookup" surprise, enforced natural-id immutability, and composite natural ids |
|
||||||
|
| [07 — Immutable entities](docs/07-immutable-entities.md) | `@Immutable`'s actual mechanism (dirty-check exclusion, not a write guard), what it does and doesn't block, and a measured flush-cost comparison against `setReadOnly()` |
|
||||||
|
| [08 — Stored procedures](docs/08-stored-procedures.md) | IN/OUT/INOUT against a real HSQLDB procedure, a genuine HSQLDB-driver result-set incompatibility, the parameter name-vs-position trap, and why procedure calls don't auto-flush |
|
||||||
|
| [09 — Testing with in-memory databases](docs/09-testing-in-memory-databases.md) | Derby's relocated dialect, the `hibernate-jcache` classpath-pollution trap, the real (and not-Derby-exclusive) cross-database divergences, and schema/test isolation strategies |
|
||||||
|
| [10 — Mocking JNDI datasources](docs/10-mocking-jndi-datasources.md) | Why `SimpleNamingContextBuilder` is gone, getting `simple-jndi` 0.25.0 actually working, the failure modes verbatim, and whether mock JNDI is still the right answer in 2026 |
|
||||||
|
| [11 — Proxies and lazy initialization](docs/11-proxies-and-lazy-initialization.md) | What a `HibernateProxy` actually is, both `LazyInitializationException` message templates, `fetchgraph` vs `loadgraph` demonstrated, and what OSIV actually masks |
|
||||||
|
| [12 — Association mappings](docs/12-association-mappings.md) | N+1 counted exactly, `MultipleBagFetchException` and its fixes, the cartesian-product trap, the `@OneToOne` lazy trap, and cascade/orphanRemoval's real (and not-so-real) failure modes |
|
||||||
|
| [13 — Date and time mapping](docs/13-date-and-time-mapping.md) | Every basic temporal type round-tripped, the six `TimeZoneStorageType` constants measured under a JVM zone change, nanosecond rounding vs. truncation across databases, and `hibernate.jdbc.time_zone`'s raw-storage effect |
|
||||||
|
| [14 — Named queries](docs/14-named-queries.md) | Startup validation's two different failure modes, Hibernate's extra `@NamedQuery` attributes, `orm.xml` named queries, and a measured (negative) answer to "are named queries faster?" |
|
||||||
|
| [15 — HQL queries](docs/15-hql-queries.md) | The column-name-vs-field-name pitfall, `JOIN` vs `JOIN FETCH`, aggregates/pagination, bulk `UPDATE`/`DELETE` bypassing the persistence context, and what each flush mode actually suppresses (including why `GenerationType.IDENTITY` defeats an INSERT-based flush-mode test) |
|
||||||
|
| [16 — Criteria API](docs/16-criteria-queries.md) | The real generated static metamodel (`Employee_`/`Department_`) vs. string paths, `root.join()` vs `root.fetch()`, aggregation and subqueries, `CriteriaUpdate`/`CriteriaDelete`, and when Criteria earns its ceremony over HQL |
|
||||||
|
| [17 — Bootstrapping EntityManager](docs/17-entitymanager-bootstrap.md) | XML vs. Jakarta Persistence 3.2's `PersistenceConfiguration`, proof that a reused persistence-unit name does not trigger an XML lookup, the corrected `jakarta.persistence.PersistenceException` type, and a measured factory-vs-EntityManager creation cost |
|
||||||
|
| [18 — Ehcache 3 L2 cache configuration](docs/18-ehcache-l2-configuration.md) | The `jakarta` classifier re-verified as a JAXB choice, not a JCache namespace switch; query-cache-without-entity-cache measured at zero SQL statements (not N+1); bulk HQL *and* native SQL updates both measured as NOT leaving the cache stale, and why; and the missing-timestamps-region "failure" that's actually just a WARN by default |
|
||||||
|
| [19 — HikariCP connection pooling](docs/19-hikaricp-connection-pooling.md) | Confirming Spring Boot 4.1.1's default `DataSource` really is Hikari, a raw non-Spring `StandardServiceRegistry` wired to `hibernate-hikaricp`, pool exhaustion's exact `SQLTransientConnectionException`, and the undocumented 2000ms floor below which `leakDetectionThreshold` is silently disabled |
|
||||||
|
| [20 — Hibernate Validator CDI integration](docs/20-hibernate-validator-cdi.md) | `@Inject` inside a `ConstraintValidator` measured both ways: a plain NullPointerException (wrapped in `ValidationException`) with no CDI container, and genuine, working injection through a real Weld SE container wired via `hibernate-validator-cdi`'s `ValidationExtension` |
|
||||||
|
| [21 — Aggregate functions](docs/21-aggregate-functions.md) | Empty-result-set aggregates return `NULL`/`0`, never `NoResultException`; `select new` with a Java record; the Criteria API equivalent; and HQL window functions (`row_number()` etc.), present since Hibernate 6.2, not new in 7 |
|
||||||
|
| [22 — Sorting](docs/22-sorting.md) | `@OrderBy` naming the property not the column, `@SortNatural`/`@SortComparator` on element collections, the dynamic-sort injection risk and its whitelist fix, Criteria `Order` across a join, null precedence via `jakarta.persistence.criteria.Nulls`, and case-insensitive sorting |
|
||||||
|
| [23 — Pagination](docs/23-pagination.md) | `setFirstResult`/`setMaxResults` translating to the dialect's real syntax, `ScrollableResults`, exactly when a `join fetch` does and doesn't fall back to in-memory pagination (and the real `HHH90003004` warning code, not `HHH000104`), keyset pagination, and the total-count-query pattern |
|
||||||
|
| [24 — Interceptors](docs/24-interceptors.md) | Implementing `Interceptor` directly now that every method is `default` (and where `EmptyInterceptor` actually went), the state-array-mutation contract, session-scoped vs. globally-registered interceptors, and bulk updates bypassing interceptor callbacks entirely |
|
||||||
|
| [25 — Hibernate Search](docs/25-hibernate-search.md) | The real current GA version (`8.4.0.Final`, not `7.3.2.Final`), two bootstrap traps that break the whole application, full-text/keyword/generic field types, fuzzy matching, `@IndexedEmbedded`, and rebuilding the index with `MassIndexer` |
|
||||||
|
|
||||||
|
## Every surprising claim from chapters 01–03, mapped to a test
|
||||||
|
|
||||||
|
Each row is one `./mvnw -Dtest=ClassName test` away from reproducing itself.
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
For chapters 04–17, the test classes are linked directly from each chapter rather than
|
||||||
|
duplicated here — every source file, test class, and captured output file a chapter discusses is
|
||||||
|
a relative link in that chapter.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw -Dtest=GetVsGetReferenceTest test
|
||||||
|
./mvnw -Dtest=MergeRefreshTest,OptimisticLockTest test
|
||||||
|
./mvnw -Dtest=IdentityBatchTest,SequenceBatchTest test
|
||||||
|
./mvnw -Dtest=AllocationSizeSweepTest test
|
||||||
|
./mvnw -Dtest=BatchSizeSweepTest test
|
||||||
|
./mvnw -Dtest=HqlQueryTest test
|
||||||
|
./mvnw -Dtest=CriteriaQueryTest test
|
||||||
|
./mvnw -Dtest=EntityManagerBootstrapTest test
|
||||||
|
./mvnw -Dtest='com.ankurm.hibernatedemo.cache.*Test' test
|
||||||
|
./mvnw -Dtest='com.ankurm.hibernatedemo.hikari.*Test' test
|
||||||
|
./mvnw -Dtest='com.ankurm.hibernatedemo.validation.*Test' test
|
||||||
|
./mvnw -Dtest=AggregateFunctionsTest test
|
||||||
|
./mvnw -Dtest=SortingTest test
|
||||||
|
./mvnw -Dtest=PaginationTest test
|
||||||
|
./mvnw -Dtest=InterceptorTest test
|
||||||
|
./mvnw -Dtest=HibernateSearchTest test
|
||||||
|
./mvnw test # the whole suite, 209 tests as of the last commit — see "Known issues" below
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known issues
|
||||||
|
|
||||||
|
**`OrmXmlNamedQueryTest` (chapter 14) currently fails, and it predates chapters 15–17.** Verified
|
||||||
|
by stashing every change made for chapters 15–17 and re-running the suite against the unmodified
|
||||||
|
tree: the same two errors reproduce identically (`No query named 'XmlQueryEmployee.findBySalaryAboveXml'`
|
||||||
|
and `No parameter named ':min' in query with named parameters []`), so this is not a regression
|
||||||
|
from the HQL/Criteria/bootstrap work. It contradicts chapter 14's own claim that `orm.xml` named
|
||||||
|
queries are "auto-discovered with no `persistence.xml`" under Spring Boot's default JPA
|
||||||
|
autoconfiguration — that claim needs re-verification against the current Hibernate/Spring Boot
|
||||||
|
versions (this repo was bumped mid-batch from Boot 4.1.0/Hibernate 7.4.1.Final to Boot
|
||||||
|
4.1.1/Hibernate 7.4.5.Final, which is one plausible place the behavior changed) before it's
|
||||||
|
trusted again. Flagged here rather than fixed silently, since chapter 14 and its post (4877) are
|
||||||
|
outside this batch's scope.
|
||||||
|
|
||||||
|
## CommandLineRunner scenarios
|
||||||
|
|
||||||
|
The original narrative scenarios (chapters 01–03 only) 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Chapters 04–17 don't have `CommandLineRunner` profiles — they're covered entirely by JUnit tests
|
||||||
|
and `javap`/`unzip` probes, captured the same way (see below).
|
||||||
|
|
||||||
|
## Regenerating captured output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerates the `CommandLineRunner`-scenario files in `docs/output/` (chapters 01–03 only).
|
||||||
|
`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. Every other transcript in
|
||||||
|
`docs/output/` (chapters 04–17, and the test-suite transcripts for 01–03) was captured directly
|
||||||
|
from `./mvnw -Dtest=... test`, `javap`, or `unzip -l` piped through the same cleanup approach, not
|
||||||
|
regenerated by a single script — see each chapter for the exact command that produced its output.
|
||||||
|
|
||||||
|
## Output index
|
||||||
|
|
||||||
|
`docs/output/` holds 101 raw, unedited transcripts, grouped here by topic prefix.
|
||||||
|
|
||||||
|
| Prefix | Files | What it captures |
|
||||||
|
|---|---|---|
|
||||||
|
| `get-vs-*` | 2 | Chapter 01: the `CommandLineRunner` scenario and the `GetVsGetReferenceTest` run |
|
||||||
|
| `merge-vs-refresh*` | 2 | Chapter 02: the `CommandLineRunner` scenario and the `MergeRefreshTest`/`OptimisticLockTest` run |
|
||||||
|
| `insert-*` | 2 | Chapter 03: the identity vs. sequence `CommandLineRunner` scenarios |
|
||||||
|
| `allocation-and-batch-size-sweeps.txt` | 1 | Chapter 03: the allocationSize and batch_size sweep test run |
|
||||||
|
| `mappingstyle-*` | 1 | Chapter 04: the full `hbm.xml`/`orm.xml`/`mapping.xml` test run |
|
||||||
|
| `persistenceannotations-*` | 1 | Chapter 05: the full persistence-annotations test run |
|
||||||
|
| `naturalid-*` | 1 | Chapter 06: the full natural-id L1/L2/mutability/composite test run |
|
||||||
|
| `immutable-*` | 2 | Chapter 07: the `@Immutable` behavior run, and the `javap` capture of the annotation itself |
|
||||||
|
| `procedure-*` | 4 | Chapter 08: happy-path and failure-mode runs, the HSQLDB driver quirk reproduction, and the `javap` API-surface capture |
|
||||||
|
| `testdb-*` | 9 | Chapter 09: dialect resolution, DDL generation, the reserved-word survey, cross-database behavior, schema isolation, startup timing, the Derby dialect failure, and the jcache classpath-pollution failure |
|
||||||
|
| `jndi-*` | 6 | Chapter 10: the `SimpleNamingContextBuilder` removal proof, the simple-jndi jar listing, Boot's JNDI autoconfigure `javap` capture, Hibernate's own JNDI datasource `javap` capture, and both the filtered and unfiltered full test runs |
|
||||||
|
| `proxy-*` | 7 | Chapter 11: proxy identity and `LazyInitializationException` (filtered and raw), entity-graph fetch behavior (filtered and raw), OSIV masking (filtered and raw), and the `javap` capture of proxy-related settings |
|
||||||
|
| `association-*` | 4 | Chapter 12: N+1 counting, the multiple-bag-fetch/cartesian-product runs, the `@OneToOne` lazy trap, and cascade/orphanRemoval |
|
||||||
|
| `datetime-*` | 7 | Chapter 13: basic temporal round trips, the `@Temporal` deprecation warning, both `@TimeZoneStorage` JVM-zone runs, nanosecond rounding on both H2 and HSQLDB, and `hibernate.jdbc.time_zone` |
|
||||||
|
| `namedquery-*` | 4 | Chapter 14: startup validation, execution/projections, `orm.xml` named queries, and the pre-parsing performance comparison |
|
||||||
|
| `hql-*` | 3 | Chapter 15: select/join pitfalls, aggregation/paging/bulk operations, and flush-mode behavior |
|
||||||
|
| `criteria-*` | 3 | Chapter 16: predicates and the static metamodel, aggregation/subqueries, and bulk update/delete |
|
||||||
|
| `bootstrap-persistenceconfiguration.txt` | 1 | Chapter 17: XML and programmatic bootstrap, the persistence-unit name-collision proof, the unconfigured-name exception, and the factory-creation timing |
|
||||||
|
| `18-*` | 7 | Chapter 18: the JCache-namespace re-verification, entity L2 caching, query-cache-without-entity-cache, bulk HQL and native SQL update cache behavior, and both missing-timestamps-region runs (default and strict) |
|
||||||
|
| `19-*` | 4 | Chapter 19: confirming Spring Boot's default `DataSource` is Hikari, the raw non-Spring bootstrap, pool exhaustion, and both leak-detection runs (the sub-2000ms silent-disable trap and a real leak caught at the floor) |
|
||||||
|
| `20-*` | 2 | Chapter 20: `@Inject` failing with a wrapped NullPointerException under plain Bean Validation, and working correctly inside a running Weld SE container |
|
||||||
|
| `21-*` | 4 | Chapter 21: empty-result-set aggregates, `select new` record construction under GROUP BY/HAVING, the Criteria API `avg()` equivalent, and the `row_number()` window function |
|
||||||
|
| `22-*` | 6 | Chapter 22: `@OrderBy` property-vs-column resolution, `@SortNatural`/`@SortComparator`, the dynamic-sort injection guard, Criteria `Order` across a join, null precedence, and case-insensitive sorting |
|
||||||
|
| `23-*` | 6 | Chapter 23: `LIMIT`/`OFFSET` translation, `ScrollableResults`, both join-fetch-pagination scenarios (root-ordered vs. collection-ordered), keyset pagination, and the total-count-query pattern |
|
||||||
|
| `24-*` | 4 | Chapter 24: session-scoped interceptor state mutation, interceptor scoping, global registration via `hibernate.session_factory.interceptor`, and bulk updates bypassing interceptor callbacks |
|
||||||
|
| `25-*` | 5 | Chapter 25: fuzzy full-text matching, exact keyword-field matching, sortable generic fields, `@IndexedEmbedded` association search, and `MassIndexer` index rebuilding |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
│ └── check_links.py walk docs/*.md and verify every relative link resolves on disk
|
||||||
|
├── src/main/java/com/ankurm/hibernatedemo/
|
||||||
|
│ ├── HibernateDemoApplication.java
|
||||||
|
│ ├── model/ Book, Note, Widget* entities (chapters 01–03)
|
||||||
|
│ ├── scenario/ one CommandLineRunner per profile (chapters 01–03)
|
||||||
|
│ ├── mappingstyle/ chapter 04 entities
|
||||||
|
│ ├── persistenceannotations/ chapter 05 entities
|
||||||
|
│ ├── naturalid/ chapter 06 entities
|
||||||
|
│ ├── immutable/ chapter 07 entities
|
||||||
|
│ ├── procedure/ chapter 08 entities
|
||||||
|
│ ├── testdb/ chapter 09 entity
|
||||||
|
│ ├── proxy/ chapter 11 entities
|
||||||
|
│ ├── association/ chapter 12 entities
|
||||||
|
│ ├── datetime/ chapter 13 entities
|
||||||
|
│ ├── namedquery/ chapter 14 entities
|
||||||
|
│ ├── query/ chapters 15–16 entities (Employee, Department — @Entity(name="QueryDept"))
|
||||||
|
│ ├── bootstrap/ chapter 17 entity (BootstrapUser)
|
||||||
|
│ ├── cache/ chapter 18 entities (CacheProduct, UncachedProduct)
|
||||||
|
│ ├── hikari/ chapter 19 entity (PoolProbe)
|
||||||
|
│ ├── validation/ chapter 20 classes (InventoryPolicy, PositiveInventory,
|
||||||
|
│ │ PositiveInventoryValidator, StockLevel) -- not JPA entities
|
||||||
|
│ ├── aggregate/ chapter 21 entities (Product, CategorySummary record)
|
||||||
|
│ ├── sorting/ chapter 22 entities (Playlist, Song, LengthThenAlphaComparator,
|
||||||
|
│ │ SongSortField)
|
||||||
|
│ ├── pagination/ chapter 23 entities (Article, Comment)
|
||||||
|
│ ├── interceptor/ chapter 24 entity + interceptor (Task, UppercasingInterceptor)
|
||||||
|
│ └── search/ chapter 25 entities (Movie, Director) -- @Indexed, on the
|
||||||
|
│ normally-scanned package tree; see README "Quickstart" above
|
||||||
|
├── src/test/java/com/ankurm/
|
||||||
|
│ ├── brokenprobe/ a deliberately broken entity kept outside the scanned package (chapter 14)
|
||||||
|
│ └── hibernatedemo/
|
||||||
|
│ ├── GetVsGetReferenceTest.java, MergeRefreshTest.java, OptimisticLockTest.java,
|
||||||
|
│ │ IdentityBatchTest.java, SequenceBatchTest.java, AllocationSizeSweepTest.java,
|
||||||
|
│ │ BatchSizeSweepTest.java, ImmutableEntityTest.java, ImmutableBulkUpdateAllowedTest.java,
|
||||||
|
│ │ ImmutableFlushCostTest.java (chapters 01–03, 07)
|
||||||
|
│ ├── mappingstyle/, persistenceannotations/, naturalid/, procedure/, testdb/,
|
||||||
|
│ │ proxy/, association/, datetime/, namedquery/, jndi/ (chapters 04–06, 08–14)
|
||||||
|
│ ├── query/ HqlQueryTest.java, CriteriaQueryTest.java (chapters 15–16)
|
||||||
|
│ ├── bootstrap/ EntityManagerBootstrapTest.java, plain JUnit, no Spring (chapter 17)
|
||||||
|
│ ├── cache/ EntityL2CacheTest, QueryCacheWithoutEntityCacheTest,
|
||||||
|
│ │ BulkUpdateBypassesCacheTest, MissingUpdateTimestampsRegionTest,
|
||||||
|
│ │ CacheApiNamespaceTest -- all raw StandardServiceRegistry, no Spring (chapter 18)
|
||||||
|
│ ├── hikari/ SpringAutoConfiguredHikariTest (Spring), HikariRawBootstrapTest (raw),
|
||||||
|
│ │ HikariLeakDetectionTest, HikariPoolExhaustionTest (chapter 19)
|
||||||
|
│ ├── validation/ PlainValidationNoCdiTest, CdiValidationTest (Weld SE) (chapter 20)
|
||||||
|
│ ├── aggregate/ AggregateFunctionsTest (chapter 21)
|
||||||
|
│ ├── sorting/ SortingTest (chapter 22)
|
||||||
|
│ ├── pagination/ PaginationTest (chapter 23)
|
||||||
|
│ ├── interceptor/ InterceptorTest (chapter 24)
|
||||||
|
│ └── search/ HibernateSearchTest (chapter 25)
|
||||||
|
└── docs/
|
||||||
|
├── 00-versions.md .. 25-hibernate-search.md
|
||||||
|
└── output/*.txt captured, unedited console transcripts (101 files)
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
----------------------------------------------------------------
|
||||||
|
Sun Sep 20 11:27:05 IST 2026:
|
||||||
|
Booting Derby version The Apache Software Foundation - Apache Derby - 10.16.1.1 - (1901046): instance a816c00e-01a0-bd63-bc58-0000085a7670
|
||||||
|
on database directory memory:/tmp/hib-work/hibernate-demo/testdb-plain with class loader jdk.internal.loader.ClassLoaders$AppClassLoader@7c84929b
|
||||||
|
Loaded from file:/root/.m2/repository/org/apache/derby/derby/10.16.1.1/derby-10.16.1.1.jar
|
||||||
|
java.vendor=Eclipse Adoptium
|
||||||
|
java.runtime.version=25.0.4.1+1-LTS
|
||||||
|
user.dir=/tmp/hib-work/hibernate-demo
|
||||||
|
os.name=Linux
|
||||||
|
os.arch=amd64
|
||||||
|
os.version=6.18.44-fc-v37
|
||||||
|
derby.system.home=null
|
||||||
|
Database Class Loader started - derby.database.classpath=''
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Sun Sep 20 11:27:06 IST 2026:
|
||||||
|
Booting Derby version The Apache Software Foundation - Apache Derby - 10.16.1.1 - (1901046): instance 42adc10b-01a0-bd63-bc58-0000085a7670
|
||||||
|
on database directory memory:/tmp/hib-work/hibernate-demo/testdb-timing0 with class loader jdk.internal.loader.ClassLoaders$AppClassLoader@7c84929b
|
||||||
|
Loaded from file:/root/.m2/repository/org/apache/derby/derby/10.16.1.1/derby-10.16.1.1.jar
|
||||||
|
java.vendor=Eclipse Adoptium
|
||||||
|
java.runtime.version=25.0.4.1+1-LTS
|
||||||
|
user.dir=/tmp/hib-work/hibernate-demo
|
||||||
|
os.name=Linux
|
||||||
|
os.arch=amd64
|
||||||
|
os.version=6.18.44-fc-v37
|
||||||
|
derby.system.home=null
|
||||||
|
Database Class Loader started - derby.database.classpath=''
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Sun Sep 20 11:27:06 IST 2026:
|
||||||
|
Booting Derby version The Apache Software Foundation - Apache Derby - 10.16.1.1 - (1901046): instance bbc70208-01a0-bd63-bc58-0000085a7670
|
||||||
|
on database directory memory:/tmp/hib-work/hibernate-demo/testdb-timing1 with class loader jdk.internal.loader.ClassLoaders$AppClassLoader@7c84929b
|
||||||
|
Loaded from file:/root/.m2/repository/org/apache/derby/derby/10.16.1.1/derby-10.16.1.1.jar
|
||||||
|
java.vendor=Eclipse Adoptium
|
||||||
|
java.runtime.version=25.0.4.1+1-LTS
|
||||||
|
user.dir=/tmp/hib-work/hibernate-demo
|
||||||
|
os.name=Linux
|
||||||
|
os.arch=amd64
|
||||||
|
os.version=6.18.44-fc-v37
|
||||||
|
derby.system.home=null
|
||||||
|
Database Class Loader started - derby.database.classpath=''
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Sun Sep 20 11:27:06 IST 2026:
|
||||||
|
Booting Derby version The Apache Software Foundation - Apache Derby - 10.16.1.1 - (1901046): instance 63628305-01a0-bd63-bc58-0000085a7670
|
||||||
|
on database directory memory:/tmp/hib-work/hibernate-demo/testdb-timing2 with class loader jdk.internal.loader.ClassLoaders$AppClassLoader@7c84929b
|
||||||
|
Loaded from file:/root/.m2/repository/org/apache/derby/derby/10.16.1.1/derby-10.16.1.1.jar
|
||||||
|
java.vendor=Eclipse Adoptium
|
||||||
|
java.runtime.version=25.0.4.1+1-LTS
|
||||||
|
user.dir=/tmp/hib-work/hibernate-demo
|
||||||
|
os.name=Linux
|
||||||
|
os.arch=amd64
|
||||||
|
os.version=6.18.44-fc-v37
|
||||||
|
derby.system.home=null
|
||||||
|
Database Class Loader started - derby.database.classpath=''
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Sun Sep 20 11:27:06 IST 2026:
|
||||||
|
Booting Derby version The Apache Software Foundation - Apache Derby - 10.16.1.1 - (1901046): instance 89804402-01a0-bd63-bc58-0000085a7670
|
||||||
|
on database directory memory:/tmp/hib-work/hibernate-demo/testdb-reservedword with class loader jdk.internal.loader.ClassLoaders$AppClassLoader@7c84929b
|
||||||
|
Loaded from file:/root/.m2/repository/org/apache/derby/derby/10.16.1.1/derby-10.16.1.1.jar
|
||||||
|
java.vendor=Eclipse Adoptium
|
||||||
|
java.runtime.version=25.0.4.1+1-LTS
|
||||||
|
user.dir=/tmp/hib-work/hibernate-demo
|
||||||
|
os.name=Linux
|
||||||
|
os.arch=amd64
|
||||||
|
os.version=6.18.44-fc-v37
|
||||||
|
derby.system.home=null
|
||||||
|
Database Class Loader started - derby.database.classpath=''
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Sun Sep 20 11:27:06 IST 2026:
|
||||||
|
Booting Derby version The Apache Software Foundation - Apache Derby - 10.16.1.1 - (1901046): instance af36c4f3-01a0-bd63-bc58-0000085a7670
|
||||||
|
on database directory memory:/tmp/hib-work/hibernate-demo/testdb-charpad with class loader jdk.internal.loader.ClassLoaders$AppClassLoader@7c84929b
|
||||||
|
Loaded from file:/root/.m2/repository/org/apache/derby/derby/10.16.1.1/derby-10.16.1.1.jar
|
||||||
|
java.vendor=Eclipse Adoptium
|
||||||
|
java.runtime.version=25.0.4.1+1-LTS
|
||||||
|
user.dir=/tmp/hib-work/hibernate-demo
|
||||||
|
os.name=Linux
|
||||||
|
os.arch=amd64
|
||||||
|
os.version=6.18.44-fc-v37
|
||||||
|
derby.system.home=null
|
||||||
|
Database Class Loader started - derby.database.classpath=''
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
# 00 — Versions
|
||||||
|
|
||||||
|
[Next: 01 — get() vs load() →](01-get-vs-load.md)
|
||||||
|
|
||||||
|
This repository is pinned to:
|
||||||
|
|
||||||
|
| Component | Version | GA date | Source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Spring Boot | `4.1.1` | 2026-07 | [spring.io/blog](https://spring.io/blog/) |
|
||||||
|
| Hibernate ORM | `7.4.5.Final` | — | resolved by Spring Boot 4.1.1's dependency management |
|
||||||
|
| Spring Framework | `7.0.9` | — | pulled in transitively by Boot 4.1.1 |
|
||||||
|
| Jakarta Persistence | `3.2.0` | — | `jakarta.persistence-api` |
|
||||||
|
| Java | `25` (Temurin 25.0.4.1 LTS) | 2025-09 | latest LTS at the time this repo was built |
|
||||||
|
| H2 | `2.4.240` | — | in-memory, `DB_CLOSE_DELAY=-1` |
|
||||||
|
| HSQLDB | `2.7.3` | — | in-memory |
|
||||||
|
| Apache Derby | `10.16.1.1` | — | in-memory; dialect lives in `hibernate-community-dialects`, not `hibernate-core` |
|
||||||
|
| simple-jndi | `0.25.0` | — | `com.github.h-thurow:simple-jndi`, test scope (the `simple-jndi:simple-jndi` groupId is an abandoned fork — don't use it) |
|
||||||
|
| JUnit Jupiter | `6.0.3` | — | via `spring-boot-starter-test` |
|
||||||
|
|
||||||
|
## The pin was deliberately bumped mid-batch: `4.1.0`/`7.4.1.Final` → `4.1.1`/`7.4.5.Final`
|
||||||
|
|
||||||
|
Earlier chapters in this repository (00–03) were originally written and verified against Spring
|
||||||
|
Boot `4.1.0`, which resolves Hibernate ORM `7.4.1.Final`. This repository has since been bumped
|
||||||
|
to Spring Boot `4.1.1`, which resolves a *different* Hibernate patch, `7.4.5.Final` — four
|
||||||
|
Hibernate patch releases ahead, from the same "4.1" Spring Boot minor line. Every chapter added
|
||||||
|
this session (04–14), and this file, are verified against the new pin: `7.4.5.Final`. If you spot
|
||||||
|
`7.4.1.Final` or Spring Boot `4.1.0` anywhere in this repository outside of this historical note,
|
||||||
|
that is stale and should be corrected.
|
||||||
|
|
||||||
|
This is worth explaining precisely, because the mechanism is the same one that could bite you on
|
||||||
|
your *next* bump, not just the one already made.
|
||||||
|
|
||||||
|
Checking `spring-boot-dependencies-4.1.0.pom` directly showed
|
||||||
|
`<hibernate.version>7.4.1.Final</hibernate.version>` — so on Boot 4.1.0, this repo's `pom.xml`
|
||||||
|
did not need to override anything to get 7.4.1.Final; the `<hibernate.version>` property was
|
||||||
|
redundant with what Boot already resolved, kept only so the pin was visible without cracking open
|
||||||
|
Boot's own POM.
|
||||||
|
|
||||||
|
That stopped 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. Bumping this repo's parent to `4.1.1`
|
||||||
|
*without* also updating the `<hibernate.version>` property in `pom.xml` would have silently kept
|
||||||
|
`7.4.1.Final` (the explicit property would have overridden Boot's own management) instead of the
|
||||||
|
`7.4.5.Final` Boot actually intends for that release — which is exactly the trap this repo avoids
|
||||||
|
by keeping the property in lockstep with whatever Boot version is pinned, rather than treating it
|
||||||
|
as a one-time, set-and-forget value.
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
The lesson generalizes past this one bump: **never assume a Spring Boot patch release leaves
|
||||||
|
Hibernate's patch version untouched.** Two Boot releases that look adjacent by patch number
|
||||||
|
("4.1.0" vs "4.1.1") can be four Hibernate patch releases apart. Always re-check
|
||||||
|
`spring-boot-dependencies-<version>.pom` directly (or run a `mvn dependency:tree` /
|
||||||
|
`dependency:resolve` against the new parent) after any Boot version bump, rather than assuming
|
||||||
|
last time's Hibernate pin still applies.
|
||||||
|
|
||||||
|
## Databases and other dependencies added since the original three-chapter batch
|
||||||
|
|
||||||
|
Chapters 04–14 exercise three JDBC databases side by side, plus a handful of dependencies none
|
||||||
|
of chapters 00–03 needed:
|
||||||
|
|
||||||
|
| Component | Version | Why it's here |
|
||||||
|
|---|---|---|
|
||||||
|
| H2 | `2.4.240` | primary in-memory database for most chapters |
|
||||||
|
| HSQLDB | `2.7.3` | genuine SQL/PSM stored procedures (chapter 08); cross-database comparison (chapter 09) |
|
||||||
|
| Apache Derby (`derby` + `derbytools`) | `10.16.1.1` | cross-database comparison (chapter 09) |
|
||||||
|
| `hibernate-community-dialects` | `7.4.5.Final` | **required**, not optional — `DerbyDialect` was removed from `hibernate-core` in Hibernate 6.2+ and now lives here, under `org.hibernate.community.dialect` |
|
||||||
|
| `com.github.h-thurow:simple-jndi` | `0.25.0` | mocking a JNDI `DataSource` without a real container (chapter 10) — test scope only |
|
||||||
|
| `tools.jackson.core:jackson-databind` | managed by Boot's BOM | required for `@JdbcTypeCode(SqlTypes.JSON)` to work at all (chapter 05) — `spring-boot-starter-data-jpa` alone does not pull in a JSON mapper |
|
||||||
|
| `hibernate-jcache` + `javax.cache:cache-api` + `org.ehcache:ehcache` (`jakarta` classifier) | `7.4.5.Final` / `1.1.1` / `3.10.8` | measuring `@NaturalIdCache` L2 behavior with real numbers (chapter 06) — see chapter 09 for the classpath-pollution side effect this has on *every other test in the repo*, which is why `hibernate.cache.use_second_level_cache: false` is pinned explicitly in `application.yml` |
|
||||||
|
|
||||||
|
## Verified against `maven-metadata.xml`, not Maven Central's search index
|
||||||
|
|
||||||
|
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)
|
||||||
Executable
+134
@@ -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)
|
||||||
Executable
+170
@@ -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)
|
||||||
Executable
+198
@@ -0,0 +1,198 @@
|
|||||||
|
# 03 — Hibernate 7 batch inserts: proving batching is working
|
||||||
|
|
||||||
|
[← Previous: 02 — merge() vs refresh()](02-merge-vs-refresh.md) | [Next: 04 — Annotations vs. XML mappings →](04-annotations-vs-xml.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) | [Next: 04 — Annotations vs. XML mappings →](04-annotations-vs-xml.md)
|
||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# 04 — Annotations vs. XML mappings in Hibernate 7.4.5 — what actually still works
|
||||||
|
|
||||||
|
[← Previous: 03 — Inserting objects](03-inserting-objects.md) | [Next: 05 — JPA persistence annotations →](05-jpa-persistence-annotations.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: Hibernate 7 annotations vs. XML mappings](https://ankurm.com/hibernate-annotations-vs-xml-mappings-making-the-right-choice-in-hibernate-7/).
|
||||||
|
|
||||||
|
Post 4863 frames this as a two-way choice: annotations vs. `orm.xml`, with `hbm.xml` waved off
|
||||||
|
as "deprecated." That framing undersells what actually happens when you boot Hibernate
|
||||||
|
7.4.5.Final with each of these on the classpath, and it misses that there are really **three**
|
||||||
|
XML dialects in play, not two. Everything below was booted, not read about — see
|
||||||
|
[`docs/output/mappingstyle-xml-vs-annotations.txt`](output/mappingstyle-xml-vs-annotations.txt) for the verbatim run, and the test classes
|
||||||
|
under [`src/test/java/com/ankurm/hibernatedemo/mappingstyle/`](../src/test/java/com/ankurm/hibernatedemo/mappingstyle/) for the exact setup of each case.
|
||||||
|
|
||||||
|
## `hbm.xml` is not dead — it is a live code path with a warning label
|
||||||
|
|
||||||
|
The Hibernate 6 story was: `hbm.xml` is deprecated, and `hibernate.transform_hbm_xml.enabled`
|
||||||
|
is a shim that rewrites it into the modern model at boot. That shim setting still exists in
|
||||||
|
7.4.5 (`org.hibernate.cfg.MappingSettings.TRANSFORM_HBM_XML`, and the whole
|
||||||
|
`org.hibernate.boot.jaxb.hbm.*` package plus an `HbmXmlTransformer` are present in
|
||||||
|
`hibernate-core-7.4.5.Final.jar`), but it is **not required** to use `hbm.xml`.
|
||||||
|
|
||||||
|
[`HbmXmlBootTest`](../src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlBootTest.java) and [`HbmXmlRuntimeTest`](../src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlRuntimeTest.java) put a real, annotation-free POJO
|
||||||
|
([`HbmEmployee`](../src/main/java/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.java)) on the classpath with only an [`.hbm.xml` mapping](../src/test/resources/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml), and boot a plain Hibernate
|
||||||
|
`SessionFactory` three ways:
|
||||||
|
|
||||||
|
- default settings (no `transform_hbm_xml.enabled` at all) — **boots successfully**, logs one
|
||||||
|
WARN: `HHH90000028: Support for <hibernate-mappings/> is deprecated ... migrate to orm.xml or
|
||||||
|
mapping.xml, or enable hibernate.transform_hbm_xml.enabled for on the fly transformation`
|
||||||
|
- `hibernate.transform_hbm_xml.enabled=true` — boots successfully, no observable behavior
|
||||||
|
difference for a simple mapping
|
||||||
|
- `hibernate.transform_hbm_xml.enabled=false` (explicit) — **also boots successfully**
|
||||||
|
|
||||||
|
`HbmXmlRuntimeTest` goes further: it actually persists and loads an `HbmEmployee` row through
|
||||||
|
the hbm.xml-only mapping, with the transform setting deliberately unset, and it round-trips
|
||||||
|
correctly. So the honest 7.4.5 status of `hbm.xml` is: **it still fully works, unmodified,
|
||||||
|
with a WARN-level deprecation notice** — not a shim you must opt into, not a hard failure, and
|
||||||
|
not silently broken. The "transform" setting appears to matter for hbm.xml features that no
|
||||||
|
longer have a native binding path in 7.x and must be rewritten into the modern model to be
|
||||||
|
understood at all; a plain `<class>`/`<id>`/`<property>` mapping like this one never needs it.
|
||||||
|
Treat any blog claim that `hbm.xml` "requires" the transform flag, or throws without it, as
|
||||||
|
wrong for 7.4.5 — it is a live code path. (Chapter 14 hits the same "orm.xml still works with
|
||||||
|
zero registration effort" theme from the named-query angle — see
|
||||||
|
[`14 — Named queries`](14-named-queries.md#named-queries-in-ormxml).)
|
||||||
|
|
||||||
|
## `orm.xml` really can define an entire entity, annotation-free
|
||||||
|
|
||||||
|
Post 4863's `orm.xml` example only *overrides* an already-annotated `Employee`. It never proves
|
||||||
|
`orm.xml` can carry a mapping on its own. [`OrmXmlMappingResourcesTest`](../src/test/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlMappingResourcesTest.java) does: [`OrmXmlOnlyEntity`](../src/main/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlOnlyEntity.java)
|
||||||
|
carries **zero** JPA/Hibernate annotations — not even `@Entity` — and its entire mapping lives
|
||||||
|
in [`orm-xml-only-mapping.xml`](../src/test/resources/orm-xml-only-mapping.xml). The Spring Boot wiring that makes this work is
|
||||||
|
`spring.jpa.mapping-resources` (confirmed via `javap` on
|
||||||
|
`org.springframework.boot.jpa.autoconfigure.JpaProperties` in `spring-boot-jpa-4.1.1.jar` —
|
||||||
|
note the package: Boot 4's autoconfigure split moved this off the old
|
||||||
|
`org.springframework.boot.autoconfigure.orm.jpa` path entirely). With that one property set,
|
||||||
|
Hibernate creates `xml_only_widgets`, and a JPQL query (`select o from OrmXmlOnlyEntity o ...`)
|
||||||
|
against it succeeds — the entity name resolves purely from the XML.
|
||||||
|
|
||||||
|
## XML wins on conflict — confirmed, not just documented
|
||||||
|
|
||||||
|
Post 4863's Q5 claims "XML always wins over annotations." [`AnnotationXmlOverrideTest`](../src/test/java/com/ankurm/hibernatedemo/mappingstyle/AnnotationXmlOverrideTest.java) proves it
|
||||||
|
mechanically: [`OverrideEntity`](../src/main/java/com/ankurm/hibernatedemo/mappingstyle/OverrideEntity.java)`.value` is annotated `@Column(name = "annotation_name")`, and a
|
||||||
|
matching [`orm-xml-override-mapping.xml`](../src/test/resources/orm-xml-override-mapping.xml) entry maps the same field to `xml_name`. Reading the runtime metamodel
|
||||||
|
(`SessionFactoryImplementor.getMappingMetamodel().getEntityDescriptor(...).getPropertyColumnNames(...)`)
|
||||||
|
confirms the live column name is `xml_name`, and a native query against that literal column
|
||||||
|
name returns the persisted value. The article's claim is correct.
|
||||||
|
|
||||||
|
## `<xml-mapping-metadata-complete/>` is not an override switch — it is an annotation kill switch
|
||||||
|
|
||||||
|
This is the sharpest correction. The natural assumption is that
|
||||||
|
`<persistence-unit-metadata><xml-mapping-metadata-complete/></persistence-unit-metadata>` means
|
||||||
|
"XML overrides annotations for the attributes XML mentions." [`XmlMappingMetadataCompleteTest`](../src/test/java/com/ankurm/hibernatedemo/mappingstyle/XmlMappingMetadataCompleteTest.java)
|
||||||
|
shows it is stronger than that: with [metadata-complete set](../src/test/resources/orm-xml-metadata-complete.xml), `OverrideEntity`'s `@Id
|
||||||
|
@GeneratedValue` on the `id` field is **entirely ignored**, even though the matching `orm.xml`
|
||||||
|
entry never mentions `id` at all. Boot fails with
|
||||||
|
`org.hibernate.AnnotationException: Entity '...OverrideEntity' has no identifier`. Metadata-complete
|
||||||
|
does not selectively override — it switches off annotation processing for the whole persistence
|
||||||
|
unit, and any attribute XML doesn't repeat is simply gone.
|
||||||
|
|
||||||
|
## The real "what XML can do that annotations can't" — a third XML dialect
|
||||||
|
|
||||||
|
Post 4863 never mentions this, and it changes the shape of the whole topic: Hibernate 7 ships a
|
||||||
|
**third** XML mapping format, distinct from both legacy `hbm.xml` and JPA-standard `orm.xml`.
|
||||||
|
It lives in `org/hibernate/xsd/mapping/mapping-7.0.xsd` inside `hibernate-core-7.4.5.Final.jar`,
|
||||||
|
under namespace `http://www.hibernate.org/xsd/orm/mapping`, and its own XSD documentation calls
|
||||||
|
it out explicitly: *"XSD which 'extends' the JPA orm.xml XSD adding support for Hibernate
|
||||||
|
specific features."* This is exactly the second migration target the `HHH90000028` deprecation
|
||||||
|
warning names ("migrate to orm.xml **or mapping.xml**").
|
||||||
|
|
||||||
|
The difference is not cosmetic. Grepping both schemas for Hibernate-only concepts:
|
||||||
|
|
||||||
|
| Element | `orm_3_2.xsd` (JPA-standard `orm.xml`) | `mapping-7.0.xsd` (Hibernate's native dialect) |
|
||||||
|
|---|---|---|
|
||||||
|
| `<natural-id>` | absent | present |
|
||||||
|
| `<formula>` / `<discriminator-formula>` / `<join-formula>` | absent | present |
|
||||||
|
| `<filter>`-related elements | absent | present (11 occurrences) |
|
||||||
|
|
||||||
|
[`MappingXmlNaturalIdTest`](../src/test/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdTest.java) proves this is not just schema noise: [`MappingXmlNaturalIdEntity`](../src/main/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdEntity.java) has
|
||||||
|
**zero** annotations, its `<natural-id>` is declared purely in [`mapping-xml-natural-id.xml`](../src/test/resources/mapping-xml-natural-id.xml)
|
||||||
|
(root element `<entity-mappings xmlns="http://www.hibernate.org/xsd/orm/mapping" version="7.0">`),
|
||||||
|
and `session.byNaturalId(...).using("sku", ...).load()` resolves it correctly. So the accurate
|
||||||
|
answer to "what can XML express that annotations can't" is actually inverted from how the
|
||||||
|
article poses it: the JPA-portable `orm.xml` dialect can express **less** than annotations
|
||||||
|
(no Hibernate extensions at all), while Hibernate's own `mapping.xml` dialect can express
|
||||||
|
**anything annotations can**, including natural ids, formulas, and filters — you trade JPA
|
||||||
|
portability for that power, not gain something unavailable to annotations. (Chapter 06 covers
|
||||||
|
`@NaturalId` from the annotation side; this is the same feature expressed purely in XML.)
|
||||||
|
|
||||||
|
## Practical takeaway
|
||||||
|
|
||||||
|
- Legacy `hbm.xml`: still boots and runs in 7.4.5, WARN-level deprecated, no forced migration.
|
||||||
|
- `orm.xml` (JPA-standard): fully capable of defining an entity from scratch; wired into Spring
|
||||||
|
Boot via `spring.jpa.mapping-resources`; portable across providers but capped at what
|
||||||
|
`orm_3_2.xsd` can express — no Hibernate-only concepts.
|
||||||
|
- Hibernate's native `mapping.xml` (`mapping-7.0.xsd`): the actual annotation-equivalent XML
|
||||||
|
dialect, including `@NaturalId`, `@Formula`, and filters — not portable to other JPA
|
||||||
|
providers, but not missing anything either.
|
||||||
|
- XML (either dialect) always wins over a conflicting annotation for the same attribute.
|
||||||
|
- `xml-mapping-metadata-complete` disables annotation processing for the *entire* persistence
|
||||||
|
unit, not just the attributes the XML repeats — a much bigger blast radius than "override."
|
||||||
|
|
||||||
|
[← Previous: 03 — Inserting objects](03-inserting-objects.md) | [Next: 05 — JPA persistence annotations →](05-jpa-persistence-annotations.md)
|
||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
# 05 — JPA persistence annotations in Hibernate 7.4.5 / Jakarta Persistence 3.2 — what's actually new or broken
|
||||||
|
|
||||||
|
[← Previous: 04 — Annotations vs. XML mappings](04-annotations-vs-xml.md) | [Next: 06 — Natural IDs →](06-natural-ids.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: mastering JPA persistence annotations in Hibernate 7](https://ankurm.com/mastering-hibernate-7-the-ultimate-guide-to-jpa-persistence-annotations/).
|
||||||
|
|
||||||
|
Post 4864 is a solid annotation catalogue; this chapter deliberately does not repeat it. Instead
|
||||||
|
it covers what is wrong, deprecated, or new in 3.2 that the article predates or gets slightly
|
||||||
|
wrong, each verified by `javap` on the real jars and a runnable test — see
|
||||||
|
[`docs/output/persistenceannotations-tests.txt`](output/persistenceannotations-tests.txt) and
|
||||||
|
[`src/test/java/com/ankurm/hibernatedemo/persistenceannotations/`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/).
|
||||||
|
|
||||||
|
## `@Temporal` is formally `@Deprecated(since = "3.2")` — and it is a silent no-op, not silent-silent
|
||||||
|
|
||||||
|
`javap -v` on `jakarta.persistence.Temporal` in `jakarta.persistence-api-3.2.0.jar` shows:
|
||||||
|
|
||||||
|
```
|
||||||
|
RuntimeVisibleAnnotations:
|
||||||
|
java.lang.Deprecated(since="3.2")
|
||||||
|
```
|
||||||
|
|
||||||
|
Post 4864 says (correctly) that `@Temporal` isn't needed for `java.time` types. What it doesn't
|
||||||
|
say: putting `@Temporal` on a `java.time.LocalDate` field anyway does **not** boot silently.
|
||||||
|
[`TemporalOnJavaTimeTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnJavaTimeTest.java) shows Hibernate logs a WARN at boot for every such field, using [`TemporalOnLocalDateEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnLocalDateEntity.java):
|
||||||
|
|
||||||
|
```
|
||||||
|
HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal]
|
||||||
|
at ...TemporalOnLocalDateEntity.eventDate.
|
||||||
|
```
|
||||||
|
|
||||||
|
The mapping itself is unaffected — the field round-trips identically with or without the
|
||||||
|
annotation — but "silent" is the wrong word for what happens; it is a one-line-per-field boot
|
||||||
|
warning, which is worth knowing if you're trying to track down noisy startup logs after a
|
||||||
|
Hibernate upgrade. (Chapter 13 measures the same deprecation warning against an `Instant` field
|
||||||
|
and covers the rest of `@Temporal`'s replacement, `@TimeZoneStorage` — see
|
||||||
|
[`13 — Date and time mapping`](13-date-and-time-mapping.md#temporal-verified-deprecated-and-verified-harmless-when-misapplied).)
|
||||||
|
|
||||||
|
## `@Enumerated` default (ORDINAL): the real failure mode, reproduced
|
||||||
|
|
||||||
|
The article correctly recommends `EnumType.STRING` over the ORDINAL default, but doesn't show
|
||||||
|
the failure concretely. [`EnumOrdinalDefaultTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/EnumOrdinalDefaultTest.java) does: persist a row with `OrderStatus.SHIPPED`
|
||||||
|
(ordinal 1 in the original 3-constant enum, [`EnumDefaultOrdinalEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumDefaultOrdinalEntity.java)), then read the **same physical row** back through a
|
||||||
|
second entity/enum pair ([`EnumReorderedV2Entity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumReorderedV2Entity.java)) where a new constant (`PENDING_REVIEW`) was inserted *before* `SHIPPED`.
|
||||||
|
No exception anywhere — the row silently comes back tagged `PENDING_REVIEW`. This is exactly the
|
||||||
|
"someone edited the enum without a migration" scenario, reproduced with two real
|
||||||
|
`SessionFactory` instances against the same physical H2 database (needed because Hibernate
|
||||||
|
refuses to map the same table twice inside one persistence unit, so this can't be done inside a
|
||||||
|
single Spring context).
|
||||||
|
|
||||||
|
## `@JdbcTypeCode(SqlTypes.JSON)` works on H2 2.4.240 — but only with a JSON mapper on the classpath
|
||||||
|
|
||||||
|
The article recommends `@JdbcTypeCode(SqlTypes.JSON)` without dependency caveats. First attempt
|
||||||
|
against the existing `hibernate-demo` pom (Boot starter + Data JPA + H2, no Jackson) failed
|
||||||
|
outright, tested against [`JsonColumnEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnEntity.java) in [`JsonColumnOnH2Test`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnOnH2Test.java):
|
||||||
|
|
||||||
|
```
|
||||||
|
org.hibernate.HibernateException: Could not find a FormatMapper for the JSON format, which is
|
||||||
|
required for mapping JSON types. JSON FormatMapper configuration is automatic, but requires that
|
||||||
|
you have either Jackson or a JSONB implementation like Yasson on the class path.
|
||||||
|
```
|
||||||
|
|
||||||
|
This matters because `spring-boot-starter-data-jpa` does **not** pull in Jackson — most real
|
||||||
|
apps have Jackson anyway (via `spring-boot-starter-web`), which is presumably why this is easy
|
||||||
|
to miss. Adding Jackson 3 makes it work cleanly, H2 storing it as a native `JSON` column type:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>tools.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
<version>3.1.5</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
(Note the groupId: Boot 4.1.1's BOM calls this artifact `tools.jackson:jackson-bom` at the BOM
|
||||||
|
level, but the actual `jackson-databind` module publishes under `tools.jackson.core`, not
|
||||||
|
`tools.jackson` — the deploy step will fail with a bare `groupId:jackson-databind` guess.)
|
||||||
|
Hibernate ships `Jackson3JsonFormatMapper` and the older `JacksonJsonFormatMapper` (Jackson 2)
|
||||||
|
side by side in 7.4.5, so either major version works once present.
|
||||||
|
|
||||||
|
## The equals/hashCode HashSet trap — reproduced end to end
|
||||||
|
|
||||||
|
Post 4864's Q5 warns against surrogate-id-based `equals()`/`hashCode()`. [`IdBasedEqualsHashSetTrapTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsHashSetTrapTest.java)
|
||||||
|
builds the actual failure: an entity ([`IdBasedEqualsEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsEntity.java) / [`IdentityHashSetEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdentityHashSetEntity.java)) with `equals()`/`hashCode()` on the `@GeneratedValue` `id`
|
||||||
|
is added to a `HashSet` while `id` is still `null`, then `persist()`d. The **same reference**,
|
||||||
|
looked up in the **same** `Set`, comes back `contains() == false` — because the hash code
|
||||||
|
changed after insertion and `HashSet` is now probing the wrong bucket. Manual iteration with
|
||||||
|
`equals()` still finds it, confirming it's specifically the hash-bucket indexing that breaks,
|
||||||
|
not equality itself. (Chapter 06 shows the natural-id-based version of `equals()`/`hashCode()`
|
||||||
|
does not have this problem — see
|
||||||
|
[`06 — Natural IDs`](06-natural-ids.md#the-equalshashcode-advice-holds-up--with-one-nuance-the-article-doesnt-mention).)
|
||||||
|
|
||||||
|
## Access type mixing: two real, non-obvious symptoms
|
||||||
|
|
||||||
|
The article never discusses `@Access`/mixed access at all. [`MixedAccessTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessTest.java) (against [`MixedAccessEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessEntity.java)) reproduces two
|
||||||
|
things worth knowing:
|
||||||
|
|
||||||
|
1. Hibernate's default PROPERTY-access strategy **requires a setter**, even for a logically
|
||||||
|
read-only derived attribute — omitting one throws `PropertyNotFoundException: Could not locate
|
||||||
|
setter method for property 'computedLabel'` at boot. A no-op setter is the workaround if the
|
||||||
|
attribute is truly meant to be read-only.
|
||||||
|
2. A PROPERTY-access getter with side effects (a call counter, here) is invoked **more than
|
||||||
|
once per flush** by Hibernate (2 calls observed for one insert) — once for dirty-check
|
||||||
|
comparison, once for the actual write. Any "just compute it in the getter" derived
|
||||||
|
PROPERTY-access attribute pays that cost on every flush, not once per logical read.
|
||||||
|
|
||||||
|
## What's actually new in Jakarta Persistence 3.2 (verified via `javap` on `jakarta.persistence-api-3.2.0.jar`)
|
||||||
|
|
||||||
|
Three things this article predates, each confirmed present in the 3.2.0 jar and exercised in
|
||||||
|
[`Jpa32NewFeaturesTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/Jpa32NewFeaturesTest.java):
|
||||||
|
|
||||||
|
- **`@EnumeratedValue`** (`@Target(FIELD)` only — cannot go on a getter) lets an enum control its
|
||||||
|
own persisted representation via a designated field. Tested against [`EnumeratedValueEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumeratedValueEntity.java): a `Priority` enum with a `code`
|
||||||
|
field (`"L"`/`"M"`/`"H"`) persists that exact string, not the ordinal or `name()` — but it
|
||||||
|
still needs `@Enumerated(EnumType.STRING)` on the entity field, or boot fails with
|
||||||
|
`@EnumeratedValue for EnumType.ORDINAL must be placed on a field whose type is byte, short, or
|
||||||
|
int` (ORDINAL is still JPA's overall default even when `@EnumeratedValue` is present).
|
||||||
|
- **`TypedQuery.getSingleResultOrNull()`** returns `null` for a zero-row match instead of
|
||||||
|
throwing `NoResultException` — confirmed via `javap` on `jakarta.persistence.TypedQuery` and
|
||||||
|
exercised directly. (Chapter 14 exercises the same method on a plain `Query` — see
|
||||||
|
[`14 — Named queries`](14-named-queries.md#getsingleresultornull-vs-getsingleresult).)
|
||||||
|
- **JPQL constructor expressions targeting a Java `record`** work: `select new
|
||||||
|
com.example.PriorityCountView(e.priority, count(e)) from ... group by e.priority` populates a
|
||||||
|
`record` [`PriorityCountView(Priority priority, long total)`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/PriorityCountView.java) via its canonical constructor,
|
||||||
|
matched positionally, exactly like a regular DTO class would have been pre-3.2.
|
||||||
|
|
||||||
|
## Numbers
|
||||||
|
|
||||||
|
| Test | Result | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `@Temporal` on `LocalDate` | boots + round-trips; 1 WARN log line per field | `persistenceannotations-tests.txt` |
|
||||||
|
| Enum ORDINAL reorder | stored ordinal 1 resolves to wrong constant, no exception | `persistenceannotations-tests.txt` |
|
||||||
|
| `@JdbcTypeCode(JSON)` on H2 | works once `tools.jackson.core:jackson-databind` present; H2 column type = `JSON` | `persistenceannotations-tests.txt` |
|
||||||
|
| Surrogate-id equals in HashSet | `contains()` false after persist, same reference | `persistenceannotations-tests.txt` |
|
||||||
|
| Mixed access getter | called 2x per flush | `persistenceannotations-tests.txt` |
|
||||||
|
| `@EnumeratedValue` | persists `"H"` not ordinal `2` or name `"HIGH"` | `persistenceannotations-tests.txt` |
|
||||||
|
|
||||||
|
The exact failure, reproduced with every JSON provider stripped off the classpath, is captured in [`docs/output/persistenceannotations-json-no-formatmapper.txt`](output/persistenceannotations-json-no-formatmapper.txt) — `spring-boot-starter-data-jpa` alone does not bring one.
|
||||||
|
|
||||||
|
[← Previous: 04 — Annotations vs. XML mappings](04-annotations-vs-xml.md) | [Next: 06 — Natural IDs →](06-natural-ids.md)
|
||||||
Executable
+182
@@ -0,0 +1,182 @@
|
|||||||
|
# 06 — Hibernate 7 Natural IDs — what L1 and L2 actually give you, measured
|
||||||
|
|
||||||
|
[← Previous: 05 — JPA persistence annotations](05-jpa-persistence-annotations.md) | [Next: 07 — Immutable entities →](07-immutable-entities.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: Hibernate 7 natural IDs](https://ankurm.com/master-hibernate-7-natural-ids-the-definitive-guide-for-high-performance-java-apps/).
|
||||||
|
|
||||||
|
Post 4865 makes several precise claims about what natural-id resolution buys you with and
|
||||||
|
without a second-level cache. All of them were tested with Hibernate Statistics rather than
|
||||||
|
taken on faith — see [`docs/output/naturalid-tests.txt`](output/naturalid-tests.txt) for the verbatim counts and the test
|
||||||
|
classes under [`src/test/java/com/ankurm/hibernatedemo/naturalid/`](../src/test/java/com/ankurm/hibernatedemo/naturalid/) for exact setup.
|
||||||
|
|
||||||
|
## The API surface: three methods, not two
|
||||||
|
|
||||||
|
`javap` on `org.hibernate.Session` in `hibernate-core-7.4.5.Final.jar` confirms all three natural-id
|
||||||
|
entry points exist, each with `Class` and entity-name (`String`) overloads:
|
||||||
|
|
||||||
|
```
|
||||||
|
NaturalIdLoadAccess<T> byNaturalId(Class<T>)
|
||||||
|
SimpleNaturalIdLoadAccess<T> bySimpleNaturalId(Class<T>)
|
||||||
|
NaturalIdMultiLoadAccess<T> byMultipleNaturalId(Class<T>)
|
||||||
|
```
|
||||||
|
|
||||||
|
Post 4865 only ever demonstrates `byNaturalId(...).using(...).load()` and
|
||||||
|
`byMultipleNaturalId(...).multiLoad(...)`. It never mentions `bySimpleNaturalId`, which is the
|
||||||
|
more ergonomic form for the single-field case the article's own `Product`/`sku` example uses —
|
||||||
|
`session.bySimpleNaturalId(Product.class).load("SKU-123")` instead of
|
||||||
|
`.byNaturalId(Product.class).using("sku", "SKU-123").load()`. Not wrong, just an incomplete API
|
||||||
|
tour; [`NaturalIdL1CacheTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL1CacheTest.java) and [`NaturalIdL2CacheTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL2CacheTest.java) use `bySimpleNaturalId` throughout to
|
||||||
|
close that gap, exercising [`NaturalIdProduct`](../src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdProduct.java) and [`CachedNaturalIdProduct`](../src/main/java/com/ankurm/hibernatedemo/naturalid/CachedNaturalIdProduct.java) respectively.
|
||||||
|
|
||||||
|
## The central question: does L1 save a query, with NO L2 cache configured?
|
||||||
|
|
||||||
|
**Yes — confirmed exactly as the article claims.** `NaturalIdL1CacheTest` persists a row, then
|
||||||
|
calls `session.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-1")` twice in the *same*
|
||||||
|
session, with no `@NaturalIdCache`, no `@Cache`, no L2 provider configured anywhere:
|
||||||
|
|
||||||
|
- 1st call: 1 query (`getPrepareStatementCount()` goes from 0 to 1)
|
||||||
|
- 2nd call, same session: **still 1** — zero additional queries, and the returned reference is
|
||||||
|
`isSameAs()` the first
|
||||||
|
|
||||||
|
A second test in the same class proves the flip side the article also asserts: a **new**
|
||||||
|
session repeating the identical lookup *does* re-fire the query (cumulative count goes from 1 to
|
||||||
|
2). So the L1 natural-id-to-PK map is real, it does save a query on repeat lookups, and it dies
|
||||||
|
with the session exactly as documented.
|
||||||
|
|
||||||
|
## Turning on L2: `hibernate-jcache` + `ehcache`
|
||||||
|
|
||||||
|
The article recommends "@NaturalIdCache is mandatory for cross-session performance" without
|
||||||
|
showing numbers. To get real ones, this required going online (these two dependencies were not
|
||||||
|
already warm in the sandbox's `~/.m2`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-jcache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.ehcache</groupId>
|
||||||
|
<artifactId>ehcache</artifactId>
|
||||||
|
<version>3.10.8</version>
|
||||||
|
<classifier>jakarta</classifier>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>org.glassfish.jaxb</groupId>
|
||||||
|
<artifactId>jaxb-runtime</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things worth flagging for anyone reproducing this: (1) `hibernate-jcache` correctly pulls
|
||||||
|
`javax.cache:cache-api` — **not** a `jakarta.cache` artifact, because JSR-107 (JCache) was never
|
||||||
|
migrated to the Jakarta namespace, unlike JPA/Bean Validation/etc.; (2) plain `org.ehcache:ehcache`
|
||||||
|
resolves but drags in an ancient `javax.xml.bind:jaxb-api` transitively through
|
||||||
|
`jaxb-runtime` that fails to resolve from Maven Central (a dead `maven.java.net` mirror) — the
|
||||||
|
`jakarta` classifier plus excluding `jaxb-runtime` sidesteps it cleanly.
|
||||||
|
|
||||||
|
Settings used (`hibernate.cache.region.factory_class=jcache`,
|
||||||
|
`hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider`) are exercised in
|
||||||
|
`NaturalIdL2CacheTest`, booted as a standalone `SessionFactory` (not through the shared Spring
|
||||||
|
context) so the L2 configuration doesn't leak into the other agents' tests. **This is the same
|
||||||
|
`hibernate-jcache` dependency that turns out to have a much bigger, repo-wide side effect than
|
||||||
|
"enables the natural-id cache when you ask for it" — see
|
||||||
|
[chapter 09's writeup of `JCacheOnClasspathAutoEnablesL2Test`](09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not)
|
||||||
|
for why `application.yml` in this repo pins `hibernate.cache.use_second_level_cache: false`
|
||||||
|
explicitly, everywhere, regardless of what any individual chapter wants.**
|
||||||
|
|
||||||
|
## The surprise: `@NaturalIdCache` populates on INSERT, not on first lookup
|
||||||
|
|
||||||
|
The natural mental model is "L2 cache fills on the first miss." That is only half true.
|
||||||
|
`bySimpleNaturalId_withNaturalIdCache_crossSession_savesTheQuery` shows that immediately after
|
||||||
|
`persist()` + `commit()` on a `@NaturalIdCache`-annotated entity, `Statistics.getNaturalIdCachePutCount()`
|
||||||
|
is already `1` — **before anyone has looked the entity up by its natural id at all.** The
|
||||||
|
consequence: both a same-process "first" lookup and a genuinely new-session lookup resolve with
|
||||||
|
**zero** additional queries and register as cache hits (`getNaturalIdCacheHitCount()` = 2 across
|
||||||
|
both). If your mental model of L2 warm-up is "miss once per cold value," that is wrong for
|
||||||
|
entities inserted through Hibernate itself.
|
||||||
|
|
||||||
|
To see the miss-then-hit path the article's mental model actually describes,
|
||||||
|
`bySimpleNaturalId_rowInsertedOutsideHibernate_firstLookupIsARealMiss_secondIsARealHit` inserts a
|
||||||
|
row via raw JDBC (bypassing Hibernate's persist-time cache population entirely). *That* row
|
||||||
|
produces a real recorded miss + put on the first `bySimpleNaturalId` call (1 query, 1 miss,
|
||||||
|
1 put), and a real hit with zero additional queries on the second, brand-new-session call. Both
|
||||||
|
behaviors are real; the article only describes the second one, and most natural-id rows in a
|
||||||
|
typical app are inserted through Hibernate, which means the first (surprising) path is actually
|
||||||
|
the common case in practice, not the edge case.
|
||||||
|
|
||||||
|
## Mutability: immutable natural ids are enforced, not just conventionally recommended
|
||||||
|
|
||||||
|
Post 4865 recommends immutability as a best practice but doesn't say what enforcement exists.
|
||||||
|
[`NaturalIdMutabilityTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdMutabilityTest.java) shows Hibernate actively checks it: mutating a
|
||||||
|
`@NaturalId` field left at its default (`mutable = false`, [`ImmutableNaturalIdEntity`](../src/main/java/com/ankurm/hibernatedemo/naturalid/ImmutableNaturalIdEntity.java)) and flushing throws
|
||||||
|
|
||||||
|
```
|
||||||
|
org.hibernate.HibernateException: An immutable natural identifier of entity
|
||||||
|
...ImmutableNaturalIdEntity was altered from `CODE-A` to `CODE-B`
|
||||||
|
```
|
||||||
|
|
||||||
|
This fires even with no `@Column(updatable = false)` guard on the column — it is Hibernate's own
|
||||||
|
natural-id consistency check, not a side effect of a JPA column setting. `@NaturalId(mutable =
|
||||||
|
true)` ([`MutableNaturalIdEntity`](../src/main/java/com/ankurm/hibernatedemo/naturalid/MutableNaturalIdEntity.java)), by contrast, flushes the change through cleanly (verified via a native query reading the
|
||||||
|
updated column back), and a subsequent `bySimpleNaturalId` lookup by the *old* value correctly
|
||||||
|
returns `null` while the *new* value resolves to the same row — no stale L1 entry survives the
|
||||||
|
mutation within the same test's fresh sessions.
|
||||||
|
|
||||||
|
## Composite natural ids: the article's exact code sample, verified
|
||||||
|
|
||||||
|
[`Department`](../src/main/java/com/ankurm/hibernatedemo/naturalid/Department.java)'s two-field `@NaturalId` (`company`, `deptCode`) is lifted straight from post
|
||||||
|
4865's example. [`CompositeNaturalIdTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/CompositeNaturalIdTest.java) proves it actually works: two [`Department`](../src/main/java/com/ankurm/hibernatedemo/naturalid/Department.java) rows with the
|
||||||
|
*same* `deptCode` ("ENG-01") at two *different* [`Company`](../src/main/java/com/ankurm/hibernatedemo/naturalid/Company.java) instances resolve independently via
|
||||||
|
`session.byNaturalId(Department.class).using("company", acme).using("deptCode", "ENG-01").load()`,
|
||||||
|
in exactly one query. The generated SQL, captured verbatim:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select d1_0.id,c1_0.id,c1_0.name,d1_0.dept_code,d1_0.name
|
||||||
|
from department d1_0
|
||||||
|
left join company c1_0 on c1_0.id=d1_0.company_id
|
||||||
|
where d1_0.company_id=? and d1_0.dept_code=?
|
||||||
|
```
|
||||||
|
|
||||||
|
Note it filters on `company_id` (the FK column), not by joining into `Company`'s own natural id
|
||||||
|
or PK independently — the composite key is resolved as a single WHERE clause, as expected.
|
||||||
|
|
||||||
|
## The equals/hashCode advice holds up — with one nuance the article doesn't mention
|
||||||
|
|
||||||
|
Posts 4864/4865 recommend basing `equals()`/`hashCode()` on the natural id rather than the
|
||||||
|
surrogate id, specifically to avoid the "hash code changes after `persist()`" trap. This was
|
||||||
|
tested head-to-head against the surrogate-id version (see the sibling `persistenceannotations`
|
||||||
|
package's [`IdBasedEqualsHashSetTrapTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsHashSetTrapTest.java), which reproduces the surrogate-id failure — see
|
||||||
|
[chapter 05](05-jpa-persistence-annotations.md#the-equalshashcode-hashset-trap--reproduced-end-to-end)):
|
||||||
|
with natural-id-based `equals()`/`hashCode()` ([`NaturalIdEqualsEntity`](../src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsEntity.java)), an entity added to a `HashSet` *before* `persist()`
|
||||||
|
is still found by `contains()` *after* `persist()` — the advice is correct, because the natural
|
||||||
|
id (and hence the hash code) is set at construction time and never changes.
|
||||||
|
|
||||||
|
The nuance: **two distinct transient objects that happen to share the same natural id value are
|
||||||
|
already `equals()` to each other, before either is persisted.** [`NaturalIdEqualsHashSetTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsHashSetTest.java)
|
||||||
|
shows a `HashSet.add()` on a second transient instance with the same `sku` but a different
|
||||||
|
`name` is silently rejected as a duplicate. That is usually the intended behavior (natural ids
|
||||||
|
are supposed to be unique business keys), but it means natural-id-based equality can mask a bug
|
||||||
|
where two *meant-to-be-different* objects were accidentally constructed with the same business
|
||||||
|
key before either ever reached the database — a failure mode the surrogate-id approach cannot
|
||||||
|
produce (two transient surrogate-id objects are never accidentally "the same" via `equals()`
|
||||||
|
unless you specifically wrote id-based equality, which the article correctly warns against for
|
||||||
|
other reasons).
|
||||||
|
|
||||||
|
## Numbers summary
|
||||||
|
|
||||||
|
| Scenario | Queries | Cache hits/misses | Source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| No L2, `bySimpleNaturalId` x2, same session | 1, then 1 (0 new) | n/a | `naturalid-tests.txt` |
|
||||||
|
| No L2, `bySimpleNaturalId`, new session | 1, then 2 cumulative (+1) | n/a | `naturalid-tests.txt` |
|
||||||
|
| `@NaturalIdCache`, insert then 2 lookups (own inserts) | 0 additional after insert | 2 hits, 0 miss | `naturalid-tests.txt` |
|
||||||
|
| `@NaturalIdCache`, row inserted outside Hibernate | 1 (miss), then 1 cumulative (0 new) | 1 miss+put, then 1 hit | `naturalid-tests.txt` |
|
||||||
|
| Composite natural id lookup | 1 | n/a | `naturalid-tests.txt` |
|
||||||
|
|
||||||
|
All counts measured via `SessionFactory.getStatistics()` in this sandbox; treat absolute timings
|
||||||
|
as indicative only, but the query *counts* are exact and reproducible (deterministic, not
|
||||||
|
timing-based).
|
||||||
|
|
||||||
|
The three natural-id entry points on `Session`, read straight off the jar, are in [`docs/output/naturalid-javap-session.txt`](output/naturalid-javap-session.txt).
|
||||||
|
|
||||||
|
[← Previous: 05 — JPA persistence annotations](05-jpa-persistence-annotations.md) | [Next: 07 — Immutable entities →](07-immutable-entities.md)
|
||||||
Executable
+130
@@ -0,0 +1,130 @@
|
|||||||
|
# 07 — `@Immutable` entities in Hibernate 7 (post 4866)
|
||||||
|
|
||||||
|
[← Previous: 06 — Natural IDs](06-natural-ids.md) | [Next: 08 — Stored procedures →](08-stored-procedures.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: Hibernate 7 immutable entities](https://ankurm.com/mastering-hibernate-7-immutable-entities-performance-safety-and-best-practices/).
|
||||||
|
Verified on Hibernate ORM 7.4.5.Final, jakarta.persistence-api 3.2.0,
|
||||||
|
H2 2.4.240, JDK 25 (Temurin 25.0.4.1).
|
||||||
|
|
||||||
|
Test classes: [`ImmutableEntityTest`](../src/test/java/com/ankurm/hibernatedemo/ImmutableEntityTest.java),
|
||||||
|
[`ImmutableBulkUpdateAllowedTest`](../src/test/java/com/ankurm/hibernatedemo/ImmutableBulkUpdateAllowedTest.java),
|
||||||
|
[`ImmutableFlushCostTest`](../src/test/java/com/ankurm/hibernatedemo/ImmutableFlushCostTest.java).
|
||||||
|
Entities: [`immutable/`](../src/main/java/com/ankurm/hibernatedemo/immutable/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw -Dtest=ImmutableEntityTest,ImmutableBulkUpdateAllowedTest,ImmutableFlushCostTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw captured output:
|
||||||
|
[`immutable-headline-and-boundaries.txt`](output/immutable-headline-and-boundaries.txt),
|
||||||
|
[`immutable-javap-annotation.txt`](output/immutable-javap-annotation.txt).
|
||||||
|
|
||||||
|
## What `@Immutable` actually is, per the class file
|
||||||
|
|
||||||
|
`javap -v org.hibernate.annotations.Immutable` (Hibernate ORM 7.4.5.Final) shows it targets
|
||||||
|
`TYPE`, `METHOD`, `FIELD` and carries **zero annotation members** — no `value()`, nothing to
|
||||||
|
configure. It's a pure marker. That matches how the source articles use it, but is worth stating
|
||||||
|
precisely: there is no per-field opt-out, no "immutable except this column" mode. Immutability at
|
||||||
|
the entity level is all-or-nothing; the granularity you get is choosing which *fields* or which
|
||||||
|
*collection* to put the annotation on, not tuning behaviour within one.
|
||||||
|
|
||||||
|
## The headline behaviour: silence, not an exception
|
||||||
|
|
||||||
|
Mutate a managed `@Immutable` entity's field and flush inside a transaction. Nothing happens —
|
||||||
|
literally nothing observable. No `UPDATE` is sent (`Statistics.getEntityUpdateCount()` stays at
|
||||||
|
`0`), and `commit()` does not throw. The row you reload afterwards is untouched. This is the part
|
||||||
|
worth building intuition around: `@Immutable` is not a guard that rejects writes, it's a filter
|
||||||
|
that makes Hibernate blind to them. If you were expecting a `StaleStateException` or a validation
|
||||||
|
failure when someone accidentally mutates one of these entities, you will not get one — you get
|
||||||
|
quiet data loss of the in-memory change, and the database keeps whatever it already had.
|
||||||
|
|
||||||
|
## The three things `@Immutable` does NOT stop
|
||||||
|
|
||||||
|
Verified independently, each behaving differently:
|
||||||
|
|
||||||
|
- **`EntityManager.remove()` / DELETE** — goes through normally. `@Immutable` only removes the
|
||||||
|
entity from *dirty-checking*; it says nothing about the persister's ability to issue a DELETE
|
||||||
|
when you explicitly ask for one.
|
||||||
|
- **Native SQL** — always works, unconditionally. Native SQL never goes through Hibernate's
|
||||||
|
entity-state machinery at all, so there is no layer for `@Immutable` to intercept.
|
||||||
|
- **Bulk HQL `update ... set ...`** — this is the one correction to make explicitly, because the
|
||||||
|
intuitive answer is wrong. It is tempting to assume bulk HQL bypasses `@Immutable` the same way
|
||||||
|
native SQL does (both skip per-entity dirty checking). It does not: Hibernate 7.4.5 refuses to
|
||||||
|
even *translate* the query, at HQL-compile time, before touching the database:
|
||||||
|
|
||||||
|
```
|
||||||
|
org.hibernate.query.sqm.InterpretationException: Error interpreting query
|
||||||
|
[The query attempts to update an immutable entity: [exchange_rate]
|
||||||
|
(set 'hibernate.query.immutable_entity_update_query_handling_mode' to suppress)]
|
||||||
|
```
|
||||||
|
|
||||||
|
The property named in the message,
|
||||||
|
`org.hibernate.cfg.QuerySettings.IMMUTABLE_ENTITY_UPDATE_QUERY_HANDLING_MODE`
|
||||||
|
(`hibernate.query.immutable_entity_update_query_handling_mode`), is a `SessionFactory`-wide
|
||||||
|
three-way enum: `EXCEPTION` (default), `WARNING`, `ALLOW`. Set to `ALLOW`, the bulk update
|
||||||
|
actually executes against the immutable table — verified in
|
||||||
|
`ImmutableBulkUpdateAllowedTest`, a separate Spring context with the property set, since it is
|
||||||
|
not a per-query hint. Bulk DELETE via HQL, by contrast, is not blocked at all — only bulk
|
||||||
|
UPDATE has this guard.
|
||||||
|
|
||||||
|
## `@Immutable` on a collection
|
||||||
|
|
||||||
|
Putting `@Immutable` on a `@OneToMany` is a *separate* annotation usage from putting it on the
|
||||||
|
owning entity — you can have a mutable parent with an immutable child collection (that's what
|
||||||
|
`RateWithAuditTrail` demonstrates). Adding an element to that collection and flushing throws, and
|
||||||
|
the exact shape matters if you're writing a catch clause: the immediate exception is
|
||||||
|
`jakarta.persistence.RollbackException` wrapping the transaction commit, and its root cause is a
|
||||||
|
plain `org.hibernate.HibernateException` (not a dedicated subclass) with the message:
|
||||||
|
|
||||||
|
```
|
||||||
|
Immutable collection was modified: [<Entity>.<collection> with owner id '<id>']
|
||||||
|
```
|
||||||
|
|
||||||
|
Catch `HibernateException` (or inspect the cause chain), not a more specific type — there isn't
|
||||||
|
one.
|
||||||
|
|
||||||
|
## `@Immutable` + `@Version`
|
||||||
|
|
||||||
|
Hibernate 7.4.5 accepts the combination without a startup error. It is exactly as inert as the
|
||||||
|
source article warns: the version column is written once at INSERT (starting at `0`) and never
|
||||||
|
increments afterward, because there is no UPDATE for it to ride along on. This isn't a distinct
|
||||||
|
code path from the headline case — it's the same "flush sees a null snapshot, skips the entity
|
||||||
|
entirely" mechanism, applied to an entity that happens to also carry a `@Version` field.
|
||||||
|
|
||||||
|
## `@Immutable` vs `Session.setReadOnly()` / `setDefaultReadOnly()`
|
||||||
|
|
||||||
|
Both `Session.setReadOnly(entity, true)` (per-instance) and `Session.setDefaultReadOnly(true)`
|
||||||
|
(session-wide default for everything loaded after the call) produce the *same observable
|
||||||
|
outcome* as `@Immutable` on a mutated-and-flushed entity: zero `UPDATE`s, no exception. The
|
||||||
|
difference is entirely about *when* the decision is made and *how durable* it is:
|
||||||
|
|
||||||
|
| | `@Immutable` | `setReadOnly()` / `setDefaultReadOnly()` |
|
||||||
|
|---|---|---|
|
||||||
|
| Scope | Class-level, every instance, every session | Per entity instance, or per session |
|
||||||
|
| Decided | At mapping time (compile time) | At runtime, per `Session` |
|
||||||
|
| Reversible | No (would need a redeploy) | Yes, per instance or per session |
|
||||||
|
| Cost paid | Never allocates a snapshot at all | Still allocates the snapshot; the read-only flag is checked at flush instead |
|
||||||
|
|
||||||
|
That cost line is the one worth measuring rather than asserting. `ImmutableFlushCostTest` loads
|
||||||
|
4,000 rows of a 12-column entity (both `@Immutable` and plain, no pending changes) into a fresh
|
||||||
|
persistence context and times a single `flush()` around the load. On this sandbox (a shared
|
||||||
|
container — treat as indicative of direction and rough magnitude, not a citable number), flushing
|
||||||
|
the mutable set took **~8.5ms**; the `@Immutable` set took **~1.9–2.5ms** across two runs — a
|
||||||
|
**3.4×–4.5× difference**, purely from Hibernate having a snapshot to compare 12 fields against on
|
||||||
|
one side and nothing to check at all on the other. `setReadOnly()`/`setDefaultReadOnly()` sit
|
||||||
|
architecturally on the "still allocates a snapshot" side of that line — they suppress the
|
||||||
|
*write*, not the *snapshot allocation and comparison* `@Immutable` skips outright. Confirming that
|
||||||
|
distinction with a clean timing delta would need a dedicated benchmark isolating snapshot
|
||||||
|
allocation specifically; this test measures the flush-time symptom, not the allocation itself, so
|
||||||
|
say that plainly rather than overclaiming a mechanism from a flush timing.
|
||||||
|
|
||||||
|
## Practical takeaway
|
||||||
|
|
||||||
|
`@Immutable` is a mapping-time, all-instances, unconditional decision. Reach for it for data that
|
||||||
|
is *architecturally* never going to change (reference data, audit rows, historical snapshots).
|
||||||
|
Reach for `Session.setReadOnly()` instead when the read-only-ness is a *per-request* or
|
||||||
|
*per-session* decision — a reporting query that happens to load entities it has no business
|
||||||
|
writing back, for instance — where you want the same flush suppression without committing the
|
||||||
|
entity class itself to being permanently immutable.
|
||||||
|
|
||||||
|
[← Previous: 06 — Natural IDs](06-natural-ids.md) | [Next: 08 — Stored procedures →](08-stored-procedures.md)
|
||||||
Executable
+183
@@ -0,0 +1,183 @@
|
|||||||
|
# 08 — Stored procedures with Hibernate 7 (merges posts 4867 + 4881)
|
||||||
|
|
||||||
|
[← Previous: 07 — Immutable entities](07-immutable-entities.md) | [Next: 09 — Testing with in-memory databases →](09-testing-in-memory-databases.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: stored procedures with Hibernate 7](https://ankurm.com/mastering-stored-procedures-with-hibernate-7-a-deep-dive-for-high-performance-java-apps/).
|
||||||
|
|
||||||
|
Posts 4867 (`@NamedStoredProcedureQuery`) and 4881 (general stored-procedure guide) cover the
|
||||||
|
same ground from two angles — annotation-driven metadata and the programmatic
|
||||||
|
`StoredProcedureQuery` API — and both demo a MySQL `DELIMITER //` procedure that was never
|
||||||
|
actually run. This chapter merges them into one topic and, for the first time, executes every
|
||||||
|
example against a real database: **HSQLDB 2.7.3**, which supports genuine SQL/PSM
|
||||||
|
`CREATE PROCEDURE` with IN/OUT/INOUT parameters and cursor-backed result sets.
|
||||||
|
|
||||||
|
Verified on Hibernate ORM 7.4.5.Final, jakarta.persistence-api 3.2.0, HSQLDB 2.7.3, JDK 25.
|
||||||
|
|
||||||
|
Test classes:
|
||||||
|
[`StoredProcedureHappyPathTest`](../src/test/java/com/ankurm/hibernatedemo/procedure/StoredProcedureHappyPathTest.java),
|
||||||
|
[`ProcedureFailureModesTest`](../src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureFailureModesTest.java),
|
||||||
|
[`ProcedureSchemaSupport`](../src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureSchemaSupport.java)
|
||||||
|
(the real `CREATE PROCEDURE` DDL). Entities:
|
||||||
|
[`procedure/`](../src/main/java/com/ankurm/hibernatedemo/procedure/) —
|
||||||
|
[`ProcEmployee`](../src/main/java/com/ankurm/hibernatedemo/procedure/ProcEmployee.java),
|
||||||
|
[`EmployeeSummary`](../src/main/java/com/ankurm/hibernatedemo/procedure/EmployeeSummary.java).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw -Dtest=StoredProcedureHappyPathTest,ProcedureFailureModesTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw output: [`procedure-happy-path.txt`](output/procedure-happy-path.txt),
|
||||||
|
[`procedure-failure-modes.txt`](output/procedure-failure-modes.txt),
|
||||||
|
[`procedure-hsqldb-jdbc-driver-quirk.txt`](output/procedure-hsqldb-jdbc-driver-quirk.txt),
|
||||||
|
[`procedure-javap-api-surface.txt`](output/procedure-javap-api-surface.txt).
|
||||||
|
|
||||||
|
## The API surface, confirmed by javap, not by reading docs
|
||||||
|
|
||||||
|
`jakarta.persistence-api-3.2.0.jar` really does contain `NamedStoredProcedureQuery`,
|
||||||
|
`StoredProcedureParameter`, and `StoredProcedureQuery` exactly where both articles say. All
|
||||||
|
three parameter modes (`IN`, `OUT`, `INOUT`) plus `REF_CURSOR` exist on `ParameterMode`. This
|
||||||
|
part of the articles was accurate; it just needed a receipt.
|
||||||
|
|
||||||
|
## IN / OUT — three call styles, same real result
|
||||||
|
|
||||||
|
A single HSQLDB procedure, `GET_TAX(IN emp_id INT, OUT tax_amount DECIMAL(10,2))`, computing
|
||||||
|
`salary * 0.15`, was called three ways and produced the same live database result each time
|
||||||
|
([`docs/output/procedure-happy-path.txt`](output/procedure-happy-path.txt)):
|
||||||
|
|
||||||
|
- `@NamedStoredProcedureQuery` + `EntityManager.createNamedStoredProcedureQuery(name)`
|
||||||
|
- unnamed, via `EntityManager.createStoredProcedureQuery("GET_TAX")` +
|
||||||
|
`registerStoredProcedureParameter(...)`
|
||||||
|
- unnamed, via `Session.createStoredProcedureQuery("GET_TAX")` (Hibernate-native entry point,
|
||||||
|
same JPA-shaped return type)
|
||||||
|
|
||||||
|
Employee id 1, salary 50,000.00 → tax **7,500.00**, exactly as the (previously unexecuted)
|
||||||
|
article predicted.
|
||||||
|
|
||||||
|
## INOUT — round-trips through the same parameter slot
|
||||||
|
|
||||||
|
`ADJUST_SALARY(INOUT sal DECIMAL(10,2), IN bonus_pct DECIMAL(5,2))` takes 1,000.00 and 10%,
|
||||||
|
returns **1,100.00** through `getOutputParameterValue("sal")` — the same parameter object used
|
||||||
|
for both the input bind and the output read. This confirms the articles' basic INOUT claim; the
|
||||||
|
part they didn't cover is what happens when you get the setup wrong (see Pitfalls below).
|
||||||
|
|
||||||
|
## Result sets: where Hibernate + HSQLDB genuinely does not work
|
||||||
|
|
||||||
|
This needed to be said plainly rather than faked. A `DYNAMIC RESULT SETS 1` procedure that opens
|
||||||
|
a cursor (`LIST_EMPLOYEES()`) works perfectly over raw JDBC — `CallableStatement.executeQuery()`
|
||||||
|
returns the rows without complaint. But Hibernate's `ProcedureCallImpl` doesn't call
|
||||||
|
`executeQuery()`; it calls `execute()` and trusts its boolean return to decide whether a
|
||||||
|
`ResultSetOutput` exists. A raw-JDBC probe isolates the exact defect:
|
||||||
|
|
||||||
|
```
|
||||||
|
execute() returned=false <- HSQLDB driver says "no result set"
|
||||||
|
getResultSet() = <live ResultSet with 2 rows> <- but there is one
|
||||||
|
```
|
||||||
|
|
||||||
|
Because Hibernate believes the (wrong) `false`, both `@NamedStoredProcedureQuery(resultClasses =
|
||||||
|
ProcEmployee.class)` and `createStoredProcedureQuery(name, "EmployeeSummaryMapping")` (the
|
||||||
|
`@SqlResultSetMapping`-to-DTO path) fail identically:
|
||||||
|
|
||||||
|
```
|
||||||
|
java.lang.IllegalStateException: Current CallableStatement was not a ResultSet, but getResultList was called
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is a real HSQLDB-JDBC-driver incompatibility, not a mapping mistake** — verified by
|
||||||
|
reproducing the underlying JDBC behaviour outside Hibernate entirely
|
||||||
|
([`docs/output/procedure-hsqldb-jdbc-driver-quirk.txt`](output/procedure-hsqldb-jdbc-driver-quirk.txt)). It blocks the "map a procedure's result
|
||||||
|
set to an entity" and "map it to a DTO via `@SqlResultSetMapping`" scenarios specifically on
|
||||||
|
HSQLDB + Hibernate 7.4.5. A database whose driver reports `execute()` correctly for cursor
|
||||||
|
results — PostgreSQL's REF_CURSOR support, or MySQL/SQL Server's direct-result-set procedures —
|
||||||
|
would not hit this; a real PostgreSQL REF_CURSOR run was not attempted in this pass (treated as
|
||||||
|
optional per scope) and would be the natural follow-up if this chapter needs the mapped-result-set
|
||||||
|
demo running end-to-end.
|
||||||
|
|
||||||
|
One incidental, useful finding from the same probe: HSQLDB does **not** enforce the classic
|
||||||
|
"consume the result set before reading OUT parameters" ordering rule some drivers impose. On a
|
||||||
|
procedure with both an OUT parameter and a cursor, the OUT value reads correctly whether you read
|
||||||
|
it before, interleaved with, or after draining the cursor. That specific folklore pitfall is real
|
||||||
|
on some databases, not on this one — worth saying explicitly rather than repeating as universal.
|
||||||
|
|
||||||
|
## The failure modes (the actual point of this chapter)
|
||||||
|
|
||||||
|
All verbatim, from `ProcedureFailureModesTest`:
|
||||||
|
|
||||||
|
- **Wrong parameter name, right position** — registering a parameter under a name the procedure
|
||||||
|
does not have (`"employee_id"` vs. the real `"emp_id"`) **does not fail and does not silently
|
||||||
|
null out**. HSQLDB's driver calls procedures with positional `{call GET_TAX(?, ?)}` syntax —
|
||||||
|
the name never reaches the database. Hibernate maps `registerStoredProcedureParameter(name,
|
||||||
|
...)` to the Nth JDBC placeholder by **registration order**, and `name` is purely a client-side
|
||||||
|
label for later `setParameter`/`getOutputParameterValue` calls. The call still returns the
|
||||||
|
correct 7,500.00. The corollary: **parameter order is the thing that must be right; the name is
|
||||||
|
cosmetic** for a driver like this one. This directly validates the one piece of caution the
|
||||||
|
original article got right ("ensure the order... matches the database definition") while
|
||||||
|
correcting the implicit assumption that a name mismatch would be caught.
|
||||||
|
- **`ParameterMode` mismatch** — registering the real IN parameter as `OUT` throws immediately at
|
||||||
|
registration/bind time:
|
||||||
|
`org.hibernate.exception.GenericJDBCException: Unable to register CallableStatement OUT
|
||||||
|
parameter [Invalid argument in JDBC call: Not OUT or INOUT mode for parameter: 1]`. Swapping
|
||||||
|
the IN/OUT slots by position produces the identical error — this is the one failure mode that
|
||||||
|
*does* fail loudly and immediately, unlike the name mismatch above.
|
||||||
|
- **`getResultList()` on a procedure with no result set** — throws the exact same
|
||||||
|
`IllegalStateException: Current CallableStatement was not a ResultSet, but getResultList was
|
||||||
|
called` as the HSQLDB result-set incompatibility above. Same exception, two different causes
|
||||||
|
(one is a real absence of a result set, the other is a driver misreporting one that exists) —
|
||||||
|
worth knowing they're indistinguishable from the exception alone.
|
||||||
|
- **"Forgetting" `execute()`** — corrects another assumption. Calling
|
||||||
|
`getOutputParameterValue()` without an explicit `execute()` call first does **not** throw
|
||||||
|
"you forgot to call execute()". Hibernate's `ProcedureCallImpl` lazily triggers the JDBC
|
||||||
|
execution itself the first time an output is requested, and returns the correct value. The
|
||||||
|
"forgetting execute()" pitfall from blog folklore is not reproducible against Hibernate
|
||||||
|
7.4.5's `StoredProcedureQuery` for OUT-parameter access.
|
||||||
|
|
||||||
|
## Flush behaviour and the persistence context — the important trap, confirmed
|
||||||
|
|
||||||
|
Persisting a new row and calling a procedure that counts rows **in the same transaction, without
|
||||||
|
an explicit flush**, does **not** see the new row:
|
||||||
|
|
||||||
|
```
|
||||||
|
COUNT_EMPLOYEES before persisting a new row = 2
|
||||||
|
COUNT_EMPLOYEES after persist() but WITHOUT an explicit flush() = 2
|
||||||
|
COUNT_EMPLOYEES after an explicit flush() = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Stored procedure calls do not trigger Hibernate's usual auto-flush-before-query behaviour. This
|
||||||
|
matters because HQL, Criteria, and even plain native queries against a synchronized entity/table
|
||||||
|
normally DO auto-flush first. A second test closes the obvious escape hatch: the Hibernate-native
|
||||||
|
`ProcedureCall`'s `addSynchronizedEntityClass(...)` — documented, for HQL/native queries, to force
|
||||||
|
exactly this kind of auto-flush — has **no effect** when called on a `ProcedureCall`. An unflushed
|
||||||
|
`persist()` stayed invisible to `COUNT_EMPLOYEES` even after declaring the synchronization. The
|
||||||
|
practical rule: **always flush explicitly before calling a stored procedure that needs to see
|
||||||
|
pending changes in the same transaction; there is no annotation-level escape hatch.**
|
||||||
|
|
||||||
|
Cache implications (2LC / query cache) were not independently re-measured here beyond confirming
|
||||||
|
the flush behaviour above — the FAQ claim that mutating procedures leave L2 cache entries stale
|
||||||
|
until manually evicted is consistent with the "no auto-flush, no auto-invalidate" pattern observed
|
||||||
|
and is the safe assumption to keep in the merged chapter.
|
||||||
|
|
||||||
|
## `ProcedureCall` (Hibernate-native) vs `StoredProcedureQuery` (JPA)
|
||||||
|
|
||||||
|
`javap org.hibernate.procedure.ProcedureCall` ([`docs/output/procedure-javap-api-surface.txt`](output/procedure-javap-api-surface.txt))
|
||||||
|
confirms it extends `jakarta.persistence.StoredProcedureQuery` — every JPA method is available —
|
||||||
|
and adds, among others:
|
||||||
|
|
||||||
|
- `markAsFunctionCall(Class<?> | int | Type<?>)` — calling a database **function**, not just a
|
||||||
|
procedure, something the JPA-standard `StoredProcedureQuery` has no direct concept of.
|
||||||
|
`FUNCTION_RETURN_TYPE_HINT` backs this.
|
||||||
|
`getFunctionReturn()` retrieves the typed function result separately from OUT parameters.
|
||||||
|
- `addSynchronizedQuerySpace(String)` / `addSynchronizedEntityName(String)` /
|
||||||
|
`addSynchronizedEntityClass(Class)` — inherited from `SynchronizeableQuery`; present on the
|
||||||
|
native API but (per above) inert for auto-flush purposes on procedure calls specifically.
|
||||||
|
- Typed parameter registration via `jakarta.persistence.metamodel.Type<T>` in addition to
|
||||||
|
`Class<T>`, and `getRegisteredParameters()` / `getParameterRegistration(...)` for introspecting
|
||||||
|
what's already bound.
|
||||||
|
- `AutoCloseable` — `ProcedureCall` can be used in try-with-resources; `StoredProcedureQuery`
|
||||||
|
cannot.
|
||||||
|
|
||||||
|
For portable, Jakarta-EE-standard code, `EntityManager.createStoredProcedureQuery(...)` /
|
||||||
|
`@NamedStoredProcedureQuery` is the right default — everything in the "happy path" section above
|
||||||
|
works identically through it. Reach for `Session.createStoredProcedureCall(...)` specifically for
|
||||||
|
function calls (`markAsFunctionCall`) or when you need to introspect parameter registrations
|
||||||
|
programmatically; do not reach for it expecting `addSynchronizedEntityClass` to save you a
|
||||||
|
manual `flush()`.
|
||||||
|
|
||||||
|
[← Previous: 07 — Immutable entities](07-immutable-entities.md) | [Next: 09 — Testing with in-memory databases →](09-testing-in-memory-databases.md)
|
||||||
Executable
+247
@@ -0,0 +1,247 @@
|
|||||||
|
# 09 — In-memory databases for testing: H2, HSQLDB and Derby, run side by side and verified
|
||||||
|
|
||||||
|
[← Previous: 08 — Stored procedures](08-stored-procedures.md) | [Next: 10 — Mocking JNDI datasources →](10-mocking-jndi-datasources.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: configuring in-memory databases for bulletproof unit testing](https://ankurm.com/master-hibernate-7-configuring-in-memory-databases-for-bulletproof-unit-testing/).
|
||||||
|
|
||||||
|
Companion code: [`TestDbWidget`](../src/main/java/com/ankurm/hibernatedemo/testdb/TestDbWidget.java) (one entity,
|
||||||
|
run against all three engines) and [`src/test/java/com/ankurm/hibernatedemo/testdb/`](../src/test/java/com/ankurm/hibernatedemo/testdb/) (six test
|
||||||
|
classes, 16 tests, all green together -- `docs/output/testdb-*.txt`). Environment: Hibernate ORM
|
||||||
|
7.4.5.Final, JDK 25, H2 2.4.240, HSQLDB 2.7.3, Apache Derby 10.16.1.1. All three bootstrapped
|
||||||
|
directly through plain Hibernate (`StandardServiceRegistryBuilder`/`MetadataSources`), not
|
||||||
|
through Spring Boot's single-datasource autoconfiguration, specifically so all three could be
|
||||||
|
stood up side by side against the exact same mapping without needing three separate Spring
|
||||||
|
contexts.
|
||||||
|
|
||||||
|
## Headline correction: Derby has no dialect in Hibernate 7 without an extra dependency
|
||||||
|
|
||||||
|
This is the biggest surprise in this chapter, and it isn't in the article being replaced at all.
|
||||||
|
Wiring up Derby against Hibernate 7.4.5.Final the "obvious" way fails, twice, in sequence:
|
||||||
|
|
||||||
|
1. With no `hibernate.dialect` set (which works fine for both H2 and HSQLDB, relying on JDBC
|
||||||
|
metadata auto-detection): `Unable to determine Dialect for Apache Derby 10.16 (please set
|
||||||
|
'hibernate.dialect' or register a Dialect resolver)`. The JDBC connection itself is fine --
|
||||||
|
the product name and version ("Apache Derby 10.16") were read successfully. Hibernate's
|
||||||
|
dialect resolver chain simply no longer recognizes it.
|
||||||
|
2. The "obvious" fix -- set `hibernate.dialect=org.hibernate.dialect.DerbyDialect`, the FQCN
|
||||||
|
every existing blog post and Stack Overflow answer uses -- fails too:
|
||||||
|
`ClassNotFoundException: Could not load requested class: org.hibernate.dialect.DerbyDialect`.
|
||||||
|
That class does not exist anywhere in `hibernate-core-7.4.5.Final.jar` (confirmed:
|
||||||
|
`unzip -l ... | grep -i derby` -- zero matches).
|
||||||
|
|
||||||
|
Hibernate 6.2+ moved a set of less-common dialects out of `hibernate-core` into a separate,
|
||||||
|
opt-in artifact, `org.hibernate.orm:hibernate-community-dialects`. Derby's dialect now lives
|
||||||
|
there, under a **different package**: `org.hibernate.community.dialect.DerbyDialect` (confirmed
|
||||||
|
present via `unzip -l hibernate-community-dialects-7.4.5.Final.jar | grep -i derby`). Both
|
||||||
|
failures, verbatim, and the working fix are in
|
||||||
|
[`docs/output/testdb-derby-dialect-not-found.txt`](output/testdb-derby-dialect-not-found.txt). The dependency this needed is already wired into this
|
||||||
|
repo's shared [`pom.xml`](../pom.xml):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-community-dialects</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
With that dependency and `hibernate.dialect=org.hibernate.community.dialect.DerbyDialect` set
|
||||||
|
explicitly, Derby resolves and works completely normally for everything else in this chapter.
|
||||||
|
|
||||||
|
## `hibernate-jcache` on the classpath turns on L2 for everyone, whether you asked or not
|
||||||
|
|
||||||
|
This is the second headline finding in this chapter, and it is not scoped to Derby, or even to
|
||||||
|
testing in-memory databases specifically -- it is a repo-wide gotcha that this chapter is the
|
||||||
|
right place to document because [`JCacheOnClasspathAutoEnablesL2Test`](../src/test/java/com/ankurm/hibernatedemo/testdb/JCacheOnClasspathAutoEnablesL2Test.java) lives in this package.
|
||||||
|
Chapter 06 needs `hibernate-jcache` + `ehcache` on the classpath to measure `@NaturalIdCache`
|
||||||
|
(see [chapter 06](06-natural-ids.md#turning-on-l2-hibernate-jcache--ehcache)), and that dependency
|
||||||
|
is shared across the whole project's single `pom.xml` -- there's no way to scope it to only the
|
||||||
|
natural-id tests.
|
||||||
|
|
||||||
|
`JCacheOnClasspathAutoEnablesL2Test` boots a bare `SessionFactory` with **zero** cache settings
|
||||||
|
configured anywhere -- no `hibernate.cache.*` property, no `@Cache`/`@NaturalIdCache` annotation
|
||||||
|
in sight -- and finds:
|
||||||
|
|
||||||
|
```
|
||||||
|
second-level cache enabled = true
|
||||||
|
region factory = org.hibernate.cache.jcache.internal.JCacheRegionFactory
|
||||||
|
```
|
||||||
|
|
||||||
|
Hibernate 7.4.5 resolves a `RegionFactory` through the `ServiceLoader` at boot, and merely
|
||||||
|
*finding one on the classpath* is enough for it to turn the second-level cache on by itself.
|
||||||
|
There is no explicit configuration anywhere that asks for this -- `grep -ic cache` against
|
||||||
|
[`application.yml`](../src/main/resources/application.yml) before this was pinned down returned `0`.
|
||||||
|
|
||||||
|
This was not a hypothetical risk -- it broke a real, unrelated test. Before
|
||||||
|
`hibernate.cache.use_second_level_cache: false` was pinned explicitly in `application.yml`, a
|
||||||
|
full `mvn test` run failed with an order-dependent error: a standalone natural-id-cache test
|
||||||
|
closed the shared Ehcache `CacheManager` it had spun up, and the next Spring-context test to
|
||||||
|
commit a transaction failed with `Cache[...] is closed`, verbatim in
|
||||||
|
[`docs/output/testdb-jcache-classpath-pollution.txt`](output/testdb-jcache-classpath-pollution.txt):
|
||||||
|
|
||||||
|
```
|
||||||
|
[ERROR] ImmutableEntityTest.nativeSqlUpdate_onImmutableEntity_alwaysWorks:184 » Rollback Error while committing the transaction [Unable to perform afterTransactionCompletion callback: Cache[com.ank
|
||||||
|
```
|
||||||
|
|
||||||
|
The fix is the explicit pin in `application.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hibernate:
|
||||||
|
cache:
|
||||||
|
use_second_level_cache: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Not "leave it unset and hope no cache provider ever lands on the classpath" -- an unset value in
|
||||||
|
this codebase resolves to `true` the moment `hibernate-jcache` is present, regardless of intent.
|
||||||
|
Any project that adds `hibernate-jcache` (or any other JCache/Ehcache/Infinispan provider) for
|
||||||
|
one narrow use case should assume it just turned L2 on globally unless it pins the setting back
|
||||||
|
down explicitly, the same way this repo does.
|
||||||
|
|
||||||
|
## Dialect auto-selection, confirmed for all three (`DialectAndDdlTest`)
|
||||||
|
|
||||||
|
| Database | Resolved `Dialect` class |
|
||||||
|
|---|---|
|
||||||
|
| H2 (plain) | `org.hibernate.dialect.H2Dialect` |
|
||||||
|
| H2 `MODE=PostgreSQL` | `org.hibernate.dialect.H2Dialect` |
|
||||||
|
| H2 `MODE=Oracle` | `org.hibernate.dialect.H2Dialect` |
|
||||||
|
| HSQLDB | `org.hibernate.dialect.HSQLDialect` |
|
||||||
|
| Derby | `org.hibernate.community.dialect.DerbyDialect` (explicit setting required, see above) |
|
||||||
|
|
||||||
|
## The MODE= question, settled
|
||||||
|
|
||||||
|
This is the single most misunderstood H2 feature, and the answer is unambiguous once you
|
||||||
|
actually build a `SessionFactory` against each URL and read off the resolved `Dialect` class
|
||||||
|
(`DialectAndDdlTest.h2WithPostgresModeInTheUrl_...` / `...OracleMode...`, both green): **`MODE=`
|
||||||
|
changes what SQL H2 itself will parse and accept -- it does not change which Hibernate `Dialect`
|
||||||
|
class gets selected.** Hibernate's dialect resolution reads the JDBC driver's own
|
||||||
|
`DatabaseMetaData.getDatabaseProductName()`, and H2 reports itself as `H2` regardless of the
|
||||||
|
`MODE=` parameter in the URL. `H2Dialect` is what generates DDL and SQL in all three cases; the
|
||||||
|
resulting `create table` statements in [`docs/output/testdb-create-table-ddl.txt`](output/testdb-create-table-ddl.txt) are
|
||||||
|
byte-for-byte identical across plain H2, `MODE=PostgreSQL` and `MODE=Oracle`. If you need
|
||||||
|
Hibernate to actually emit PostgreSQL- or Oracle-flavoured SQL, you set `hibernate.dialect`
|
||||||
|
yourself; `MODE=` alone will not do it, and testing against H2 in a given `MODE=` is not the
|
||||||
|
same thing as testing against `PostgreSQLDialect`.
|
||||||
|
|
||||||
|
## Generated DDL, and where it turned out to be identical, not different
|
||||||
|
|
||||||
|
The same `TestDbWidget` mapping (`GenerationType.AUTO` id, `varchar(5)` SKU, an `@Lob` clob
|
||||||
|
description, a `boolean`, and a reserved-word-shaped column defensively quoted as `"order"`)
|
||||||
|
produces **textually identical** `create table` and `create sequence` statements across H2,
|
||||||
|
HSQLDB and Derby ([`docs/output/testdb-create-table-ddl.txt`](output/testdb-create-table-ddl.txt)):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create sequence TestDbWidget_SEQ start with 1 increment by 50
|
||||||
|
create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id))
|
||||||
|
```
|
||||||
|
|
||||||
|
This corrects an assumption going in: the expectation was to find `varchar` vs `character
|
||||||
|
varying`, or different boolean/clob keyword choices, across these three specific engines.
|
||||||
|
They didn't diverge -- all three dialects happen to accept the same ANSI-shaped keywords
|
||||||
|
(`boolean`, `varchar(n)`, `clob`) for this particular mapping. The real divergence, it turned
|
||||||
|
out, is not in the DDL keywords Hibernate chooses, but in which *identifiers* each engine's
|
||||||
|
parser accepts unquoted -- see below.
|
||||||
|
|
||||||
|
## `GenerationType.AUTO`: identical across all three here
|
||||||
|
|
||||||
|
`AUTO` resolved to a native `SEQUENCE` (`TestDbWidget_SEQ`, allocation size 50) on **all three**
|
||||||
|
engines -- H2, HSQLDB and Derby all support native sequences, so Hibernate 7 has no reason to
|
||||||
|
fall back to `TABLE` or `IDENTITY` for any of them. This is worth stating plainly because AUTO's
|
||||||
|
reputation for being unpredictable comes from databases like MySQL that lack native sequences
|
||||||
|
entirely; for this specific trio, AUTO does not actually diverge.
|
||||||
|
|
||||||
|
## The concrete cross-database failure case, proven with real SQL (`CrossDatabaseBehaviorTest`)
|
||||||
|
|
||||||
|
The brief for this chapter specifically asked for a genuine "passes on X, fails on Y" case, not
|
||||||
|
a folklore restatement. Two were found, one confirmed and one corrected:
|
||||||
|
|
||||||
|
**Reserved-word divergence -- real, and NOT the word you'd expect.** The obvious first guess,
|
||||||
|
an unquoted `order` column, turned out to be the wrong probe: H2, HSQLDB and Derby all reject it
|
||||||
|
identically ([`docs/output/testdb-reserved-word-survey.txt`](output/testdb-reserved-word-survey.txt), a 22-word survey run to find one
|
||||||
|
that actually diverges). The word that genuinely splits the three: **`value`**. H2 rejects an
|
||||||
|
unquoted `value` column outright:
|
||||||
|
|
||||||
|
```
|
||||||
|
JdbcSQLSyntaxErrorException: Syntax error in SQL statement "create table reserved_word_test (id integer, [*]value integer)"; expected "identifier"
|
||||||
|
```
|
||||||
|
|
||||||
|
HSQLDB and Derby both accept it without complaint. An entity field named `value` with no
|
||||||
|
`@Column(name = "\"value\"")` escaping builds a working schema on two of these three engines and
|
||||||
|
throws a SQL syntax error, specifically on H2 -- the reverse of what most people would guess,
|
||||||
|
since H2 has a reputation as the "permissive" one.
|
||||||
|
|
||||||
|
**CHAR padding -- real, but NOT Derby-exclusive, contrary to widely-repeated folklore.** The
|
||||||
|
brief called this "a classic Derby thing." Measured directly (`CHAR(10)` holding `'AB'`, read
|
||||||
|
back via `ResultSet.getString()`): H2, HSQLDB and Derby **all** return `"AB "` (length
|
||||||
|
10, space-padded) -- not just Derby. A naive `"AB".equals(value)` fails against all three
|
||||||
|
engines for a `CHAR` column; only `VARCHAR` avoids the padding. The trap is real and worth
|
||||||
|
keeping in the article; attributing it to Derby specifically is not.
|
||||||
|
|
||||||
|
## Schema isolation: pollution proven, then the fix proven (`SchemaIsolationTest`)
|
||||||
|
|
||||||
|
Four ordered tests against one shared `SessionFactory`, deliberately built to show both failure
|
||||||
|
and fix in the same run:
|
||||||
|
|
||||||
|
1. `testA` inserts a row and does nothing to clean up.
|
||||||
|
2. `testB` -- which inserts nothing itself -- immediately sees `count >= 1`. The leftover row
|
||||||
|
from `testA` is visible because nothing isolated the two tests from each other.
|
||||||
|
3. `testC` inserts a row inside a transaction, then **rolls back** instead of committing.
|
||||||
|
4. `testD` looks specifically for `testC`'s row by its unique SKU and finds zero -- rollback
|
||||||
|
isolated it completely.
|
||||||
|
|
||||||
|
`create-drop` (used to build the shared `SessionFactory` here) only isolates at the
|
||||||
|
*SessionFactory* level -- one schema for the whole test class's lifetime, dropped and recreated
|
||||||
|
once. It does nothing to isolate individual *tests* from each other; that isolation has to come
|
||||||
|
from something else -- a transaction rolled back per test (what `testC`/`testD` show, and what
|
||||||
|
Spring's `@Transactional` does for you automatically in `@SpringBootTest` classes), or a full
|
||||||
|
`create-drop` re-run per test method (correct, but noticeably slower since it re-creates the
|
||||||
|
whole schema every time), or a fresh `@DirtiesContext`-forced new `ApplicationContext` (correct,
|
||||||
|
but the heaviest option of the three since it rebuilds the entire Spring context, not just the
|
||||||
|
schema).
|
||||||
|
|
||||||
|
## `DB_CLOSE_DELAY=-1`, proven with a before/after (`DbCloseDelayTest`)
|
||||||
|
|
||||||
|
Two tests, same shape, one difference in the URL. Without `DB_CLOSE_DELAY=-1`: create a table,
|
||||||
|
insert a row, close the only open connection, reconnect with a fresh connection to the *same*
|
||||||
|
URL --
|
||||||
|
|
||||||
|
```
|
||||||
|
JdbcSQLSyntaxErrorException: Table "T" not found (this database is empty)
|
||||||
|
```
|
||||||
|
|
||||||
|
H2 tore the entire in-memory database down the instant the last connection closed; the
|
||||||
|
"reconnect" actually created a brand-new, empty database that happens to share a name. With
|
||||||
|
`DB_CLOSE_DELAY=-1` on the same URL, the identical close-then-reconnect sequence sees the row
|
||||||
|
that was inserted before the close. This is exactly why every entry in `TestDbSupport.Db`'s H2
|
||||||
|
variants carries this flag, and why forgetting it produces intermittent, connection-timing-
|
||||||
|
dependent test failures rather than a clean, consistent error.
|
||||||
|
|
||||||
|
## Startup time (indicative only -- shared sandbox container)
|
||||||
|
|
||||||
|
Three `SessionFactory` builds per engine, `System.nanoTime()`, in this specific shared sandbox
|
||||||
|
([`docs/output/testdb-startup-timing.txt`](output/testdb-startup-timing.txt)):
|
||||||
|
|
||||||
|
```
|
||||||
|
H2: 1628 ms, 83 ms, 62 ms
|
||||||
|
HSQLDB: 238 ms, 79 ms, 74 ms
|
||||||
|
DERBY: 637 ms, 176 ms, 125 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
The first run of whichever engine happens to go first pays a one-time JVM/driver class-loading
|
||||||
|
cost (1.6s for H2 here) that has nothing to do with that database specifically -- note HSQLDB's
|
||||||
|
first run, second in sequence, only cost 238ms. Steady-state, all three build a `SessionFactory`
|
||||||
|
against an empty in-memory schema in well under 200ms. Treat the specific numbers as
|
||||||
|
order-of-magnitude only; this container is shared and not isolated for benchmarking.
|
||||||
|
|
||||||
|
## Testcontainers: not runnable here, kept honest and short
|
||||||
|
|
||||||
|
Testcontainers 2.0.5 is the current GA and is where real cross-database parity testing belongs
|
||||||
|
once H2/HSQLDB/Derby's dialect emulation gaps (MODE= not actually changing SQL dialect, Derby's
|
||||||
|
now-separate community dialect, the CHAR-padding and reserved-word divergences documented above)
|
||||||
|
matter enough to require the real database. **Docker was not available in this sandbox** (no
|
||||||
|
`docker` binary, no `/var/run/docker.sock`), so nothing Testcontainers-based was attempted or
|
||||||
|
run here -- this is stated plainly rather than faked. The recommendation, unchanged from
|
||||||
|
standard practice: use the in-memory engines for fast unit-level ORM tests in the everyday CI
|
||||||
|
loop, and Testcontainers for a slower, separate integration stage that exercises the real
|
||||||
|
production database engine.
|
||||||
|
|
||||||
|
[← Previous: 08 — Stored procedures](08-stored-procedures.md) | [Next: 10 — Mocking JNDI datasources →](10-mocking-jndi-datasources.md)
|
||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
# 10 — Mocking JNDI DataSources, verified against Spring Framework 7.0.9 and simple-jndi 0.25.0
|
||||||
|
|
||||||
|
[← Previous: 09 — Testing with in-memory databases](09-testing-in-memory-databases.md) | [Next: 11 — Proxies and lazy initialization →](11-proxies-and-lazy-initialization.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: mocking JNDI datasources](https://ankurm.com/testing-hibernate-7-mocking-jndi-datasources-without-the-container/).
|
||||||
|
|
||||||
|
Companion code: [`src/test/java/com/ankurm/hibernatedemo/jndi/`](../src/test/java/com/ankurm/hibernatedemo/jndi/) (three test classes —
|
||||||
|
[`JndiDataSourceResolutionTest`](../src/test/java/com/ankurm/hibernatedemo/jndi/JndiDataSourceResolutionTest.java),
|
||||||
|
[`HibernateJndiDataSourceTest`](../src/test/java/com/ankurm/hibernatedemo/jndi/HibernateJndiDataSourceTest.java),
|
||||||
|
[`CrossTestPollutionTest`](../src/test/java/com/ankurm/hibernatedemo/jndi/CrossTestPollutionTest.java) —
|
||||||
|
seven tests, all green together in one run -- see [`docs/output/jndi-tests-run.txt`](output/jndi-tests-run.txt)
|
||||||
|
(filtered) and [`docs/output/jndi-full-run.txt`](output/jndi-full-run.txt) (unfiltered Surefire capture of the same
|
||||||
|
run). Environment: Hibernate ORM 7.4.5.Final, Spring Boot 4.1.1, Spring Framework 7.0.9, JDK 25,
|
||||||
|
H2 2.4.240, simple-jndi 0.25.0 (`com.github.h-thurow:simple-jndi`, test scope).
|
||||||
|
|
||||||
|
## `SimpleNamingContextBuilder`: gone, and it's not a recent change
|
||||||
|
|
||||||
|
The article this replaces doesn't use `SimpleNamingContextBuilder`, but it's the most commonly
|
||||||
|
recommended "just use Spring's mock JNDI" answer elsewhere, so it's worth settling with evidence.
|
||||||
|
`unzip -l` against the actual jars ([`docs/output/jndi-simplenamingcontextbuilder-removal.txt`](output/jndi-simplenamingcontextbuilder-removal.txt)):
|
||||||
|
|
||||||
|
- `spring-test-5.3.31.jar` (last of the 5.x line): `org/springframework/mock/jndi/` present,
|
||||||
|
8 class files including `SimpleNamingContextBuilder.class`.
|
||||||
|
- `spring-test-6.0.0.jar`: zero matches for `naming` or `jndi` anywhere in the jar.
|
||||||
|
- `spring-test-7.0.9.jar` (what this whole blog batch verifies against): same, zero matches.
|
||||||
|
|
||||||
|
So it went in Spring Framework 6.0.0 -- the same release that moved the whole framework from
|
||||||
|
`javax.*` to `jakarta.*` for Jakarta EE 9. There is **no direct built-in replacement** in
|
||||||
|
`spring-test` itself; the practical answer for the last several years has been a third-party
|
||||||
|
library, which is exactly why this chapter exists. One nuance worth stating precisely: JNDI
|
||||||
|
(`javax.naming.*`) is a **Java SE API** shipped in the `java.naming` module, not a Jakarta EE
|
||||||
|
API, so it did not get renamed to `jakarta.naming` the way `javax.persistence` and
|
||||||
|
`javax.servlet` did. simple-jndi's `MemoryContextFactory` still `implements
|
||||||
|
javax.naming.spi.InitialContextFactory` in 2026, and always will unless the JDK itself changes
|
||||||
|
it.
|
||||||
|
|
||||||
|
## Getting simple-jndi 0.25.0 actually working
|
||||||
|
|
||||||
|
First correction to the article's own dependency block: the artifact coordinates it used,
|
||||||
|
`simple-jndi:simple-jndi:0.11.4.1`, are an old, essentially abandoned groupId. The maintained
|
||||||
|
fork used throughout this blog batch is `com.github.h-thurow:simple-jndi:0.25.0`. Second: the
|
||||||
|
article's `jndi.properties` sets `java.naming.provider.url=org.osjava.sj.memory
|
||||||
|
.MemoryContextFactory` -- that package does not exist in the 0.25.0 jar at all
|
||||||
|
([`docs/output/jndi-simplejndi-jar-listing.txt`](output/jndi-simplejndi-jar-listing.txt)). The real class is
|
||||||
|
`org.osjava.sj.MemoryContextFactory`, and the property that should carry it is
|
||||||
|
`java.naming.factory.initial`, not `java.naming.provider.url`.
|
||||||
|
|
||||||
|
A working bind-then-lookup, from `JndiDataSourceResolutionTest`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.MemoryContextFactory");
|
||||||
|
System.setProperty("org.osjava.sj.jndi.shared", "true"); // see below -- this one is easy to miss
|
||||||
|
|
||||||
|
Context ctx = new InitialContext();
|
||||||
|
ctx.createSubcontext("java:"); ctx.createSubcontext("java:comp"); /* ...etc */
|
||||||
|
ctx.bind(JNDI_NAME, dataSource);
|
||||||
|
|
||||||
|
DataSource looked = (DataSource) new InitialContext().lookup(JNDI_NAME);
|
||||||
|
```
|
||||||
|
|
||||||
|
`org.osjava.sj.jndi.shared=true` is the detail every abbreviated example skips, and skipping it
|
||||||
|
produces a confusing failure: without it, `javap -c` on `MemoryContextFactory.class`
|
||||||
|
([`docs/output/proxy-settings-javap.txt`](output/proxy-settings-javap.txt)'s sibling investigation technique, applied here) shows
|
||||||
|
the factory branches on that exact property name and, if it's not `"true"`, hands back a **brand
|
||||||
|
new, empty** `MemoryContext` on every single `new InitialContext()` call instead of consulting
|
||||||
|
its static, JVM-shared cache. A `bind()` through one `InitialContext` instance is then invisible
|
||||||
|
to a `lookup()` through a different one -- even inside the same test method, if the code happens
|
||||||
|
to construct more than one `InitialContext`. This was not a hypothetical: it's exactly the first
|
||||||
|
failure this investigation hit.
|
||||||
|
|
||||||
|
Also demonstrated, and also driving Hibernate itself (not just a raw JDBC lookup): Hibernate's
|
||||||
|
`hibernate.connection.datasource` setting (constant `DATASOURCE` in `org.hibernate.cfg
|
||||||
|
.JdbcSettings`, confirmed via `javap`, [`docs/output/jndi-hibernate-datasource-setting-javap.txt`](output/jndi-hibernate-datasource-setting-javap.txt))
|
||||||
|
resolves a JNDI name into a real, working `SessionFactory` --
|
||||||
|
`HibernateJndiDataSourceTest` builds one and runs `SELECT 1` through it. Verbatim log line
|
||||||
|
proving the resolution actually went through JNDI, not a URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
HHH10001005: Database info:
|
||||||
|
DataSource JNDI name [jdbc/HibernateTestDS]
|
||||||
|
Database JDBC URL [jdbc:h2:mem:hibernate-jndi-test]
|
||||||
|
...
|
||||||
|
Pool: DataSourceConnectionProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
## The failure modes, verbatim
|
||||||
|
|
||||||
|
`NoInitialContextException` when `java.naming.factory.initial` is never set:
|
||||||
|
|
||||||
|
```
|
||||||
|
Need to specify class name in environment or system property, or in an application resource file: java.naming.factory.initial
|
||||||
|
```
|
||||||
|
|
||||||
|
`NameNotFoundException` on an unbound name -- message is just the name itself:
|
||||||
|
|
||||||
|
```
|
||||||
|
java:comp/env/jdbc/DoesNotExist
|
||||||
|
```
|
||||||
|
|
||||||
|
`NameAlreadyBoundException` across two tests sharing a JVM -- reproduced on purpose in
|
||||||
|
`CrossTestPollutionTest` (test A binds and never cleans up; test B tries to bind the same name):
|
||||||
|
|
||||||
|
```
|
||||||
|
Name jdbc/SharedAcrossTests already bound. Use rebind() to override
|
||||||
|
```
|
||||||
|
|
||||||
|
That message's own suggestion (`rebind()` instead of `bind()`) does make the immediate error go
|
||||||
|
away, but it is a band-aid, not the fix -- it papers over test A's leak rather than closing it.
|
||||||
|
`CrossTestPollutionTest`'s third test spells out the real fix: whatever a test binds, that same
|
||||||
|
test unbinds in `@AfterEach`, unconditionally, so nothing survives to the next test class in the
|
||||||
|
same JVM.
|
||||||
|
|
||||||
|
This surfaced for real, by accident, in this exact investigation: once all three JNDI test
|
||||||
|
classes ran together in one Surefire invocation instead of one at a time, `HibernateJndiDataSourceTest`'s
|
||||||
|
`@BeforeEach` started throwing `NameAlreadyBoundException` on `ctx.createSubcontext("jdbc")` --
|
||||||
|
a *different* test class in the same run had already created that subcontext and never removed
|
||||||
|
it. The fix applied ([`docs/output/jndi-tests-run.txt`](output/jndi-tests-run.txt) shows the resulting clean run) was to make
|
||||||
|
subcontext creation idempotent (catch `NameAlreadyBoundException`, treat "already there" as
|
||||||
|
success) in addition to unbinding leaf names in `@AfterEach`. Simple-JNDI's shared, static,
|
||||||
|
JVM-wide namespace is not a toy problem confined to a contrived demo -- it is the normal
|
||||||
|
behavior of the library, and it bit this test suite the first time the suite ran as a whole.
|
||||||
|
|
||||||
|
## Boot 4.1.1 specifics: `spring.datasource.jndi-name` is alive, relocated
|
||||||
|
|
||||||
|
`javap` against the actual 4.1.1 jars ([`docs/output/jndi-boot-autoconfig-javap.txt`](output/jndi-boot-autoconfig-javap.txt)) confirms
|
||||||
|
both pieces still exist:
|
||||||
|
|
||||||
|
- `org.springframework.boot.jdbc.autoconfigure.JndiDataSourceAutoConfiguration` -- a real class,
|
||||||
|
with a `dataSource(DataSourceProperties, ApplicationContext)` factory method.
|
||||||
|
- `DataSourceProperties.jndiName` -- the field backing `spring.datasource.jndi-name`, with its
|
||||||
|
getter/setter intact.
|
||||||
|
|
||||||
|
The relocation matters for anyone grepping for it in the wrong place: this is in the
|
||||||
|
**`spring-boot-jdbc`** module, not `spring-boot-autoconfigure` -- `spring-boot-autoconfigure-4.1.1.jar`
|
||||||
|
has zero matches for `jndi` at all. This is the same Boot 4 autoconfigure-module split noted
|
||||||
|
elsewhere in this batch (JPA landed in `spring-boot-jpa`; JDBC/datasource landed in
|
||||||
|
`spring-boot-jdbc`).
|
||||||
|
|
||||||
|
What did **not** get resolved in this sandbox: a full `@SpringBootTest` actually resolving
|
||||||
|
`spring.datasource.jndi-name` end-to-end through Boot's own `JndiDataSourceAutoConfiguration`.
|
||||||
|
Standalone simple-jndi bind/lookup worked perfectly (proven above, repeatedly, including through
|
||||||
|
Spring's own `JndiTemplate` called directly). But inside a real `ApplicationContext` refresh,
|
||||||
|
the `dataSource` bean's JNDI lookup consistently threw `NameNotFoundException` even though: the
|
||||||
|
binding was moved to a static initializer (to run before `SpringExtension`'s `BeforeAllCallback`,
|
||||||
|
which fires before a test class's own `@BeforeAll`); the class loading, `System.identityHashCode`,
|
||||||
|
and classloader of `MemoryContextFactory` were confirmed identical between the successful
|
||||||
|
standalone lookup and the failing in-context one via a diagnostic `BeanFactoryPostProcessor`; the
|
||||||
|
relevant system properties (`java.naming.factory.initial`, `org.osjava.sj.jndi.shared`) were
|
||||||
|
confirmed present and correct at the point of failure; and no `jndi.properties` resource or
|
||||||
|
JNDI `InitialContextFactoryBuilder` registration was found anywhere on the 107-jar test
|
||||||
|
classpath. The root cause was not found. `HibernateJndiDataSourceTest` (Hibernate's own
|
||||||
|
`hibernate.connection.datasource`, no Spring autoconfiguration involved) is the test that
|
||||||
|
carries the "Hibernate/Spring resolves it by name" claim for this chapter -- the
|
||||||
|
Boot-autoconfiguration-specific path is documented as class-and-property-exist-but-live-wiring-unverified,
|
||||||
|
not glossed over as working.
|
||||||
|
|
||||||
|
## Is mock JNDI still the right answer in 2026?
|
||||||
|
|
||||||
|
Being honest about what this investigation actually found: mock JNDI is a legacy technique kept
|
||||||
|
alive for the shrinking set of applications that still get deployed into a real Java EE/Jakarta
|
||||||
|
EE application server (WildFly, Payara) where a container-managed `DataSource` is genuinely the
|
||||||
|
only path to a connection. For anything running as a Spring Boot fat jar -- the overwhelming
|
||||||
|
majority of new work -- there is no container JNDI tree to fake in the first place, so mocking
|
||||||
|
one in tests is solving a problem the production topology doesn't have. The honest recommendation
|
||||||
|
for a Boot application in 2026 is what the rest of this repo already does (see
|
||||||
|
[chapter 09](09-testing-in-memory-databases.md)): an in-memory database
|
||||||
|
or Testcontainers wired through `spring.datasource.url`, not JNDI. Reach for simple-jndi
|
||||||
|
specifically when the application under test really is deployed via JNDI in production and the
|
||||||
|
test needs to mirror that lookup path -- not as a generic "how do I mock a DataSource" answer.
|
||||||
|
|
||||||
|
[← Previous: 09 — Testing with in-memory databases](09-testing-in-memory-databases.md) | [Next: 11 — Proxies and lazy initialization →](11-proxies-and-lazy-initialization.md)
|
||||||
Executable
+197
@@ -0,0 +1,197 @@
|
|||||||
|
# 11 — Proxies and LazyInitializationException, verified against Hibernate 7.4.5.Final
|
||||||
|
|
||||||
|
[← Previous: 10 — Mocking JNDI datasources](10-mocking-jndi-datasources.md) | [Next: 12 — Association mappings →](12-association-mappings.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: proxies and the LazyInitializationException](https://ankurm.com/mastering-hibernate-7-proxies-and-the-lazyinitializationexception/).
|
||||||
|
|
||||||
|
Companion code: [`src/main/java/com/ankurm/hibernatedemo/proxy/`](../src/main/java/com/ankurm/hibernatedemo/proxy/) (entities —
|
||||||
|
[`ProxyBook`](../src/main/java/com/ankurm/hibernatedemo/proxy/ProxyBook.java),
|
||||||
|
[`ProxyPublisher`](../src/main/java/com/ankurm/hibernatedemo/proxy/ProxyPublisher.java),
|
||||||
|
[`ProxyReview`](../src/main/java/com/ankurm/hibernatedemo/proxy/ProxyReview.java)) and
|
||||||
|
[`src/test/java/com/ankurm/hibernatedemo/proxy/`](../src/test/java/com/ankurm/hibernatedemo/proxy/) (six test classes, twelve tests, all green).
|
||||||
|
Raw output: `docs/output/proxy-*.txt`. Environment: Hibernate ORM 7.4.5.Final, Spring Boot
|
||||||
|
4.1.1, Spring Framework 7.0.9, Jakarta Persistence 3.2.0, JDK 25 (Temurin), H2 2.4.240.
|
||||||
|
|
||||||
|
This chapter and [chapter 01](01-get-vs-load.md) are the same mechanism seen from two directions:
|
||||||
|
01 measures what `get()` vs `getReference()` actually do to the persistence context and when a
|
||||||
|
`SELECT` fires; this chapter measures what the *object* `getReference()` hands back actually is,
|
||||||
|
and what breaks when it outlives its session. Read together, they cover the whole proxy
|
||||||
|
lifecycle from creation to `LazyInitializationException`.
|
||||||
|
|
||||||
|
## What a proxy actually is, and the two ways to break naive code with it
|
||||||
|
|
||||||
|
`getReference()` does not hand you back an instance of your entity class. In 7.4.5.Final it
|
||||||
|
hands you a **ByteBuddy-generated nested class named `$HibernateProxy`** —
|
||||||
|
`com.ankurm.hibernatedemo.proxy.ProxyBook$HibernateProxy`, confirmed live in
|
||||||
|
[`docs/output/proxy-lazy-init-and-identity.txt`](output/proxy-lazy-init-and-identity.txt). That is not the Javassist-era naming scheme
|
||||||
|
(`EntityName_$$_javassist_N`) that still circulates in older blog posts and even in some current
|
||||||
|
ones — Hibernate moved its default bytecode provider to ByteBuddy years ago, and 7.4.5 keeps the
|
||||||
|
proxy as a literal static nested class of the entity itself, not a same-package sibling class.
|
||||||
|
|
||||||
|
Two things follow from that, and both are demonstrated in `ProxyIdentityTest`:
|
||||||
|
|
||||||
|
- `proxy instanceof ProxyBook` is `true` — the generated class extends your entity.
|
||||||
|
- `proxy.getClass() == ProxyBook.class` is `false`. Any code that branches on `getClass()`
|
||||||
|
instead of `instanceof`, or relies on the JPA-default `equals()`/`hashCode()` (object
|
||||||
|
identity), silently breaks: a `HashSet<ProxyPublisher>` containing the real, managed instance
|
||||||
|
does not recognize a proxy for the exact same row as a member (`proxyIdentityTest
|
||||||
|
.naiveEqualsAndHashSet_cannotRecognizeProxyAndRealInstanceAsTheSameRow`, asserted, not
|
||||||
|
claimed). Chapter 01's own proxy-identity experiment reaches the identical conclusion from the
|
||||||
|
`get()`/`getReference()` side — see
|
||||||
|
[`01 — get() vs getReference(), Proxy identity experiment`](01-get-vs-load.md#proxy-identity-experiment).
|
||||||
|
|
||||||
|
`Hibernate.getClass(proxy)` is the fix — it unwraps to the real entity class regardless of proxy
|
||||||
|
state, and it's asserted to return `ProxyPublisher.class` even for an uninitialized reference.
|
||||||
|
|
||||||
|
## The exception, verbatim — and it is not one message, it's two
|
||||||
|
|
||||||
|
Every blog post on this topic (including the one this replaces) quotes a single wording for
|
||||||
|
`LazyInitializationException`. Running it for real turns up **two different message templates**,
|
||||||
|
depending on whether the frozen thing is a to-one association or a collection:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Collection (ProxyBook.reviews, a LAZY @OneToMany), accessed after session close:
|
||||||
|
Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews' with key '1' (no session)
|
||||||
|
|
||||||
|
# To-one proxy (a getReference() result), accessed after session close:
|
||||||
|
Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#3] - no session
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the capitalization on the second one too: it's `Could` (capital) and `no session`
|
||||||
|
(lower-case s) — not the commonly-quoted "could not initialize proxy ... no **S**ession". Both
|
||||||
|
are `org.hibernate.LazyInitializationException` (verified: `ex.getClass().getName()` printed and
|
||||||
|
asserted equal to that string), but if you're grepping logs for one exact phrase, you need both.
|
||||||
|
See `LazyInitializationTest` for both reproductions and [`docs/output/proxy-lazy-init-and-identity.txt`](output/proxy-lazy-init-and-identity.txt)
|
||||||
|
for the verbatim capture (also mirrored, unfiltered, in [`docs/output/proxy-lazy-and-identity-run.txt`](output/proxy-lazy-and-identity-run.txt)).
|
||||||
|
|
||||||
|
## `Hibernate.initialize()` / `isInitialized()` / `unproxy()` — confirmed against the 7.4.5 jar
|
||||||
|
|
||||||
|
`javap` against `hibernate-core-7.4.5.Final.jar` (full output in
|
||||||
|
[`docs/output/proxy-settings-javap.txt`](output/proxy-settings-javap.txt)) shows the signatures actually shipped:
|
||||||
|
|
||||||
|
```
|
||||||
|
public static void initialize(java.lang.Object) throws org.hibernate.HibernateException;
|
||||||
|
public static boolean isInitialized(java.lang.Object);
|
||||||
|
public static java.lang.Object unproxy(java.lang.Object);
|
||||||
|
public static <T> T unproxy(T, java.lang.Class<T>);
|
||||||
|
```
|
||||||
|
|
||||||
|
`unproxy()` has **two** overloads, not one — a no-cast version that returns `Object`, and a
|
||||||
|
typed version that takes the target `Class<T>` and returns `T` directly. Both are exercised in
|
||||||
|
`LazyInitializationTest.hibernateUnproxy_...`. A side effect worth knowing: calling
|
||||||
|
`Hibernate.unproxy()` on an uninitialized proxy *initializes it* — `isInitialized()` flips from
|
||||||
|
`false` to `true` as a consequence, not just as a precondition.
|
||||||
|
|
||||||
|
## fetchgraph vs. loadgraph, demonstrated, not just stated
|
||||||
|
|
||||||
|
Every article states the rule ("loadgraph keeps defaults for what's not named; fetchgraph forces
|
||||||
|
everything not named to LAZY") and almost none show it happening. `EntityGraphFetchTest` builds
|
||||||
|
one entity (`ProxyBook`) with two associations of different default fetch types — `publisher`
|
||||||
|
(`@ManyToOne`, default EAGER) and `reviews` (`@OneToMany`, explicit LAZY) — and one named graph
|
||||||
|
that mentions only `reviews`:
|
||||||
|
|
||||||
|
| Hint | SQL joins | `publisher` after fetch | `reviews` after fetch |
|
||||||
|
|---|---|---|---|
|
||||||
|
| none (plain `find()`) | `left join proxy_publisher` only | initialized (mapped EAGER) | **not** initialized (mapped LAZY) |
|
||||||
|
| `jakarta.persistence.loadgraph` | `left join proxy_publisher` **and** `left join proxy_review` | initialized | initialized |
|
||||||
|
| `jakarta.persistence.fetchgraph` | `left join proxy_review` **only** | **not initialized** (forced to LAZY, despite EAGER mapping) | initialized |
|
||||||
|
|
||||||
|
The middle column is the whole point: with `fetchgraph`, `book.getPublisher()` comes back as a
|
||||||
|
`ProxyPublisher$HibernateProxy` even though the mapping says EAGER, because fetchgraph treats
|
||||||
|
the graph as the *entire* fetch plan rather than an addition to the mapping's defaults. Verbatim
|
||||||
|
SQL for all three shapes is in [`docs/output/proxy-entitygraph-fetch-vs-load.txt`](output/proxy-entitygraph-fetch-vs-load.txt) (raw run in
|
||||||
|
[`docs/output/proxy-entitygraph-run.txt`](output/proxy-entitygraph-run.txt)). Chapter 12 covers the collection-side counterpart of this same
|
||||||
|
lazy/eager boundary — N+1 counting and `@BatchSize` — see
|
||||||
|
[`12 — Association mappings`](12-association-mappings.md#counting-the-n1).
|
||||||
|
|
||||||
|
## `hibernate.enable_lazy_load_no_trans`: still there, still works, now flagged `@Unsafe`
|
||||||
|
|
||||||
|
This setting has a reputation for being on its way out. It is not gone in 7.4.5.Final —
|
||||||
|
`org.hibernate.cfg.TransactionSettings.ENABLE_LAZY_LOAD_NO_TRANS` still resolves to the string
|
||||||
|
`hibernate.enable_lazy_load_no_trans` (confirmed by `javap`, see
|
||||||
|
[`docs/output/proxy-settings-javap.txt`](output/proxy-settings-javap.txt)). What *is* new-ish and worth a headline: the constant now
|
||||||
|
carries a `@org.hibernate.cfg.Unsafe` marker annotation — an empty marker interface Hibernate's
|
||||||
|
own source uses to flag settings it does not want you reaching for, without actually removing
|
||||||
|
them. `OpenInViewAndLazyLoadNoTransTest` turns it on
|
||||||
|
(`spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true`) and confirms it functionally
|
||||||
|
does what it always did: a to-one proxy, accessed after its originating session and transaction
|
||||||
|
have both closed, initializes successfully instead of throwing — Hibernate opens a temporary
|
||||||
|
session behind the scenes to service exactly that one access. The `@Unsafe` label is
|
||||||
|
Hibernate's own commentary on why you generally shouldn't reach for this, not a sign it's been
|
||||||
|
deprecated or removed.
|
||||||
|
|
||||||
|
## OSIV: the default, its warning, and what it actually masks
|
||||||
|
|
||||||
|
`spring.jpa.open-in-view` defaults to `true` when left unset, and Boot prints a warning at
|
||||||
|
startup that says exactly that — verified verbatim (not paraphrased) against
|
||||||
|
`org.springframework.boot.jpa.autoconfigure.JpaBaseConfiguration$JpaWebConfiguration`'s
|
||||||
|
bytecode:
|
||||||
|
|
||||||
|
```
|
||||||
|
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed
|
||||||
|
during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
|
||||||
|
```
|
||||||
|
|
||||||
|
(Note the package: `org.springframework.boot.jpa.autoconfigure`, not `org.springframework.boot
|
||||||
|
.autoconfigure.orm.jpa` — Boot 4 split its autoconfigure module and JPA's auto-configuration now
|
||||||
|
lives in the separate `spring-boot-jpa` artifact.)
|
||||||
|
|
||||||
|
Proving the *masking* claim required a real HTTP request — OSIV is implemented by
|
||||||
|
`OpenEntityManagerInViewInterceptor`, which only exists for actual servlet requests, so
|
||||||
|
`OsivDefaultWarningTest` and `OsivDisabledExceptionTest` spin up a real embedded Tomcat
|
||||||
|
(`@SpringBootTest(webEnvironment = RANDOM_PORT)`) with one controller
|
||||||
|
([`OsivBookController`](../src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookController.java)/[`OsivBookService`](../src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookService.java)) that returns a `ProxyBook` entity directly and lets
|
||||||
|
Jackson serialize `getReviews()` on the response-writing thread — deliberately not a DTO,
|
||||||
|
because the DTO is the fix, and the point here is what happens without it. Same code, same
|
||||||
|
data, only the property differs:
|
||||||
|
|
||||||
|
```
|
||||||
|
# open-in-view left unset (Boot default true): GET /osiv/books/1
|
||||||
|
200 {"title":"OSIV Default Book","publisher":{...},"id":1,"reviews":[{"comment":"Rendered fine","id":1}]}
|
||||||
|
|
||||||
|
# open-in-view=false: GET /osiv/books/2 (identical entity graph, identical controller)
|
||||||
|
500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/osiv/books/2"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side log for the second case (captured via `CapturedOutput`, not inferred):
|
||||||
|
|
||||||
|
```
|
||||||
|
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write
|
||||||
|
JSON: Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews'
|
||||||
|
with key '2' (no session)]
|
||||||
|
```
|
||||||
|
|
||||||
|
Worth calling out as its own gotcha: the HTTP error *body* Boot's default `BasicErrorController`
|
||||||
|
returns is a bare `{"timestamp":...,"status":500,...}` even with
|
||||||
|
`server.error.include-message=always` set — the failure happens inside the Jackson
|
||||||
|
`HttpMessageConverter` while writing the response, which Boot reports as a generic 500 rather
|
||||||
|
than routing the original exception's message into the error attributes map. If you only look at
|
||||||
|
the HTTP response, you will not learn why it failed; you have to check the application log,
|
||||||
|
which is exactly what real incident response looks like for this failure mode. Full sequence in
|
||||||
|
[`docs/output/proxy-osiv-and-no-trans.txt`](output/proxy-osiv-and-no-trans.txt) (raw run in [`docs/output/proxy-osiv-run.txt`](output/proxy-osiv-run.txt)).
|
||||||
|
|
||||||
|
## Bytecode enhancement (`hibernate-enhance-maven-plugin`) — not run here
|
||||||
|
|
||||||
|
The article draft this replaces implies bytecode enhancement changes proxy behavior in ways
|
||||||
|
worth demonstrating. Two settings for it are real and confirmed present in 7.4.5.Final —
|
||||||
|
`org.hibernate.cfg.BytecodeSettings.ENHANCER_ENABLE_LAZY_INITIALIZATION`,
|
||||||
|
`ENHANCER_ENABLE_DIRTY_TRACKING`, and `ENHANCER_ENABLE_ASSOCIATION_MANAGEMENT` all exist
|
||||||
|
([`docs/output/proxy-settings-javap.txt`](output/proxy-settings-javap.txt)). Wiring up the actual Maven plugin to enhance classes at
|
||||||
|
build time and diff the resulting behavior was not attempted: a quick check for
|
||||||
|
`org.hibernate.orm.tooling:hibernate-enhance-maven-plugin:7.4.5.Final` and
|
||||||
|
`org.hibernate.orm:hibernate-enhance-maven-plugin:7.4.5.Final` on Maven Central both came back
|
||||||
|
"could not be resolved" in this sandbox. Per the brief's own guidance, this looked like a rabbit
|
||||||
|
hole rather than a quick add — flagging it honestly rather than guessing at what enhancement
|
||||||
|
would do differently here.
|
||||||
|
|
||||||
|
## Testcontainers, Docker, and the honest gap
|
||||||
|
|
||||||
|
Not applicable to this chapter directly (see [chapter 09](09-testing-in-memory-databases.md) for
|
||||||
|
that), but it's worth noting here too: everything above ran against a single shared H2
|
||||||
|
in-memory database in a sandboxed container. No Docker was available, so nothing here was
|
||||||
|
cross-checked against a real Postgres/MySQL proxy-and-lazy-loading story — the
|
||||||
|
LazyInitializationException and proxy identity mechanics are Hibernate-internal and
|
||||||
|
database-agnostic, but that claim itself rests on understanding of Hibernate's architecture
|
||||||
|
rather than an executed cross-database test.
|
||||||
|
|
||||||
|
[← Previous: 10 — Mocking JNDI datasources](10-mocking-jndi-datasources.md) | [Next: 12 — Association mappings →](12-association-mappings.md)
|
||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
# 12 — Association mappings: what the numbers actually say
|
||||||
|
|
||||||
|
[← Previous: 11 — Proxies and lazy initialization](11-proxies-and-lazy-initialization.md) | [Next: 13 — Date and time mapping →](13-date-and-time-mapping.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: Hibernate 7 association mappings](https://ankurm.com/master-hibernate-7-association-mappings-the-ultimate-guide-for-high-performance-java-apps/).
|
||||||
|
|
||||||
|
Every claim below was produced by a JUnit test in [`src/test/java/com/ankurm/hibernatedemo/association/`](../src/test/java/com/ankurm/hibernatedemo/association/),
|
||||||
|
run against Hibernate 7.4.5.Final / H2 2.4.240, with `hibernate.generate_statistics=true` and
|
||||||
|
`Statistics.getPrepareStatementCount()` as the counter. Nothing here is asserted from memory --
|
||||||
|
every number has a test that fails if the number changes. Chapter 11 covers the same lazy vs.
|
||||||
|
eager boundary from the to-one/proxy side (`LazyInitializationException`, `fetchgraph` vs.
|
||||||
|
`loadgraph`) -- see [`11 — Proxies and lazy initialization`](11-proxies-and-lazy-initialization.md);
|
||||||
|
this chapter is the collection side of that same story.
|
||||||
|
|
||||||
|
## Counting the N+1
|
||||||
|
|
||||||
|
Seed: 100 authors, 3 books each. [`NPlusOneTest`](../src/test/java/com/ankurm/hibernatedemo/association/NPlusOneTest.java) measures four strategies against the identical
|
||||||
|
data:
|
||||||
|
|
||||||
|
| Strategy | Query count | Test |
|
||||||
|
|---|---|---|
|
||||||
|
| Naive lazy iteration (`author.getBooks().size()` in a loop) | **101** | `naiveLazyIteration_firesOneQueryPerAuthor_theClassicNPlusOne` |
|
||||||
|
| JPQL `JOIN FETCH` | **1** | `jpqlFetchJoin_firesExactlyOneQuery` |
|
||||||
|
| `@EntityGraph` (`jakarta.persistence.fetchgraph` hint) | **1** | `entityGraph_firesExactlyOneQuery` |
|
||||||
|
| `@BatchSize(size = 10)` | **11** | `batchSize10_collapsesNPlusOneIntoCeilNOverBatchSizePlusOne` |
|
||||||
|
|
||||||
|
The batch-size math is worth spelling out: with 100 authors and a batch size of 10, Hibernate
|
||||||
|
issues `ceil(100 / 10) = 10` batched `IN (...)` selects for the collections, plus the 1 select
|
||||||
|
for the authors themselves -- `11` total, exactly matching `ceil(N / batchSize) + 1`. This is not
|
||||||
|
an approximation; `BatchSizeSweepTest`-style math generalizes: doubling `batchSize` to 20 would
|
||||||
|
give `ceil(100/20)+1 = 6`.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/association-n-plus-one.txt`](output/association-n-plus-one.txt).
|
||||||
|
|
||||||
|
## MultipleBagFetchException
|
||||||
|
|
||||||
|
[`BagAuthorList`](../src/main/java/com/ankurm/hibernatedemo/association/BagAuthorList.java) has two `List` (bag-semantics) collections: `books` and `awards`. Fetch-joining
|
||||||
|
both in one JPQL query --
|
||||||
|
|
||||||
|
```java
|
||||||
|
SELECT a FROM BagAuthorList a JOIN FETCH a.books JOIN FETCH a.awards
|
||||||
|
```
|
||||||
|
|
||||||
|
-- throws. The **verbatim** exception, captured from a real run:
|
||||||
|
|
||||||
|
```
|
||||||
|
wrapper class: java.lang.IllegalArgumentException
|
||||||
|
root cause class: org.hibernate.loader.MultipleBagFetchException
|
||||||
|
message: cannot simultaneously fetch multiple bags: [com.ankurm.hibernatedemo.association.BagAuthorList.awards, com.ankurm.hibernatedemo.association.BagAuthorList.books]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correction worth flagging**: `EntityManager.createQuery(...).getResultList()` wraps this as
|
||||||
|
`java.lang.IllegalArgumentException`, not `jakarta.persistence.PersistenceException`. If your
|
||||||
|
code catches `PersistenceException` expecting to handle Hibernate query failures uniformly, this
|
||||||
|
one slips past it.
|
||||||
|
|
||||||
|
Two fixes, both measured, in [`BagFetchTest`](../src/test/java/com/ankurm/hibernatedemo/association/BagFetchTest.java):
|
||||||
|
|
||||||
|
- **Fix 1 -- use `Set` instead of `List`.** [`BagAuthorSet`](../src/main/java/com/ankurm/hibernatedemo/association/BagAuthorSet.java) (identical shape, `Set` collections)
|
||||||
|
runs the same double-fetch-join query with **zero** exceptions and **1** query total.
|
||||||
|
- **Fix 2 -- two separate queries**, one `JOIN FETCH` each. **2** queries total, no exception,
|
||||||
|
same data assembled in the application.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/association-multiplebag-and-cartesian.txt`](output/association-multiplebag-and-cartesian.txt).
|
||||||
|
|
||||||
|
## The cartesian-product trap
|
||||||
|
|
||||||
|
Fetch-joining two collections that *are* allowed (both `Set`s) does not throw, but it does not
|
||||||
|
avoid the underlying join math either. With 1 author, 4 books, 3 awards, fetch-joining both
|
||||||
|
collections in one query:
|
||||||
|
|
||||||
|
- Raw SQL join row count: **12** (4 x 3 -- one row per (book, award) pair).
|
||||||
|
- Entities returned to the application (via `SELECT DISTINCT` + Hibernate's root-entity
|
||||||
|
de-duplication): **1**, fully populated with all 4 books and all 3 awards.
|
||||||
|
|
||||||
|
The row count explosion is real and happens at the database and JDBC layer regardless of how
|
||||||
|
many entities eventually come back -- for large collections this is where "the query is fast in
|
||||||
|
isolation but the app is slow" reports come from.
|
||||||
|
|
||||||
|
## The `@OneToOne` lazy trap
|
||||||
|
|
||||||
|
[`LazyUser`](../src/main/java/com/ankurm/hibernatedemo/association/LazyUser.java)`.profile` is the non-owning (`mappedBy`) side of an optional `@OneToOne`, declared
|
||||||
|
`FetchType.LAZY`. Without bytecode enhancement, Hibernate cannot build a lazy proxy for it -- it
|
||||||
|
has no foreign key of its own to defer against, so it cannot know whether a [`LazyProfile`](../src/main/java/com/ankurm/hibernatedemo/association/LazyProfile.java) row exists
|
||||||
|
without querying. Measured in [`OneToOneLazyTest`](../src/test/java/com/ankurm/hibernatedemo/association/OneToOneLazyTest.java):
|
||||||
|
|
||||||
|
```
|
||||||
|
LazyUser.find(): 2 queries fired BEFORE touching getProfile() at all
|
||||||
|
after touching getProfile(): 2 queries total (no further query needed -- it already ran eagerly)
|
||||||
|
```
|
||||||
|
|
||||||
|
The annotation says `LAZY`; the runtime behavior is eager. This is the trap.
|
||||||
|
|
||||||
|
The fix is not `@MapsId` alone -- it's removing the inverse mapping and querying by the shared
|
||||||
|
primary key on demand:
|
||||||
|
|
||||||
|
```
|
||||||
|
MiUser.find() (no mappedBy field at all): 1 query
|
||||||
|
explicit MiProfile.find() by shared PK when actually needed: 2 total queries
|
||||||
|
```
|
||||||
|
|
||||||
|
Loading the user alone costs exactly 1 query; [`MiProfile`](../src/main/java/com/ankurm/hibernatedemo/association/MiProfile.java) is fetched only when the code actually
|
||||||
|
asks for it, using the same primary key value (`@MapsId`), via [`MiUser`](../src/main/java/com/ankurm/hibernatedemo/association/MiUser.java).
|
||||||
|
|
||||||
|
Raw output: [`docs/output/association-onetoone-lazy-trap.txt`](output/association-onetoone-lazy-trap.txt).
|
||||||
|
|
||||||
|
## Cascade and orphanRemoval
|
||||||
|
|
||||||
|
Two real behaviors, tested separately in [`CascadeOrphanTest`](../src/test/java/com/ankurm/hibernatedemo/association/CascadeOrphanTest.java) against [`CascadeAuthor`](../src/main/java/com/ankurm/hibernatedemo/association/CascadeAuthor.java)/[`CascadeBook`](../src/main/java/com/ankurm/hibernatedemo/association/CascadeBook.java) --
|
||||||
|
and one of them is a correction of the common claim.
|
||||||
|
|
||||||
|
**Correction**: the frequently repeated claim is "assigning a new collection to an
|
||||||
|
`orphanRemoval=true` field silently deletes the old rows." That's not what happens.
|
||||||
|
`CascadeOrphanTest.cascadeAllPlusOrphanRemoval_reassigningTheCollectionThrowsInsteadOfSilentlyDeleting`
|
||||||
|
shows Hibernate detects the dereferenced managed collection and throws at commit time:
|
||||||
|
|
||||||
|
```
|
||||||
|
jakarta.persistence.RollbackException: Error while committing the transaction
|
||||||
|
[A collection with orphan deletion was no longer referenced by the owning entity instance:
|
||||||
|
com.ankurm.hibernatedemo.association.CascadeAuthor.books]
|
||||||
|
root cause: org.hibernate.HibernateException
|
||||||
|
```
|
||||||
|
|
||||||
|
The scenario that *does* silently delete is mutating the **same** managed collection instance in
|
||||||
|
place -- e.g. `managed.getBooks().removeIf(...)`, the realistic pattern that reaches production.
|
||||||
|
That test shows books going from 3 to 1 with no exception:
|
||||||
|
|
||||||
|
```
|
||||||
|
cascade=ALL + orphanRemoval=true, in-place removeIf(): books before=3, books after=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**No `orphanRemoval`**: removing a child from the collection and flushing does nothing to the
|
||||||
|
row -- no DELETE, no FK update. The row and its FK are untouched:
|
||||||
|
|
||||||
|
```
|
||||||
|
orphanRemoval=false: after removing book2 from author.books and flushing,
|
||||||
|
book2 row still exists = true, author_id still = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw output: [`docs/output/association-cascade-orphan.txt`](output/association-cascade-orphan.txt).
|
||||||
|
|
||||||
|
## The owning side
|
||||||
|
|
||||||
|
Mutating only the inverse (`mappedBy`) side of a bidirectional association -- adding a book to
|
||||||
|
`author.getBooks()` without ever calling `book.setAuthor(author)` -- never persists anything.
|
||||||
|
The owning side (the entity holding the `@JoinColumn`) is the only thing Hibernate looks at when
|
||||||
|
deciding what to write:
|
||||||
|
|
||||||
|
```
|
||||||
|
owning side test: mutated only author2.getBooks().add(book) (inverse side),
|
||||||
|
book.author after flush = null (FK not written)
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw output: [`docs/output/association-cascade-orphan.txt`](output/association-cascade-orphan.txt).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Claim | Verified value |
|
||||||
|
|---|---|
|
||||||
|
| Naive N+1 for 100 authors | 101 queries |
|
||||||
|
| Fetch join / entity graph | 1 query |
|
||||||
|
| `@BatchSize(10)` for 100 authors | 11 queries (`ceil(100/10)+1`) |
|
||||||
|
| `MultipleBagFetchException` wrapper | `IllegalArgumentException`, not `PersistenceException` |
|
||||||
|
| Cartesian join (4x3) | 12 SQL rows -> 1 deduplicated entity |
|
||||||
|
| `mappedBy @OneToOne(LAZY)` | Still 2 queries -- eager despite the annotation |
|
||||||
|
| `@MapsId` + no inverse field | 1 query for the parent; profile fetched only on demand |
|
||||||
|
| Reassigning an orphanRemoval collection | Throws `HibernateException`, does not silently delete |
|
||||||
|
| In-place mutation of an orphanRemoval collection | Does silently delete |
|
||||||
|
| Inverse-side-only mutation | FK never written |
|
||||||
|
|
||||||
|
[← Previous: 11 — Proxies and lazy initialization](11-proxies-and-lazy-initialization.md) | [Next: 13 — Date and time mapping →](13-date-and-time-mapping.md)
|
||||||
Executable
+204
@@ -0,0 +1,204 @@
|
|||||||
|
# 13 — Date and time mapping: what actually round-trips
|
||||||
|
|
||||||
|
[← Previous: 12 — Association mappings](12-association-mappings.md) | [Next: 14 — Named queries →](14-named-queries.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: Hibernate 7 date/time mapping](https://ankurm.com/mastering-hibernate-7-date-time-mapping-java-time-timezones-and-jdbc-4-2/).
|
||||||
|
|
||||||
|
Everything below comes from a JUnit test in [`src/test/java/com/ankurm/hibernatedemo/datetime/`](../src/test/java/com/ankurm/hibernatedemo/datetime/)
|
||||||
|
against Hibernate 7.4.5.Final, run on H2 2.4.240 (and HSQLDB 2.7.3 where noted), Java 25. The
|
||||||
|
sandbox JVM's own default time zone during these runs was `Asia/Calcutta` (+05:30) unless a test
|
||||||
|
explicitly overrides `-Duser.timezone`.
|
||||||
|
|
||||||
|
## Basic temporal types round trip
|
||||||
|
|
||||||
|
[`TemporalTypesEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TemporalTypesEntity.java) maps every basic temporal type in one entity. Generated DDL
|
||||||
|
([`BasicTemporalTypesTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/BasicTemporalTypesTest.java), H2 2.4.240):
|
||||||
|
|
||||||
|
```
|
||||||
|
create table temporal_types (
|
||||||
|
id bigint generated by default as identity,
|
||||||
|
instant timestamp(6) with time zone,
|
||||||
|
legacy_calendar timestamp(6),
|
||||||
|
legacy_date_as_date date,
|
||||||
|
legacy_date_as_timestamp timestamp(6),
|
||||||
|
legacy_date_no_temporal timestamp(6),
|
||||||
|
local_date date,
|
||||||
|
local_date_time timestamp(6),
|
||||||
|
local_time time(0),
|
||||||
|
offset_date_time timestamp(6) with time zone,
|
||||||
|
zoned_date_time timestamp(6) with time zone,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Round trip of every value came back correct ([`docs/output/datetime-basic-types.txt`](output/datetime-basic-types.txt)). One
|
||||||
|
finding worth flagging: `legacyDateNoTemporal` is a `java.util.Date` field with **no**
|
||||||
|
`@Temporal` annotation at all. It did not fail to bootstrap and did not throw -- Hibernate 7.4.5
|
||||||
|
defaulted it to a `TIMESTAMP` column and round-tripped it correctly. The old JPA-provider
|
||||||
|
requirement that `@Temporal` is mandatory on `Date`/`Calendar` fields does not hold here.
|
||||||
|
|
||||||
|
## `@Temporal` verified deprecated, and verified harmless when misapplied
|
||||||
|
|
||||||
|
```
|
||||||
|
$ javap -v jakarta.persistence.Temporal # from jakarta.persistence-api-3.2.0.jar
|
||||||
|
Deprecated: true
|
||||||
|
RuntimeVisibleAnnotations:
|
||||||
|
java.lang.Deprecated(since="3.2")
|
||||||
|
```
|
||||||
|
|
||||||
|
`@Temporal` is formally deprecated since Jakarta Persistence 3.2 -- confirmed by bytecode
|
||||||
|
inspection, not the javadoc prose. Chapter 05 confirms the identical boot-time warning on a
|
||||||
|
`LocalDate` field; this chapter's [`TemporalOnJavaTimeEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TemporalOnJavaTimeEntity.java) puts it on an `Instant` field instead
|
||||||
|
-- see [`05 — JPA persistence annotations`](05-jpa-persistence-annotations.md#temporal-is-formally-deprecatedsince--32--and-it-is-a-silent-no-op-not-silent-silent)
|
||||||
|
for the `LocalDate` case.
|
||||||
|
|
||||||
|
Using it anyway on `java.time` fields (`java.util.Date`/`Calendar` are its only legal targets)
|
||||||
|
does not break anything in Hibernate 7.4.5. [`TemporalAnnotationTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/TemporalAnnotationTest.java) puts
|
||||||
|
`@Temporal(TemporalType.TIMESTAMP)` on an `Instant` field: the application context boots, and the
|
||||||
|
value round-trips exactly. The framework logs a deprecation warning at boot
|
||||||
|
(`HHH90000033: Encountered use of deprecated annotation ... at ...instantWithTemporalAnnotation`)
|
||||||
|
but does not reject it. See [`docs/output/datetime-temporal-annotation.txt`](output/datetime-temporal-annotation.txt).
|
||||||
|
|
||||||
|
## The central experiment: `@TimeZoneStorage`
|
||||||
|
|
||||||
|
```
|
||||||
|
$ javap org.hibernate.annotations.TimeZoneStorageType # hibernate-core-7.4.5.Final.jar
|
||||||
|
NATIVE, NORMALIZE, NORMALIZE_UTC, COLUMN, AUTO, DEFAULT
|
||||||
|
```
|
||||||
|
|
||||||
|
Six constants, not five -- `DEFAULT` is a real enum member (a sentinel meaning "consult
|
||||||
|
`hibernate.timezone.default_storage`"), separate from the five storage strategies.
|
||||||
|
|
||||||
|
**What "default" resolves to.** `hibernate.timezone.default_storage` (confirmed present as
|
||||||
|
`org.hibernate.cfg.MappingSettings.TIMEZONE_DEFAULT_STORAGE`) defaults, when unset, to
|
||||||
|
`TimeZoneStorageType.DEFAULT` itself -- a second layer of indirection resolved by
|
||||||
|
`MetadataBuildingOptions.getDefaultTimeZoneStorage()`, which asks the current SQL **Dialect**
|
||||||
|
for its `TimeZoneSupport` and converts that into a storage strategy (confirmed by
|
||||||
|
decompiling `MetadataBuilderImpl` and `TimeZoneStorageHelper` in hibernate-core 7.4.5.Final --
|
||||||
|
not from a blog post). In practice, on H2 (which has native `TIMESTAMP WITH TIME ZONE` support),
|
||||||
|
a column with **no** `@TimeZoneStorage` annotation at all ([`TimeZoneStorageEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageEntity.java)) behaved identically to `NATIVE` in
|
||||||
|
every test below, per [`TimeZoneStorageTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageTest.java).
|
||||||
|
|
||||||
|
**Storing `+05:30` and reading it back**, under the JVM's own default zone (`Asia/Calcutta`,
|
||||||
|
itself `+05:30` -- chosen deliberately as a first baseline where the JVM zone matches the data):
|
||||||
|
|
||||||
|
```
|
||||||
|
TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NATIVE = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NORMALIZE = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z
|
||||||
|
TZ_MODE COLUMN = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE AUTO = 2026-06-15T14:00+05:30
|
||||||
|
```
|
||||||
|
|
||||||
|
That alone doesn't show much -- `NORMALIZE` had nothing to normalize *to* since the JVM zone
|
||||||
|
already matched. Rerunning the identical test with `-Duser.timezone=America/New_York` (JVM
|
||||||
|
default zone changed, database untouched) is where the real behavior shows up:
|
||||||
|
|
||||||
|
```
|
||||||
|
TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30 <- unchanged
|
||||||
|
TZ_MODE NATIVE = 2026-06-15T14:00+05:30 <- unchanged
|
||||||
|
TZ_MODE NORMALIZE = 2026-06-15T04:30-04:00 <- CHANGED: re-expressed in JVM's zone
|
||||||
|
TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z <- unchanged (always UTC)
|
||||||
|
TZ_MODE COLUMN = 2026-06-15T14:00+05:30 <- unchanged
|
||||||
|
TZ_MODE AUTO = 2026-06-15T14:00+05:30 <- unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
Both readings represent the exact same instant (`2026-06-15T08:30:00Z`); only the *displayed*
|
||||||
|
offset for `NORMALIZE` moved, because `NORMALIZE` explicitly re-expresses the stored value in
|
||||||
|
whatever the JVM's current default zone is at read time. Every other mode is immune to a change
|
||||||
|
in the JVM's default time zone -- **this is the JVM-default-timezone hazard, made concrete**: if
|
||||||
|
your fleet ever runs with inconsistent `user.timezone` settings (a classic container migration
|
||||||
|
issue), `NORMALIZE` is the one mode that will show you a different offset for identical data
|
||||||
|
depending on which box read it. `NATIVE`, `COLUMN`, `AUTO`, and Hibernate's default all store and
|
||||||
|
return the exact offset supplied, immune to the reading JVM's zone.
|
||||||
|
|
||||||
|
**`COLUMN` mode's DDL** does add a second column, exactly as advertised:
|
||||||
|
|
||||||
|
```
|
||||||
|
create table tz_storage (
|
||||||
|
...
|
||||||
|
column_mode_col timestamp(6) with time zone,
|
||||||
|
column_mode_col_tz integer,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(On HSQLDB, which lacks a native `WITH TIME ZONE` timestamp type for every mode, `AUTO` also
|
||||||
|
picks up a companion `_tz integer` column -- confirming `AUTO`'s behavior is dialect-dependent,
|
||||||
|
consistent with the `TimeZoneSupport`-driven resolution above.)
|
||||||
|
|
||||||
|
Raw output: [`docs/output/datetime-timezone-storage-default-jvm.txt`](output/datetime-timezone-storage-default-jvm.txt),
|
||||||
|
[`docs/output/datetime-timezone-storage-nydefault.txt`](output/datetime-timezone-storage-nydefault.txt).
|
||||||
|
|
||||||
|
## Second-precision / truncation
|
||||||
|
|
||||||
|
Stored `LocalDateTime`/`Instant` ([`NanoPrecisionEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/NanoPrecisionEntity.java)) with `123456789` ns and read back, via
|
||||||
|
[`NanosecondTruncationTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationTest.java) (H2) and [`NanosecondTruncationHsqldbTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationHsqldbTest.java) (HSQLDB):
|
||||||
|
|
||||||
|
| Database | Nanos in | Nanos out | Behavior |
|
||||||
|
|---|---|---|---|
|
||||||
|
| H2 2.4.240 | 123456789 | **123457000** | Rounds to microsecond precision |
|
||||||
|
| HSQLDB 2.7.3 | 123456789 | **123456000** | Truncates to microsecond precision |
|
||||||
|
|
||||||
|
Same input, two different databases, two different results -- H2 rounds the last three digits
|
||||||
|
away, HSQLDB drops them. Neither preserves true nanosecond precision (both cap at `timestamp(6)`,
|
||||||
|
i.e. microseconds), but "rounds" vs "truncates" is a real, silent, database-specific behavior
|
||||||
|
difference that can shift a stored value by up to half a microsecond depending on which engine is
|
||||||
|
under the app.
|
||||||
|
|
||||||
|
**Correction**: `@Column(precision = 9)` on the temporal field had **zero effect** on the
|
||||||
|
generated DDL or the stored precision in this experiment -- the column type stayed
|
||||||
|
`timestamp(6)` regardless, on both databases. JPA's `precision`/`scale` `@Column` attributes are
|
||||||
|
defined for **numeric** (`DECIMAL`) columns; they do not control fractional-second digits on a
|
||||||
|
temporal column in Hibernate 7.4.5. A common piece of blog advice ("use
|
||||||
|
`@Column(precision = 6)` to force microsecond storage") does not do anything here -- the
|
||||||
|
precision was already fixed at 6 by the dialect's default temporal column type, with or without
|
||||||
|
the annotation.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/datetime-nanosecond-h2.txt`](output/datetime-nanosecond-h2.txt), [`docs/output/datetime-nanosecond-hsqldb.txt`](output/datetime-nanosecond-hsqldb.txt).
|
||||||
|
|
||||||
|
## `hibernate.jdbc.time_zone`
|
||||||
|
|
||||||
|
Confirmed to exist as `org.hibernate.cfg.JdbcSettings.JDBC_TIME_ZONE`. Setting it to
|
||||||
|
`America/New_York` (JVM default left at `Asia/Calcutta`) and inspecting the **raw** stored value
|
||||||
|
via a native `CAST(... AS VARCHAR)` query, in [`JdbcTimeZoneTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/JdbcTimeZoneTest.java):
|
||||||
|
|
||||||
|
```
|
||||||
|
original LocalDateTime = 2026-07-04T09:00
|
||||||
|
raw DB value for LocalDateTime column = 2026-07-03 23:30:00 <- shifted!
|
||||||
|
round-tripped LocalDateTime = 2026-07-04T09:00 <- but reads back correctly
|
||||||
|
|
||||||
|
original OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30
|
||||||
|
raw DB value for NATIVE offset column = 2026-07-04 09:00:00+05:30 <- unchanged
|
||||||
|
round-tripped OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30
|
||||||
|
```
|
||||||
|
|
||||||
|
`hibernate.jdbc.time_zone` converts the wall-clock value of a zone-less `LocalDateTime` into the
|
||||||
|
configured zone before it is bound to the JDBC driver -- the literal bytes stored in the database
|
||||||
|
shift, even though the application-level round trip through the *same* Hibernate configuration is
|
||||||
|
transparent (you get your `LocalDateTime` back unchanged). The danger is exactly the
|
||||||
|
"looks fine in the app, wrong when another tool reads the table directly" class of bug. It has
|
||||||
|
**no effect** on a value that already carries an explicit offset (`OffsetDateTime` with
|
||||||
|
`TimeZoneStorage.NATIVE`) -- that value is bound and stored exactly as given, confirmed by both
|
||||||
|
the raw column value and the round trip being unchanged.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/datetime-jdbc-time-zone.txt`](output/datetime-jdbc-time-zone.txt).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Claim | Verified value |
|
||||||
|
|---|---|
|
||||||
|
| `@Temporal` deprecated since | Jakarta Persistence 3.2 (confirmed via `javap -v`) |
|
||||||
|
| `@Temporal` misapplied to `java.time` | Logs a deprecation warning, does not fail |
|
||||||
|
| `TimeZoneStorageType` constants | NATIVE, NORMALIZE, NORMALIZE_UTC, COLUMN, AUTO, DEFAULT (6, not 5) |
|
||||||
|
| Hibernate 7.4.5 default storage on H2 | Behaves like NATIVE (dialect-derived, not a fixed constant) |
|
||||||
|
| JVM-zone hazard | Only `NORMALIZE` changes displayed offset when JVM zone changes |
|
||||||
|
| `COLUMN` mode DDL | Adds a companion `_tz integer` column |
|
||||||
|
| Nanosecond round trip | H2 rounds to microseconds; HSQLDB truncates to microseconds |
|
||||||
|
| `@Column(precision=9)` on a temporal field | No effect on generated DDL or stored precision |
|
||||||
|
| `hibernate.jdbc.time_zone` | Shifts raw stored value for zone-less types; no effect on explicit-offset types |
|
||||||
|
|
||||||
|
`@Temporal`'s deprecation is visible in the class file itself — see [`docs/output/datetime-javap-temporal-deprecated.txt`](output/datetime-javap-temporal-deprecated.txt).
|
||||||
|
|
||||||
|
[← Previous: 12 — Association mappings](12-association-mappings.md) | [Next: 14 — Named queries →](14-named-queries.md)
|
||||||
Executable
+210
@@ -0,0 +1,210 @@
|
|||||||
|
# 14 — Named queries: what startup validation, caching, and "faster" actually mean
|
||||||
|
|
||||||
|
[← Previous: 13 — Date and time mapping](13-date-and-time-mapping.md) | [Back to README →](../README.md) | [Next: 15 — HQL queries →](15-hql-queries.md)
|
||||||
|
|
||||||
|
Backs [ankurm.com: Hibernate 7 named queries](https://ankurm.com/master-hibernate-7-named-queries-clean-efficient-and-maintainable-data-access/).
|
||||||
|
|
||||||
|
Everything below comes from JUnit tests in [`src/test/java/com/ankurm/hibernatedemo/namedquery/`](../src/test/java/com/ankurm/hibernatedemo/namedquery/)
|
||||||
|
(and one deliberately-broken entity, [`BrokenNamedQueryEmployee`](../src/test/java/com/ankurm/brokenprobe/BrokenNamedQueryEmployee.java), kept outside the app's scanned package, explained below),
|
||||||
|
run against Hibernate 7.4.5.Final / H2 2.4.240.
|
||||||
|
|
||||||
|
## Startup validation
|
||||||
|
|
||||||
|
`hibernate.query.startup_check` is a real setting -- confirmed present as
|
||||||
|
`org.hibernate.cfg.QuerySettings.QUERY_STARTUP_CHECKING` -- and it does exactly what the name
|
||||||
|
suggests.
|
||||||
|
|
||||||
|
A deliberately broken `@NamedQuery` (`e.firsNam` instead of `e.firstName`) fails
|
||||||
|
`SessionFactory` construction with the check enabled (Hibernate's default):
|
||||||
|
|
||||||
|
```
|
||||||
|
wrapper class: org.hibernate.query.NamedQueryValidationException
|
||||||
|
verbatim message: Errors in named queries:
|
||||||
|
[1] Error in query named 'BrokenNamedQueryEmployee.badProperty': Could not resolve attribute
|
||||||
|
'firsNam' of 'com.ankurm.brokenprobe.BrokenNamedQueryEmployee'
|
||||||
|
[SELECT e FROM BrokenNamedQueryEmployee e WHERE e.firsNam = :name]
|
||||||
|
```
|
||||||
|
|
||||||
|
With `hibernate.query.startup_check=false`, the identical broken entity builds a
|
||||||
|
`SessionFactory` successfully. The same broken query only fails once it is actually executed --
|
||||||
|
and with a **different** exception:
|
||||||
|
|
||||||
|
```
|
||||||
|
class: java.lang.IllegalArgumentException
|
||||||
|
message: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'firsNam' ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`NamedQueryValidationException` at boot vs. `IllegalArgumentException` (wrapping
|
||||||
|
`UnknownPathException`) at call time -- the contrast is the article's best argument for leaving
|
||||||
|
the check on: the failure mode changes from "the deploy pipeline stops" to "a user's request
|
||||||
|
throws in production."
|
||||||
|
|
||||||
|
**Engineering note on how this was reproduced safely**: the broken entity
|
||||||
|
(`com.ankurm.brokenprobe.BrokenNamedQueryEmployee`) lives outside the
|
||||||
|
`com.ankurm.hibernatedemo` package tree on purpose. Spring Boot's default JPA entity scan walks
|
||||||
|
every subpackage under the `@SpringBootApplication` class's package
|
||||||
|
(`com.ankurm.hibernatedemo`), so a broken `@NamedQuery` anywhere in that tree would fail
|
||||||
|
`@SpringBootTest` context bootstrap for *every* test in this shared repository, not just this
|
||||||
|
one. [`NamedQueryStartupValidationTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryStartupValidationTest.java) instead uses a fully standalone Hibernate bootstrap
|
||||||
|
(`StandardServiceRegistryBuilder` + `MetadataSources`, no Spring involved at all) so the broken
|
||||||
|
entity never touches the shared application context.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/namedquery-startup-validation.txt`](output/namedquery-startup-validation.txt).
|
||||||
|
|
||||||
|
## `jakarta.persistence.NamedQuery` vs `org.hibernate.annotations.NamedQuery`
|
||||||
|
|
||||||
|
Both exist and are usable in Hibernate 7.4.5 / Jakarta Persistence 3.2, confirmed via `javap`.
|
||||||
|
The JPA-standard annotation is minimal:
|
||||||
|
|
||||||
|
```
|
||||||
|
jakarta.persistence.NamedQuery: name(), query(), resultClass(), lockMode(), hints()
|
||||||
|
```
|
||||||
|
|
||||||
|
Hibernate's own extends that meaningfully, exercised on [`HibernateExtraEmployee`](../src/main/java/com/ankurm/hibernatedemo/namedquery/HibernateExtraEmployee.java):
|
||||||
|
|
||||||
|
```
|
||||||
|
org.hibernate.annotations.NamedQuery: name(), query(), resultClass(), flush(), flushMode(),
|
||||||
|
cacheable(), cacheRegion(), fetchSize(), timeout(), comment(),
|
||||||
|
cacheStoreMode(), cacheRetrieveMode(), cacheMode(), readOnly()
|
||||||
|
```
|
||||||
|
|
||||||
|
`cacheable`, `flush`/`flushMode`, `timeout`, and `readOnly` have no JPA-standard equivalent on
|
||||||
|
`@NamedQuery` itself (JPA's `hints()` array can express some of these indirectly via magic
|
||||||
|
strings, but Hibernate's annotation gives typed attributes).
|
||||||
|
|
||||||
|
**One of these extras demonstrated actually taking effect**: `cacheable = true` on
|
||||||
|
`HibernateExtraEmployee.cacheableFindAll` genuinely populates the second-level query cache (JCache
|
||||||
|
+ Ehcache configured explicitly for this test, since it is off by default -- see
|
||||||
|
[chapter 09's writeup of the classpath-pollution trap](09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not)
|
||||||
|
for why that's off by default repo-wide). Two separate
|
||||||
|
`EntityManager`s, same query, `Statistics` counters:
|
||||||
|
|
||||||
|
```
|
||||||
|
cacheable=true named query: puts after 1st run = 1, cache hits after 2nd run = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The put on the first call and the hit on the second are both real, measured, not assumed.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/namedquery-execution-and-projections.txt`](output/namedquery-execution-and-projections.txt).
|
||||||
|
|
||||||
|
## `@NamedNativeQuery` + `@SqlResultSetMapping`, and the JPA 3.2 alternative
|
||||||
|
|
||||||
|
A `@NamedNativeQuery` mapped via `@SqlResultSetMapping` with `@ConstructorResult` into a plain
|
||||||
|
DTO class ([`EmployeeDto`](../src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeDto.java), on [`NqEmployee`](../src/main/java/com/ankurm/hibernatedemo/namedquery/NqEmployee.java)) works exactly as documented, per [`NamedQueryExecutionTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryExecutionTest.java):
|
||||||
|
|
||||||
|
```
|
||||||
|
Employee.byNativeDto(ACTIVE): [EmployeeDto{id=1, firstName=Native1}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Jakarta Persistence 3.2 does allow a `record` as a JPQL constructor-expression target** --
|
||||||
|
tested directly, not merely inferred from the spec text:
|
||||||
|
|
||||||
|
```java
|
||||||
|
record EmployeeRecordDto(Long id, String firstName) {}
|
||||||
|
|
||||||
|
SELECT NEW com.ankurm.hibernatedemo.namedquery.EmployeeRecordDto(e.id, e.firstName)
|
||||||
|
FROM NqEmployee e WHERE e.firstName = :name
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
JPQL constructor expression into a record: [EmployeeRecordDto[id=3, firstName=RecordTest]]
|
||||||
|
```
|
||||||
|
|
||||||
|
No special configuration needed -- a canonical [`EmployeeRecordDto`](../src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeRecordDto.java) constructor is matched exactly like any
|
||||||
|
other multi-argument constructor.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/namedquery-execution-and-projections.txt`](output/namedquery-execution-and-projections.txt).
|
||||||
|
|
||||||
|
## Named queries in `orm.xml`
|
||||||
|
|
||||||
|
A named query defined purely in [`META-INF/orm.xml`](../src/main/resources/META-INF/orm.xml) (no annotation at all,
|
||||||
|
[`XmlQueryEmployee`](../src/main/java/com/ankurm/hibernatedemo/namedquery/XmlQueryEmployee.java)`.findBySalaryAboveXml`) is picked up automatically by Spring Boot's default JPA
|
||||||
|
bootstrap -- **no `persistence.xml` and no explicit `<mapping-file>` registration required**; it
|
||||||
|
is discovered simply by being at the conventional `META-INF/orm.xml` classpath location. This is
|
||||||
|
the same "orm.xml just works with zero registration" theme chapter 04 documents for
|
||||||
|
`spring.jpa.mapping-resources`-driven entities -- see
|
||||||
|
[`04 — Annotations vs. XML mappings`](04-annotations-vs-xml.md#ormxml-really-can-define-an-entire-entity-annotation-free).
|
||||||
|
|
||||||
|
It works side by side with an annotation-defined named query on the same entity, per [`OrmXmlNamedQueryTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/OrmXmlNamedQueryTest.java):
|
||||||
|
|
||||||
|
```
|
||||||
|
annotation-defined named query result: 1 rows
|
||||||
|
orm.xml-defined named query result: 1 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
And when `orm.xml` defines a named query with the **same name** as one already declared via
|
||||||
|
`@NamedQuery` on the entity, the XML definition wins -- proven by giving the annotated version a
|
||||||
|
deliberately wrong predicate (`salary < 0`) and the XML version the correct one:
|
||||||
|
|
||||||
|
```
|
||||||
|
XmlQueryEmployee.overridden (annotation says salary<0, orm.xml says salary>:min): 1 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
If the annotation had won, this would have returned 0 rows.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/namedquery-ormxml.txt`](output/namedquery-ormxml.txt).
|
||||||
|
|
||||||
|
## Does pre-parsing actually help? (Measured, not assumed)
|
||||||
|
|
||||||
|
The common claim is that named queries are faster because they are "pre-parsed." Hibernate's own
|
||||||
|
query-plan cache is keyed by the **query string**, not by whether the string came from a
|
||||||
|
`@NamedQuery` or an inline JPQL literal -- so after the very first execution of either, both
|
||||||
|
paths hit the same cached AST/plan. [`NamedQueryPerformanceTest`](../src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryPerformanceTest.java) measures this directly: 500
|
||||||
|
warmup iterations, then 5000 measured iterations of a named query and the identical inline JPQL
|
||||||
|
string, interleaved call-by-call (to cancel out JIT/GC ordering bias) on a shared, otherwise-idle
|
||||||
|
in-memory H2 database:
|
||||||
|
|
||||||
|
```
|
||||||
|
run 1: named avg=124.18 us/call, inline avg=113.77 us/call, ratio (named/inline)=1.09
|
||||||
|
run 2: named avg=136.74 us/call, inline avg=128.27 us/call, ratio (named/inline)=1.07
|
||||||
|
```
|
||||||
|
|
||||||
|
**Honest finding**: across two runs the ratio stayed within ~10% either direction of 1.0, which
|
||||||
|
is noise for a shared sandbox container, not a real effect. We could not measure a performance
|
||||||
|
advantage for named queries over the identical inline JPQL string once both have been warmed up.
|
||||||
|
The commonly repeated "named queries are faster because they're pre-parsed" claim should be
|
||||||
|
retired as stated -- the real, verifiable benefits of named queries are the ones demonstrated
|
||||||
|
above: fail-fast startup validation, a place to attach Hibernate-specific extras like
|
||||||
|
`cacheable`, and centralizing query text -- not raw per-call execution speed.
|
||||||
|
|
||||||
|
Raw output: [`docs/output/namedquery-preparse-performance.txt`](output/namedquery-preparse-performance.txt).
|
||||||
|
|
||||||
|
## `getSingleResultOrNull()` vs `getSingleResult()`
|
||||||
|
|
||||||
|
Both confirmed present via `javap jakarta.persistence.Query` (Jakarta Persistence 3.2.0):
|
||||||
|
|
||||||
|
```
|
||||||
|
public abstract java.lang.Object getSingleResult();
|
||||||
|
public abstract java.lang.Object getSingleResultOrNull();
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior on zero rows, from a real run:
|
||||||
|
|
||||||
|
```
|
||||||
|
getSingleResultOrNull() on zero rows returned: null
|
||||||
|
getSingleResult() on zero rows threw: jakarta.persistence.NoResultException:
|
||||||
|
No result found for query [SELECT e FROM NqEmployee e WHERE e.firstName = :n]
|
||||||
|
```
|
||||||
|
|
||||||
|
`getSingleResultOrNull()` (added in Jakarta Persistence 3.2) is the null-returning alternative
|
||||||
|
that avoids a try/catch around `NoResultException` for the common "may or may not exist" lookup.
|
||||||
|
Chapter 05 covers the same method on the more specific `TypedQuery` interface -- see
|
||||||
|
[`05 — JPA persistence annotations`](05-jpa-persistence-annotations.md#whats-actually-new-in-jakarta-persistence-32-verified-via-javap-on-jakartapersistence-api-320jar).
|
||||||
|
|
||||||
|
Raw output: [`docs/output/namedquery-execution-and-projections.txt`](output/namedquery-execution-and-projections.txt).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Claim | Verified value |
|
||||||
|
|---|---|
|
||||||
|
| `hibernate.query.startup_check` | Exists (`QuerySettings.QUERY_STARTUP_CHECKING`); default behavior fails fast at boot |
|
||||||
|
| Broken named query, check enabled | `NamedQueryValidationException` at `SessionFactory` build |
|
||||||
|
| Broken named query, check disabled | Boots fine; fails at call time with `IllegalArgumentException`/`UnknownPathException` |
|
||||||
|
| `jakarta.persistence.NamedQuery` vs Hibernate's | Hibernate's adds cacheable/flush/timeout/readOnly/comment/cache* |
|
||||||
|
| `cacheable=true` | Measurably populates and hits the 2nd-level query cache |
|
||||||
|
| Record as JPQL constructor target | Works directly, JPA 3.2 |
|
||||||
|
| `orm.xml` named queries | Auto-discovered with no `persistence.xml`; override same-named annotations |
|
||||||
|
| Named query vs inline JPQL speed | No measurable difference after warmup (ratio ~1.0-1.1 across runs) |
|
||||||
|
| `getSingleResultOrNull()` | Present since JPA 3.2; returns null instead of throwing `NoResultException` |
|
||||||
|
|
||||||
|
[← Previous: 13 — Date and time mapping](13-date-and-time-mapping.md) | [Back to README →](../README.md) | [Next: 15 — HQL queries →](15-hql-queries.md)
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# 15 — HQL: the query language, its pitfalls, and what flush mode actually controls
|
||||||
|
|
||||||
|
[← Previous: 14 — Named queries](14-named-queries.md) | [Back to README →](../README.md) | [Next: 16 — Criteria API →](16-criteria-queries.md)
|
||||||
|
|
||||||
|
Backs ankurm.com post 4879 (HQL queries).
|
||||||
|
|
||||||
|
Everything below comes from [`HqlQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java),
|
||||||
|
run against Hibernate 7.4.5.Final / H2 2.4.240, using two new entities built for this chapter and
|
||||||
|
the next: [`Employee`](../src/main/java/com/ankurm/hibernatedemo/query/Employee.java) and
|
||||||
|
[`Department`](../src/main/java/com/ankurm/hibernatedemo/query/Department.java) (mapped with
|
||||||
|
`@Entity(name = "QueryDept")` -- the plain class name `Department` was already taken by chapter
|
||||||
|
06's `naturalid.Department`, and Hibernate requires entity names to be unique across the whole
|
||||||
|
persistence unit, not just per package).
|
||||||
|
|
||||||
|
## `FROM` and `WHERE`, and the pitfall that fails loudly
|
||||||
|
|
||||||
|
The smallest HQL query skips `SELECT` entirely:
|
||||||
|
|
||||||
|
```java
|
||||||
|
em.createQuery("FROM Employee", Employee.class).getResultList();
|
||||||
|
```
|
||||||
|
|
||||||
|
A `WHERE` clause with a named parameter is the normal shape for anything filtered:
|
||||||
|
|
||||||
|
```java
|
||||||
|
em.createQuery("SELECT e FROM Employee e WHERE e.status = :status", Employee.class)
|
||||||
|
.setParameter("status", "ACTIVE")
|
||||||
|
.getResultList();
|
||||||
|
```
|
||||||
|
|
||||||
|
**The classic mistake**: writing the database column name instead of the entity field name.
|
||||||
|
`Employee`'s column is `first_name`, but its Java field is `firstName`. HQL resolves against the
|
||||||
|
*entity model*, not the schema, so this fails before any SQL is even generated -- not a silent
|
||||||
|
wrong-result bug, a loud one:
|
||||||
|
|
||||||
|
```
|
||||||
|
columnNameInsteadOfFieldName: IllegalArgumentException: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'first_name' of 'com.ankurm.hibernatedemo.query.Employee' [SELECT e FROM Employee e WHERE e.first_name = 'Ada']
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Trap**: this only fails loudly because the attribute name is *wrong*. If you'd written
|
||||||
|
> `e.department.name` where the association happens to share a column name with something on the
|
||||||
|
> root entity, HQL still resolves it correctly -- the failure mode above only catches typos, not
|
||||||
|
> confusion about what a path actually points to. Read the exception type, not just its presence:
|
||||||
|
> `UnknownPathException` means "this attribute doesn't exist," not "this predicate is slow" or
|
||||||
|
> "this join is wrong."
|
||||||
|
|
||||||
|
Source: [`HqlQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java). Raw
|
||||||
|
output: [`docs/output/hql-select-and-joins.txt`](output/hql-select-and-joins.txt).
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- [Jakarta Persistence 3.2 query language spec, §4](https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2#query-language) (`rel="nofollow"`)
|
||||||
|
- Chapter 14's [named-query startup validation](14-named-queries.md#startup-validation) catches this same class of typo at boot instead of at call time, if you move the query into a `@NamedQuery`
|
||||||
|
|
||||||
|
## `JOIN` without `FETCH` does not prevent the N+1 it looks like it prevents
|
||||||
|
|
||||||
|
A plain `JOIN` in HQL is there to *filter*, not to *load*. It issues one SELECT for the query
|
||||||
|
itself, but touching the association afterward still fires a separate SELECT per distinct value:
|
||||||
|
|
||||||
|
```java
|
||||||
|
List<Employee> withDept = em.createQuery(
|
||||||
|
"SELECT e FROM Employee e JOIN e.department d WHERE d.name = :deptName", Employee.class)
|
||||||
|
.setParameter("deptName", "Engineering")
|
||||||
|
.getResultList();
|
||||||
|
// 1 statement so far
|
||||||
|
withDept.forEach(e -> e.getDepartment().getName());
|
||||||
|
// now more than 1 -- one extra SELECT per distinct department touched
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
joinWithoutFetch: 1 statements for the query, 2 after touching department
|
||||||
|
```
|
||||||
|
|
||||||
|
`JOIN FETCH` is the fix -- it loads the association eagerly in the same query, so touching it
|
||||||
|
afterward costs nothing extra:
|
||||||
|
|
||||||
|
```java
|
||||||
|
em.createQuery("SELECT e FROM Employee e LEFT JOIN FETCH e.department", Employee.class).getResultList();
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
joinFetch: 1 statement total, 5 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`HqlQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java). Raw
|
||||||
|
output: [`docs/output/hql-select-and-joins.txt`](output/hql-select-and-joins.txt).
|
||||||
|
|
||||||
|
This is the single most common Hibernate performance bug in production code, and it looks
|
||||||
|
completely correct on a code review: the `JOIN` is *right there*, so it reads as "this loads the
|
||||||
|
department." It doesn't. The distinction only shows up under a profiler or `Statistics` counters,
|
||||||
|
which is exactly what this test uses instead of trusting the query text.
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- Chapter 12 covers the mapping side of this -- [`@ManyToOne(fetch = LAZY)` and when eager beats lazy](12-association-mappings.md)
|
||||||
|
- Chapter 11's [proxy and lazy-initialization chapter](11-proxies-and-lazy-initialization.md) is the deeper mechanism: what `getDepartment()` actually returns before it's touched
|
||||||
|
|
||||||
|
## Aggregates, `GROUP BY`, and pagination
|
||||||
|
|
||||||
|
Straightforward HQL: `COUNT`, `AVG` with `GROUP BY`, and `setFirstResult`/`setMaxResults` for
|
||||||
|
paging.
|
||||||
|
|
||||||
|
```java
|
||||||
|
em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult();
|
||||||
|
|
||||||
|
em.createQuery(
|
||||||
|
"SELECT d.name, AVG(e.salary) FROM Employee e JOIN e.department d GROUP BY d.name ORDER BY d.name",
|
||||||
|
Object[].class).getResultList();
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
aggregateCount: 5
|
||||||
|
avgSalaryGroupByDepartment: Engineering -> 95000.0
|
||||||
|
avgSalaryGroupByDepartment: Marketing -> 71500.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Both averages were hand-computed from the seed data and asserted to match exactly (within a
|
||||||
|
0.01 offset for floating-point rounding) -- not just "a number came back."
|
||||||
|
|
||||||
|
Pagination combines `setFirstResult` (offset) and `setMaxResults` (limit), ordered so the pages
|
||||||
|
are deterministic:
|
||||||
|
|
||||||
|
```java
|
||||||
|
em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class)
|
||||||
|
.setFirstResult(0).setMaxResults(2).getResultList();
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
pagination: page1=[Byron, Hamilton], page2=[Hopper, Johnson]
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`HqlQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java). Raw
|
||||||
|
output: [`docs/output/hql-aggregation-paging-bulk.txt`](output/hql-aggregation-paging-bulk.txt).
|
||||||
|
|
||||||
|
For pagination past a few thousand rows, offset-based paging degrades because the database still
|
||||||
|
has to scan and discard every skipped row -- chapter 16's [pagination-adjacent bulk operations](16-criteria-queries.md)
|
||||||
|
and chapter 09's [in-memory database chapter](09-testing-in-memory-databases.md) both touch on
|
||||||
|
where that starts to matter; keyset pagination is the usual fix, out of scope for this chapter.
|
||||||
|
|
||||||
|
## Bulk `UPDATE` and `DELETE` bypass the persistence context
|
||||||
|
|
||||||
|
HQL's `UPDATE`/`DELETE` execute directly against the database as a single SQL statement -- they
|
||||||
|
do **not** load entities into the persistence context first, and they do **not** update any
|
||||||
|
entity that's already loaded there:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Employee loaded = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class)
|
||||||
|
.getSingleResult(); // status = INACTIVE, now managed
|
||||||
|
|
||||||
|
int updated = em.createQuery("UPDATE Employee e SET e.status = 'ARCHIVED' WHERE e.status = 'INACTIVE'")
|
||||||
|
.executeUpdate();
|
||||||
|
// updated == 1, and the row in the DATABASE now says ARCHIVED
|
||||||
|
|
||||||
|
// but `loaded` -- already in the persistence context -- still says INACTIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
bulkUpdate: updated=1 rows, stale in-memory status=INACTIVE, reloaded status=ARCHIVED
|
||||||
|
```
|
||||||
|
|
||||||
|
Only after `em.clear()` and a fresh `find()` does the already-loaded entity's Java field catch up
|
||||||
|
to what the database now holds. This is the same "bulk operations bypass the persistence context"
|
||||||
|
warning that appears throughout Hibernate's own reference docs, demonstrated here by asserting the
|
||||||
|
stale value stays stale until a clear-and-reload, not just stating it.
|
||||||
|
|
||||||
|
`DELETE` bulk operations are simpler -- one SQL statement, no persistence-context interaction to
|
||||||
|
worry about since there's no entity state left to go stale:
|
||||||
|
|
||||||
|
```java
|
||||||
|
em.createQuery("DELETE FROM Employee e WHERE e.department.id = :deptId")
|
||||||
|
.setParameter("deptId", marketingId).executeUpdate();
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
bulkDelete: deleted=2 rows, remaining=3
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`HqlQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java). Raw
|
||||||
|
output: [`docs/output/hql-aggregation-paging-bulk.txt`](output/hql-aggregation-paging-bulk.txt).
|
||||||
|
|
||||||
|
> **Trap**: `em.remove()` on a *managed* entity is not the same operation as bulk `DELETE`.
|
||||||
|
> `remove()` triggers cascades and lifecycle callbacks per entity; bulk `DELETE` is one SQL
|
||||||
|
> statement against the database and skips both. Choose deliberately -- bulk `DELETE` is much
|
||||||
|
> faster for large sets, `remove()` is correct when cascades or `@PreRemove` matter.
|
||||||
|
|
||||||
|
## Flush mode: what actually suppresses the auto-flush, and what doesn't
|
||||||
|
|
||||||
|
This section exists because of a dead end worth documenting. The first version of these tests
|
||||||
|
tried to prove flush-mode behavior by `persist()`-ing a *new* `Employee` and checking whether a
|
||||||
|
later query saw it. That approach is broken by construction: `Employee`'s `@Id` uses
|
||||||
|
`GenerationType.IDENTITY`, and **IDENTITY forces an immediate INSERT on `persist()`, independent
|
||||||
|
of flush mode entirely** -- Hibernate has to round-trip to the database right away to obtain the
|
||||||
|
generated key, before it can even hand back a usable entity reference. Flush mode never got a
|
||||||
|
chance to defer anything. (This is the same fact chapter 03 documents from the insert side --
|
||||||
|
[`GenerationType.IDENTITY` disables JDBC batching](03-inserting-objects.md) for the identical
|
||||||
|
underlying reason.)
|
||||||
|
|
||||||
|
The fix: dirty an **already-loaded, already-managed** entity with `setSalary(...)` instead of
|
||||||
|
inserting a new one. An `UPDATE` is not tied to id generation, so this correctly isolates what
|
||||||
|
flush mode controls.
|
||||||
|
|
||||||
|
**Default (`AUTO`)** flushes the dirty change before a query that could be affected by it runs, so
|
||||||
|
a fresh query in the same transaction sees the update even though nothing called `flush()`
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Employee ada = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Byron'", Employee.class).getSingleResult();
|
||||||
|
ada.setSalary(999_999.0); // dirtied, not explicitly flushed
|
||||||
|
Double seen = em.createQuery("SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class).getSingleResult();
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
defaultFlushMode: salary seen by a fresh query after an unflushed dirty change = 999999.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**`jakarta.persistence.FlushModeType.COMMIT`** is *implementation-defined*, not a guarantee --
|
||||||
|
its own javadoc says a provider is "permitted, but not required" to flush before a query. Verified
|
||||||
|
directly against Hibernate 7.4.5: it chooses **not** to flush, so the same dirty change stays
|
||||||
|
invisible to a query set to this mode:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.setFlushMode(jakarta.persistence.FlushModeType.COMMIT)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
jakartaCommitFlushMode: salary seen by query under FlushModeType.COMMIT = 98000.0 (pre-update value was 98000.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hibernate's own native `org.hibernate.FlushMode.MANUAL`** has no `jakarta.persistence`
|
||||||
|
equivalent, and it is the one mode that genuinely, unconditionally suppresses auto-flush until an
|
||||||
|
explicit `session.flush()` call:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Session session = em.unwrap(Session.class);
|
||||||
|
session.setHibernateFlushMode(FlushMode.MANUAL);
|
||||||
|
linus.setSalary(123_123.0);
|
||||||
|
// query here still sees the OLD value
|
||||||
|
session.flush();
|
||||||
|
// query here sees the NEW value
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
nativeManualFlushMode: before explicit flush=92000.0, after=123123.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`HqlQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java). Raw
|
||||||
|
output: [`docs/output/hql-flush-modes.txt`](output/hql-flush-modes.txt).
|
||||||
|
|
||||||
|
> **Trap**: if you're trying to prove (or debug) flush-mode behavior and your test entity uses
|
||||||
|
> `GenerationType.IDENTITY`, use an `UPDATE` on an already-managed row, not a `persist()`. This
|
||||||
|
> cost real debugging time in this repo before the root cause -- IDENTITY's forced immediate
|
||||||
|
> INSERT -- was found by reading the log line ordering rather than assuming flush mode was
|
||||||
|
> broken.
|
||||||
|
|
||||||
|
This distinction matters most in tight loops: `FlushMode.MANUAL` plus batched explicit flushes is
|
||||||
|
a real, measurable performance technique for bulk write-heavy code (see chapter 03's batching
|
||||||
|
findings), but only for updates -- not for anything that also needs a database-generated identity
|
||||||
|
key back immediately.
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- [`jakarta.persistence.FlushModeType` javadoc](https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/flushmodetype) (`rel="nofollow"`) -- the exact "permitted, but not required" wording
|
||||||
|
- Chapter 03's [`GenerationType.IDENTITY` and JDBC batching](03-inserting-objects.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Claim | Verified value |
|
||||||
|
|---|---|
|
||||||
|
| Column name instead of field name in HQL | Fails at query-build time with `UnknownPathException`, not silently |
|
||||||
|
| `JOIN` alone (no `FETCH`) | Filters correctly, but touching the association after still costs N extra SELECTs |
|
||||||
|
| `JOIN FETCH` | Loads root + association in exactly one SELECT |
|
||||||
|
| `GROUP BY` averages | Match hand-computed values exactly |
|
||||||
|
| Pagination (`setFirstResult`/`setMaxResults`) | Returns correct, non-overlapping slices when ordered |
|
||||||
|
| Bulk `UPDATE`/`DELETE` | Execute directly against the DB; already-loaded managed entities go stale until `clear()` + reload |
|
||||||
|
| `GenerationType.IDENTITY` + flush mode | IDENTITY forces immediate INSERT on `persist()`, defeating flush-mode-based INSERT deferral entirely |
|
||||||
|
| Default `AUTO` flush | Flushes a dirty UPDATE before a query that could see it |
|
||||||
|
| `FlushModeType.COMMIT` (jakarta) | Implementation-defined; Hibernate 7.4.5 chooses not to flush |
|
||||||
|
| `FlushMode.MANUAL` (Hibernate native) | Genuinely, unconditionally suppresses auto-flush until explicit `flush()` |
|
||||||
|
|
||||||
|
[← Previous: 14 — Named queries](14-named-queries.md) | [Back to README →](../README.md) | [Next: 16 — Criteria API →](16-criteria-queries.md)
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 16 — Criteria API: type-safe queries with the real generated metamodel
|
||||||
|
|
||||||
|
[← Previous: 15 — HQL queries](15-hql-queries.md) | [Back to README →](../README.md) | [Next: 17 — Bootstrapping EntityManager →](17-entitymanager-bootstrap.md)
|
||||||
|
|
||||||
|
Backs ankurm.com post 4880 (Criteria API).
|
||||||
|
|
||||||
|
Everything below comes from [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java),
|
||||||
|
sharing the same [`Employee`](../src/main/java/com/ankurm/hibernatedemo/query/Employee.java)/[`Department`](../src/main/java/com/ankurm/hibernatedemo/query/Department.java)
|
||||||
|
entities as chapter 15. `Employee_` and `Department_` are **real, generated** static metamodel
|
||||||
|
classes, produced at build time by `hibernate-jpamodelgen`, wired into
|
||||||
|
[`pom.xml`](../pom.xml)'s `maven-compiler-plugin` via `annotationProcessorPaths` (not a plain
|
||||||
|
dependency -- that's the setting that actually triggers annotation processing during
|
||||||
|
`javac`, confirmed by inspecting `target/generated-sources/annotations/` after a build and finding
|
||||||
|
real `Employee_.java`/`Department_.java` files there, not hand-written stand-ins).
|
||||||
|
|
||||||
|
## String paths work, but they're not type-checked
|
||||||
|
|
||||||
|
The most basic Criteria query builds a predicate from a plain string field name, exactly like a
|
||||||
|
map lookup:
|
||||||
|
|
||||||
|
```java
|
||||||
|
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||||
|
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
|
||||||
|
Root<Employee> root = cr.from(Employee.class);
|
||||||
|
|
||||||
|
Predicate salaryGreater = cb.gt(root.get("salary"), 90_000.0);
|
||||||
|
Predicate nameLike = cb.like(root.get("lastName"), "%o%");
|
||||||
|
cr.select(root).where(cb.and(salaryGreater, nameLike)).orderBy(cb.asc(root.get("lastName")));
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
stringPathPredicates: [Byron, Hopper, Torvalds]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Read that result carefully**: `salary > 90000` alone would already return exactly Byron, Hopper,
|
||||||
|
and Torvalds -- `lastName LIKE '%o%'` doesn't narrow the set further here, because all three of
|
||||||
|
those last names genuinely contain an `o`. Both predicates are doing real work in general, they
|
||||||
|
just happen to agree on this particular seed data; the test asserts the actual combined result,
|
||||||
|
not an assumption about which predicate "mattered."
|
||||||
|
|
||||||
|
`root.get("salary")` compiles even if `salary` were misspelled -- the typo would only surface at
|
||||||
|
runtime, as an `IllegalArgumentException` when Hibernate tries to resolve the path. That's the
|
||||||
|
whole reason the static metamodel exists.
|
||||||
|
|
||||||
|
Source: [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
|
||||||
|
Raw output: [`docs/output/criteria-predicates-and-metamodel.txt`](output/criteria-predicates-and-metamodel.txt).
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- [Jakarta Persistence 3.2 Criteria API spec, §6](https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2#criteria-api) (`rel="nofollow"`)
|
||||||
|
|
||||||
|
## The static metamodel is compiler-checked, and it's real
|
||||||
|
|
||||||
|
The identical query, rewritten through `Employee_`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
cr.select(root)
|
||||||
|
.where(cb.gt(root.get(Employee_.salary), 90_000.0))
|
||||||
|
.orderBy(cb.asc(root.get(Employee_.lastName)));
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
staticMetamodel: [Byron, Hopper, Torvalds]
|
||||||
|
```
|
||||||
|
|
||||||
|
Same result as the string-path version above -- proving `Employee_.salary` and `Employee_.lastName`
|
||||||
|
are genuinely wired to the same underlying attributes, not just present and unused. Misspell
|
||||||
|
`Employee_.salery` and the build fails at `javac`, not at test time three months later when
|
||||||
|
someone renames the `salary` field and forgets the string literal thirty call sites away.
|
||||||
|
|
||||||
|
Joins work the same way, through the metamodel's generated association fields:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Join<Employee, Department> department = root.join(Employee_.department, JoinType.INNER);
|
||||||
|
cr.select(root).where(cb.equal(department.get(Department_.name), "Engineering"));
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
joinViaMetamodel: 3 engineering employees
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
|
||||||
|
Raw output: [`docs/output/criteria-predicates-and-metamodel.txt`](output/criteria-predicates-and-metamodel.txt).
|
||||||
|
|
||||||
|
The one-time setup cost -- one `annotationProcessorPaths` block -- is the whole tradeoff. Once
|
||||||
|
it's in place, every entity gets its `_`-suffixed metamodel class for free on every build; nothing
|
||||||
|
in application code has to opt in per-entity.
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- [Chapter 04's annotations-vs-XML chapter](04-annotations-vs-xml.md) if metamodel generation from XML-mapped entities matters for your setup
|
||||||
|
- [`hibernate-jpamodelgen` on Maven Central](https://mvnrepository.com/artifact/org.hibernate.orm/hibernate-jpamodelgen) (`rel="nofollow"`)
|
||||||
|
|
||||||
|
## `root.join()` vs `root.fetch()` -- the same trap as HQL's bare `JOIN`
|
||||||
|
|
||||||
|
This is chapter 15's `JOIN` vs `JOIN FETCH` distinction, in Criteria API form, and it's just as
|
||||||
|
easy to get wrong here because `root.join()` *looks* like it should load the association:
|
||||||
|
|
||||||
|
```java
|
||||||
|
root.join(Employee_.department, JoinType.INNER); // filters, does NOT eagerly load
|
||||||
|
// ... after the query runs and results are touched:
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
rootJoinVsFetch: join+touch=3 statements, fetch+touch=1 statement
|
||||||
|
```
|
||||||
|
|
||||||
|
`root.join()` alone still costs one extra SELECT per distinct department touched afterward --
|
||||||
|
identical N+1 shape to HQL's bare `JOIN`. `root.fetch()` is the actual fix, and it needs
|
||||||
|
`.distinct(true)` on the query to avoid duplicate rows when the fetched association is a
|
||||||
|
collection (harmless but wasteful here since `department` is `@ManyToOne`, kept for the habit):
|
||||||
|
|
||||||
|
```java
|
||||||
|
root2.fetch(Employee_.department, JoinType.INNER);
|
||||||
|
cr2.select(root2).distinct(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
That second query costs exactly one statement total, even after touching every returned entity's
|
||||||
|
department.
|
||||||
|
|
||||||
|
Source: [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
|
||||||
|
Raw output: [`docs/output/criteria-predicates-and-metamodel.txt`](output/criteria-predicates-and-metamodel.txt).
|
||||||
|
|
||||||
|
> **Trap**: `Join` and `Fetch` are different interfaces in the Criteria API (`root.join()` returns
|
||||||
|
> a `Join`, `root.fetch()` returns a `Fetch`), which is part of why it's easy to reach for the
|
||||||
|
> wrong one -- your IDE will happily autocomplete either. Reach for `fetch()` specifically when
|
||||||
|
> you intend to read the association afterward; reach for `join()` when it's purely a filter.
|
||||||
|
|
||||||
|
## Aggregation and subqueries
|
||||||
|
|
||||||
|
`CriteriaBuilder.avg()` on a metamodel path, against a `Double`-typed query:
|
||||||
|
|
||||||
|
```java
|
||||||
|
CriteriaQuery<Double> avgQuery = cb.createQuery(Double.class);
|
||||||
|
Root<Employee> avgRoot = avgQuery.from(Employee.class);
|
||||||
|
avgQuery.select(cb.avg(avgRoot.get(Employee_.salary)));
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
aggregation: average salary = 85600.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Hand-computed from the seed data ((95000+98000+92000+72000+71000)/5 = 85600) and asserted to
|
||||||
|
match, not just observed.
|
||||||
|
|
||||||
|
A correlated-by-value subquery, finding employees above the company-wide average:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Subquery<Double> sub = mainQuery.subquery(Double.class);
|
||||||
|
Root<Employee> subRoot = sub.from(Employee.class);
|
||||||
|
sub.select(cb.avg(subRoot.get(Employee_.salary)));
|
||||||
|
mainQuery.select(empRoot).where(cb.gt(empRoot.get(Employee_.salary), sub));
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
subquery: above-average earners (avg=85600) = [Byron, Hopper, Torvalds]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Worth reading twice**: the naive assumption is "only the single highest earner clears the
|
||||||
|
average" -- that's wrong here. The two Marketing salaries (72000 and 71000) pull the company-wide
|
||||||
|
average down to 85600, well below every Engineering salary, so all three Engineering employees
|
||||||
|
clear it, not just Grace Hopper at the top. This is a real example of why "above average" queries
|
||||||
|
need the actual average computed, not eyeballed -- an earlier draft of this test asserted the
|
||||||
|
wrong single-employee result and was caught by actually computing the average by hand and
|
||||||
|
comparing.
|
||||||
|
|
||||||
|
`or()` combines predicates with a varargs overload -- worth calling out only because it's easy to
|
||||||
|
reach for `cb.equal(...).or(...)` chaining instead and get confused about operator precedence:
|
||||||
|
|
||||||
|
```java
|
||||||
|
cb.or(cb.equal(root.get(Employee_.status), "INACTIVE"), cb.equal(root.get(Employee_.lastName), "Hamilton"))
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
orPredicate: [Torvalds, Hamilton]
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
|
||||||
|
Raw output: [`docs/output/criteria-aggregation-and-subquery.txt`](output/criteria-aggregation-and-subquery.txt).
|
||||||
|
|
||||||
|
## `CriteriaUpdate` and `CriteriaDelete` -- bulk operations, type-safe
|
||||||
|
|
||||||
|
The same bulk-operation semantics as chapter 15's HQL `UPDATE`/`DELETE` (single SQL statement,
|
||||||
|
bypasses the persistence context for already-loaded entities), expressed through
|
||||||
|
`CriteriaBuilder.createCriteriaUpdate()`/`createCriteriaDelete()` instead of a query string:
|
||||||
|
|
||||||
|
```java
|
||||||
|
CriteriaUpdate<Employee> update = cb.createCriteriaUpdate(Employee.class);
|
||||||
|
Root<Employee> updRoot = update.from(Employee.class);
|
||||||
|
update.set(Employee_.salary, cb.prod(updRoot.get(Employee_.salary), 1.1))
|
||||||
|
.where(cb.equal(updRoot.get(Employee_.department).get(Department_.id), engineeringId));
|
||||||
|
int updated = em.createQuery(update).executeUpdate();
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
criteriaUpdate: 3 rows updated, Ada's new salary = 104500.00000000001
|
||||||
|
```
|
||||||
|
|
||||||
|
`cb.prod(...)` is Criteria's typed multiplication (`salary * 1.1`), and the trailing
|
||||||
|
`.00000000001` on the result is ordinary `double` floating-point representation, not a bug --
|
||||||
|
`95000.0 * 1.1` doesn't land on an exact binary fraction, which is exactly why the assertion in
|
||||||
|
the test uses an offset-based comparison instead of exact equality.
|
||||||
|
|
||||||
|
```java
|
||||||
|
CriteriaDelete<Employee> delete = cb.createCriteriaDelete(Employee.class);
|
||||||
|
Root<Employee> delRoot = delete.from(Employee.class);
|
||||||
|
delete.where(cb.isNull(delRoot.get(Employee_.department)));
|
||||||
|
int deleted = em.createQuery(delete).executeUpdate();
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
criteriaDelete: deleted=1, remaining=5
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`CriteriaQueryTest`](../src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java).
|
||||||
|
Raw output: [`docs/output/criteria-bulk-update-delete.txt`](output/criteria-bulk-update-delete.txt).
|
||||||
|
|
||||||
|
> **Trap**: `CriteriaUpdate`/`CriteriaDelete` need their own `Root`, created via
|
||||||
|
> `update.from(...)`/`delete.from(...)` -- you cannot reuse a `Root` from a `CriteriaQuery`
|
||||||
|
> built earlier in the same method, even against the same entity type. They're different root
|
||||||
|
> instances tied to different query objects.
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- Chapter 15's [bulk `UPDATE`/`DELETE` bypass the persistence context](15-hql-queries.md#bulk-update-and-delete-bypass-the-persistence-context) -- identical caveat, different API surface
|
||||||
|
|
||||||
|
## When to reach for Criteria over HQL
|
||||||
|
|
||||||
|
Neither API is strictly "better" -- they solve different problems:
|
||||||
|
|
||||||
|
| | HQL | Criteria API |
|
||||||
|
|---|---|---|
|
||||||
|
| Readability for a fixed, known query | Higher -- reads like SQL | Lower -- more ceremony per query |
|
||||||
|
| Compile-time safety | None -- typos in path expressions fail at runtime | Full, with the generated metamodel |
|
||||||
|
| Dynamically building predicates (search filters, optional criteria) | Painful -- string concatenation or conditional clause-building | Natural -- build up `Predicate`s in a loop, combine with `cb.and()`/`cb.or()` |
|
||||||
|
| Startup validation via `@NamedQuery` | Yes (see chapter 14) | No equivalent |
|
||||||
|
|
||||||
|
The dynamic-predicate case is where Criteria earns its ceremony: a search endpoint with five
|
||||||
|
optional filter fields is a genuinely painful HQL string-building exercise and a clean loop of
|
||||||
|
`if (filter != null) predicates.add(cb.equal(...))` in Criteria.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Claim | Verified value |
|
||||||
|
|---|---|
|
||||||
|
| `root.get("string")` vs `root.get(Employee_.field)` | Both produce identical results; only the metamodel version is compiler-checked |
|
||||||
|
| `Employee_`/`Department_` | Real, generated by `hibernate-jpamodelgen` via `annotationProcessorPaths`, not hand-written |
|
||||||
|
| `root.join()` | Filters via SQL join; does NOT eagerly load the association |
|
||||||
|
| `root.fetch()` | Loads the association in the same SELECT |
|
||||||
|
| `cb.avg()` | Matches hand-computed average exactly |
|
||||||
|
| Subquery for "above average" | All three Engineering employees qualify -- Marketing salaries pull the average down further than expected |
|
||||||
|
| `CriteriaUpdate`/`CriteriaDelete` | Same bulk-operation semantics as HQL `UPDATE`/`DELETE`: single statement, bypasses the persistence context |
|
||||||
|
|
||||||
|
[← Previous: 15 — HQL queries](15-hql-queries.md) | [Back to README →](../README.md) | [Next: 17 — Bootstrapping EntityManager →](17-entitymanager-bootstrap.md)
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# 17 — Bootstrapping EntityManager: XML, PersistenceConfiguration, and what a factory costs
|
||||||
|
|
||||||
|
[← Previous: 16 — Criteria API](16-criteria-queries.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs ankurm.com post 4855 (bootstrapping EntityManager).
|
||||||
|
|
||||||
|
Everything below comes from [`EntityManagerBootstrapTest`](../src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java) --
|
||||||
|
deliberately **plain JUnit, not `@SpringBootTest`**. The whole point of this chapter is
|
||||||
|
bootstrapping a raw JPA `EntityManagerFactory` with no Spring involved, the way a Java SE
|
||||||
|
application, a batch job, or a unit test outside a Spring context would. Every other chapter in
|
||||||
|
this repo runs inside Spring Boot's auto-configured persistence; this one deliberately doesn't.
|
||||||
|
|
||||||
|
## Two ways to bootstrap, both exercised for real
|
||||||
|
|
||||||
|
**XML**: a [`persistence.xml`](../src/test/resources/META-INF/persistence.xml) on the classpath at
|
||||||
|
the conventional `META-INF/persistence.xml` location, resolved purely by unit name:
|
||||||
|
|
||||||
|
```java
|
||||||
|
try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("XmlBootstrapPU")) {
|
||||||
|
EntityManager em = emf.createEntityManager();
|
||||||
|
em.getTransaction().begin();
|
||||||
|
BootstrapUser user = new BootstrapUser("Ankur", "[email protected]");
|
||||||
|
em.persist(user);
|
||||||
|
em.getTransaction().commit();
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
xmlBootstrap: persisted and reloaded user id=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Programmatic**, via Jakarta Persistence 3.2's `PersistenceConfiguration` -- new in this spec
|
||||||
|
version, confirmed present via `javap` against `jakarta.persistence-api-3.2.0.jar` (constants
|
||||||
|
like `JDBC_URL`, `JDBC_DRIVER`, `JDBC_USER`, `JDBC_PASSWORD` map to the same string property keys
|
||||||
|
as the XML form's `<property>` elements -- there's no dedicated `.jdbcUrl(String)` builder
|
||||||
|
method, connection details go through `.property(PersistenceConfiguration.JDBC_URL, ...)`):
|
||||||
|
|
||||||
|
```java
|
||||||
|
PersistenceConfiguration config = new PersistenceConfiguration("ProgrammaticPU")
|
||||||
|
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
|
||||||
|
.managedClass(BootstrapUser.class)
|
||||||
|
.property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver")
|
||||||
|
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-programmatic;DB_CLOSE_DELAY=-1")
|
||||||
|
.property(PersistenceConfiguration.JDBC_USER, "sa")
|
||||||
|
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
|
||||||
|
.property("hibernate.hbm2ddl.auto", "create-drop");
|
||||||
|
|
||||||
|
try (EntityManagerFactory emf = config.createEntityManagerFactory()) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
programmaticBootstrap: persisted user id=1 with zero persistence.xml units named 'ProgrammaticPU'
|
||||||
|
```
|
||||||
|
|
||||||
|
That second result is the point of the test's name: `"ProgrammaticPU"` has **no** matching
|
||||||
|
`<persistence-unit>` anywhere in `persistence.xml`. This only works at all if
|
||||||
|
`PersistenceConfiguration` genuinely builds a persistence unit from code, with zero XML lookup.
|
||||||
|
|
||||||
|
Source: [`EntityManagerBootstrapTest`](../src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java),
|
||||||
|
[`persistence.xml`](../src/test/resources/META-INF/persistence.xml). Raw output:
|
||||||
|
[`docs/output/bootstrap-persistenceconfiguration.txt`](output/bootstrap-persistenceconfiguration.txt).
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- [`jakarta.persistence.PersistenceConfiguration` javadoc](https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/persistenceconfiguration) (`rel="nofollow"`)
|
||||||
|
|
||||||
|
## Reusing a persistence-unit name does not trigger an XML lookup — proven two ways
|
||||||
|
|
||||||
|
This is the claim most worth being skeptical of, so it's verified twice, independently.
|
||||||
|
|
||||||
|
The setup: build a `PersistenceConfiguration` with the name `"XmlBootstrapPU"` -- **the exact
|
||||||
|
same name** as the real XML-defined unit -- but with completely different properties, pointed at
|
||||||
|
a third H2 database (`bootstrap-namecollision`) built purely in code:
|
||||||
|
|
||||||
|
```java
|
||||||
|
PersistenceConfiguration config = new PersistenceConfiguration("XmlBootstrapPU")
|
||||||
|
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
|
||||||
|
.managedClass(BootstrapUser.class)
|
||||||
|
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-namecollision;DB_CLOSE_DELAY=-1")
|
||||||
|
// ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**First proof** -- a native query asking the database itself which database it is:
|
||||||
|
|
||||||
|
```java
|
||||||
|
List<Object> row = em.createNativeQuery("SELECT DATABASE()").getResultList();
|
||||||
|
String actualDb = (String) row.get(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
persistenceUnitNameCollision: connected database = BOOTSTRAP-NAMECOLLISION (unit name 'XmlBootstrapPU' reused on purpose)
|
||||||
|
```
|
||||||
|
|
||||||
|
If the reused name had triggered any XML lookup or merge, the factory would be connected to
|
||||||
|
`bootstrap-xml` (the real XML unit's database) instead. It isn't.
|
||||||
|
|
||||||
|
**Second proof** -- Hibernate's own bootstrap log, for the *same test run*, showing two
|
||||||
|
completely different `PersistenceUnitInfo` entries logged under the identical name at two
|
||||||
|
different points in the run: once for the programmatic config above, and once later when
|
||||||
|
`xmlBootstrap_createsFactoryFromPersistenceXmlAndPersistsAUser` runs and actually does load the
|
||||||
|
real XML unit:
|
||||||
|
|
||||||
|
```
|
||||||
|
23:40:17.710 [main] INFO org.hibernate.orm.jpa -- HHH008540: Processing PersistenceUnitInfo [name: XmlBootstrapPU]
|
||||||
|
Database JDBC URL [jdbc:h2:mem:bootstrap-namecollision;DB_CLOSE_DELAY=-1]
|
||||||
|
Default catalog/schema: BOOTSTRAP-NAMECOLLISION/PUBLIC
|
||||||
|
|
||||||
|
23:40:17.814 [main] INFO org.hibernate.orm.jpa -- HHH008540: Processing PersistenceUnitInfo [name: XmlBootstrapPU]
|
||||||
|
Database JDBC URL [jdbc:h2:mem:bootstrap-xml;DB_CLOSE_DELAY=-1]
|
||||||
|
Default catalog/schema: BOOTSTRAP-XML/PUBLIC
|
||||||
|
```
|
||||||
|
|
||||||
|
Same persistence-unit name, two entirely different JDBC URLs, logged 104ms apart in the same JVM.
|
||||||
|
The programmatic config's own properties won completely, both times a `PersistenceConfiguration`
|
||||||
|
was used regardless of what XML on the classpath also happened to define under that name.
|
||||||
|
|
||||||
|
Source: [`EntityManagerBootstrapTest`](../src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java).
|
||||||
|
Raw output: [`docs/output/bootstrap-persistenceconfiguration.txt`](output/bootstrap-persistenceconfiguration.txt)
|
||||||
|
(the full log block, both `HHH008540` lines included verbatim).
|
||||||
|
|
||||||
|
> **Trap**: this is good news for testing (a programmatic config can safely reuse a
|
||||||
|
> production-sounding unit name without risk of accidentally inheriting production XML settings
|
||||||
|
> from the classpath), but it also means a typo that *happens* to collide with a real unit name
|
||||||
|
> will not get "caught" by any merge behavior -- there is none. Treat the name purely as a label
|
||||||
|
> once you're on the `PersistenceConfiguration` path.
|
||||||
|
|
||||||
|
## An unconfigured name fails loudly, and the exception type has changed
|
||||||
|
|
||||||
|
`Persistence.createEntityManagerFactory(name)` with no properties map and no matching
|
||||||
|
`PersistenceConfiguration` or XML unit has nowhere left to look:
|
||||||
|
|
||||||
|
```java
|
||||||
|
assertThatThrownBy(() -> Persistence.createEntityManagerFactory("TotallyUnknownPU"))
|
||||||
|
.isInstanceOf(PersistenceException.class);
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
unconfiguredUnitName: jakarta.persistence.PersistenceException: No Persistence provider for EntityManager named TotallyUnknownPU
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correction to the original version of this article**: it claimed this throws
|
||||||
|
`javax.persistence.PersistenceException`. That namespace is stale -- Jakarta EE moved the entire
|
||||||
|
`javax.persistence.*` package to `jakarta.persistence.*` starting with Jakarta Persistence 3.0,
|
||||||
|
and Hibernate 7.4.5 / Jakarta Persistence 3.2.0 (what this repo runs) only knows the `jakarta.*`
|
||||||
|
form. The real, verified exception is `jakarta.persistence.PersistenceException`. (Post 4859, in
|
||||||
|
the `spring-boot-demo` companion repo, had the identical `javax` → `jakarta` staleness in an
|
||||||
|
unrelated exception type -- this class of correction has now shown up twice across this blog's
|
||||||
|
Hibernate/JPA coverage, which suggests it's worth grepping your own older posts for `javax.persistence`
|
||||||
|
if you haven't already.)
|
||||||
|
|
||||||
|
Source: [`EntityManagerBootstrapTest`](../src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java).
|
||||||
|
Raw output: [`docs/output/bootstrap-persistenceconfiguration.txt`](output/bootstrap-persistenceconfiguration.txt).
|
||||||
|
|
||||||
|
## Creating an `EntityManagerFactory` is measurably not cheap
|
||||||
|
|
||||||
|
Not a Metaspace-exhaustion reproduction -- that needs sustained, uncollectable class-loader growth
|
||||||
|
across many thousands of factories, and isn't something to deliberately trigger in a shared
|
||||||
|
sandbox. What **is** safely measurable in one run: factory creation timing versus
|
||||||
|
`EntityManager` creation timing, from the same `PersistenceConfiguration`:
|
||||||
|
|
||||||
|
```
|
||||||
|
repeatedFactoryCreation: createEntityManagerFactory() took 1630 ms, createEntityManager() took 36 ms -- the factory call is the one doing schema validation, service registry bootstrap, and metadata scanning; the EntityManager call is comparatively trivial
|
||||||
|
```
|
||||||
|
|
||||||
|
Order of magnitude, not a precise benchmark (this ran in a shared sandbox container, and the
|
||||||
|
absolute milliseconds will vary by machine) -- but the **ratio** is the point: the factory build
|
||||||
|
did roughly 45x the work of creating an `EntityManager` from an already-built factory. This is the
|
||||||
|
concrete, measured version of "never create an `EntityManagerFactory` per request" -- the advice
|
||||||
|
usually gets repeated without a number attached to it.
|
||||||
|
|
||||||
|
Source: [`EntityManagerBootstrapTest`](../src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java).
|
||||||
|
Raw output: [`docs/output/bootstrap-persistenceconfiguration.txt`](output/bootstrap-persistenceconfiguration.txt).
|
||||||
|
|
||||||
|
> **Should you ever call this yourself?** In a Spring Boot application, essentially never --
|
||||||
|
> Spring's `LocalContainerEntityManagerFactoryBean` (what every other chapter in this repo runs
|
||||||
|
> on) builds exactly one `EntityManagerFactory` at startup and hands out `EntityManager`s from it
|
||||||
|
> per request/transaction via `@PersistenceContext`/`@Autowired EntityManagerFactory`. This
|
||||||
|
> chapter's raw bootstrapping is for the cases genuinely outside a DI container: a standalone
|
||||||
|
> Java SE batch job, a library that must not assume Spring is present, or -- as here -- a unit
|
||||||
|
> test that wants to prove something about bootstrapping itself without inheriting a whole
|
||||||
|
> Spring context.
|
||||||
|
|
||||||
|
Going deeper:
|
||||||
|
- [`jakarta.persistence.Persistence` javadoc](https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/persistence) (`rel="nofollow"`)
|
||||||
|
- Spring's own [`LocalContainerEntityManagerFactoryBean`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBean.html) (`rel="nofollow"`) for how the rest of this repo actually gets its `EntityManagerFactory`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Claim | Verified value |
|
||||||
|
|---|---|
|
||||||
|
| XML bootstrap via `persistence.xml` | Works, resolved purely by unit name |
|
||||||
|
| `PersistenceConfiguration` (JPA 3.2) | Works with zero matching `persistence.xml` unit |
|
||||||
|
| No dedicated `.jdbcUrl()` builder method | Confirmed via `javap`; JDBC settings go through `.property(PersistenceConfiguration.JDBC_URL, ...)` |
|
||||||
|
| Reusing a `persistence.xml` unit's name in a programmatic config | Does NOT trigger any XML lookup or merge -- proven via `SELECT DATABASE()` and via duplicate `HHH008540` log lines with different JDBC URLs |
|
||||||
|
| Unconfigured unit name | Throws `jakarta.persistence.PersistenceException` (corrected from a stale `javax.persistence.PersistenceException` claim) |
|
||||||
|
| `createEntityManagerFactory()` vs `createEntityManager()` cost | Factory creation measured at roughly 45x the cost of creating an `EntityManager` from an already-built factory |
|
||||||
|
|
||||||
|
[← Previous: 16 — Criteria API](16-criteria-queries.md) | [Back to README →](../README.md)
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# 18 — Ehcache 3 second-level cache configuration
|
||||||
|
|
||||||
|
[← Previous: 17 — Bootstrapping EntityManager](17-entitymanager-bootstrap.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4884 (Ehcache 3 L2 cache configuration).
|
||||||
|
|
||||||
|
Chapter 06 already turned on the L2 cache once, for `@NaturalIdCache`. This chapter is the
|
||||||
|
general case -- plain entity caching, query caching, and the four specific pitfalls the original
|
||||||
|
article warned about -- each one actually reproduced (or, in three of the four cases, disproven)
|
||||||
|
rather than repeated as received wisdom. Every test here boots its own isolated, raw
|
||||||
|
`StandardServiceRegistry`/`SessionFactory` (the same pattern chapter 09 established), never the
|
||||||
|
shared Spring context, so none of this leaks into any other chapter's tests.
|
||||||
|
|
||||||
|
## The dependency question this chapter does NOT re-litigate
|
||||||
|
|
||||||
|
The original article's warning -- "without the `jakarta` classifier, Ehcache 3 ships the older
|
||||||
|
`javax.cache` JCache API; Hibernate 7 requires `jakarta.cache`; using the wrong artifact causes a
|
||||||
|
`ClassNotFoundException`" -- is false, and chapter 06 already established why:
|
||||||
|
[JSR-107 (JCache) was never migrated to the Jakarta namespace](06-natural-ids.md#turning-on-l2-hibernate-jcache--ehcache),
|
||||||
|
unlike JPA or Bean Validation. `javax.cache.Caching` is the one and only JCache API entry point,
|
||||||
|
with or without Ehcache's own `jakarta` classifier.
|
||||||
|
|
||||||
|
This chapter re-verifies that claim independently rather than just linking to it, because it's
|
||||||
|
central enough to the post to deserve its own primary-source check:
|
||||||
|
[`CacheApiNamespaceTest`](../src/test/java/com/ankurm/hibernatedemo/cache/CacheApiNamespaceTest.java)
|
||||||
|
confirms `javax.cache.Caching` loads fine on this classpath, and that `jakarta.cache.Cache` throws
|
||||||
|
`ClassNotFoundException` -- there is no such package, ever, regardless of classifier:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath (jar: file:/.../cache-api-1.1.1.jar)
|
||||||
|
RESULT[cache-api-no-jakarta-namespace]: Class.forName("jakarta.cache.Cache") -> ClassNotFoundException -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's own "jakarta" classifier on org.ehcache:ehcache.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/18-cache-api-namespace.txt)
|
||||||
|
|
||||||
|
So what *is* the `jakarta` classifier for? Comparing `ehcache-3.10.8.jar` and
|
||||||
|
`ehcache-3.10.8-jakarta.jar` directly -- their Gradle module metadata, their `.pom` (identical,
|
||||||
|
both declare `javax.cache:cache-api` as a dependency), and disassembled classes in
|
||||||
|
`org.ehcache.xml.model` and `ConfigurationParser.class` -- shows the difference is entirely about
|
||||||
|
which JAXB runtime major version Ehcache uses **internally, to parse its own `ehcache.xml`
|
||||||
|
config file**: the plain jar imports `javax.xml.bind`, the `jakarta` jar imports
|
||||||
|
`jakarta.xml.bind`. Nothing in either jar touches `javax.cache` vs. a `jakarta.cache` package,
|
||||||
|
because the latter doesn't exist. Pick the classifier that matches whichever JAXB runtime is
|
||||||
|
already on your classpath (Jakarta EE 9+ stacks, including Spring Boot 3+/4+, want the `jakarta`
|
||||||
|
classifier) -- not because of JCache.
|
||||||
|
|
||||||
|
## The smallest thing that works: caching one entity
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
@jakarta.persistence.Cacheable
|
||||||
|
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "productCache")
|
||||||
|
public class CacheProduct {
|
||||||
|
@Id @GeneratedValue private Long id;
|
||||||
|
@Column(nullable = false) private String name;
|
||||||
|
private Double price;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
[`CacheProduct.java`](../src/main/java/com/ankurm/hibernatedemo/cache/CacheProduct.java)
|
||||||
|
|
||||||
|
Two annotations, both required: `@jakarta.persistence.Cacheable` is the portable JPA marker that
|
||||||
|
says "this entity may be cached"; `@org.hibernate.annotations.Cache` is Hibernate's own annotation
|
||||||
|
that actually turns caching on and picks the concurrency strategy and region name. Leaving either
|
||||||
|
one off means no L2 caching happens for this entity at all.
|
||||||
|
|
||||||
|
The Ehcache config backing `productCache` lives in
|
||||||
|
[`ehcache-chapter18.xml`](../src/test/resources/ehcache-chapter18.xml): a 30-minute TTL, 1000
|
||||||
|
heap entries, 10MB offheap. `default-update-timestamps-region` and `default-query-results-region`
|
||||||
|
are also declared explicitly -- more on why that matters below.
|
||||||
|
|
||||||
|
### Surprise: `persist()` + `commit()` already puts the entity in the cache
|
||||||
|
|
||||||
|
The natural mental model is "L2 fills on the first cache *miss* -- so the very first `get()`
|
||||||
|
after inserting a row still costs one SELECT, a cold read, before later reads become free." That
|
||||||
|
model is wrong, and this chapter is the second time this exact repository has caught it: chapter
|
||||||
|
06 already documented it for `@NaturalIdCache`, and
|
||||||
|
[`EntityL2CacheTest`](../src/test/java/com/ankurm/hibernatedemo/cache/EntityL2CacheTest.java)
|
||||||
|
confirms it generalizes to plain `@Cacheable`/`@Cache` entity caching too. `persist()` +
|
||||||
|
`commit()` populates the L2 region itself, so even the "first" `get()` for that id afterward,
|
||||||
|
from a brand-new session, is already a cache hit with zero SQL statements:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries=0 | session2 (brand-new session, same id) cumulative queries=0, L2 entity cache hits=2
|
||||||
|
```
|
||||||
|
[(full transcript)](output/18-entity-l2-cache.txt)
|
||||||
|
|
||||||
|
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
|
||||||
|
<strong>Trap:</strong> if you're benchmarking L2 cache savings by comparing a "cold" first
|
||||||
|
<code>get()</code> against a "warm" second one, you're measuring the wrong thing for any entity
|
||||||
|
you just inserted in the same test. The real cold read only happens for a row that existed in the
|
||||||
|
database <em>before</em> the current process ever touched it in a session -- reboot the
|
||||||
|
<code>SessionFactory</code> (or evict the region) between the insert and the "cold" read if you
|
||||||
|
want a fair baseline.
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
## Query cache without entity cache: NOT the N+1 the article claimed
|
||||||
|
|
||||||
|
The article's second warning: "Query Cache without Entity Cache causes N+1 selects." The scenario
|
||||||
|
is a query with `setCacheable(true)` over an entity that has no `@Cacheable`/`@Cache` of its own
|
||||||
|
-- [`UncachedProduct`](../src/main/java/com/ankurm/hibernatedemo/cache/UncachedProduct.java),
|
||||||
|
built specifically to have no L2 region at all. The claim was that resolving the query cache's
|
||||||
|
remembered id list from a brand-new session would cost one SELECT per row.
|
||||||
|
|
||||||
|
Measured directly, that's false. A second, brand-new session repeating the identical
|
||||||
|
`setCacheable(true)` query costs **zero** SQL statements, not five:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements=1, query-cache misses=1 | second run (new session, query-cache HIT) SQL statements=0, query-cache hits=1, L2 entity-cache hits=0 -- contrary to the original article, the second run costs ZERO SQL statements: the query cache stores the full row tuples, not just the 5 ids, and Hibernate rebuilds UncachedProduct instances straight from that stored data. The zero L2 entity-cache hits confirm this does not depend on UncachedProduct having its own L2 region at all -- it has none.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/18-query-cache-without-entity-cache.txt), source:
|
||||||
|
[`QueryCacheWithoutEntityCacheTest`](../src/test/java/com/ankurm/hibernatedemo/cache/QueryCacheWithoutEntityCacheTest.java)
|
||||||
|
|
||||||
|
The mechanism: the query cache region doesn't store just the matching row ids. It stores the full
|
||||||
|
hydrated tuple data for each result row at the moment the query first ran, and Hibernate
|
||||||
|
reconstitutes entity instances directly from that stored data on a cache hit -- confirmed here by
|
||||||
|
`getSecondLevelCacheHitCount()` staying at zero even on the hit, which rules out a secretly
|
||||||
|
entity-cached explanation. This test doesn't rule out an N+1 appearing for a query returning
|
||||||
|
associations Hibernate must still initialize per row, or a partial-hit scenario after individual
|
||||||
|
entries are evicted -- only the specific, simple case the article described is measured and
|
||||||
|
corrected here.
|
||||||
|
|
||||||
|
- Going deeper: [Hibernate's own query cache documentation](https://docs.jboss.org/hibernate/orm/7.4/userguide/html_single/Hibernate_User_Guide.html#caching-query) (`rel="nofollow"`) on how query-cache regions store result tuples.
|
||||||
|
|
||||||
|
## Bulk mutations: also not what the article claimed, in two different ways
|
||||||
|
|
||||||
|
The article's third warning: "Bulk HQL mutations bypass cache -- a bulk `update` executes direct
|
||||||
|
SQL, so an already-cached entity goes stale until manually evicted." Measured with
|
||||||
|
[`BulkUpdateBypassesCacheTest`](../src/test/java/com/ankurm/hibernatedemo/cache/BulkUpdateBypassesCacheTest.java),
|
||||||
|
this is false for both the HQL/JPQL form and, once actually tested, the native SQL form too.
|
||||||
|
|
||||||
|
**HQL/JPQL bulk update, via `Session.createMutationQuery(...).executeUpdate()`:** a fresh session
|
||||||
|
sees the new value immediately.
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cache-bulk-update-hql-not-stale]: price in the database after the bulk HQL update=999.0 | price a brand-new session's get() actually returns=999.0 -- contrary to the original article, a bulk HQL update via createMutationQuery does NOT leave the L2 cache stale. BulkOperationCleanupAction evicts CacheProduct's entire region automatically once the statement's transaction commits, so no manual sessionFactory.getCache().evictEntityData(...) call is needed for this case.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/18-bulk-update-hql.txt)
|
||||||
|
|
||||||
|
Verified by disassembling `hibernate-core-7.4.5.Final.jar`: every bulk HQL/JPQL `update`/`delete`
|
||||||
|
registers an `org.hibernate.action.internal.BulkOperationCleanupAction` as an
|
||||||
|
after-transaction-completion process. Its `EntityCleanup` inner class calls
|
||||||
|
`EntityDataAccess.lockRegion()` then `EntityDataAccess.removeAll(session)` for every entity type
|
||||||
|
the statement's parsed "query spaces" (tables) touch -- the entire region, not just the one row.
|
||||||
|
Hibernate can do this because `createMutationQuery` parses the HQL/JPQL itself before running it.
|
||||||
|
|
||||||
|
The natural follow-up question -- and the working hypothesis going into the second test below --
|
||||||
|
was whether raw/native SQL escapes this, since Hibernate's HQL parser never sees a native
|
||||||
|
statement and so can't name the affected query spaces. Measuring it disproves that hypothesis
|
||||||
|
too:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cache-bulk-update-native-not-stale]: price in the database after the native SQL update=999.0 | price a brand-new session's get() actually returns=999.0 | L2 puts recorded before the native update=1 | L2 hits before/after the post-update read=1/1 -- the original hypothesis (native SQL bypasses query-space tracking, so the L2 entry survives stale) was WRONG. The entity WAS cached (one L2 put), yet the post-update read is not an L2 hit at all: Hibernate cannot verify a native statement's affected tables, so it conservatively invalidates every region it knows about rather than none of them.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/18-bulk-update-native.txt)
|
||||||
|
|
||||||
|
Hibernate's actual default for an unqualified native DML statement is the conservative *opposite*
|
||||||
|
of "do nothing": unable to prove which regions the statement leaves safe, it invalidates every
|
||||||
|
region it knows about. The entity cache ends up empty either way -- an HQL bulk update reaches
|
||||||
|
that outcome by naming exactly what it evicts, a native statement reaches the same outcome by
|
||||||
|
assuming it must evict everything.
|
||||||
|
|
||||||
|
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
|
||||||
|
<strong>Trap this creates in the other direction:</strong> that "invalidate everything" default
|
||||||
|
means a native SQL statement executed through Hibernate can quietly evict L2 regions for entities
|
||||||
|
that statement never touched, in a high-traffic app with many cached entity types. If you need to
|
||||||
|
avoid that blast radius, narrow it explicitly with
|
||||||
|
<code>NativeQuery#addSynchronizedEntityClass()</code>/<code>addSynchronizedQuerySpace()</code>
|
||||||
|
rather than relying on the conservative default.
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
- Going deeper: `docs/output/18-bulk-update-hql.txt` and `18-bulk-update-native.txt` both include
|
||||||
|
the manual `sessionFactory.getCache().evictEntityData(...)` call and its result, for the case
|
||||||
|
where you do need to force an eviction explicitly.
|
||||||
|
|
||||||
|
## The missing-region warning that isn't a startup failure
|
||||||
|
|
||||||
|
The article's fourth warning: "Missing `default-update-timestamps-region`: required for the Query
|
||||||
|
Cache -- omitting it causes startup errors." Also false, with the defaults this repo pins.
|
||||||
|
[`MissingUpdateTimestampsRegionTest`](../src/test/java/com/ankurm/hibernatedemo/cache/MissingUpdateTimestampsRegionTest.java)
|
||||||
|
boots a query-cache-enabled `SessionFactory` against
|
||||||
|
[`ehcache-chapter18-missing-timestamps.xml`](../src/test/resources/ehcache-chapter18-missing-timestamps.xml),
|
||||||
|
which deliberately omits that region entirely:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cache-missing-timestamps-region]: SessionFactory built with NO error -- contrary to the original article, the default MissingCacheStrategy (CREATE_WARN) auto-creates the missing default-update-timestamps-region and only logs HHH90001006
|
||||||
|
```
|
||||||
|
[(full transcript)](output/18-missing-timestamps-region.txt)
|
||||||
|
|
||||||
|
`hibernate-jcache`'s default `MissingCacheStrategy` is `CREATE_WARN` (external representation
|
||||||
|
`"create-warn"`, confirmed by disassembling `MissingCacheStrategy.class` in
|
||||||
|
`hibernate-jcache-7.4.5.Final.jar`, which also confirms the other two values, `FAIL` and
|
||||||
|
`CREATE`): a missing region is created on the fly with provider-specific default policies, and
|
||||||
|
Hibernate only logs `HHH90001006`. If you actually want the hard failure the article describes --
|
||||||
|
useful in CI, to catch a forgotten region definition before production -- it's an explicit
|
||||||
|
opt-in, not the default:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.applySetting("hibernate.javax.cache.missing_cache_strategy", "fail")
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cache-missing-timestamps-region-strict]: hibernate.javax.cache.missing_cache_strategy=fail -> org.hibernate.service.spi.ServiceException: On-the-fly creation of JCache Cache objects is not supported [default-update-timestamps-region] -- this is how to opt into the hard-failure behavior the original article assumed was the default.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/18-missing-timestamps-region-strict.txt)
|
||||||
|
|
||||||
|
- Going deeper: set `missing_cache_strategy` to `create` (rather than the default `create-warn`)
|
||||||
|
if you want the auto-create behavior without the WARN log line at all.
|
||||||
|
|
||||||
|
## Production checklist
|
||||||
|
|
||||||
|
- Pin `hibernate.cache.use_second_level_cache: false` at the application-wide level if you're
|
||||||
|
not deliberately turning L2 on everywhere -- see [chapter 09](09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not)
|
||||||
|
for why having `hibernate-jcache` merely on the classpath is enough to auto-enable it via
|
||||||
|
ServiceLoader discovery, with zero explicit configuration.
|
||||||
|
- Match the Ehcache classifier to your JAXB runtime (`jakarta` for Jakarta EE 9+/Spring Boot
|
||||||
|
3+/4+), not to any assumption about the JCache API namespace -- there is only one, `javax.cache`.
|
||||||
|
- Decide `missing_cache_strategy` deliberately: `create-warn` (the default) is forgiving in dev,
|
||||||
|
`fail` catches a missing region definition in CI before it reaches production.
|
||||||
|
- If you rely on native SQL DML for bulk changes, know that Hibernate's conservative
|
||||||
|
cache-invalidation default means a native statement can evict L2 regions for entities it never
|
||||||
|
touched -- narrow the blast radius with `addSynchronizedEntityClass()` if that matters at your
|
||||||
|
scale.
|
||||||
|
- Remember the persist-then-get() surprise when benchmarking: a row inserted in the same session
|
||||||
|
or process is already cached by the time you "cold" `get()` it.
|
||||||
|
|
||||||
|
[← Previous: 17 — Bootstrapping EntityManager](17-entitymanager-bootstrap.md) | [Back to README →](../README.md)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 19 — HikariCP connection pooling
|
||||||
|
|
||||||
|
[← Previous: 18 — Ehcache 3 L2 cache configuration](18-ehcache-l2-configuration.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4886 (HikariCP connection pooling).
|
||||||
|
|
||||||
|
## The claim that needs no repo at all to check, and is still worth checking
|
||||||
|
|
||||||
|
"Spring Boot uses HikariCP by default" is one of those facts everyone repeats and almost no one
|
||||||
|
verifies against their own project. This repo's own `application.yml` names no connection pool
|
||||||
|
at all -- no `spring.datasource.type`, no `spring.datasource.hikari.*` block -- and the `DataSource`
|
||||||
|
bean Spring Boot 4.1.1 hands out anyway really is a `HikariDataSource`:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[hikari-spring-default]: dataSource class=com.zaxxer.hikari.HikariDataSource | pool name=HikariPool-1 | maximumPoolSize=10 | minimumIdle=10 | connectionTimeout=30000ms | idleTimeout=600000ms -- these are HikariCP's own built-in defaults (maximumPoolSize=10, minimumIdle defaults to maximumPoolSize), not anything this project set.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/19-spring-default-hikari.txt), source:
|
||||||
|
[`SpringAutoConfiguredHikariTest`](../src/test/java/com/ankurm/hibernatedemo/hikari/SpringAutoConfiguredHikariTest.java)
|
||||||
|
|
||||||
|
Confirmed against `HikariConfig.class` itself (disassembled from `HikariCP-7.0.2.jar`):
|
||||||
|
`maxPoolSize` defaults to `10`, `minIdle` defaults to `-1` (meaning "unset"), and `validate()`
|
||||||
|
resolves an unset `minIdle` up to whatever `maxPoolSize` ends up being -- which is exactly the
|
||||||
|
`minimumIdle=10` this test observes with nothing configured.
|
||||||
|
|
||||||
|
## Bootstrapping HikariCP with zero Spring involved
|
||||||
|
|
||||||
|
The original article's own example is plain Hibernate, no Spring Boot. Reproducing that
|
||||||
|
faithfully needed one more dependency this repo didn't already have:
|
||||||
|
[`org.hibernate.orm:hibernate-hikaricp`](../pom.xml), the integration jar that teaches a raw
|
||||||
|
`StandardServiceRegistry` how to talk to HikariCP at all -- test-scoped, since the Spring-managed
|
||||||
|
chapters never need it.
|
||||||
|
|
||||||
|
```java
|
||||||
|
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
||||||
|
.applySetting("hibernate.connection.provider_class", "hikari")
|
||||||
|
.applySetting("hibernate.hikari.maximumPoolSize", "7")
|
||||||
|
.applySetting("hibernate.hikari.poolName", "hibernate-demo-ch19-pool")
|
||||||
|
.applySetting("hibernate.hikari.connectionTimeout", "5000")
|
||||||
|
// ... driver_class, url, username as usual
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
[`HikariRawBootstrapTest.java`](../src/test/java/com/ankurm/hibernatedemo/hikari/HikariRawBootstrapTest.java)
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[hikari-raw-bootstrap]: ConnectionProvider class=org.hibernate.hikaricp.internal.HikariCPConnectionProvider | isUnwrappableAs(HikariDataSource)=true
|
||||||
|
RESULT[hikari-raw-bootstrap-config]: poolName=hibernate-demo-ch19-pool | maximumPoolSize=7 | connectionTimeout=5000ms -- every value traces back to a hibernate.hikari.* setting passed into StandardServiceRegistryBuilder, with zero Spring involved.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/19-raw-bootstrap.txt)
|
||||||
|
|
||||||
|
Two things confirmed by disassembling `hibernate-hikaricp-7.4.5.Final.jar` rather than assumed
|
||||||
|
from the property name: `hibernate.connection.provider_class` accepts the short aliases `hikari`
|
||||||
|
or `hikaricp`, not only the fully-qualified class name (both are registered in
|
||||||
|
`StrategyRegistrationProviderImpl`); and every `hibernate.hikari.*`-prefixed setting has that
|
||||||
|
prefix stripped and is handed to `new HikariConfig(properties)` as-is -- `HikariConfigurationUtil`
|
||||||
|
declares the prefix itself as the public constant `CONFIG_PREFIX = "hibernate.hikari."`. That
|
||||||
|
means any `HikariConfig` setter is reachable this way, not just the handful mentioned here.
|
||||||
|
|
||||||
|
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
|
||||||
|
<strong>Trap:</strong> case matters. <code>hibernate.hikari.maximumPoolSize</code> maps to
|
||||||
|
<code>HikariConfig#setMaximumPoolSize</code> because HikariCP's own property loader does exact,
|
||||||
|
case-sensitive bean-property matching -- <code>hibernate.hikari.maximumpoolsize</code> (all
|
||||||
|
lowercase) silently does nothing rather than failing loudly.
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
- Going deeper: [`hibernate.javax.cache.uri`'s own classpath-prefix trap](18-ehcache-l2-configuration.md) is a different config-loading mechanism worth contrasting with this one -- one strips a prefix and applies bean-property reflection, the other passes a raw string straight to a resource loader.
|
||||||
|
|
||||||
|
## Failure mode 1: pool exhaustion is a named exception, not a hang
|
||||||
|
|
||||||
|
Lead with what actually breaks. Every connection in the pool checked out, and one more request
|
||||||
|
comes in:
|
||||||
|
|
||||||
|
```java
|
||||||
|
config.setMaximumPoolSize(1);
|
||||||
|
config.setConnectionTimeout(1000);
|
||||||
|
Connection held = dataSource.getConnection();
|
||||||
|
// a second dataSource.getConnection() from here...
|
||||||
|
```
|
||||||
|
[`HikariPoolExhaustionTest.java`](../src/test/java/com/ankurm/hibernatedemo/hikari/HikariPoolExhaustionTest.java)
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[hikari-pool-exhaustion]: maximumPoolSize=1, connectionTimeout=1000ms | second getConnection() waited=1004ms before throwing java.sql.SQLTransientConnectionException: exhaustion-pool - Connection is not available, request timed out after 1002ms (total=1, active=1, idle=0, waiting=0)
|
||||||
|
```
|
||||||
|
[(full transcript)](output/19-pool-exhaustion.txt)
|
||||||
|
|
||||||
|
This is worth knowing verbatim because it's exactly what you'll grep application logs for: a
|
||||||
|
`SQLTransientConnectionException` (a real, distinct exception type -- catchable separately from
|
||||||
|
other SQL errors), naming the pool by name, stating the timeout, and reporting the pool's live
|
||||||
|
`total`/`active`/`idle`/`waiting` counts at the moment it gave up. `SQLTransientConnectionException`
|
||||||
|
being a `java.sql.SQLTransientException` also means a retry framework that specifically retries
|
||||||
|
transient SQL errors will treat this one as retryable by default -- worth confirming that's
|
||||||
|
actually what you want under sustained load, rather than assuming it.
|
||||||
|
|
||||||
|
- Going deeper: raising `maximumPoolSize` is the obvious fix, but it isn't free -- each connection is a real OS thread and socket on both ends; [HikariCP's own sizing guidance](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing) (`rel="nofollow"`) argues for pool sizes much smaller than most defaults assume, based on `connections = ((core_count * 2) + effective_spindle_count)`.
|
||||||
|
|
||||||
|
## Failure mode 2: leak detection has a floor the article never mentioned
|
||||||
|
|
||||||
|
"Set `leakDetectionThreshold`" is the standard advice for catching code that checks out a
|
||||||
|
connection and forgets to close it. What isn't standard advice: HikariCP enforces a hard floor on
|
||||||
|
that setting, and violating it fails silently rather than loudly.
|
||||||
|
|
||||||
|
```java
|
||||||
|
config.setLeakDetectionThreshold(500); // under the floor
|
||||||
|
HikariDataSource ds = new HikariDataSource(config); // validate() runs right here
|
||||||
|
```
|
||||||
|
[`HikariLeakDetectionTest.java`](../src/test/java/com/ankurm/hibernatedemo/hikari/HikariLeakDetectionTest.java)
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[hikari-leak-threshold-floor]: requested leakDetectionThreshold=500ms | actual leakDetectionThreshold after construction=0ms | logged warnings=1 | message=HikariPool-1 - leakDetectionThreshold is less than 2000ms or more than maxLifetime, disabling it. -- HikariCP does not clamp 500ms up to 2000ms, it disables leak detection entirely and logs a WARN naming the reason.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/19-leak-detection.txt)
|
||||||
|
|
||||||
|
Confirmed by disassembling `HikariConfig.class`: any value under 2000ms (and any value above
|
||||||
|
`maxLifetime`, when `maxLifetime` is set) is reset straight to `0` -- disabled -- inside
|
||||||
|
`validate()`, which runs synchronously the moment `new HikariDataSource(config)` is called. It
|
||||||
|
does not round up to 2000ms and it does not throw; it just quietly turns the feature off and logs
|
||||||
|
one WARN through `HikariConfig`'s own logger. A value at or above the floor behaves as documented:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[hikari-leak-detection]: leakDetectionThreshold=2000ms | logger=com.zaxxer.hikari.pool.ProxyLeakTask | level=WARN | message=Connection leak detection triggered for conn1: url=jdbc:h2:mem:hikarileak user=SA on thread main, stack trace follows
|
||||||
|
```
|
||||||
|
[(same transcript)](output/19-leak-detection.txt)
|
||||||
|
|
||||||
|
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
|
||||||
|
<strong>Trap:</strong> if you copy a "fast" leak-detection setting from a unit test into
|
||||||
|
production config -- 200ms, 500ms, anything under two seconds -- HikariCP accepts it without
|
||||||
|
error and simply never checks for leaks at all. The only sign is a single WARN logged once, at
|
||||||
|
startup, that's easy to miss in a noisy boot log.
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
- Going deeper: the leaked-connection warning includes a captured stack trace of the original checkout site (visible in [the full transcript](output/19-leak-detection.txt)) -- that's what makes this setting worth turning on in staging even when it costs a little overhead, since it points straight at the offending code path rather than just the symptom.
|
||||||
|
|
||||||
|
## Production checklist
|
||||||
|
|
||||||
|
- Don't trust "HikariCP is the default" from memory for a project you haven't checked -- confirm
|
||||||
|
the actual `DataSource` bean type, the way `SpringAutoConfiguredHikariTest` does here.
|
||||||
|
- Size the pool from real concurrency needs, not a round number -- HikariCP's own guidance argues
|
||||||
|
for pools smaller than most defaults assume.
|
||||||
|
- Set `connectionTimeout` deliberately: it's what turns pool exhaustion from "the caller hangs"
|
||||||
|
into "the caller gets a typed, catchable exception in a bounded time."
|
||||||
|
- Set `leakDetectionThreshold` to at least 2000ms if you set it at all -- anything lower is
|
||||||
|
silently a no-op, not a more sensitive check.
|
||||||
|
- In a non-Spring bootstrap, remember `hibernate.hikari.*` property names are case-sensitive bean
|
||||||
|
property names, not free-form config keys.
|
||||||
|
|
||||||
|
[← Previous: 18 — Ehcache 3 L2 cache configuration](18-ehcache-l2-configuration.md) | [Back to README →](../README.md)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 20 — Hibernate Validator CDI integration
|
||||||
|
|
||||||
|
[← Previous: 19 — HikariCP connection pooling](19-hikaricp-connection-pooling.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4887 (Hibernate Validator CDI integration).
|
||||||
|
|
||||||
|
This is the odd chapter out in the batch: no Spring, no database, no Hibernate ORM at all. The
|
||||||
|
question the original article raised is purely about Jakarta Bean Validation and CDI --
|
||||||
|
`@Inject` inside a `ConstraintValidator`, with and without a CDI container actually running -- so
|
||||||
|
this chapter measures that in isolation, the same way chapter 17 isolated raw JPA bootstrap from
|
||||||
|
Spring.
|
||||||
|
|
||||||
|
## The claim, and the two things it depends on
|
||||||
|
|
||||||
|
A `ConstraintValidator` that needs a collaborator -- a policy object, a lookup service, anything
|
||||||
|
that isn't a static constant -- naturally reaches for `@Inject`. Whether that actually works
|
||||||
|
depends entirely on how the `ValidatorFactory` was built, not on the annotation itself:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class PositiveInventoryValidator implements ConstraintValidator<PositiveInventory, Integer> {
|
||||||
|
@Inject
|
||||||
|
private InventoryPolicy policy; // no null-guard, on purpose
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isValid(Integer quantity, ConstraintValidatorContext context) {
|
||||||
|
return quantity == null || quantity >= policy.minimumThreshold();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
[`PositiveInventoryValidator.java`](../src/main/java/com/ankurm/hibernatedemo/validation/PositiveInventoryValidator.java)
|
||||||
|
|
||||||
|
`InventoryPolicy` is a plain `@ApplicationScoped` CDI bean with one method,
|
||||||
|
`minimumThreshold()`, returning `5` -- deliberately not a hardcoded constant in the validator
|
||||||
|
itself, so injection either genuinely happens or the validator has nothing to call.
|
||||||
|
[`InventoryPolicy.java`](../src/main/java/com/ankurm/hibernatedemo/validation/InventoryPolicy.java)
|
||||||
|
|
||||||
|
## Without CDI: `@Inject` is not processed at all, and the failure is a plain NPE
|
||||||
|
|
||||||
|
```java
|
||||||
|
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
|
||||||
|
Validator validator = factory.getValidator();
|
||||||
|
validator.validate(new StockLevel(3)); // throws
|
||||||
|
```
|
||||||
|
[`PlainValidationNoCdiTest.java`](../src/test/java/com/ankurm/hibernatedemo/validation/PlainValidationNoCdiTest.java)
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cdi-plain-validation-no-injection]: validating StockLevel(3) with Validation.buildDefaultValidatorFactory() (no CDI container running) throws jakarta.validation.ValidationException -> caused by java.lang.NullPointerException
|
||||||
|
```
|
||||||
|
[(full transcript)](output/20-plain-validation-no-cdi.txt)
|
||||||
|
|
||||||
|
`Validation.buildDefaultValidatorFactory()`'s default `ConstraintValidatorFactory` builds a
|
||||||
|
validator with plain reflection -- roughly `Class.getDeclaredConstructor().newInstance()`. It has
|
||||||
|
no idea what CDI or `@Inject` even are; the annotation is simply never looked at, so `policy`
|
||||||
|
stays `null`. The `NullPointerException` from calling `policy.minimumThreshold()` is not a
|
||||||
|
validation failure -- it's a validator bug, and Hibernate Validator itself is honest about that:
|
||||||
|
it wraps the unexpected exception in a `jakarta.validation.ValidationException` rather than
|
||||||
|
letting the raw NPE escape unannounced, which is exactly what the transcript shows.
|
||||||
|
|
||||||
|
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
|
||||||
|
<strong>Trap:</strong> this failure only shows up when the constraint actually runs against a
|
||||||
|
non-null value. A validator that only reaches the injected field on certain code paths can pass
|
||||||
|
every test that happens not to exercise those paths, then NPE the first time production data
|
||||||
|
takes the other branch.
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
## With CDI: the same code, actually injected
|
||||||
|
|
||||||
|
```java
|
||||||
|
WeldContainer container = new Weld().initialize();
|
||||||
|
Validator validator = container.select(Validator.class).get();
|
||||||
|
validator.validate(new StockLevel(3)); // 1 violation
|
||||||
|
validator.validate(new StockLevel(5)); // 0 violations
|
||||||
|
validator.validate(new StockLevel(10)); // 0 violations
|
||||||
|
```
|
||||||
|
[`CdiValidationTest.java`](../src/test/java/com/ankurm/hibernatedemo/validation/CdiValidationTest.java)
|
||||||
|
|
||||||
|
```
|
||||||
|
RESULT[cdi-validation-injection-works]: validator obtained from a running Weld SE container | StockLevel(3) violations=1 | StockLevel(5) violations=0 | StockLevel(10) violations=0 -- InventoryPolicy.minimumThreshold()=5 was actually injected and actually used, no NullPointerException anywhere.
|
||||||
|
```
|
||||||
|
[(full transcript)](output/20-cdi-validation-injection.txt)
|
||||||
|
|
||||||
|
The mechanism, not just the result: `hibernate-validator-cdi-9.1.3.Final.jar` registers
|
||||||
|
`org.hibernate.validator.cdi.ValidationExtension` as a
|
||||||
|
`jakarta.enterprise.inject.spi.Extension` (confirmed by its own
|
||||||
|
`META-INF/services/jakarta.enterprise.inject.spi.Extension` file). Once Weld SE discovers that
|
||||||
|
extension on the classpath, it contributes CDI beans for `Validator` and `ValidatorFactory` whose
|
||||||
|
`ConstraintValidatorFactory` is `org.hibernate.validator.cdi.spi.InjectingConstraintValidatorFactory`
|
||||||
|
-- a factory that builds each `ConstraintValidator` instance through the CDI `BeanManager`
|
||||||
|
instead of plain reflection, resolving `@Inject` fields the same way any other managed bean's
|
||||||
|
are resolved. Get the `Validator` from `Validation.buildDefaultValidatorFactory()` even while a
|
||||||
|
CDI container happens to be running elsewhere in the same JVM, and you're back to the first,
|
||||||
|
broken case -- what matters is *which* `ValidatorFactory` built the validator, not merely whether
|
||||||
|
a container exists somewhere.
|
||||||
|
|
||||||
|
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
|
||||||
|
<strong>Trap:</strong> a project can have Weld or another CDI implementation on its classpath and
|
||||||
|
still get the plain, non-injecting behavior everywhere it calls
|
||||||
|
<code>Validation.buildDefaultValidatorFactory()</code> directly instead of obtaining the
|
||||||
|
<code>Validator</code>/<code>ValidatorFactory</code> as a CDI-managed bean.
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
- Going deeper: this repo pins `hibernate-validator-cdi`, `weld-se-core`, and `org.glassfish.expressly` to specific, mutually-verified versions in [`pom.xml`](../pom.xml) -- `hibernate-validator-cdi:9.1.3.Final` requires `jakarta.enterprise.cdi-api:4.1.0` (checked against its own `pom.xml`), and `weld-se-core:6.0.4.Final` is the version that actually resolves that exact 4.1.0, confirmed with a throwaway `mvn dependency:tree` before writing any of this chapter's code.
|
||||||
|
- Going deeper: `hibernate-validator-cdi` also ships a method-validation interceptor (`ValidationInterceptor`, visible in the jar's contents) for validating `@Valid` parameters on CDI-managed bean methods -- out of scope for this chapter, which is about constructor/field injection into the validator itself, not method interception.
|
||||||
|
|
||||||
|
## Production checklist
|
||||||
|
|
||||||
|
- If a `ConstraintValidator` needs a collaborator, know which `ValidatorFactory` will actually
|
||||||
|
build it in production. In a full Jakarta EE server or a Spring Boot app with
|
||||||
|
`spring-boot-starter-validation` (which wires its own Spring-aware `ConstraintValidatorFactory`,
|
||||||
|
a separate mechanism from the CDI one measured here), injection works by a different, already
|
||||||
|
container-managed path -- this chapter's contrast is specifically about the CDI portable
|
||||||
|
extension versus the plain, no-container default.
|
||||||
|
- Never let a validator dereference an `@Inject`ed field without at least considering what happens
|
||||||
|
if it's ever built by a plain `ConstraintValidatorFactory` -- a defensive null-check turns an
|
||||||
|
opaque `ValidationException` into a clear "this validator requires CDI" message.
|
||||||
|
- `hibernate-validator-cdi` is a thin CDI portable extension over `hibernate-validator` itself,
|
||||||
|
not a different validation engine -- adding it changes *how* validators are instantiated, not
|
||||||
|
what Bean Validation itself does.
|
||||||
|
|
||||||
|
[← Previous: 19 — HikariCP connection pooling](19-hikaricp-connection-pooling.md) | [Back to README →](../README.md)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 21 — Aggregate functions
|
||||||
|
|
||||||
|
[← Previous: 20 — Hibernate Validator CDI integration](20-hibernate-validator-cdi.md) | [Next: 22 — Sorting →](22-sorting.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4888 (aggregate functions).
|
||||||
|
|
||||||
|
## What an aggregate query returns over zero rows
|
||||||
|
|
||||||
|
`count`, `sum`, `avg`, `min`, `max` all still return exactly one result row when the underlying
|
||||||
|
table (or the filtered subset) has zero matching rows — SQL's aggregate functions are defined
|
||||||
|
over the whole result set, not per-row, so there is always one row to return. `count(*)` on an
|
||||||
|
empty set is `0`; every other aggregate is `NULL`. Neither case throws
|
||||||
|
`NoResultException` — `getSingleResult()` on an aggregate query only throws that when the query
|
||||||
|
itself is malformed, never because the aggregate happened to be computed over nothing.
|
||||||
|
|
||||||
|
```java
|
||||||
|
Long count = em.createQuery("select count(p) from Product p where p.category = :c", Long.class)
|
||||||
|
.setParameter("c", "no-such-category-zzz").getSingleResult();
|
||||||
|
Double sum = em.createQuery("select sum(p.price) from Product p where p.category = :c", Double.class)
|
||||||
|
.setParameter("c", "no-such-category-zzz").getSingleResult();
|
||||||
|
```
|
||||||
|
[`AggregateFunctionsTest.java`](../src/test/java/com/ankurm/hibernatedemo/aggregate/AggregateFunctionsTest.java)
|
||||||
|
— [captured output](output/21-empty-result-set.txt)
|
||||||
|
|
||||||
|
## `select new` with a Java record
|
||||||
|
|
||||||
|
Hibernate 7 accepts a Java record's canonical constructor as a `select new` target exactly like
|
||||||
|
it accepts a class constructor — no special annotation, no adapter, just a record whose
|
||||||
|
constructor parameter types and order match the query's projection:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record CategorySummary(String category, long productCount, double averagePrice) {}
|
||||||
|
```
|
||||||
|
[`CategorySummary.java`](../src/main/java/com/ankurm/hibernatedemo/aggregate/CategorySummary.java)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select new com.ankurm.hibernatedemo.aggregate.CategorySummary(p.category, count(p), avg(p.price))
|
||||||
|
from Product p group by p.category having count(p) > 1 order by p.category
|
||||||
|
```
|
||||||
|
`HAVING count(p) > 1` filters on the *aggregated* group, after `GROUP BY` has collapsed the rows
|
||||||
|
— a category with exactly one product is excluded by `HAVING`, not merely left ungrouped.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/aggregate/AggregateFunctionsTest.java) —
|
||||||
|
[output](output/21-groupby-having-record.txt)
|
||||||
|
|
||||||
|
## The Criteria API equivalent
|
||||||
|
|
||||||
|
`CriteriaBuilder.avg(...)`, `.sum(...)`, `.count(...)` and friends build the same aggregate SQL
|
||||||
|
without a string query:
|
||||||
|
|
||||||
|
```java
|
||||||
|
cq.multiselect(root.get("category"), cb.avg(root.get("price")))
|
||||||
|
.where(cb.equal(root.get("category"), "Cables"))
|
||||||
|
.groupBy(root.get("category"));
|
||||||
|
```
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/aggregate/AggregateFunctionsTest.java) —
|
||||||
|
[output](output/21-criteria-avg.txt)
|
||||||
|
|
||||||
|
## Window functions — not a Hibernate 7 feature
|
||||||
|
|
||||||
|
HQL's `OVER` clause (`row_number()`, `rank()`, `lead()`, `lag()`, and the ordered-set / inverse
|
||||||
|
distribution aggregates) is real and works exactly as SQL's window functions do. It is **not**
|
||||||
|
new in Hibernate 7, though it is sometimes described that way: `javap` against
|
||||||
|
`hibernate-core-7.4.5.Final.jar`'s `CommonFunctionFactory` shows a `windowFunctions()`
|
||||||
|
registration method that has existed since Hibernate 6.2, when HQL's window-function support was
|
||||||
|
first added. Hibernate 7 continues to support it; it did not introduce it.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select p.name, p.price, row_number() over (partition by p.category order by p.price desc)
|
||||||
|
from Product p where p.category = 'Mice' order by p.price desc
|
||||||
|
```
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/aggregate/AggregateFunctionsTest.java) —
|
||||||
|
[output](output/21-window-row-number.txt)
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- `getSingleResult()` vs `getResultList()` on an aggregate query: prefer `getSingleResult()` only
|
||||||
|
when the query has no `GROUP BY` — a grouped aggregate can legitimately return many rows.
|
||||||
|
- `StatelessSession` skips the persistence context entirely for a pure read-and-aggregate
|
||||||
|
workload, avoiding the memory overhead of first-level cache entries the aggregate result
|
||||||
|
itself never needs.
|
||||||
|
- [Hibernate ORM Criteria API documentation](https://docs.jboss.org/hibernate/orm/7.4/userguide/html_single/Hibernate_User_Guide.html#criteria)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 22 — Sorting
|
||||||
|
|
||||||
|
[← Previous: 21 — Aggregate functions](21-aggregate-functions.md) | [Next: 23 — Pagination →](23-pagination.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4889 (sorting).
|
||||||
|
|
||||||
|
## `@OrderBy` names the property, not the column
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Column(name = "song_title")
|
||||||
|
private String title;
|
||||||
|
```
|
||||||
|
```java
|
||||||
|
@OneToMany(mappedBy = "playlist")
|
||||||
|
@OrderBy("title asc")
|
||||||
|
private List<Song> songs;
|
||||||
|
```
|
||||||
|
`title` is the entity property; the column it's stored under is `song_title`, deliberately
|
||||||
|
different from the property name. Hibernate resolves `@OrderBy`'s value against the entity's
|
||||||
|
metamodel, not the mapped table, so it translates the property to the right column itself — a
|
||||||
|
raw column name here would be a coincidence at best.
|
||||||
|
[`Playlist.java`](../src/main/java/com/ankurm/hibernatedemo/sorting/Playlist.java) — [test](../src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java) —
|
||||||
|
[output](output/22-orderby-property-name.txt)
|
||||||
|
|
||||||
|
## `@SortNatural` and `@SortComparator` on element collections
|
||||||
|
|
||||||
|
```java
|
||||||
|
@SortNatural
|
||||||
|
private SortedSet<String> tags = new TreeSet<>();
|
||||||
|
|
||||||
|
@SortComparator(LengthThenAlphaComparator.class)
|
||||||
|
private SortedSet<String> genres = new TreeSet<>(new LengthThenAlphaComparator());
|
||||||
|
```
|
||||||
|
Both rebuild a real `java.util.TreeSet` in memory when the collection is loaded — this is not an
|
||||||
|
`ORDER BY` added to the collection's own SQL fetch. `@SortComparator`'s class needs a no-arg
|
||||||
|
constructor; Hibernate instantiates it by reflection.
|
||||||
|
[`Playlist.java`](../src/main/java/com/ankurm/hibernatedemo/sorting/Playlist.java),
|
||||||
|
[`LengthThenAlphaComparator.java`](../src/main/java/com/ankurm/hibernatedemo/sorting/LengthThenAlphaComparator.java)
|
||||||
|
— [output](output/22-sort-natural-and-comparator.txt)
|
||||||
|
|
||||||
|
## Dynamic sorting: the injection risk, and the fix
|
||||||
|
|
||||||
|
Concatenating a caller-supplied field name directly into an `order by` clause hands that caller a
|
||||||
|
way to inject arbitrary HQL — a path onto an unrelated entity, a nested expression, or simply a
|
||||||
|
string that breaks the query as a denial-of-service. The fix is a whitelist checked *before* the
|
||||||
|
string ever reaches the query, not an attempt to sanitize it:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private static final Set<String> ALLOWED = Set.of("title", "artist", "rating");
|
||||||
|
|
||||||
|
public static String toHqlPropertyOrThrow(String requested) {
|
||||||
|
if (!ALLOWED.contains(requested)) {
|
||||||
|
throw new IllegalArgumentException(...);
|
||||||
|
}
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
[`SongSortField.java`](../src/main/java/com/ankurm/hibernatedemo/sorting/SongSortField.java) —
|
||||||
|
[test](../src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java) —
|
||||||
|
[output](output/22-dynamic-injection-guard.txt)
|
||||||
|
|
||||||
|
## Criteria `Order` across a join
|
||||||
|
|
||||||
|
`Order` is not limited to the query root's own columns — a joined entity's property works the
|
||||||
|
same way:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Join<Song, Playlist> playlistJoin = root.join("playlist");
|
||||||
|
cq.select(playlistJoin.get("name")).orderBy(cb.asc(playlistJoin.get("name")));
|
||||||
|
```
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java) —
|
||||||
|
[output](output/22-criteria-order-join.txt)
|
||||||
|
|
||||||
|
## Null precedence via `jakarta.persistence.criteria.Nulls`
|
||||||
|
|
||||||
|
Jakarta Persistence 3.2 (Hibernate 7's baseline) added `jakarta.persistence.criteria.Nulls`
|
||||||
|
(`FIRST`, `LAST`, `NONE`) and the matching `CriteriaBuilder.asc(Expression, Nulls)` /
|
||||||
|
`.desc(Expression, Nulls)` overloads — confirmed with `javap` against
|
||||||
|
`jakarta.persistence-api-3.2.0.jar`, not assumed from documentation prose:
|
||||||
|
|
||||||
|
```java
|
||||||
|
cq.orderBy(cb.asc(root.get("rating"), Nulls.LAST), cb.asc(root.get("title")));
|
||||||
|
```
|
||||||
|
This makes null precedence explicit in the generated SQL's `ORDER BY`, independent of whatever a
|
||||||
|
given dialect's own default null-ordering would otherwise do.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java) —
|
||||||
|
[output](output/22-null-precedence.txt)
|
||||||
|
|
||||||
|
## Case-insensitive sorting via `cb.lower()`
|
||||||
|
|
||||||
|
```java
|
||||||
|
cq.orderBy(cb.asc(cb.lower(root.get("title"))));
|
||||||
|
```
|
||||||
|
The comparison happens on the lower-cased *value*, computed by the database, not on the raw
|
||||||
|
column — `"Apple"` sorts before `"banana"` despite the capital letter.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java) —
|
||||||
|
[output](output/22-case-insensitive.txt)
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- `@OrderBy` with no value defaults to the collection's primary key, ascending — easy to miss
|
||||||
|
when a collection appears correctly ordered by accident and then isn't after a schema change.
|
||||||
|
- A `SortedMap` supports the same `@SortNatural`/`@SortComparator` pair as a `SortedSet`, sorting
|
||||||
|
by key.
|
||||||
|
- [Jakarta Persistence 3.2 specification, §7.2 (`Nulls`, `Order`)](https://jakarta.ee/specifications/persistence/3.2/)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 23 — Pagination
|
||||||
|
|
||||||
|
[← Previous: 22 — Sorting](22-sorting.md) | [Next: 24 — Interceptors →](24-interceptors.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4890 (pagination).
|
||||||
|
|
||||||
|
## `setFirstResult`/`setMaxResults` translate to the dialect's real syntax
|
||||||
|
|
||||||
|
```java
|
||||||
|
query.setFirstResult(2);
|
||||||
|
query.setMaxResults(2);
|
||||||
|
```
|
||||||
|
generates, on H2:
|
||||||
|
```sql
|
||||||
|
... order by a1_0.sequence offset ? rows fetch first ? rows only
|
||||||
|
```
|
||||||
|
Different dialects render this differently (`LIMIT ... OFFSET ...` on MySQL/PostgreSQL,
|
||||||
|
`OFFSET ... FETCH ...` on SQL Server/H2, `ROWNUM` tricks on older Oracle) — the JPA-level API is
|
||||||
|
the same everywhere; only the generated SQL shape changes.
|
||||||
|
[`PaginationTest.java`](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
||||||
|
[output](output/23-limit-offset.txt)
|
||||||
|
|
||||||
|
## `ScrollableResults` with `ScrollMode.FORWARD_ONLY`
|
||||||
|
|
||||||
|
```java
|
||||||
|
try (ScrollableResults<Article> results = session.createQuery(hql, Article.class)
|
||||||
|
.setReadOnly(true)
|
||||||
|
.scroll(ScrollMode.FORWARD_ONLY)) {
|
||||||
|
while (results.next()) {
|
||||||
|
Article a = results.get();
|
||||||
|
// process one row at a time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
No `List<Article>` holding every row is built by application code — rows are pulled from the JDBC
|
||||||
|
`ResultSet` one at a time as `next()`/`get()` are called. Whether this actually avoids loading the
|
||||||
|
whole result set into memory server-side too depends on the JDBC driver's own fetch-size
|
||||||
|
behavior, not on this API alone.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
||||||
|
[output](output/23-scrollable-forward-only.txt)
|
||||||
|
|
||||||
|
## `JOIN FETCH` + pagination: it depends on what you order by
|
||||||
|
|
||||||
|
The often-repeated claim is that `join fetch` over a `to-many` association combined with
|
||||||
|
`setFirstResult`/`setMaxResults` always falls back to loading everything into memory and
|
||||||
|
paginating in application code, logging a warning. Tested directly against
|
||||||
|
`hibernate-core-7.4.5.Final.jar`, this is only half true:
|
||||||
|
|
||||||
|
- **Ordering by a column on the root entity** (`order by a.sequence`): Hibernate 7.4.5's query
|
||||||
|
translator paginates a *derived subquery* of root ids first (its own `OFFSET`/`FETCH`), then
|
||||||
|
joins the fetched collection onto that already-paginated set of ids. No in-memory fallback, no
|
||||||
|
warning.
|
||||||
|
- **Ordering by a column on the fetched collection itself** (`order by c.body`, where `c` is the
|
||||||
|
joined collection alias): the "paginate the root ids first" trick can't work, because the sort
|
||||||
|
key isn't a root-entity column. This is the query shape that reproduces the real in-memory
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- root-ordered: paginates a derived subquery of ids, then joins
|
||||||
|
select a1_0.id, c1_0.article_id, ... from (
|
||||||
|
select distinct a1_0.id, a1_0.sequence, a1_0.title from article a1_0
|
||||||
|
where ... order by a1_0.sequence offset ? rows fetch first ? rows only
|
||||||
|
) a1_0 join comment c1_0 on a1_0.id = c1_0.article_id order by a1_0.sequence
|
||||||
|
```
|
||||||
|
|
||||||
|
The warning's real message and code, read directly out of `QueryLogging.i18n.properties` in the
|
||||||
|
jar:
|
||||||
|
```
|
||||||
|
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
|
||||||
|
```
|
||||||
|
Not `HHH000104`, a code sometimes quoted for this that belongs to a different, older message
|
||||||
|
entirely — checked with `javap` against `QueryLogging_$logger.class`, not assumed from a search
|
||||||
|
result.
|
||||||
|
[Tests](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
||||||
|
[root-ordered output](output/23-joinfetch-root-order-no-warning.txt),
|
||||||
|
[collection-ordered output](output/23-joinfetch-collection-order-warning.txt)
|
||||||
|
|
||||||
|
## Keyset (seek) pagination
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select a from Article a where a.id > :lastId order by a.id asc
|
||||||
|
```
|
||||||
|
with `setMaxResults(pageSize)` and no `setFirstResult` at all. Each page's `WHERE` clause carries
|
||||||
|
the previous page's last id, so the database never has to count-and-skip rows the way `OFFSET`
|
||||||
|
does — the cost of fetching page 500 is the same as fetching page 1.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
||||||
|
[output](output/23-keyset-seek.txt)
|
||||||
|
|
||||||
|
## The total-count-query pattern
|
||||||
|
|
||||||
|
A "Page 2 of 7" UI needs two separate queries — a `COUNT` and a `LIMIT`/`OFFSET` `SELECT` — not
|
||||||
|
one query doing both; SQL has no way to return a page of rows and the total matching count in a
|
||||||
|
single result set without a window function trick most codebases don't bother with for this.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
||||||
|
[output](output/23-total-count-pattern.txt)
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- Deep `OFFSET` pagination degrades because the database still has to *generate and discard*
|
||||||
|
every skipped row before reaching the page — keyset pagination sidesteps this entirely, at the
|
||||||
|
cost of not supporting arbitrary "jump to page N" navigation.
|
||||||
|
- `Slice`/`Page` abstractions (Spring Data) wrap the count-query pattern automatically; knowing
|
||||||
|
the two-query shape underneath explains why a `Pageable` with `unpaged()` sort still issues a
|
||||||
|
`COUNT`.
|
||||||
|
- [Hibernate ORM 7.4 User Guide — pagination](https://docs.jboss.org/hibernate/orm/7.4/userguide/html_single/Hibernate_User_Guide.html#pagination)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 24 — Interceptors
|
||||||
|
|
||||||
|
[← Previous: 23 — Pagination](23-pagination.md) | [Next: 25 — Hibernate Search →](25-hibernate-search.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4891 (interceptors).
|
||||||
|
|
||||||
|
## `Interceptor` is all default methods now — extending `EmptyInterceptor` is obsolete
|
||||||
|
|
||||||
|
`javap` against `org.hibernate.Interceptor` in `hibernate-core-7.4.5.Final.jar` shows every
|
||||||
|
method (`onSave`, `onFlushDirty`, `onDelete`, `onLoad`, `findDirty`, and the rest) declared
|
||||||
|
`default`. There is no longer a reason to extend a no-op base class just to override one or two
|
||||||
|
callbacks — implement `Interceptor` directly:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class UppercasingInterceptor implements Interceptor {
|
||||||
|
@Override
|
||||||
|
public boolean onFlushDirty(Object entity, Object id, Object[] currentState,
|
||||||
|
Object[] previousState, String[] propertyNames, Type[] types) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
[`UppercasingInterceptor.java`](../src/main/java/com/ankurm/hibernatedemo/interceptor/UppercasingInterceptor.java)
|
||||||
|
|
||||||
|
The old public `org.hibernate.EmptyInterceptor` base class **still exists in the 7.4.5.Final
|
||||||
|
jar** — but only as `org.hibernate.internal.EmptyInterceptor`: a `final`, singleton-only class
|
||||||
|
(`public static final Interceptor INSTANCE`) that isn't meant to be extended by application code
|
||||||
|
any more. It moved from a public API class to an internal implementation detail, confirmed
|
||||||
|
by `javap`, not by assuming it was simply deleted.
|
||||||
|
|
||||||
|
Also note the identifier parameter type: `onSave`/`onFlushDirty`/`onDelete`/`onLoad` all take
|
||||||
|
`Object id`, not `java.io.Serializable id` — Hibernate 6 widened this across the whole interface,
|
||||||
|
since an application is free to use a non-`Serializable` identifier type.
|
||||||
|
|
||||||
|
## The state-array-mutation contract
|
||||||
|
|
||||||
|
```java
|
||||||
|
private boolean uppercaseNameIfPresent(Object[] state, String[] propertyNames) {
|
||||||
|
for (int i = 0; i < propertyNames.length; i++) {
|
||||||
|
if ("name".equals(propertyNames[i]) && state[i] instanceof String s) {
|
||||||
|
String upper = s.toUpperCase(Locale.ROOT);
|
||||||
|
if (!upper.equals(s)) {
|
||||||
|
state[i] = upper;
|
||||||
|
return true; // tells Hibernate: yes, I changed the state array, flush it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The boolean return value **is** the contract: `true` tells Hibernate the `state` array was
|
||||||
|
actually mutated, so the (possibly changed) values get flushed to the database; `false` (or
|
||||||
|
returning without touching `state`) leaves the original values untouched. Look the property up
|
||||||
|
by name in `propertyNames` — the array's index order is Hibernate's internal property ordering,
|
||||||
|
not necessarily the entity's declaration order.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/interceptor/InterceptorTest.java) —
|
||||||
|
[output](output/24-session-scoped-mutation.txt)
|
||||||
|
|
||||||
|
## Session-scoped vs. globally-registered interceptors
|
||||||
|
|
||||||
|
```java
|
||||||
|
sessionFactory.withOptions().interceptor(myInterceptor).openSession();
|
||||||
|
```
|
||||||
|
scopes the interceptor to that one `Session` — a plain `sessionFactory.openSession()` elsewhere
|
||||||
|
is completely unaffected.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/interceptor/InterceptorTest.java) —
|
||||||
|
[output](output/24-interceptor-scoping.txt)
|
||||||
|
|
||||||
|
To register one for *every* `Session` a `SessionFactory` ever opens, set
|
||||||
|
`hibernate.session_factory.interceptor` once, at `SessionFactory` build time — this is exactly
|
||||||
|
the property a Spring Boot `HibernatePropertiesCustomizer` bean sets under the hood when it calls
|
||||||
|
`properties.put("hibernate.session_factory.interceptor", interceptor)`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
new StandardServiceRegistryBuilder()
|
||||||
|
.applySetting("hibernate.session_factory.interceptor", globalInterceptor)
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
Demonstrated here on a standalone, non-Spring registry deliberately — registering a global
|
||||||
|
interceptor on this repo's *shared* Spring-managed `SessionFactory` would retroactively affect
|
||||||
|
every other chapter's tests that reuse the same cached Spring context.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/interceptor/InterceptorTest.java) —
|
||||||
|
[output](output/24-global-via-property.txt)
|
||||||
|
|
||||||
|
## Bulk HQL updates bypass interceptor callbacks entirely
|
||||||
|
|
||||||
|
```java
|
||||||
|
session.createMutationQuery("update Task set name = 'renamed by bulk update' where id = :id")
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate();
|
||||||
|
```
|
||||||
|
`onFlushDirty` never fires for this. A bulk HQL (or native SQL) mutation changes rows directly in
|
||||||
|
the database via a single `UPDATE`/`DELETE` statement — it never loads a managed entity instance
|
||||||
|
into the persistence context, and `onFlushDirty` needs a managed entity's dirty state to fire
|
||||||
|
against in the first place. Anything an interceptor is relied on for (auditing, denormalized
|
||||||
|
field maintenance) has to be handled separately for bulk operations.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/interceptor/InterceptorTest.java) —
|
||||||
|
[output](output/24-bulk-update-bypass.txt)
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- `StatelessSession` never calls interceptor callbacks at all, by design — it exists specifically
|
||||||
|
to skip persistence-context machinery for bulk-style work.
|
||||||
|
- An interceptor that mutates unrelated fields on every flush is a subtle source of extra
|
||||||
|
`UPDATE` statements — mutate only when the value actually needs to change, exactly as this
|
||||||
|
chapter's `uppercaseNameIfPresent` checks `!upper.equals(s)` before returning `true`.
|
||||||
|
- [Hibernate ORM 7.4 User Guide — interceptors](https://docs.jboss.org/hibernate/orm/7.4/userguide/html_single/Hibernate_User_Guide.html#events-interceptors)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 25 — Hibernate Search
|
||||||
|
|
||||||
|
[← Previous: 24 — Interceptors](24-interceptors.md) | [Back to README →](../README.md)
|
||||||
|
|
||||||
|
Backs the rewrite of ankurm.com post 4892 (Hibernate Search).
|
||||||
|
|
||||||
|
## The version this repo's own earlier post got wrong
|
||||||
|
|
||||||
|
The previous version of this post claimed `7.3.2.Final`. Checked against
|
||||||
|
`repo1.maven.org/maven2/org/hibernate/search/hibernate-search-mapper-orm/maven-metadata.xml`,
|
||||||
|
the current GA line is **`8.4.0.Final`** — its own `pom.xml` depends on
|
||||||
|
`hibernate-core:7.4.0.Final`, compatible with this repo's pinned `7.4.5.Final` (same
|
||||||
|
major.minor line). Hibernate Search's own version numbering does not track Hibernate ORM's —
|
||||||
|
Search 8.x pairs with ORM 7.x, not because "8" follows "7" but because that's simply what its own
|
||||||
|
POM resolves.
|
||||||
|
|
||||||
|
## Two configuration traps that break the whole application, not just this chapter
|
||||||
|
|
||||||
|
Both of these were found by actually wiring this chapter up against the real jars, not by
|
||||||
|
reading about the API:
|
||||||
|
|
||||||
|
1. **Naming an analyzer that doesn't exist.** `@FullTextField(analyzer = "english")` fails
|
||||||
|
application startup outright with `HSEARCH000353: Unknown analyzer: 'english'` — the Lucene
|
||||||
|
backend ships no predefined analyzer under that name; a custom analyzer needs to be registered
|
||||||
|
via a `LuceneAnalysisConfigurer` bean first. Omitting the `analyzer` attribute uses Hibernate
|
||||||
|
Search's own built-in default, which is enough for straightforward full-text matching.
|
||||||
|
2. **`@IndexedEmbedded` on an association with no defined inverse side.** Fails bootstrap with
|
||||||
|
`HSEARCH700020: Unable to find the inverse side of the association` — Hibernate Search needs
|
||||||
|
to know how to find every `Movie` that embeds a given `Director` so it can reindex them when
|
||||||
|
the `Director` changes. Either add a `@OneToMany(mappedBy = ...)` back-reference, or, if
|
||||||
|
reindex-on-update isn't needed, opt out explicitly:
|
||||||
|
```java
|
||||||
|
@ManyToOne
|
||||||
|
@IndexedEmbedded
|
||||||
|
@IndexingDependency(reindexOnUpdate = ReindexOnUpdate.SHALLOW)
|
||||||
|
private Director director;
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `com.ankurm.hibernatedemo.search.Movie` is `@Indexed` and lives in this repository's
|
||||||
|
normally-scanned package tree, **every** test in this repo that boots the shared Spring context
|
||||||
|
now bootstraps Hibernate Search too — confirmed the hard way, by watching an unrelated,
|
||||||
|
already-passing aggregate-function test fail until the backend was configured correctly. See
|
||||||
|
`src/main/resources/application.yml`'s `hibernate.search.*` block and its comment.
|
||||||
|
[`Movie.java`](../src/main/java/com/ankurm/hibernatedemo/search/Movie.java),
|
||||||
|
[`Director.java`](../src/main/java/com/ankurm/hibernatedemo/search/Director.java)
|
||||||
|
|
||||||
|
## Field types: full-text, keyword, and generic
|
||||||
|
|
||||||
|
```java
|
||||||
|
@FullTextField
|
||||||
|
private String title; // analyzed, tokenized, fuzzy-matchable
|
||||||
|
|
||||||
|
@KeywordField
|
||||||
|
private String genre; // stored and compared as ONE whole value, never tokenized
|
||||||
|
|
||||||
|
@GenericField(sortable = Sortable.YES)
|
||||||
|
private int releaseYear; // plain value, explicitly opted into sorting
|
||||||
|
```
|
||||||
|
A `@KeywordField` search has to match the *entire* stored value — `"science"` does not match a
|
||||||
|
stored `"Science Fiction"` the way a tokenized `@FullTextField` term would.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/search/HibernateSearchTest.java) —
|
||||||
|
[keyword output](output/25-keyword-exact-match.txt), [sortable output](output/25-sortable-generic-field.txt)
|
||||||
|
|
||||||
|
## Fuzzy full-text matching
|
||||||
|
|
||||||
|
```java
|
||||||
|
searchSession.search(Movie.class)
|
||||||
|
.where(f -> f.match().field("title").matching("Godfaher").fuzzy(1))
|
||||||
|
.fetchHits(20);
|
||||||
|
```
|
||||||
|
`.fuzzy(1)` tolerates a one-character edit distance — a typo a plain SQL `LIKE '%Godfaher%'`
|
||||||
|
would never match.
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/search/HibernateSearchTest.java) —
|
||||||
|
[output](output/25-fulltext-fuzzy.txt)
|
||||||
|
|
||||||
|
## `@IndexedEmbedded`: searching through an association
|
||||||
|
|
||||||
|
`Director` itself carries no `@Indexed` annotation — it only appears inside `Movie`'s index
|
||||||
|
because `Movie.director` is `@IndexedEmbedded`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
searchSession.search(Movie.class)
|
||||||
|
.where(f -> f.bool()
|
||||||
|
.must(f.match().field("title").matching("Iea"))
|
||||||
|
.must(f.match().field("director.name").matching("Iea Christopher Nolan")))
|
||||||
|
.fetchHits(20);
|
||||||
|
```
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/search/HibernateSearchTest.java) —
|
||||||
|
[output](output/25-indexed-embedded.txt)
|
||||||
|
|
||||||
|
## `MassIndexer`: rebuilding the index from the database
|
||||||
|
|
||||||
|
```java
|
||||||
|
searchSession.workspace().purge(); // empty the index; the database row is untouched
|
||||||
|
searchSession.massIndexer(Movie.class).startAndWait();
|
||||||
|
```
|
||||||
|
After `purge()`, a search for a row that's still in the database returns zero hits — the index
|
||||||
|
and the database are two separate stores, and nothing keeps them in sync automatically once the
|
||||||
|
index falls behind. `MassIndexer` rebuilds the index straight from what's in the database,
|
||||||
|
without re-persisting anything — the fix for "the index went stale" or "this table existed
|
||||||
|
before Hibernate Search was added to the project."
|
||||||
|
[Test](../src/test/java/com/ankurm/hibernatedemo/search/HibernateSearchTest.java) —
|
||||||
|
[output](output/25-mass-indexer.txt)
|
||||||
|
|
||||||
|
## Coordination strategies, briefly
|
||||||
|
|
||||||
|
This chapter uses Hibernate Search's default coordination: indexing happens synchronously, in
|
||||||
|
the same thread and transaction as the entity change. For a JTA or distributed deployment,
|
||||||
|
`hibernate-search-mapper-orm-coordination-outbox-polling` (confirmed to exist at the same
|
||||||
|
`8.4.0.Final` line on Maven Central) writes index updates to an outbox table first and applies
|
||||||
|
them asynchronously — trading immediate search-index consistency for not blocking the write
|
||||||
|
transaction on indexing work. This repo's tests rely on synchronous indexing specifically so a
|
||||||
|
search immediately after a `commit()` is guaranteed to see the new data; that guarantee does not
|
||||||
|
hold under the outbox strategy without an explicit wait.
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- Automatic indexing tracks entity changes through Hibernate's own event system — a bulk HQL/SQL
|
||||||
|
mutation bypasses it exactly the same way it bypasses interceptor callbacks (chapter 24);
|
||||||
|
reindex explicitly (or via `MassIndexer`) after any bulk write.
|
||||||
|
- `hibernate-search-backend-elasticsearch` is a drop-in alternative to the Lucene backend used
|
||||||
|
here for a deployment that already runs Elasticsearch or OpenSearch — the annotations on
|
||||||
|
`Movie`/`Director` do not change; only the `pom.xml` dependency and backend properties do.
|
||||||
|
- [Hibernate Search 8.4 reference documentation](https://docs.jboss.org/hibernate/search/8.4/reference/en-US/html_single/)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=BulkUpdateBypassesCacheTest#bulkHqlUpdate_doesNotLeaveTheL2EntityCacheStale_becauseHibernateAutoEvictsTheRegion
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[cache-bulk-update-hql-not-stale]: price in the database after the bulk HQL update=999.0 | price a brand-new session's get() actually returns=999.0 -- contrary to the original article, a bulk HQL update via createMutationQuery does NOT leave the L2 cache stale. BulkOperationCleanupAction evicts CacheProduct's entire region automatically once the statement's transaction commits, so no manual sessionFactory.getCache().evictEntityData(...) call is needed for this case.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ mvn -o -B test -Dtest=BulkUpdateBypassesCacheTest#bulkNativeSqlUpdate_alsoDoesNotLeaveTheCacheStale_hibernateInvalidatesEverythingItCannotParse
|
||||||
|
(trimmed to the test's own RESULT line and the surefire summary for the whole class)
|
||||||
|
|
||||||
|
RESULT[cache-bulk-update-native-not-stale]: price in the database after the native SQL update=999.0 | price a brand-new session's get() actually returns=999.0 | L2 puts recorded before the native update=1 | L2 hits before/after the post-update read=1/1 -- the original hypothesis (native SQL bypasses query-space tracking, so the L2 entry survives stale) was WRONG. The entity WAS cached (one L2 put), yet the post-update read is not an L2 hit at all: Hibernate cannot verify a native statement's affected tables, so it conservatively invalidates every region it knows about rather than none of them.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.cache.BulkUpdateBypassesCacheTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.450 s -- in com.ankurm.hibernatedemo.cache.BulkUpdateBypassesCacheTest
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
$ mvn -o -B test -Dtest=CacheApiNamespaceTest
|
||||||
|
(trimmed to the test's own RESULT lines and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath (jar: file:/root/.m2/repository/javax/cache/cache-api/1.1.1/cache-api-1.1.1.jar)
|
||||||
|
RESULT[cache-api-no-jakarta-namespace]: Class.forName("jakarta.cache.Cache") -> ClassNotFoundException -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's own "jakarta" classifier on org.ehcache:ehcache.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.cache.CacheApiNamespaceTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.115 s -- in com.ankurm.hibernatedemo.cache.CacheApiNamespaceTest
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
$ mvn -o -B test -Dtest=EntityL2CacheTest
|
||||||
|
(trimmed to the Hibernate/Ehcache lifecycle lines and the test's own RESULT line)
|
||||||
|
|
||||||
|
10:03:54.907 [main] INFO org.hibernate.orm.cache -- HHH90001028: Second-level cache region factory [org.hibernate.cache.jcache.internal.JCacheRegionFactory]
|
||||||
|
10:03:56.200 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'default-query-results-region' created in EhcacheManager.
|
||||||
|
10:03:56.247 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'productCache' created in EhcacheManager.
|
||||||
|
10:03:56.249 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'default-update-timestamps-region' created in EhcacheManager.
|
||||||
|
10:03:56.768 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
|
||||||
|
RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries=0 | session2 (brand-new session, same id) cumulative queries=0, L2 entity cache hits=2
|
||||||
|
10:03:56.946 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'productCache' removed from EhcacheManager.
|
||||||
|
10:03:56.949 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'default-query-results-region' removed from EhcacheManager.
|
||||||
|
10:03:56.949 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'default-update-timestamps-region' removed from EhcacheManager.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.cache.EntityL2CacheTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.290 s -- in com.ankurm.hibernatedemo.cache.EntityL2CacheTest
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ mvn -o -B test -Dtest=MissingUpdateTimestampsRegionTest#settingMissingCacheStrategyToFail_reproducesTheHardStartupErrorTheArticleDescribed
|
||||||
|
(trimmed to the test's own RESULT line and the surefire summary for the whole class)
|
||||||
|
|
||||||
|
RESULT[cache-missing-timestamps-region-strict]: hibernate.javax.cache.missing_cache_strategy=fail -> org.hibernate.service.spi.ServiceException: On-the-fly creation of JCache Cache objects is not supported [default-update-timestamps-region] -- this is how to opt into the hard-failure behavior the original article assumed was the default.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.cache.MissingUpdateTimestampsRegionTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.028 s -- in com.ankurm.hibernatedemo.cache.MissingUpdateTimestampsRegionTest
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
$ mvn -o -B test -Dtest=MissingUpdateTimestampsRegionTest#queryCacheEnabled_withNoUpdateTimestampsRegionInEhcacheXml_buildsFineWithOnlyAWarning
|
||||||
|
(trimmed to the WARN log line Hibernate emits and the test's own RESULT line)
|
||||||
|
|
||||||
|
10:04:11.993 [main] WARN org.hibernate.orm.cache -- HHH90001006: Missing cache region [default-update-timestamps-region] was created with provider-specific default policies. Explicitly configure the region and its policies, or disable this warning by setting 'hibernate.javax.cache.missing_cache_strategy' to 'create'.
|
||||||
|
10:04:12.002 [main] WARN org.hibernate.orm.cache -- HHH90001006: Missing cache region [default-query-results-region] was created with provider-specific default policies. Explicitly configure the region and its policies, or disable this warning by setting 'hibernate.javax.cache.missing_cache_strategy' to 'create'.
|
||||||
|
RESULT[cache-missing-timestamps-region]: SessionFactory built with NO error -- contrary to the original article, the default MissingCacheStrategy (CREATE_WARN) auto-creates the missing default-update-timestamps-region and only logs HHH90001006
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ mvn -o -B test -Dtest=QueryCacheWithoutEntityCacheTest
|
||||||
|
(trimmed to the test's own RESULT line and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements=1, query-cache misses=1 | second run (new session, query-cache HIT) SQL statements=0, query-cache hits=1, L2 entity-cache hits=0 -- contrary to the original article, the second run costs ZERO SQL statements: the query cache stores the full row tuples, not just the 5 ids, and Hibernate rebuilds UncachedProduct instances straight from that stored data. The zero L2 entity-cache hits confirm this does not depend on UncachedProduct having its own L2 region at all -- it has none.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.cache.QueryCacheWithoutEntityCacheTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.374 s -- in com.ankurm.hibernatedemo.cache.QueryCacheWithoutEntityCacheTest
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HikariLeakDetectionTest
|
||||||
|
(trimmed to the test's own RESULT lines and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[hikari-leak-threshold-floor]: requested leakDetectionThreshold=500ms | actual leakDetectionThreshold after construction=0ms | logged warnings=1 | message=HikariPool-1 - leakDetectionThreshold is less than 2000ms or more than maxLifetime, disabling it. -- HikariCP does not clamp 500ms up to 2000ms, it disables leak detection entirely and logs a WARN naming the reason.
|
||||||
|
RESULT[hikari-leak-detection]: leakDetectionThreshold=2000ms | logger=com.zaxxer.hikari.pool.ProxyLeakTask | level=WARN | message=Connection leak detection triggered for conn1: url=jdbc:h2:mem:hikarileak user=SA on thread main, stack trace follows
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.hikari.HikariLeakDetectionTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.334 s -- in com.ankurm.hibernatedemo.hikari.HikariLeakDetectionTest
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HikariPoolExhaustionTest
|
||||||
|
(trimmed to the test's own RESULT line and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[hikari-pool-exhaustion]: maximumPoolSize=1, connectionTimeout=1000ms | second getConnection() waited=1004ms before throwing java.sql.SQLTransientConnectionException: exhaustion-pool - Connection is not available, request timed out after 1000ms (total=1, active=1, idle=0, waiting=0)
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.hikari.HikariPoolExhaustionTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.351 s -- in com.ankurm.hibernatedemo.hikari.HikariPoolExhaustionTest
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HikariRawBootstrapTest
|
||||||
|
(trimmed to the test's own RESULT lines and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[hikari-raw-bootstrap]: ConnectionProvider class=org.hibernate.hikaricp.internal.HikariCPConnectionProvider | isUnwrappableAs(HikariDataSource)=true
|
||||||
|
RESULT[hikari-raw-bootstrap-config]: poolName=hibernate-demo-ch19-pool | maximumPoolSize=7 | connectionTimeout=5000ms -- every value traces back to a hibernate.hikari.* setting passed into StandardServiceRegistryBuilder, with zero Spring involved.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.hikari.HikariRawBootstrapTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.705 s -- in com.ankurm.hibernatedemo.hikari.HikariRawBootstrapTest
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ mvn -o -B test -Dtest=SpringAutoConfiguredHikariTest
|
||||||
|
(trimmed to the test's own RESULT line and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[hikari-spring-default]: dataSource class=com.zaxxer.hikari.HikariDataSource | pool name=HikariPool-1 | maximumPoolSize=10 | minimumIdle=10 | connectionTimeout=30000ms | idleTimeout=600000ms -- these are HikariCP's own built-in defaults (maximumPoolSize=10, minimumIdle defaults to maximumPoolSize), not anything this project set.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.hikari.SpringAutoConfiguredHikariTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.470 s -- in com.ankurm.hibernatedemo.hikari.SpringAutoConfiguredHikariTest
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
$ mvn -o -B test -Dtest=CdiValidationTest
|
||||||
|
(trimmed to the Weld startup line, the test's own RESULT line, and the surefire summary)
|
||||||
|
|
||||||
|
10:23:06.811 [main] INFO org.jboss.weld.Version -- WELD-000900: 6.0.4 (Final)
|
||||||
|
RESULT[cdi-validation-injection-works]: validator obtained from a running Weld SE container | StockLevel(3) violations=1 | StockLevel(5) violations=0 | StockLevel(10) violations=0 -- InventoryPolicy.minimumThreshold()=5 was actually injected and actually used, no NullPointerException anywhere.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.validation.CdiValidationTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.150 s -- in com.ankurm.hibernatedemo.validation.CdiValidationTest
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ mvn -o -B test -Dtest=PlainValidationNoCdiTest
|
||||||
|
(trimmed to the test's own RESULT line and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[cdi-plain-validation-no-injection]: validating StockLevel(3) with Validation.buildDefaultValidatorFactory() (no CDI container running) throws jakarta.validation.ValidationException -> caused by java.lang.NullPointerException
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.validation.PlainValidationNoCdiTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.345 s -- in com.ankurm.hibernatedemo.validation.PlainValidationNoCdiTest
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ mvn -o -B test -Dtest=AggregateFunctionsTest#criteriaApi_avgWithGroupBy_matchesHqlEquivalent
|
||||||
|
(trimmed to the generated SQL and the test's own RESULT line)
|
||||||
|
|
||||||
|
/* <criteria> */ select p1_0.category c0,avg(p1_0.price) c1 from product p1_0 where p1_0.category=? group by c0
|
||||||
|
RESULT[aggregate-criteria-avg]: Criteria API cb.avg(root.get("price")) for category='Cables' -- average=11.0 -- same numeric result as the equivalent HQL avg(p.price), just built without a string query.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
$ mvn -o -B test -Dtest=AggregateFunctionsTest#emptyResultSet_countIsZero_sumAndAvgAreNull_neitherThrows
|
||||||
|
(trimmed to the test's own RESULT line and the surefire summary)
|
||||||
|
|
||||||
|
RESULT[aggregate-empty-result-set]: over 0 matching rows -- count(p)=0 (never null) | sum(p.price)=null | avg(p.price)=null -- getSingleResult() returned normally for all three, no NoResultException, because SQL's aggregate functions over zero rows still produce exactly one result row.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Test set: com.ankurm.hibernatedemo.aggregate.AggregateFunctionsTest
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.465 s -- in com.ankurm.hibernatedemo.aggregate.AggregateFunctionsTest
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=AggregateFunctionsTest#groupByHaving_selectNewRecord_producesTypedSummaries
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[aggregate-groupby-having-record]: HAVING count(p) > 1 kept only categories with more than one product -- Keyboards(count=3, avg=99.0) -- Monitors (1 product) was correctly excluded by HAVING, not just by GROUP BY.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ mvn -o -B test -Dtest=AggregateFunctionsTest#windowFunction_rowNumberOverPartitionByCategory
|
||||||
|
(trimmed to the generated SQL and the test's own RESULT line)
|
||||||
|
|
||||||
|
/* select p.name, p.price, row_number() over (partition by p.category order by p.price desc) from Product p where p.category = 'Mice' order by p.price desc */ select p1_0.name,p1_0.price,row_number() over(partition by p1_0.category order by p1_0.price desc) from product p1_0 where p1_0.category='Mice' order by p1_0.price desc
|
||||||
|
RESULT[aggregate-window-row-number]: row_number() over (partition by category order by price desc) for the Mice category -- Wireless B=rank1 Wireless A=rank2 Wired C=rank3 -- HQL's window-function support (the OVER clause), present since Hibernate 6.2 and still current in 7.4.5.Final, not a Hibernate-7-only feature.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=SortingTest#caseInsensitiveSorting_viaCbLower
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[sorting-case-insensitive]: cb.lower(root.get("title")) ascending -- [Apple, banana, cherry] -- 'Apple' sorts before 'banana' despite the capital A, because the comparison happens on the lower-cased value, not the raw column.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=SortingTest#criteriaOrder_acrossAJoin
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[sorting-criteria-order-join]: Criteria root.join("playlist") ordered by the JOINED entity's name -- [A-Playlist, B-Playlist] -- proves Order in the Criteria API is not limited to the root entity's own columns.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ mvn -o -B test -Dtest=SortingTest#dynamicSorting_unwhitelistedFieldRejected_whitelistedFieldWorks
|
||||||
|
(trimmed to the test's own RESULT line; the exact set-iteration order of the allowed-values list
|
||||||
|
in the exception message can vary between runs -- java.util.Set.of() makes no ordering guarantee)
|
||||||
|
|
||||||
|
RESULT[sorting-dynamic-injection-guard]: whitelist rejected 'id) --' with IllegalArgumentException ("'id) --' is not a sortable field; allowed values are [artist, title, rating]") before it ever reached the query engine | whitelisted field 'artist' produced order by s.artist -- result: [Alpha Band, Zeta Band] -- the string never touches the HQL unless it's one of the three known-safe property names.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=SortingTest#nullPrecedence_viaJakartaPersistenceCriteriaNulls
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[sorting-null-precedence]: cb.asc(root.get("rating"), Nulls.LAST) -- [Rated Low, Rated High, Unrated One, Unrated Two] -- both unrated songs sort after every rated song regardless of what H2's own default null-ordering for ASC would otherwise do, because Nulls.LAST is explicit in the generated SQL's ORDER BY, not left to the dialect's default.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ mvn -o -B test -Dtest=SortingTest#orderByUsesPropertyName_notColumnName
|
||||||
|
(trimmed to the generated SQL and the test's own RESULT line)
|
||||||
|
|
||||||
|
select s1_0.playlist_id,s1_0.id,s1_0.artist,s1_0.rating,s1_0.song_title from song s1_0 where s1_0.playlist_id=? order by s1_0.song_title
|
||||||
|
RESULT[sorting-orderby-property-name]: @OrderBy("title asc") on the songs collection, where the entity property is 'title' but the mapped column is 'song_title' -- loaded order: [Alpha, Mike, Zulu] -- Hibernate resolved the PROPERTY name to the right column itself.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=SortingTest#sortNaturalAndSortComparator_onElementCollections
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[sorting-natural-and-comparator]: @SortNatural tags=[acoustic, live, rock] (plain alphabetical) | @SortComparator genres=[pop, folk, jazz-fusion] (shortest name first, alphabetical tiebreaker) -- both are real java.util.TreeSet instances rebuilt in memory on load, not an ORDER BY on the collection table.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
$ mvn -o -B test -Dtest=PaginationTest#joinFetchOrderedByCollectionColumn_fallsBackToInMemoryPagination
|
||||||
|
(trimmed to the runtime WARN log line, the generated SQL, and the test's own RESULT line)
|
||||||
|
|
||||||
|
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
|
||||||
|
select distinct a1_0.id,c1_0.article_id,c1_0.id,c1_0.body,a1_0.sequence,a1_0.title from article a1_0 join comment c1_0 on a1_0.id=c1_0.article_id where a1_0.title like 'JoinFetchOrder-%' escape '' order by c1_0.body
|
||||||
|
RESULT[pagination-joinfetch-collection-order-warning]: join fetch + setFirstResult/setMaxResults, ordered by a column on the FETCHED COLLECTION (c.body) -- Hibernate logs its own HHH90003004 warning ("firstResult/maxResults specified with collection fetch; applying in memory"), not the HHH000104 code sometimes quoted for this; that code belongs to a different, older message entirely. Page size returned: 2 distinct articles, computed by loading the full joined result set into memory and paginating it there in application code.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ mvn -o -B test -Dtest=PaginationTest#joinFetchOrderedByRoot_paginatesViaDerivedTable_noInMemoryFallback
|
||||||
|
(trimmed to the generated SQL and the test's own RESULT line)
|
||||||
|
|
||||||
|
select a1_0.id,c1_0.article_id,c1_0.id,c1_0.body,a1_0.sequence,a1_0.title from (select distinct a1_0.id,a1_0.sequence,a1_0.title from article a1_0 where a1_0.title like 'JoinFetch-%' escape '' and exists(select 1 from comment c1_0 where a1_0.id=c1_0.article_id) order by a1_0.sequence offset ? rows fetch first ? rows only) a1_0(id,sequence,title) join comment c1_0 on a1_0.id=c1_0.article_id order by a1_0.sequence
|
||||||
|
RESULT[pagination-joinfetch-root-order-no-warning]: join fetch + setFirstResult/setMaxResults, ordered by a ROOT-entity column -- no HHH90003004 warning was logged; the generated SQL (see the committed transcript) paginates a derived subquery of article ids first, then joins the comments onto that already-paginated set. Page size: 2 distinct articles.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=PaginationTest#keysetPagination_avoidsOffsetEntirely
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[pagination-keyset-seek]: keyset page 1 (id > 0) -- [Keyset-1, Keyset-2, Keyset-3, Keyset-4, Keyset-5] | keyset page 2 (id > last id of page 1) -- [Keyset-6, Keyset-7, Keyset-8, Keyset-9, Keyset-10] -- each page's WHERE clause carries the previous page's last id, so the database never has to count-and-skip rows the way OFFSET does.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ mvn -o -B test -Dtest=PaginationTest#limitOffset_translatesToDialectSyntax
|
||||||
|
(trimmed to the generated SQL and the test's own RESULT line)
|
||||||
|
|
||||||
|
select a1_0.id,a1_0.sequence,a1_0.title from article a1_0 where a1_0.title like 'LimitOffset-%' escape '' order by a1_0.sequence offset ? rows fetch first ? rows only
|
||||||
|
RESULT[pagination-limit-offset]: setFirstResult(2).setMaxResults(2) over 5 rows ordered by sequence -- page contents: [LimitOffset-3, LimitOffset-4] -- items 3 and 4 of 5, confirming the OFFSET skipped exactly 2 rows and the LIMIT capped the page at exactly 2.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=PaginationTest#scrollableResults_forwardOnly_readsWithoutLoadingWholeListUpfront
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[pagination-scrollable-forward-only]: ScrollMode.FORWARD_ONLY walked all 10 rows one at a time via results.next()/results.get() -- first three encountered: [Scroll-1, Scroll-2, Scroll-3] -- no List<Article> holding all 10 rows was ever built by this test's own code, unlike getResultList().
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=PaginationTest#totalCountQuery_forPageOfMPattern
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[pagination-total-count-pattern]: 7 matching rows, page size 3 -> 3 total pages ('Page 2 of 3') | page 2 contents: [CountPattern-4, CountPattern-5, CountPattern-6] -- two separate queries (a COUNT and a LIMIT/OFFSET SELECT), not one query doing both.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ mvn -o -B test -Dtest=InterceptorTest#bulkHqlUpdate_bypassesInterceptorCallbacksEntirely
|
||||||
|
(trimmed to the generated SQL and the test's own RESULT line)
|
||||||
|
|
||||||
|
update task t1_0 set name='renamed by bulk update' where t1_0.id=?
|
||||||
|
RESULT[interceptor-bulk-update-bypass]: onSaveCalls after the initial insert=1 | onFlushDirtyCalls after a bulk 'update Task set name = ...' executeUpdate()=0 (still 0) | actual persisted name: 'renamed by bulk update' -- the bulk HQL statement changed the row directly in the database without loading a Task instance into the persistence context at all, so onFlushDirty (which needs a managed entity's dirty state to fire against) never had anything to call.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=InterceptorTest#globalInterceptorViaSessionFactoryInterceptorProperty_appliesToEverySessionAutomatically
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[interceptor-global-via-property]: hibernate.session_factory.interceptor set once at SessionFactory build time -- onSave fired 2 times across 2 independent openSession() calls that never mentioned the interceptor themselves -- this is the mechanism a Spring Boot HibernatePropertiesCustomizer bean uses to register an interceptor application-wide.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=InterceptorTest#plainSessionWithoutInterceptor_leavesNameUnchanged
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[interceptor-scoping]: a plain sessionFactory.openSession() with no interceptor supplied left the name exactly as the application wrote it -- 'mow the lawn' -- the interceptor used by the previous test is scoped to the specific Session it was passed to via withOptions().interceptor(...), not to the SessionFactory as a whole.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=InterceptorTest#sessionScopedInterceptor_mutatesStateArray_onSaveAndOnFlushDirty
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[interceptor-session-scoped-mutation]: onSave called 1 time(s), onFlushDirty called 1 time(s) -- name after insert, reloaded from the database: 'WASH THE CAR' | name after update, reloaded from the database: 'BUY MILK' -- both mutations happened inside the interceptor's state array, not in application code, and both are visible in what was actually persisted.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HibernateSearchTest#fullTextSearch_withFuzzyMatching_findsATypo
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[search-fulltext-fuzzy]: searching title for 'Godfaher' (a one-character typo of 'Godfather') with .fuzzy(1) matched: [Fts The Godfather, Fts The Godfather Part II] -- a plain SQL LIKE '%Godfaher%' would have matched nothing.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HibernateSearchTest#indexedEmbedded_searchesThroughTheAssociation
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[search-indexed-embedded]: field 'director.name' matched the full keyword value 'Iea Christopher Nolan' -- [Iea Inception] -- Director itself carries no @Indexed annotation at all; its @KeywordField only exists inside Movie's index because of @IndexedEmbedded on the director association.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HibernateSearchTest#keywordField_exactMatchOnly_noPartialOrCaseInsensitiveMatch
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[search-keyword-exact-match]: @KeywordField genre matched by the exact stored value 'Science Fiction' -> 1 hit(s) | the same field searched with the partial, lowercase 'science' -> 0 hit(s) -- a KeywordField is compared whole, unlike a FullTextField's tokenized and lower-cased terms.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HibernateSearchTest#massIndexer_rebuildsTheIndexFromTheDatabase
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[search-mass-indexer]: hits before purge=1 | hits after workspace().purge() (row still in H2, index emptied)=0 | hits after massIndexer(Movie.class).startAndWait() (index rebuilt straight from the database, no re-persisting)=1.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$ mvn -o -B test -Dtest=HibernateSearchTest#sortableGenericField_ordersByReleaseYear
|
||||||
|
(trimmed to the test's own RESULT line)
|
||||||
|
|
||||||
|
RESULT[search-sortable-generic-field]: sort(f -> f.field("releaseYear").desc()) over the 3 Sgf-prefixed movies -- years in the order returned: [1993, 1982, 1975] -- @GenericField(sortable = Sortable.YES) is what makes this sort possible; the default is NOT sortable.
|
||||||
+8
@@ -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
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
cascade=ALL + orphanRemoval=true, in-place removeIf(): books before=3, books after=1
|
||||||
|
orphanRemoval=false: after removing book2 from author.books and flushing, book2 row still exists = true, author_id still = 1
|
||||||
|
owning side test: mutated only author2.getBooks().add(book) (inverse side), book.author after flush = null (FK not written)
|
||||||
|
cascade=ALL + orphanRemoval=true, reassigning the collection reference -- wrapper: jakarta.persistence.RollbackException
|
||||||
|
cascade=ALL + orphanRemoval=true, reassigning the collection reference -- root cause: org.hibernate.HibernateException: A collection with orphan deletion was no longer referenced by the owning entity instance: com.ankurm.hibernatedemo.association.CascadeAuthor.books
|
||||||
|
/* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
|
Hibernate: /* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
|
/* delete for com.ankurm.hibernatedemo.association.CascadeBook */delete from cascade_book where id=?
|
||||||
|
Hibernate: /* delete for com.ankurm.hibernatedemo.association.CascadeBook */delete from cascade_book where id=?
|
||||||
|
/* delete for com.ankurm.hibernatedemo.association.CascadeBook */delete from cascade_book where id=?
|
||||||
|
Hibernate: /* delete for com.ankurm.hibernatedemo.association.CascadeBook */delete from cascade_book where id=?
|
||||||
|
/* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
|
Hibernate: /* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
|
/* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
|
Hibernate: /* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
|
/* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
|
Hibernate: /* DELETE FROM CascadeBook */ delete from cascade_book cb1_0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Fix #2 (two queries): 2 queries fired, books=4, awards=3
|
||||||
|
Cartesian product: 4 books x 3 awards for 1 author -> raw SQL join rows = 12, distinct entities returned = 1
|
||||||
|
MultipleBagFetchException reproduction -- wrapper class: java.lang.IllegalArgumentException
|
||||||
|
MultipleBagFetchException reproduction -- root cause class: org.hibernate.loader.MultipleBagFetchException
|
||||||
|
MultipleBagFetchException reproduction -- verbatim message: cannot simultaneously fetch multiple bags: [com.ankurm.hibernatedemo.association.BagAuthorList.awards, com.ankurm.hibernatedemo.association.BagAuthorList.books]
|
||||||
|
Fix #1 (Set instead of List): 1 distinct authors returned, 1 queries fired
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
@BatchSize(10): 100 authors, 11 queries (expected 11 = 1 + ceil(100/10)), books touched = 300
|
||||||
|
@EntityGraph (fetchgraph hint): 100 authors, 1 queries, books touched = 300
|
||||||
|
JPQL JOIN FETCH: 100 authors, 1 queries, books touched = 300
|
||||||
|
=== Side-by-side query counts for 100 authors x 3 books each ===
|
||||||
|
naive lazy iteration : 101 queries
|
||||||
|
JPQL JOIN FETCH : 1 queries
|
||||||
|
@EntityGraph : 1 queries
|
||||||
|
@BatchSize(10) : 11 queries
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
LazyUser.find(): 2 queries fired BEFORE touching getProfile() at all (expected 2: user + eager profile join/select)
|
||||||
|
after touching getProfile(): 2 queries total (profile=loaded)
|
||||||
|
MiUser.find() (no mappedBy field at all): 1 query
|
||||||
|
explicit MiProfile.find() by shared PK when actually needed: 2 total queries
|
||||||
|
select lu1_0.id,lu1_0.username from lazy_user lu1_0 where lu1_0.id=?
|
||||||
|
Hibernate: select lu1_0.id,lu1_0.username from lazy_user lu1_0 where lu1_0.id=?
|
||||||
|
select lp1_0.id,lp1_0.bio,lp1_0.user_id from lazy_profile lp1_0 where lp1_0.user_id=?
|
||||||
|
Hibernate: select lp1_0.id,lp1_0.bio,lp1_0.user_id from lazy_profile lp1_0 where lp1_0.user_id=?
|
||||||
|
select mu1_0.id,mu1_0.username from mi_user mu1_0 where mu1_0.id=?
|
||||||
|
Hibernate: select mu1_0.id,mu1_0.username from mi_user mu1_0 where mu1_0.id=?
|
||||||
|
select mp1_0.id,mp1_0.bio,u1_0.id,u1_0.username from mi_profile mp1_0 join mi_user u1_0 on u1_0.id=mp1_0.id where mp1_0.id=?
|
||||||
|
Hibernate: select mp1_0.id,mp1_0.bio,u1_0.id,u1_0.username from mi_profile mp1_0 join mi_user u1_0 on u1_0.id=mp1_0.id where mp1_0.id=?
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# EntityManagerBootstrapTest -- filtered run output (DEMO log lines, plus the two Hibernate
|
||||||
|
# "Processing PersistenceUnitInfo" log lines that corroborate the name-collision finding).
|
||||||
|
# Full raw run captured from: mvn -Dtest=EntityManagerBootstrapTest test
|
||||||
|
|
||||||
|
23:40:15.984 [main] INFO DEMO -- unconfiguredUnitName: jakarta.persistence.PersistenceException: No Persistence provider for EntityManager named TotallyUnknownPU
|
||||||
|
|
||||||
|
23:40:17.701 [main] INFO DEMO -- repeatedFactoryCreation: createEntityManagerFactory() took 1630 ms, createEntityManager() took 36 ms -- the factory call is the one doing schema validation, service registry bootstrap, and metadata scanning; the EntityManager call is comparatively trivial
|
||||||
|
|
||||||
|
23:40:17.710 [main] INFO org.hibernate.orm.jpa -- HHH008540: Processing PersistenceUnitInfo [name: XmlBootstrapPU]
|
||||||
|
Database JDBC URL [jdbc:h2:mem:bootstrap-namecollision;DB_CLOSE_DELAY=-1]
|
||||||
|
Default catalog/schema: BOOTSTRAP-NAMECOLLISION/PUBLIC
|
||||||
|
23:40:17.802 [main] INFO DEMO -- persistenceUnitNameCollision: connected database = BOOTSTRAP-NAMECOLLISION (unit name 'XmlBootstrapPU' reused on purpose)
|
||||||
|
|
||||||
|
23:40:17.814 [main] INFO org.hibernate.orm.jpa -- HHH008540: Processing PersistenceUnitInfo [name: XmlBootstrapPU]
|
||||||
|
Database JDBC URL [jdbc:h2:mem:bootstrap-xml;DB_CLOSE_DELAY=-1]
|
||||||
|
Default catalog/schema: BOOTSTRAP-XML/PUBLIC
|
||||||
|
23:40:17.896 [main] INFO DEMO -- xmlBootstrap: persisted and reloaded user id=1
|
||||||
|
|
||||||
|
23:40:17.959 [main] INFO DEMO -- programmaticBootstrap: persisted user id=1 with zero persistence.xml units named 'ProgrammaticPU'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
aggregation: average salary = 85600.0
|
||||||
|
subquery: above-average earners (avg=85600) = [Byron, Hopper, Torvalds]
|
||||||
|
orPredicate: [Torvalds, Hamilton]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
criteriaUpdate: 3 rows updated, Ada's new salary = 104500.00000000001
|
||||||
|
criteriaDelete: deleted=1, remaining=5
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
stringPathPredicates: [Byron, Hopper, Torvalds]
|
||||||
|
staticMetamodel: [Byron, Hopper, Torvalds]
|
||||||
|
joinViaMetamodel: 3 engineering employees
|
||||||
|
rootJoinVsFetch: join+touch=3 statements, fetch+touch=1 statement
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
ROUNDTRIP localDate = 2026-03-15
|
||||||
|
ROUNDTRIP localDateTime = 2026-03-15T10:30:45
|
||||||
|
ROUNDTRIP localTime = 10:30:45
|
||||||
|
ROUNDTRIP instant = 2026-03-15T10:30:45Z
|
||||||
|
ROUNDTRIP offsetDateTime = 2026-03-15T10:30:45+05:30
|
||||||
|
ROUNDTRIP zonedDateTime = 2026-03-15T10:30:45+01:00
|
||||||
|
ROUNDTRIP legacyDateAsDate = 2026-03-15
|
||||||
|
ROUNDTRIP legacyDateAsTimestamp = 2026-03-15 10:30:45.0
|
||||||
|
ROUNDTRIP legacyDateNoTemporal = 2026-03-15 10:30:45.0 (class=class java.sql.Timestamp)
|
||||||
|
ROUNDTRIP legacyCalendar = Sun Mar 15 10:30:45 IST 2026
|
||||||
|
JVM default timezone during this run = Asia/Calcutta
|
||||||
|
create table temporal_types (id bigint generated by default as identity, instant timestamp(6) with time zone, legacy_calendar timestamp(6), legacy_date_as_date date, legacy_date_as_timestamp timestamp(6), legacy_date_no_temporal timestamp(6), local_date date, local_date_time timestamp(6), local_time time(0), offset_date_time timestamp(6) with time zone, zoned_date_time timestamp(6) with time zone, primary key (id))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
$ javap -v -cp <jakarta.persistence-api-3.2.0> jakarta.persistence.Temporal | grep -A2 Deprecated
|
||||||
|
#10 = Utf8 Temporal.java
|
||||||
|
#11 = Utf8 Deprecated
|
||||||
|
#12 = Utf8 RuntimeVisibleAnnotations
|
||||||
|
#13 = Utf8 Ljava/lang/Deprecated;
|
||||||
|
#14 = Utf8 since
|
||||||
|
#15 = Utf8 3.2
|
||||||
|
#16 = Utf8 Ljava/lang/annotation/Target;
|
||||||
|
--
|
||||||
|
SourceFile: "Temporal.java"
|
||||||
|
Deprecated: true
|
||||||
|
RuntimeVisibleAnnotations:
|
||||||
|
0: #13(#14=s#15)
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
hibernate.jdbc.time_zone=America/New_York -- original LocalDateTime = 2026-07-04T09:00
|
||||||
|
hibernate.jdbc.time_zone=America/New_York -- raw DB value for LocalDateTime column = 2026-07-03 23:30:00
|
||||||
|
hibernate.jdbc.time_zone=America/New_York -- round-tripped LocalDateTime = 2026-07-04T09:00
|
||||||
|
hibernate.jdbc.time_zone=America/New_York -- original OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30
|
||||||
|
hibernate.jdbc.time_zone=America/New_York -- raw DB value for NATIVE offset column = 2026-07-04 09:00:00+05:30
|
||||||
|
hibernate.jdbc.time_zone=America/New_York -- round-tripped OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
H2 2.4.240: original LocalDateTime nanos = 123456789
|
||||||
|
H2 2.4.240: plain column (precision default) nanos = 123457000 (value=2026-01-01T12:00:00.123457)
|
||||||
|
H2 2.4.240: @Column(precision=9) column nanos = 123457000 (value=2026-01-01T12:00:00.123457)
|
||||||
|
H2 2.4.240: original Instant nanos = 123456789
|
||||||
|
H2 2.4.240: plain Instant column nanos = 123457000 (value=2027-01-15T08:00:00.123457Z)
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
HSQLDB 2.7.3: original LocalDateTime nanos = 123456789
|
||||||
|
HSQLDB 2.7.3: plain column (precision default) nanos = 123456000 (value=2026-01-01T12:00:00.123456)
|
||||||
|
HSQLDB 2.7.3: @Column(precision=9) column nanos = 123456000 (value=2026-01-01T12:00:00.123456)
|
||||||
|
HSQLDB 2.7.3: original Instant nanos = 123456789
|
||||||
|
HSQLDB 2.7.3: plain Instant column nanos = 123456000 (value=2027-01-15T08:00:00.123456Z)
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] at com.ankurm.hibernatedemo.datetime.TemporalOnJavaTimeEntity.instantWithTemporalAnnotation.
|
||||||
|
@Temporal(TIMESTAMP) on Instant field: boot succeeded, round-tripped value = 2026-05-20T09:15:30Z (expected 2026-05-20T09:15:30Z)
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
JVM user.timezone system property = Asia/Calcutta
|
||||||
|
JVM TimeZone.getDefault() = Asia/Calcutta
|
||||||
|
ORIGINAL stored = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NATIVE = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NORMALIZE = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z
|
||||||
|
TZ_MODE COLUMN = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE AUTO = 2026-06-15T14:00+05:30
|
||||||
|
create table tz_storage (id bigint generated by default as identity, auto_col timestamp(6) with time zone, column_mode_col timestamp(6) with time zone, column_mode_col_tz integer, native_col timestamp(6) with time zone, no_annotation_col timestamp(6) with time zone, normalize_col timestamp(6), normalize_utc_col timestamp(6) with time zone, primary key (id))
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
JVM user.timezone system property = America/New_York
|
||||||
|
JVM TimeZone.getDefault() = America/New_York
|
||||||
|
ORIGINAL stored = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NATIVE = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE NORMALIZE = 2026-06-15T04:30-04:00
|
||||||
|
TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z
|
||||||
|
TZ_MODE COLUMN = 2026-06-15T14:00+05:30
|
||||||
|
TZ_MODE AUTO = 2026-06-15T14:00+05:30
|
||||||
Executable
+70
@@ -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
|
||||||
Executable
+46
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
aggregateCount: 5
|
||||||
|
avgSalaryGroupByDepartment: Engineering -> 95000.0
|
||||||
|
avgSalaryGroupByDepartment: Marketing -> 71500.0
|
||||||
|
pagination: page1=[Byron, Hamilton], page2=[Hopper, Johnson]
|
||||||
|
bulkUpdate: updated=1 rows, stale in-memory status=INACTIVE, reloaded status=ARCHIVED
|
||||||
|
bulkDelete: deleted=2 rows, remaining=3
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
defaultFlushMode: salary seen by a fresh query after an unflushed dirty change = 999999.0
|
||||||
|
jakartaCommitFlushMode: salary seen by query under FlushModeType.COMMIT = 98000.0 (pre-update value was 98000.0)
|
||||||
|
nativeManualFlushMode: before explicit flush=92000.0, after=123123.0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
whereWithNamedParameter: 4 active employees
|
||||||
|
columnNameInsteadOfFieldName: IllegalArgumentException: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'first_name' of 'com.ankurm.hibernatedemo.query.Employee' [SELECT e FROM Employee e WHERE e.first_name = 'Ada']
|
||||||
|
joinWithoutFetch: 1 statements for the query, 2 after touching department
|
||||||
|
joinFetch: 1 statement total, 5 rows
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
with handling-mode=allow, bulk HQL UPDATE rowsAffected=1
|
||||||
|
row after allowed bulk HQL update: ExchangeRate{id=1, pair=ZAR/USD, rate=1.0000}
|
||||||
|
flush() over 4000 loaded MUTABLE rows (12 cols, no pending changes): 8.62815 ms
|
||||||
|
flush() over 4000 loaded @Immutable rows (12 cols, no pending changes): 2.535115 ms
|
||||||
|
ratio (mutable / immutable) = 3.403455070085578
|
||||||
|
CAVEAT: single-run, shared-container timing -- indicative only, not a benchmark result.
|
||||||
|
in-memory field mutated to 999.9999, about to flush inside a transaction
|
||||||
|
Statistics.getEntityUpdateCount() after mutate+flush = 0
|
||||||
|
reloaded from DB: ExchangeRate{id=3, pair=USD/EUR, rate=0.9200}
|
||||||
|
Session.setReadOnly(entity,true) then mutate+flush -> entityUpdateCount = 0
|
||||||
|
native SQL UPDATE rows=1
|
||||||
|
row after native SQL update: ExchangeRate{id=4, pair=CHF/USD, rate=7.7000}
|
||||||
|
bulk HQL 'update ExchangeRate set ...' on an @Immutable entity threw: org.hibernate.query.sqm.InterpretationException: Error interpreting query [The query attempts to update an immutable entity: [exchange_rate] (set 'hibernate.query.immutable_entity_update_query_handling_mode' to suppress)] [update ExchangeRate set rate = :r where id = :id]
|
||||||
|
adding to an @Immutable collection and flushing threw: jakarta.persistence.RollbackException: Error while committing the transaction [Immutable collection was modified: [com.ankurm.hibernatedemo.immutable.RateWithAuditTrail.auditTrails with owner id '1']]
|
||||||
|
root cause class: org.hibernate.HibernateException message: Immutable collection was modified: [com.ankurm.hibernatedemo.immutable.RateWithAuditTrail.auditTrails with owner id '1']
|
||||||
|
persisted @Immutable+@Version entity, version after insert = 0
|
||||||
|
after mutate+flush, version = 0, rate = 0.0950
|
||||||
|
after EntityManager.remove() on an @Immutable entity, find() returns: null
|
||||||
|
after setDefaultReadOnly(true) + mutate + flush, rate = 6.9000
|
||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
Classfile /tmp/j-immutable/org/hibernate/annotations/Immutable.class
|
||||||
|
Last modified Feb 1, 1980; size 422 bytes
|
||||||
|
SHA-256 checksum 8a49d58789086dfab9871a67e8247e527ef2675d685ac086db603719ca4c5813
|
||||||
|
Compiled from "Immutable.java"
|
||||||
|
public interface org.hibernate.annotations.Immutable extends java.lang.annotation.Annotation
|
||||||
|
minor version: 0
|
||||||
|
major version: 61
|
||||||
|
flags: (0x2601) ACC_PUBLIC, ACC_INTERFACE, ACC_ABSTRACT, ACC_ANNOTATION
|
||||||
|
this_class: #1 // org/hibernate/annotations/Immutable
|
||||||
|
super_class: #3 // java/lang/Object
|
||||||
|
interfaces: 1, fields: 0, methods: 0, attributes: 2
|
||||||
|
Constant pool:
|
||||||
|
#1 = Class #2 // org/hibernate/annotations/Immutable
|
||||||
|
#2 = Utf8 org/hibernate/annotations/Immutable
|
||||||
|
#3 = Class #4 // java/lang/Object
|
||||||
|
#4 = Utf8 java/lang/Object
|
||||||
|
#5 = Class #6 // java/lang/annotation/Annotation
|
||||||
|
#6 = Utf8 java/lang/annotation/Annotation
|
||||||
|
#7 = Utf8 SourceFile
|
||||||
|
#8 = Utf8 Immutable.java
|
||||||
|
#9 = Utf8 RuntimeVisibleAnnotations
|
||||||
|
#10 = Utf8 Ljava/lang/annotation/Target;
|
||||||
|
#11 = Utf8 value
|
||||||
|
#12 = Utf8 Ljava/lang/annotation/ElementType;
|
||||||
|
#13 = Utf8 TYPE
|
||||||
|
#14 = Utf8 METHOD
|
||||||
|
#15 = Utf8 FIELD
|
||||||
|
#16 = Utf8 Ljava/lang/annotation/Retention;
|
||||||
|
#17 = Utf8 Ljava/lang/annotation/RetentionPolicy;
|
||||||
|
#18 = Utf8 RUNTIME
|
||||||
|
{
|
||||||
|
}
|
||||||
|
SourceFile: "Immutable.java"
|
||||||
|
RuntimeVisibleAnnotations:
|
||||||
|
0: #10(#11=[e#12.#13,e#12.#14,e#12.#15])
|
||||||
|
java.lang.annotation.Target(
|
||||||
|
value=[Ljava/lang/annotation/ElementType;.TYPE,Ljava/lang/annotation/ElementType;.METHOD,Ljava/lang/annotation/ElementType;.FIELD]
|
||||||
|
)
|
||||||
|
1: #16(#11=e#17.#18)
|
||||||
|
java.lang.annotation.Retention(
|
||||||
|
value=Ljava/lang/annotation/RetentionPolicy;.RUNTIME
|
||||||
|
)
|
||||||
|
Compiled from "ImmutableEntityUpdateQueryHandlingMode.java"
|
||||||
|
public final class org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode extends java.lang.Enum<org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode> {
|
||||||
|
public static final org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode ALLOW;
|
||||||
|
public static final org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode WARNING;
|
||||||
|
public static final org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode EXCEPTION;
|
||||||
|
private static final org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode[] $VALUES;
|
||||||
|
public static org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode[] values();
|
||||||
|
public static org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode valueOf(java.lang.String);
|
||||||
|
private org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode();
|
||||||
|
public static org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode interpret(java.lang.Object);
|
||||||
|
private static org.hibernate.query.spi.ImmutableEntityUpdateQueryHandlingMode[] $values();
|
||||||
|
static {};
|
||||||
|
}
|
||||||
Executable
+71
@@ -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)
|
||||||
Executable
+104
@@ -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)
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Boot 4.1.1: JndiDataSourceAutoConfiguration and spring.datasource.jndi-name -- relocated module
|
||||||
|
# Jar: spring-boot-jdbc-4.1.1.jar (NOT spring-boot-autoconfigure-4.1.1.jar -- that jar has zero 'jndi' matches)
|
||||||
|
|
||||||
|
Compiled from "JndiDataSourceAutoConfiguration.java"
|
||||||
|
public final class org.springframework.boot.jdbc.autoconfigure.JndiDataSourceAutoConfiguration {
|
||||||
|
public org.springframework.boot.jdbc.autoconfigure.JndiDataSourceAutoConfiguration();
|
||||||
|
javax.sql.DataSource dataSource(org.springframework.boot.jdbc.autoconfigure.DataSourceProperties, org.springframework.context.ApplicationContext);
|
||||||
|
private void excludeMBeanIfNecessary(java.lang.Object, java.lang.String, org.springframework.context.ApplicationContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private java.lang.String password;
|
||||||
|
private java.lang.String jndiName;
|
||||||
|
private org.springframework.boot.jdbc.EmbeddedDatabaseConnection embeddedDatabaseConnection;
|
||||||
|
--
|
||||||
|
public java.lang.String determinePassword();
|
||||||
|
public java.lang.String getJndiName();
|
||||||
|
public void setJndiName(java.lang.String);
|
||||||
|
public org.springframework.boot.jdbc.EmbeddedDatabaseConnection getEmbeddedDatabaseConnection();
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
|
||||||
|
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/tmp/tools/maven/lib/guice-5.1.0-classes.jar)
|
||||||
|
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
|
||||||
|
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
|
||||||
|
23:11:22.031 [main] INFO DEMO -- testA bound jdbc/SharedAcrossTests -- no @AfterEach unbind on purpose, to force the pollution
|
||||||
|
23:11:22.102 [main] ERROR org.osjava.sj.jndi.MemoryContext -- bind() jdbc/SharedAcrossTests already bound in MemoryContext{namesToObjects={jdbc/SharedAcrossTests=ds0: url=jdbc:h2:mem:pollution-a;DB_CLOSE_DELAY=-1 user=}, subContexts={jdbc=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@aafcffa, nameInNamespace=jdbc, nameLock=true}}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@6955cb39, nameInNamespace=, nameLock=false}
|
||||||
|
23:11:22.108 [main] INFO DEMO -- testB's bind() of the SAME name testA left behind failed verbatim with: javax.naming.NameAlreadyBoundException: Name jdbc/SharedAcrossTests already bound. Use rebind() to override
|
||||||
|
23:11:22.110 [main] INFO DEMO -- ctx.rebind() instead of ctx.bind() -- the standard fix -- succeeded: ds1: url=jdbc:h2:mem:pollution-b;DB_CLOSE_DELAY=-1 user=
|
||||||
|
23:11:22.115 [main] INFO DEMO -- cleanup: unbound jdbc/SharedAcrossTests so it does not leak into any test that runs after this class
|
||||||
|
23:11:22.379 [main] INFO DEMO -- looked-up DataSource produced a valid connection: jdbc:h2:mem:jnditestdb
|
||||||
|
23:11:22.383 [main] INFO DEMO -- verbatim NoInitialContextException message: Need to specify class name in environment or system property, or in an application resource file: java.naming.factory.initial
|
||||||
|
23:11:22.389 [main] INFO DEMO -- verbatim NameNotFoundException message: java:comp/env/jdbc/DoesNotExist
|
||||||
|
23:11:22.395 [main] ERROR org.osjava.sj.jndi.MemoryContext -- bind() jdbc already bound in MemoryContext{namesToObjects={}, subContexts={java:comp/env/jdbc=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@5ebd56e9, nameInNamespace=java:comp/env/jdbc, nameLock=true}, jdbc=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@aafcffa, nameInNamespace=jdbc, nameLock=true}, java:comp=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@63f34b70, nameInNamespace=java:comp, nameLock=true}, java:=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@641856, nameInNamespace=java:, nameLock=true}, java:comp/env=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@1b58ff9e, nameInNamespace=java:comp/env, nameLock=true}}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@6955cb39, nameInNamespace=, nameLock=false}
|
||||||
|
23:11:22.495 [main] INFO org.hibernate.orm.core -- HHH000001: Hibernate ORM core version 7.4.5.Final
|
||||||
|
23:11:22.793 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info:
|
||||||
|
DataSource JNDI name [jdbc/HibernateTestDS]
|
||||||
|
Database JDBC URL [jdbc:h2:mem:hibernate-jndi-test]
|
||||||
|
Database driver: H2 JDBC Driver
|
||||||
|
Database dialect: H2Dialect
|
||||||
|
Database version: 2.4.240
|
||||||
|
Default catalog/schema: HIBERNATE-JNDI-TEST/PUBLIC
|
||||||
|
Autocommit mode: undefined/unknown
|
||||||
|
Isolation level: READ_COMMITTED [default READ_COMMITTED]
|
||||||
|
JDBC fetch size: 100
|
||||||
|
Pool: DataSourceConnectionProvider
|
||||||
|
Minimum pool size: undefined/unknown
|
||||||
|
Maximum pool size: undefined/unknown
|
||||||
|
23:11:23.395 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
|
||||||
|
23:11:23.523 [main] INFO DEMO -- Hibernate SessionFactory built from JNDI name 'jdbc/HibernateTestDS' ran SELECT 1 -> 1
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
public static final java.lang.String JAKARTA_JTA_DATASOURCE;
|
||||||
|
public static final java.lang.String JAKARTA_NON_JTA_DATASOURCE;
|
||||||
|
public static final java.lang.String DATASOURCE;
|
||||||
|
public static final java.lang.String JPA_JTA_DATASOURCE;
|
||||||
|
public static final java.lang.String JPA_NON_JTA_DATASOURCE;
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# simple-jndi 0.25.0 jar listing -- confirms actual package layout
|
||||||
|
# The old article's jndi.properties used java.naming.provider.url=org.osjava.sj.memory.MemoryContextFactory
|
||||||
|
# -- there is no org.osjava.sj.memory package in this jar at all. The real class is:
|
||||||
|
2532 2025-02-22 10:12 org/osjava/sj/MemoryContextFactory.class
|
||||||
|
1617 2025-02-22 10:12 org/osjava/sj/SimpleJndiContextFactory$1.class
|
||||||
|
1506 2025-02-22 10:12 org/osjava/sj/MemoryContextFactory$1.class
|
||||||
|
339 2025-02-22 10:12 org/osjava/sj/SimpleContextFactory.class
|
||||||
|
2830 2025-02-22 10:12 org/osjava/sj/SimpleJndiContextFactory.class
|
||||||
|
2320 2025-02-22 10:12 org/osjava/sj/ContextFactory.class
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Spring Framework 5.3.31 spring-test.jar -- org.springframework.mock.jndi package PRESENT
|
||||||
|
0 2023-11-16 08:03 org/springframework/mock/jndi/
|
||||||
|
4126 2023-11-16 08:03 org/springframework/mock/jndi/SimpleNamingContext$AbstractNamingEnumeration.class
|
||||||
|
262 2023-11-16 08:03 org/springframework/mock/jndi/package-info.class
|
||||||
|
6147 2023-11-16 08:03 org/springframework/mock/jndi/SimpleNamingContextBuilder.class
|
||||||
|
1833 2023-11-16 08:03 org/springframework/mock/jndi/ExpectedLookupTemplate.class
|
||||||
|
1979 2023-11-16 08:03 org/springframework/mock/jndi/SimpleNamingContext$NameClassPairEnumeration.class
|
||||||
|
265 2023-11-16 08:03 org/springframework/mock/jndi/SimpleNamingContext$1.class
|
||||||
|
1808 2023-11-16 08:03 org/springframework/mock/jndi/SimpleNamingContext$BindingEnumeration.class
|
||||||
|
9440 2023-11-16 08:03 org/springframework/mock/jndi/SimpleNamingContext.class
|
||||||
|
|
||||||
|
# Spring Framework 6.0.0 spring-test.jar -- org.springframework.mock.jndi package: NO MATCH (package removed)
|
||||||
|
(no matches -- package is gone)
|
||||||
|
|
||||||
|
# Spring Framework 7.0.9 spring-test.jar (the version this blog batch verifies against) -- same check
|
||||||
|
(no matches -- still gone)
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# JndiDataSourceResolutionTest + HibernateJndiDataSourceTest + CrossTestPollutionTest
|
||||||
|
# filtered run output, all 7 tests green together (proves the pollution fix works)
|
||||||
|
|
||||||
|
23:11:22.031 [main] INFO DEMO -- testA bound jdbc/SharedAcrossTests -- no @AfterEach unbind on purpose, to force the pollution
|
||||||
|
23:11:22.102 [main] ERROR org.osjava.sj.jndi.MemoryContext -- bind() jdbc/SharedAcrossTests already bound in MemoryContext{namesToObjects={jdbc/SharedAcrossTests=ds0: url=jdbc:h2:mem:pollution-a;DB_CLOSE_DELAY=-1 user=}, subContexts={jdbc=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@aafcffa, nameInNamespace=jdbc, nameLock=true}}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@6955cb39, nameInNamespace=, nameLock=false}
|
||||||
|
23:11:22.108 [main] INFO DEMO -- testB's bind() of the SAME name testA left behind failed verbatim with: javax.naming.NameAlreadyBoundException: Name jdbc/SharedAcrossTests already bound. Use rebind() to override
|
||||||
|
23:11:22.110 [main] INFO DEMO -- ctx.rebind() instead of ctx.bind() -- the standard fix -- succeeded: ds1: url=jdbc:h2:mem:pollution-b;DB_CLOSE_DELAY=-1 user=
|
||||||
|
23:11:22.115 [main] INFO DEMO -- cleanup: unbound jdbc/SharedAcrossTests so it does not leak into any test that runs after this class
|
||||||
|
23:11:22.379 [main] INFO DEMO -- looked-up DataSource produced a valid connection: jdbc:h2:mem:jnditestdb
|
||||||
|
23:11:22.383 [main] INFO DEMO -- verbatim NoInitialContextException message: Need to specify class name in environment or system property, or in an application resource file: java.naming.factory.initial
|
||||||
|
23:11:22.389 [main] INFO DEMO -- verbatim NameNotFoundException message: java:comp/env/jdbc/DoesNotExist
|
||||||
|
23:11:22.395 [main] ERROR org.osjava.sj.jndi.MemoryContext -- bind() jdbc already bound in MemoryContext{namesToObjects={}, subContexts={java:comp/env/jdbc=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@5ebd56e9, nameInNamespace=java:comp/env/jdbc, nameLock=true}, jdbc=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@aafcffa, nameInNamespace=jdbc, nameLock=true}, java:comp=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@63f34b70, nameInNamespace=java:comp, nameLock=true}, java:=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@641856, nameInNamespace=java:, nameLock=true}, java:comp/env=MemoryContext{namesToObjects={}, subContexts={}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@1b58ff9e, nameInNamespace=java:comp/env, nameLock=true}}, env={org.osjava.sj.root=, org.osjava.sj.jndi.shared=true, java.naming.factory.initial=org.osjava.sj.MemoryContextFactory, org.osjava.sj.delimiter=., org.osjava.sj.jndi.ignoreClose=false, jndi.syntax.separator=., jndi.syntax.direction=left_to_right, org.osjava.sj.factory=org.osjava.sj.MemoryContextFactory}, nameParser=org.osjava.sj.jndi.SimpleNameParser@6955cb39, nameInNamespace=, nameLock=false}
|
||||||
|
23:11:22.495 [main] INFO org.hibernate.orm.core -- HHH000001: Hibernate ORM core version 7.4.5.Final
|
||||||
|
23:11:22.793 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info:
|
||||||
|
DataSource JNDI name [jdbc/HibernateTestDS]
|
||||||
|
Database JDBC URL [jdbc:h2:mem:hibernate-jndi-test]
|
||||||
|
Database driver: H2 JDBC Driver
|
||||||
|
Database dialect: H2Dialect
|
||||||
|
Database version: 2.4.240
|
||||||
|
Pool: DataSourceConnectionProvider
|
||||||
|
23:11:23.395 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
|
||||||
|
23:11:23.523 [main] INFO DEMO -- Hibernate SessionFactory built from JNDI name 'jdbc/HibernateTestDS' ran SELECT 1 -> 1
|
||||||
|
|
||||||
|
Tests run: 3 -- JndiDataSourceResolutionTest
|
||||||
|
Tests run: 1 -- HibernateJndiDataSourceTest
|
||||||
|
Tests run: 3 -- CrossTestPollutionTest
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user