commit 8568c0ce6ca1745c8265378b0c89198b84a3879f Author: asmhatre Date: Sun Sep 6 10:30:19 2026 +0530 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) diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..b13f319 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +*.class +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ankur Mhatre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100755 index 0000000..608dd47 --- /dev/null +++ b/README.md @@ -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). diff --git a/derby.log b/derby.log new file mode 100644 index 0000000..c9e571d --- /dev/null +++ b/derby.log @@ -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='' diff --git a/docs/00-versions.md b/docs/00-versions.md new file mode 100755 index 0000000..f0abb21 --- /dev/null +++ b/docs/00-versions.md @@ -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 +`7.4.1.Final` — so on Boot 4.1.0, this repo's `pom.xml` +did not need to override anything to get 7.4.1.Final; the `` 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 `` 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-.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) diff --git a/docs/01-get-vs-load.md b/docs/01-get-vs-load.md new file mode 100755 index 0000000..542017a --- /dev/null +++ b/docs/01-get-vs-load.md @@ -0,0 +1,134 @@ +# 01 — get() vs getReference() + +[← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md) + +Backs [ankurm.com: Hibernate 7 — get() vs load()](https://ankurm.com/hibernate-7-get-vs-load-which-one-should-you-actually-use/). + +Test class: [`GetVsGetReferenceTest`](../src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java). +Run it yourself: + +```bash +git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git +cd hibernate-demo +./mvnw -Dtest=GetVsGetReferenceTest test +``` + +Every number and exception class name below came from that command, not from documentation or +memory. Raw captured output: [`docs/output/get-vs-getreference-tests.txt`](output/get-vs-getreference-tests.txt). + +## Contract vs observation + +The JPA/Hibernate contract for these two methods is short: `get()` fetches now and may return +`null`; `getReference()` defers and may throw once accessed. That contract is real and both +methods honor it. What it doesn't tell you is what happens once the *same id* has already been +touched once in the *same session* — and that's where the interesting behavior lives, because it's +governed by the persistence context, not by the method you happen to call second. + +## Mental model + +Stop thinking of `get()` vs `getReference()` as "eager vs lazy." Think of it as what you're telling +Hibernate you need: + +- `get()` says **"I need the entity."** Hibernate will do whatever it takes — including firing a + `SELECT` against an id it already has a reference for — to hand you something with real data + behind it. +- `getReference()` says **"I need a reference."** Hibernate will hand you the cheapest possible + object that satisfies that and defers everything else, including telling you the row doesn't + exist. + +That framing predicts the session-matrix results in the next section better than "eager vs lazy" +does — see the `getReference()` → `get()` row in particular. + +## Four calls, four outcomes + +| # | Call | Fires a `SELECT` at the call site? | Row missing | Row exists | +|---|---|---|---|---| +| 1 | `session.get(Book.class, id)` | Yes, immediately | returns `null` | returns the real entity | +| 2 | `session.get(Book.class, id)` (missing id) | Yes, immediately | returns `null` | — | +| 3 | `session.getReference(Book.class, id)` | No — deferred to first non-id accessor | proxy returned, no error yet | proxy returned, no `SELECT` yet | +| 4 | `session.getReference(Book.class, id)` (missing id), then `.getTitle()` | Yes, on first accessor call | throws `jakarta.persistence.EntityNotFoundException` on access | — | + +Row 4 is worth being precise about: the exception class is `jakarta.persistence.EntityNotFoundException`, +not `org.hibernate.ObjectNotFoundException` — the name still used in a lot of older Hibernate +discussion. Running it against 7.4.1.Final settles which one this version actually throws. + +## Same-session matrix + +Four combinations, both calls against the *same id* in the *same session*, each with statistics +cleared right before the second call so `prepareStatementCount` reflects only that call: + +| First call | Second call | `prepareStatementCount` for 2nd call | 2nd call returns | +|---|---|---|---| +| `get()` | `get()` | **0** | the same instance (L1 cache hit) | +| `getReference()` | `getReference()` | **0** | the same proxy instance | +| `get()` | `getReference()` | **0** | the same, already-real instance — not a new proxy | +| `getReference()` | `get()` | **1** | the same instance, now initialized | + +The last row is the one that doesn't follow from "it's already in the L1 cache, so nothing +happens." It does happen: calling `get()` against an id that already has an *uninitialized* proxy +sitting in the persistence context still fires a `SELECT`. `get()`'s contract is "hand back a real, +usable entity" — an uninitialized proxy doesn't satisfy that, so Hibernate initializes it in place +and returns the same object reference, now with real data behind it. The reverse direction +(`get()` then `getReference()`) needs nothing further, because a real, fully-loaded instance +already satisfies whatever `getReference()` was going to ask for. + +This wasn't something I went looking for — it fell out of writing the fourth row of the matrix +and reading the log, which is the actual argument for building the matrix instead of reasoning +about two of the four cells and assuming the rest. + +## Proxy identity experiment + +Six checks against the same proxy, all in one test: + +```java +assertThat(proxy).isInstanceOf(Book.class); // true +assertThat(Hibernate.getClass(proxy)).isEqualTo(Book.class); // true -- the REAL class +assertThat(proxy.getClass()).isNotEqualTo(Book.class); // true -- proxy.getClass() is Book$HibernateProxy +assertThat(real.equals(proxy)).isFalse(); // false +assertThat(proxy.equals(real)).isFalse(); // false, both directions +assertThat(new HashSet<>(List.of(real)).contains(proxy)).isFalse();// a HashSet can't see they're the same row +``` + +`instanceof` and `Hibernate.getClass()` both see through the proxy to the real type. `getClass()` +does not — a Hibernate proxy's runtime class is a generated `Book$HibernateProxy`, never `Book` +itself, which is why `Hibernate.getClass()` exists as the "give me the real entity class" escape +hatch. `equals()` breaks in both directions because `Book` never overrides it, so Java's default +falls back to reference identity — this is not a Hibernate quirk, it's plain Java doing exactly +what an un-overridden `equals()` always does once two different objects (a proxy and a loaded +instance) represent the same row. The `HashSet` check is the concrete cost of that: a collection +built on default `equals()`/`hashCode()` cannot recognize the proxy and the real instance as the +same database row, silently. + +A seventh check, in a separate test, confirms the other well-known proxy trap: a proxy that +outlives the session that created it throws `org.hibernate.LazyInitializationException` the +moment a non-id accessor is called on it — a different failure from `EntityNotFoundException`, +worth not confusing with it. + +## What surprised me building this + +Two things, not one. + +The proxy-equals-breaking result was expected going in, just not in its full shape — I expected +`equals()` to be asymmetric or to depend on which side calls it. It doesn't; it fails identically +in both directions, which is simpler and worse than a half-remembered version of this story +usually gets described. + +The one I didn't expect at all was the `getReference()` → `get()` row of the session matrix. The +intuitive prediction — "the id is already in the L1 cache, so the second call is free" — is true +for three of the four matrix combinations and wrong for exactly this one, because `get()`'s +contract requires more than presence in the cache; it requires the object behind that cache entry +to actually be usable as loaded data. Predicting three cells right and getting the fourth wrong +in a way that only shows up by actually building all four is the whole argument for running the +matrix instead of describing two of its cells from memory. + +## Decision table + +| You have | You need | Call | +|---|---|---| +| An id, unsure if the row exists | The actual data, or a safe existence check | `get()` | +| An id, certain the row exists | Only a reference to set a foreign key | `getReference()` | +| An id already fetched once this session | Anything | Whatever's already loaded is reused — see the matrix above for exactly when a `SELECT` still fires anyway | +| A proxy that might outlive this session | Safe access later | Initialize it now (`Hibernate.initialize(proxy)`), or don't let it leave the session | +| Two references to the same row from mixed `get()`/`getReference()` calls, going into a `Set` or `equals()`-based comparison | Correct identity behavior | Override `equals()`/`hashCode()` on the id — the un-overridden default will not survive the proxy boundary | + +[← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md) diff --git a/docs/02-merge-vs-refresh.md b/docs/02-merge-vs-refresh.md new file mode 100755 index 0000000..152e580 --- /dev/null +++ b/docs/02-merge-vs-refresh.md @@ -0,0 +1,170 @@ +# 02 — merge() vs refresh() + +[← Previous: 01 — get() vs getReference()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md) + +Backs [ankurm.com: merge() vs refresh()](https://ankurm.com/mastering-hibernate-7-merging-vs-refreshing-entities-for-robust-data-consistency/). + +Test classes: [`MergeRefreshTest`](../src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java), +[`OptimisticLockTest`](../src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java). +Entity: [`Book`](../src/main/java/com/ankurm/hibernatedemo/model/Book.java) — note the real +`@Version` column and the `notes` LAZY collection, both load-bearing for the experiments below. + +```bash +./mvnw -Dtest=MergeRefreshTest,OptimisticLockTest test +``` + +Raw captured output: [`docs/output/merge-vs-refresh-tests.txt`](output/merge-vs-refresh-tests.txt). +(The `docs/output/merge-vs-refresh.txt` file, unchanged, is the older `CommandLineRunner` +transcript behind the `mergerefresh` profile mentioned in the README — a different, narrower +scenario than the three experiments below.) + +## Contract vs observation + +The textbook framing is "`merge()` pushes Java state to the database, `refresh()` pulls database +state into Java — opposite directions of the same kind of operation." That's accurate as a +description of data flow and useless as a guide to which one is dangerous. The three experiments +below are about the part the contract doesn't specify: what each method does when the state it's +holding is already stale, which is precisely the situation both exist to handle. + +## Reframing: this is about entity state, not "data consistency" + +`merge()` and `refresh()` don't care about your application's notion of consistency. They care +about exactly one thing: what identity state (`@Version` value, or an unflushed field on a managed +instance) the object handed to them holds at the moment they're called. Everything below follows +from that, entity-state mechanics, not from anything data-consistency-flavored. + +## Experiment 1 — merge() of a detached instance + +**Config:** a `Book` row exists in the database. A session already holds its *own* managed +instance of that row (via `get()`), before `merge()` is ever called on a separately-detached copy +of the same row. + +**Expected:** `merge()` returns some object representing the updated state. + +**Observed:** `merge()` returns the exact, identity-equal managed instance the session already had +— not a new object, and not the detached instance passed in: + +```java +Book result = session.merge(detached); +assertThat(result).isSameAs(managed); // true +assertThat(result).isNotSameAs(detached); // true +``` + +This is the precise version of "`merge()` returns a managed copy" — it's not just *a* managed +copy, it's *the* one instance this persistence context has already committed to tracking for this +row, reused rather than replaced. + +## Experiment 2 — optimistic-lock conflict: WHEN does it surface? + +**Config:** a detached `Book` instance holds `version=0`. A second, independent session has since +updated the same row and committed, advancing the database to `version=1`. The stale detached +instance is then edited and merged. + +**Expected (the common but imprecise claim):** "`merge()` throws `OptimisticLockException`." + +**Observed, precisely:** it does — and specifically **at the `merge()` call itself**, not at +`flush()` and not at `tx.commit()`: + +``` +OptimisticLockException surfaced directly from the merge() call. +exception: jakarta.persistence.OptimisticLockException: Row was already updated or deleted by +another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '4'] +``` + +The test doesn't assume this — it wraps `session.merge(detached)` in a `try/catch` first and only +falls through to asserting the exception at `commit()` if `merge()` itself didn't throw, logging +which path actually happened. On 7.4.1.Final, `merge()` re-selects the row as part of copying +state and compares versions right there, before any flush is even scheduled — so the failure is as +early as it can possibly be. This matters in code that wraps `merge()` calls expecting the +exception only at commit time: on this version, it never gets that far. + +## Experiment 3 — merge() with a LAZY collection under CascadeType.MERGE + +**Config:** `Book.notes` is `FetchType.LAZY` and cascades `MERGE`. A `Book` is loaded and detached +*without ever touching* `.getNotes()` — the collection proxy is confirmed uninitialized before +detachment. The detached instance is edited and merged. + +**Expected (the naive, plausible-sounding claim):** "an unfetched LAZY collection is never +touched by `merge()`, since it was never loaded in the first place." + +**Observed:** the opposite. `merge()` initializes the collection anyway: + +```java +Book merged = session.merge(detached); +assertThat(Hibernate.isInitialized(merged.getNotes())).isTrue(); // true -- NOT false +``` + +The reason is cascading itself: `CascadeType.MERGE` on `notes` means merging the parent requires +merging each element of that collection too, and Hibernate cannot cascade to elements it hasn't +loaded — so it loads them first. Remove `cascade = CascadeType.MERGE` from `Book.notes` and +re-run this exact test and the result flips: the collection stays uninitialized, because nothing +requires Hibernate to look at it. Cascading, not laziness, decides whether `merge()` touches an +unfetched collection. + +## Experiment 4 — refresh() silently discards an unflushed edit + +**Config:** the `USER_EDIT` / `ADMIN_EDIT` scenario. A `Book.status` row starts at `USER_EDIT`. A +separate admin process loads the row, sets `status=ADMIN_EDIT`, and commits. A second session then +loads the row (now `ADMIN_EDIT` in the database), makes a local, unflushed edit back to +`USER_EDIT`, and calls `session.refresh()` on the managed instance. + +**Observed:** + +```java +managed.setStatus("USER_EDIT"); // local edit, never sent to the database +session.refresh(managed); +assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT"); // the local edit is gone +``` + +No exception. No warning. `refresh()` re-runs the `SELECT` and overwrites every field on the +managed instance with what the database currently holds — including the field holding the pending +edit that was never flushed. The edit isn't rejected; it's erased. + +## What surprised me building this + +Going in, the plan was to demonstrate "`merge()` can silently overwrite concurrent changes" as +the headline risk — that's the framing most write-ups use, and it's the one the article originally +carried. Running Experiments 2 and 4 back to back showed the opposite. With a real `@Version` +column in place, `merge()` is the one that refuses to write a stale change — loudly, at the +earliest possible point. `refresh()` is the one that destroys data without a sound, and it does it +to an edit that was never even sent to the database. The risk isn't "which method can overwrite +the database" — both can, that's their job. It's "which one fails loudly when the state it's +holding is stale," and on a versioned entity that's `refresh()`, not `merge()` — backwards from +how the pairing is usually described. Strip the `@Version` column out and Experiment 2 flips: an +unversioned `merge()` would apply the stale write without complaint. The column isn't incidental +to the result; it's the entire reason the result comes out this way. + +The LAZY-collection result (Experiment 3) was the other correction: the plan going in was to +show that unfetched LAZY state is never touched by `merge()`. It is, specifically because of the +cascade — a fact only visible by running the on/off comparison rather than asserting the more +intuitive-sounding half of it. + +## Decision tree + +``` +Do you have a DETACHED instance you want written to the database? +├─ Yes → merge() +│ Does the entity carry @Version? +│ ├─ Yes → a stale write throws OptimisticLockException AT THE merge() CALL — loud, safe +│ └─ No → a stale write silently overwrites the current row — no different from update() +│ +└─ No, you have a MANAGED instance and want it to reflect the current database row + → refresh() + Does it have an unflushed local edit? + ├─ Yes → that edit is silently discarded, no exception — refresh() is NOT reversible + └─ No → refresh() is a safe, ordinary re-read +``` + +## Pessimistic locking, briefly + +Pessimistic locking (`LockModeType.PESSIMISTIC_WRITE`, issuing `SELECT ... FOR UPDATE`) is the +other tool for the same underlying problem — it prevents the conflict from ever existing rather +than detecting it after the fact. Reach for it only when the retry cost of an +`OptimisticLockException` is genuinely unacceptable (real-time seat/ticket reservation, high +per-row contention); it holds a database lock for the duration of the transaction, which is the +wrong tradeoff for the common case of a REST API with real user think-time between load and save. +`@Version` optimistic locking is the sane default; this repo doesn't carry a dedicated scenario for +pessimistic locking because there's no surprising runtime behavior to verify here beyond "the lock +is held until commit," which the database's own documentation already states correctly. + +[← Previous: 01 — get() vs getReference()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md) diff --git a/docs/03-inserting-objects.md b/docs/03-inserting-objects.md new file mode 100755 index 0000000..a1f705b --- /dev/null +++ b/docs/03-inserting-objects.md @@ -0,0 +1,198 @@ +# 03 — Hibernate 7 batch inserts: proving batching is working + +[← Previous: 02 — merge() vs refresh()](02-merge-vs-refresh.md) | [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) diff --git a/docs/04-annotations-vs-xml.md b/docs/04-annotations-vs-xml.md new file mode 100755 index 0000000..40bf7c0 --- /dev/null +++ b/docs/04-annotations-vs-xml.md @@ -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 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 ``/``/`` 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. + +## `` is not an override switch — it is an annotation kill switch + +This is the sharpest correction. The natural assumption is that +`` 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) | +|---|---|---| +| `` | absent | present | +| `` / `` / `` | absent | present | +| ``-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 `` is declared purely in [`mapping-xml-natural-id.xml`](../src/test/resources/mapping-xml-natural-id.xml) +(root element ``), +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) diff --git a/docs/05-jpa-persistence-annotations.md b/docs/05-jpa-persistence-annotations.md new file mode 100755 index 0000000..220b8aa --- /dev/null +++ b/docs/05-jpa-persistence-annotations.md @@ -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 + + tools.jackson.core + jackson-databind + 3.1.5 + +``` + +(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) diff --git a/docs/06-natural-ids.md b/docs/06-natural-ids.md new file mode 100755 index 0000000..445320d --- /dev/null +++ b/docs/06-natural-ids.md @@ -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 byNaturalId(Class) +SimpleNaturalIdLoadAccess bySimpleNaturalId(Class) +NaturalIdMultiLoadAccess byMultipleNaturalId(Class) +``` + +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 + + org.hibernate.orm + hibernate-jcache + + + org.ehcache + ehcache + 3.10.8 + jakarta + + + org.glassfish.jaxb + jaxb-runtime + + + +``` + +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) diff --git a/docs/07-immutable-entities.md b/docs/07-immutable-entities.md new file mode 100755 index 0000000..d6fb891 --- /dev/null +++ b/docs/07-immutable-entities.md @@ -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: [. with owner 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) diff --git a/docs/08-stored-procedures.md b/docs/08-stored-procedures.md new file mode 100755 index 0000000..306f588 --- /dev/null +++ b/docs/08-stored-procedures.md @@ -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() = <- 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` in addition to + `Class`, 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) diff --git a/docs/09-testing-in-memory-databases.md b/docs/09-testing-in-memory-databases.md new file mode 100755 index 0000000..a66f68d --- /dev/null +++ b/docs/09-testing-in-memory-databases.md @@ -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 + + org.hibernate.orm + hibernate-community-dialects + runtime + +``` + +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) diff --git a/docs/10-mocking-jndi-datasources.md b/docs/10-mocking-jndi-datasources.md new file mode 100755 index 0000000..493b8ba --- /dev/null +++ b/docs/10-mocking-jndi-datasources.md @@ -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) diff --git a/docs/11-proxies-and-lazy-initialization.md b/docs/11-proxies-and-lazy-initialization.md new file mode 100755 index 0000000..1c8f734 --- /dev/null +++ b/docs/11-proxies-and-lazy-initialization.md @@ -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` 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 unproxy(T, java.lang.Class); +``` + +`unproxy()` has **two** overloads, not one — a no-cast version that returns `Object`, and a +typed version that takes the target `Class` 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) diff --git a/docs/12-association-mappings.md b/docs/12-association-mappings.md new file mode 100755 index 0000000..dae15f9 --- /dev/null +++ b/docs/12-association-mappings.md @@ -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) diff --git a/docs/13-date-and-time-mapping.md b/docs/13-date-and-time-mapping.md new file mode 100755 index 0000000..c1ad925 --- /dev/null +++ b/docs/13-date-and-time-mapping.md @@ -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) diff --git a/docs/14-named-queries.md b/docs/14-named-queries.md new file mode 100755 index 0000000..5b0ebbc --- /dev/null +++ b/docs/14-named-queries.md @@ -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 `` 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) diff --git a/docs/15-hql-queries.md b/docs/15-hql-queries.md new file mode 100644 index 0000000..c0aaa35 --- /dev/null +++ b/docs/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 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) diff --git a/docs/16-criteria-queries.md b/docs/16-criteria-queries.md new file mode 100644 index 0000000..3455672 --- /dev/null +++ b/docs/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 cr = cb.createQuery(Employee.class); +Root 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 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 avgQuery = cb.createQuery(Double.class); +Root 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 sub = mainQuery.subquery(Double.class); +Root 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 update = cb.createCriteriaUpdate(Employee.class); +Root 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 delete = cb.createCriteriaDelete(Employee.class); +Root 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) diff --git a/docs/17-entitymanager-bootstrap.md b/docs/17-entitymanager-bootstrap.md new file mode 100644 index 0000000..0c74877 --- /dev/null +++ b/docs/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", "admin@ankurm.com"); + 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 `` 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 +`` 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 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) diff --git a/docs/18-ehcache-l2-configuration.md b/docs/18-ehcache-l2-configuration.md new file mode 100644 index 0000000..be27414 --- /dev/null +++ b/docs/18-ehcache-l2-configuration.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) + +
+Trap: if you're benchmarking L2 cache savings by comparing a "cold" first +get() 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 before the current process ever touched it in a session -- reboot the +SessionFactory (or evict the region) between the insert and the "cold" read if you +want a fair baseline. +
+ +## 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. + +
+Trap this creates in the other direction: 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 +NativeQuery#addSynchronizedEntityClass()/addSynchronizedQuerySpace() +rather than relying on the conservative default. +
+ +- 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) diff --git a/docs/19-hikaricp-connection-pooling.md b/docs/19-hikaricp-connection-pooling.md new file mode 100644 index 0000000..0dd3585 --- /dev/null +++ b/docs/19-hikaricp-connection-pooling.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. + +
+Trap: case matters. hibernate.hikari.maximumPoolSize maps to +HikariConfig#setMaximumPoolSize because HikariCP's own property loader does exact, +case-sensitive bean-property matching -- hibernate.hikari.maximumpoolsize (all +lowercase) silently does nothing rather than failing loudly. +
+ +- 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) + +
+Trap: 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. +
+ +- 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) diff --git a/docs/20-hibernate-validator-cdi.md b/docs/20-hibernate-validator-cdi.md new file mode 100644 index 0000000..09e75e4 --- /dev/null +++ b/docs/20-hibernate-validator-cdi.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 { + @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. + +
+Trap: 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. +
+ +## 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. + +
+Trap: a project can have Weld or another CDI implementation on its classpath and +still get the plain, non-injecting behavior everywhere it calls +Validation.buildDefaultValidatorFactory() directly instead of obtaining the +Validator/ValidatorFactory as a CDI-managed bean. +
+ +- 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) diff --git a/docs/21-aggregate-functions.md b/docs/21-aggregate-functions.md new file mode 100644 index 0000000..4de6b78 --- /dev/null +++ b/docs/21-aggregate-functions.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) diff --git a/docs/22-sorting.md b/docs/22-sorting.md new file mode 100644 index 0000000..4d2c542 --- /dev/null +++ b/docs/22-sorting.md @@ -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 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 tags = new TreeSet<>(); + +@SortComparator(LengthThenAlphaComparator.class) +private SortedSet 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 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 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/) diff --git a/docs/23-pagination.md b/docs/23-pagination.md new file mode 100644 index 0000000..36c1057 --- /dev/null +++ b/docs/23-pagination.md @@ -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
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
` 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) diff --git a/docs/24-interceptors.md b/docs/24-interceptors.md new file mode 100644 index 0000000..33cec35 --- /dev/null +++ b/docs/24-interceptors.md @@ -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) diff --git a/docs/25-hibernate-search.md b/docs/25-hibernate-search.md new file mode 100644 index 0000000..b97ef1c --- /dev/null +++ b/docs/25-hibernate-search.md @@ -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/) diff --git a/docs/output/18-bulk-update-hql.txt b/docs/output/18-bulk-update-hql.txt new file mode 100644 index 0000000..88481f2 --- /dev/null +++ b/docs/output/18-bulk-update-hql.txt @@ -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. diff --git a/docs/output/18-bulk-update-native.txt b/docs/output/18-bulk-update-native.txt new file mode 100644 index 0000000..20c190b --- /dev/null +++ b/docs/output/18-bulk-update-native.txt @@ -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 diff --git a/docs/output/18-cache-api-namespace.txt b/docs/output/18-cache-api-namespace.txt new file mode 100644 index 0000000..19bc64a --- /dev/null +++ b/docs/output/18-cache-api-namespace.txt @@ -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 diff --git a/docs/output/18-entity-l2-cache.txt b/docs/output/18-entity-l2-cache.txt new file mode 100644 index 0000000..3232704 --- /dev/null +++ b/docs/output/18-entity-l2-cache.txt @@ -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 diff --git a/docs/output/18-missing-timestamps-region-strict.txt b/docs/output/18-missing-timestamps-region-strict.txt new file mode 100644 index 0000000..a8234d6 --- /dev/null +++ b/docs/output/18-missing-timestamps-region-strict.txt @@ -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 diff --git a/docs/output/18-missing-timestamps-region.txt b/docs/output/18-missing-timestamps-region.txt new file mode 100644 index 0000000..2750c2c --- /dev/null +++ b/docs/output/18-missing-timestamps-region.txt @@ -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 diff --git a/docs/output/18-query-cache-without-entity-cache.txt b/docs/output/18-query-cache-without-entity-cache.txt new file mode 100644 index 0000000..fca24e5 --- /dev/null +++ b/docs/output/18-query-cache-without-entity-cache.txt @@ -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 diff --git a/docs/output/19-leak-detection.txt b/docs/output/19-leak-detection.txt new file mode 100644 index 0000000..0f4b470 --- /dev/null +++ b/docs/output/19-leak-detection.txt @@ -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 diff --git a/docs/output/19-pool-exhaustion.txt b/docs/output/19-pool-exhaustion.txt new file mode 100644 index 0000000..7bd2678 --- /dev/null +++ b/docs/output/19-pool-exhaustion.txt @@ -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 diff --git a/docs/output/19-raw-bootstrap.txt b/docs/output/19-raw-bootstrap.txt new file mode 100644 index 0000000..30f9ef1 --- /dev/null +++ b/docs/output/19-raw-bootstrap.txt @@ -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 diff --git a/docs/output/19-spring-default-hikari.txt b/docs/output/19-spring-default-hikari.txt new file mode 100644 index 0000000..31b9dd8 --- /dev/null +++ b/docs/output/19-spring-default-hikari.txt @@ -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 diff --git a/docs/output/20-cdi-validation-injection.txt b/docs/output/20-cdi-validation-injection.txt new file mode 100644 index 0000000..94be378 --- /dev/null +++ b/docs/output/20-cdi-validation-injection.txt @@ -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 diff --git a/docs/output/20-plain-validation-no-cdi.txt b/docs/output/20-plain-validation-no-cdi.txt new file mode 100644 index 0000000..90c0547 --- /dev/null +++ b/docs/output/20-plain-validation-no-cdi.txt @@ -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 diff --git a/docs/output/21-criteria-avg.txt b/docs/output/21-criteria-avg.txt new file mode 100644 index 0000000..3b4350e --- /dev/null +++ b/docs/output/21-criteria-avg.txt @@ -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) + +/* */ 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. diff --git a/docs/output/21-empty-result-set.txt b/docs/output/21-empty-result-set.txt new file mode 100644 index 0000000..89ff664 --- /dev/null +++ b/docs/output/21-empty-result-set.txt @@ -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 diff --git a/docs/output/21-groupby-having-record.txt b/docs/output/21-groupby-having-record.txt new file mode 100644 index 0000000..5eb340d --- /dev/null +++ b/docs/output/21-groupby-having-record.txt @@ -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. diff --git a/docs/output/21-window-row-number.txt b/docs/output/21-window-row-number.txt new file mode 100644 index 0000000..807badf --- /dev/null +++ b/docs/output/21-window-row-number.txt @@ -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. diff --git a/docs/output/22-case-insensitive.txt b/docs/output/22-case-insensitive.txt new file mode 100644 index 0000000..13c1d5c --- /dev/null +++ b/docs/output/22-case-insensitive.txt @@ -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. diff --git a/docs/output/22-criteria-order-join.txt b/docs/output/22-criteria-order-join.txt new file mode 100644 index 0000000..02284c2 --- /dev/null +++ b/docs/output/22-criteria-order-join.txt @@ -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. diff --git a/docs/output/22-dynamic-injection-guard.txt b/docs/output/22-dynamic-injection-guard.txt new file mode 100644 index 0000000..aba4e8f --- /dev/null +++ b/docs/output/22-dynamic-injection-guard.txt @@ -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. diff --git a/docs/output/22-null-precedence.txt b/docs/output/22-null-precedence.txt new file mode 100644 index 0000000..0b6c500 --- /dev/null +++ b/docs/output/22-null-precedence.txt @@ -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. diff --git a/docs/output/22-orderby-property-name.txt b/docs/output/22-orderby-property-name.txt new file mode 100644 index 0000000..e9a4d07 --- /dev/null +++ b/docs/output/22-orderby-property-name.txt @@ -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. diff --git a/docs/output/22-sort-natural-and-comparator.txt b/docs/output/22-sort-natural-and-comparator.txt new file mode 100644 index 0000000..ed6ee84 --- /dev/null +++ b/docs/output/22-sort-natural-and-comparator.txt @@ -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. diff --git a/docs/output/23-joinfetch-collection-order-warning.txt b/docs/output/23-joinfetch-collection-order-warning.txt new file mode 100644 index 0000000..8ef5bac --- /dev/null +++ b/docs/output/23-joinfetch-collection-order-warning.txt @@ -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. diff --git a/docs/output/23-joinfetch-root-order-no-warning.txt b/docs/output/23-joinfetch-root-order-no-warning.txt new file mode 100644 index 0000000..861bd4b --- /dev/null +++ b/docs/output/23-joinfetch-root-order-no-warning.txt @@ -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. diff --git a/docs/output/23-keyset-seek.txt b/docs/output/23-keyset-seek.txt new file mode 100644 index 0000000..1042699 --- /dev/null +++ b/docs/output/23-keyset-seek.txt @@ -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. diff --git a/docs/output/23-limit-offset.txt b/docs/output/23-limit-offset.txt new file mode 100644 index 0000000..602a355 --- /dev/null +++ b/docs/output/23-limit-offset.txt @@ -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. diff --git a/docs/output/23-scrollable-forward-only.txt b/docs/output/23-scrollable-forward-only.txt new file mode 100644 index 0000000..080c22e --- /dev/null +++ b/docs/output/23-scrollable-forward-only.txt @@ -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
holding all 10 rows was ever built by this test's own code, unlike getResultList(). diff --git a/docs/output/23-total-count-pattern.txt b/docs/output/23-total-count-pattern.txt new file mode 100644 index 0000000..21add99 --- /dev/null +++ b/docs/output/23-total-count-pattern.txt @@ -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. diff --git a/docs/output/24-bulk-update-bypass.txt b/docs/output/24-bulk-update-bypass.txt new file mode 100644 index 0000000..b488e9c --- /dev/null +++ b/docs/output/24-bulk-update-bypass.txt @@ -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. diff --git a/docs/output/24-global-via-property.txt b/docs/output/24-global-via-property.txt new file mode 100644 index 0000000..2b55c4e --- /dev/null +++ b/docs/output/24-global-via-property.txt @@ -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. diff --git a/docs/output/24-interceptor-scoping.txt b/docs/output/24-interceptor-scoping.txt new file mode 100644 index 0000000..a1cba8f --- /dev/null +++ b/docs/output/24-interceptor-scoping.txt @@ -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. diff --git a/docs/output/24-session-scoped-mutation.txt b/docs/output/24-session-scoped-mutation.txt new file mode 100644 index 0000000..694b9e1 --- /dev/null +++ b/docs/output/24-session-scoped-mutation.txt @@ -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. diff --git a/docs/output/25-fulltext-fuzzy.txt b/docs/output/25-fulltext-fuzzy.txt new file mode 100644 index 0000000..b2b7d71 --- /dev/null +++ b/docs/output/25-fulltext-fuzzy.txt @@ -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. diff --git a/docs/output/25-indexed-embedded.txt b/docs/output/25-indexed-embedded.txt new file mode 100644 index 0000000..1c25b92 --- /dev/null +++ b/docs/output/25-indexed-embedded.txt @@ -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. diff --git a/docs/output/25-keyword-exact-match.txt b/docs/output/25-keyword-exact-match.txt new file mode 100644 index 0000000..9c9002d --- /dev/null +++ b/docs/output/25-keyword-exact-match.txt @@ -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. diff --git a/docs/output/25-mass-indexer.txt b/docs/output/25-mass-indexer.txt new file mode 100644 index 0000000..cc69f85 --- /dev/null +++ b/docs/output/25-mass-indexer.txt @@ -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. diff --git a/docs/output/25-sortable-generic-field.txt b/docs/output/25-sortable-generic-field.txt new file mode 100644 index 0000000..d4b8ec3 --- /dev/null +++ b/docs/output/25-sortable-generic-field.txt @@ -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. diff --git a/docs/output/allocation-and-batch-size-sweeps.txt b/docs/output/allocation-and-batch-size-sweeps.txt new file mode 100755 index 0000000..de09b4e --- /dev/null +++ b/docs/output/allocation-and-batch-size-sweeps.txt @@ -0,0 +1,8 @@ +allocationSize=50, batch_size=1, 30 rows -> prepareStatementCount=32 +allocationSize=50, batch_size=10, 30 rows -> prepareStatementCount=2 +allocationSize=50, batch_size=25, 30 rows -> prepareStatementCount=2 +allocationSize=50, batch_size=50, 30 rows -> prepareStatementCount=2 +allocationSize=50, batch_size=25, 30 rows -> prepareStatementCount=3 +allocationSize=25, batch_size=25, 30 rows -> prepareStatementCount=4 +allocationSize=10, batch_size=25, 30 rows -> prepareStatementCount=5 +allocationSize=1, batch_size=25, 30 rows -> prepareStatementCount=31 diff --git a/docs/output/association-cascade-orphan.txt b/docs/output/association-cascade-orphan.txt new file mode 100755 index 0000000..3ce933c --- /dev/null +++ b/docs/output/association-cascade-orphan.txt @@ -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 diff --git a/docs/output/association-multiplebag-and-cartesian.txt b/docs/output/association-multiplebag-and-cartesian.txt new file mode 100755 index 0000000..16f362d --- /dev/null +++ b/docs/output/association-multiplebag-and-cartesian.txt @@ -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 diff --git a/docs/output/association-n-plus-one.txt b/docs/output/association-n-plus-one.txt new file mode 100755 index 0000000..16bbee7 --- /dev/null +++ b/docs/output/association-n-plus-one.txt @@ -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 diff --git a/docs/output/association-onetoone-lazy-trap.txt b/docs/output/association-onetoone-lazy-trap.txt new file mode 100755 index 0000000..2afe793 --- /dev/null +++ b/docs/output/association-onetoone-lazy-trap.txt @@ -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=? diff --git a/docs/output/bootstrap-persistenceconfiguration.txt b/docs/output/bootstrap-persistenceconfiguration.txt new file mode 100644 index 0000000..bd5cf7a --- /dev/null +++ b/docs/output/bootstrap-persistenceconfiguration.txt @@ -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' diff --git a/docs/output/criteria-aggregation-and-subquery.txt b/docs/output/criteria-aggregation-and-subquery.txt new file mode 100644 index 0000000..71c0365 --- /dev/null +++ b/docs/output/criteria-aggregation-and-subquery.txt @@ -0,0 +1,3 @@ +aggregation: average salary = 85600.0 +subquery: above-average earners (avg=85600) = [Byron, Hopper, Torvalds] +orPredicate: [Torvalds, Hamilton] diff --git a/docs/output/criteria-bulk-update-delete.txt b/docs/output/criteria-bulk-update-delete.txt new file mode 100644 index 0000000..dbec722 --- /dev/null +++ b/docs/output/criteria-bulk-update-delete.txt @@ -0,0 +1,2 @@ +criteriaUpdate: 3 rows updated, Ada's new salary = 104500.00000000001 +criteriaDelete: deleted=1, remaining=5 diff --git a/docs/output/criteria-predicates-and-metamodel.txt b/docs/output/criteria-predicates-and-metamodel.txt new file mode 100644 index 0000000..c3ebeeb --- /dev/null +++ b/docs/output/criteria-predicates-and-metamodel.txt @@ -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 diff --git a/docs/output/datetime-basic-types.txt b/docs/output/datetime-basic-types.txt new file mode 100755 index 0000000..7d82c3f --- /dev/null +++ b/docs/output/datetime-basic-types.txt @@ -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)) diff --git a/docs/output/datetime-javap-temporal-deprecated.txt b/docs/output/datetime-javap-temporal-deprecated.txt new file mode 100644 index 0000000..7261043 --- /dev/null +++ b/docs/output/datetime-javap-temporal-deprecated.txt @@ -0,0 +1,13 @@ +$ javap -v -cp 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) diff --git a/docs/output/datetime-jdbc-time-zone.txt b/docs/output/datetime-jdbc-time-zone.txt new file mode 100755 index 0000000..1091bf0 --- /dev/null +++ b/docs/output/datetime-jdbc-time-zone.txt @@ -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 diff --git a/docs/output/datetime-nanosecond-h2.txt b/docs/output/datetime-nanosecond-h2.txt new file mode 100755 index 0000000..15333cd --- /dev/null +++ b/docs/output/datetime-nanosecond-h2.txt @@ -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) diff --git a/docs/output/datetime-nanosecond-hsqldb.txt b/docs/output/datetime-nanosecond-hsqldb.txt new file mode 100755 index 0000000..f53769b --- /dev/null +++ b/docs/output/datetime-nanosecond-hsqldb.txt @@ -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) diff --git a/docs/output/datetime-temporal-annotation.txt b/docs/output/datetime-temporal-annotation.txt new file mode 100755 index 0000000..22aeb32 --- /dev/null +++ b/docs/output/datetime-temporal-annotation.txt @@ -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) diff --git a/docs/output/datetime-timezone-storage-default-jvm.txt b/docs/output/datetime-timezone-storage-default-jvm.txt new file mode 100755 index 0000000..efa8a2b --- /dev/null +++ b/docs/output/datetime-timezone-storage-default-jvm.txt @@ -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)) diff --git a/docs/output/datetime-timezone-storage-nydefault.txt b/docs/output/datetime-timezone-storage-nydefault.txt new file mode 100755 index 0000000..1667a9c --- /dev/null +++ b/docs/output/datetime-timezone-storage-nydefault.txt @@ -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 diff --git a/docs/output/get-vs-getreference-tests.txt b/docs/output/get-vs-getreference-tests.txt new file mode 100755 index 0000000..013e4dc --- /dev/null +++ b/docs/output/get-vs-getreference-tests.txt @@ -0,0 +1,70 @@ +Hibernate: select next value for book_seq +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Effective Java] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [1] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +Hibernate: select next value for book_seq +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Domain-Driven Design] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [2] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [2] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Outlives Session] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [3] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Matrix: getReference/getReference] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [4] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Matrix: get/getReference] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [5] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [5] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Matrix: get/get] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [6] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [6] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Proxy Identity] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [7] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [7] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [7] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [999111222] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [999333444] +getReference() on a missing id, once accessed, threw: jakarta.persistence.EntityNotFoundException: No row with the given identifier exists for entity [com.ankurm.hibernatedemo.model.Book with id '999333444'] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Matrix: getReference/get] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [8] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [8] +get() after getReference(): prepareStatementCount for this call = 1, returned class = com.ankurm.hibernatedemo.model.Book$HibernateProxy diff --git a/docs/output/get-vs-load.txt b/docs/output/get-vs-load.txt new file mode 100755 index 0000000..2938941 --- /dev/null +++ b/docs/output/get-vs-load.txt @@ -0,0 +1,46 @@ +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence widget_seq start with 1 increment by 25 +Hibernate: select next value for book_seq +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,title,version,id) values (?,?,?,?) +binding parameter (1:VARCHAR) <- [Joshua Bloch] +binding parameter (2:VARCHAR) <- [Effective Java] +binding parameter (3:BIGINT) <- [0] +binding parameter (4:BIGINT) <- [1] +SEED: inserted Book id=1 +--- Step 1: session.get() on an existing id --- +about to call session.get(Book.class, 1) +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +get() returned: Book{id=1, title=Effective Java, author=Joshua Bloch, version=0} +--- Step 2: session.get() on a missing id --- +about to call session.get(Book.class, 999001) +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [999001] +get() returned: null (no exception thrown) +--- Step 3: session.getReference() on an existing id --- +getReference() returned proxy of class com.ankurm.hibernatedemo.model.Book$HibernateProxy -- no SELECT above this line +now calling proxy.getTitle() ... +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +getTitle() returned 'Effective Java' -- the SELECT for this ran just above this line +--- Step 4: session.getReference() on a missing id --- +getReference() returned a proxy for a row that does not exist -- no exception yet: com.ankurm.hibernatedemo.model.Book$HibernateProxy +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [999001] +accessing the proxy threw jakarta.persistence.EntityNotFoundException: No row with the given identifier exists for entity [com.ankurm.hibernatedemo.model.Book with id '999001'] +--- Step 5: proxy accessed after its session is closed --- +session closed. proxy in hand: com.ankurm.hibernatedemo.model.Book$HibernateProxy +accessing the proxy after close threw org.hibernate.LazyInitializationException: Could not initialize proxy [com.ankurm.hibernatedemo.model.Book#1] - no session +--- Step 6: proxy identity vs a real loaded instance --- +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +real.getClass() = com.ankurm.hibernatedemo.model.Book +proxy.getClass() = com.ankurm.hibernatedemo.model.Book$HibernateProxy +proxy instanceof Book.class: true +real.getClass() == proxy.getClass(): false +real.equals(proxy) before proxy access: false diff --git a/docs/output/hql-aggregation-paging-bulk.txt b/docs/output/hql-aggregation-paging-bulk.txt new file mode 100644 index 0000000..e120c37 --- /dev/null +++ b/docs/output/hql-aggregation-paging-bulk.txt @@ -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 diff --git a/docs/output/hql-flush-modes.txt b/docs/output/hql-flush-modes.txt new file mode 100644 index 0000000..a776118 --- /dev/null +++ b/docs/output/hql-flush-modes.txt @@ -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 diff --git a/docs/output/hql-select-and-joins.txt b/docs/output/hql-select-and-joins.txt new file mode 100644 index 0000000..766f90e --- /dev/null +++ b/docs/output/hql-select-and-joins.txt @@ -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 diff --git a/docs/output/immutable-headline-and-boundaries.txt b/docs/output/immutable-headline-and-boundaries.txt new file mode 100755 index 0000000..5a13b78 --- /dev/null +++ b/docs/output/immutable-headline-and-boundaries.txt @@ -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 diff --git a/docs/output/immutable-javap-annotation.txt b/docs/output/immutable-javap-annotation.txt new file mode 100755 index 0000000..cdb0e14 --- /dev/null +++ b/docs/output/immutable-javap-annotation.txt @@ -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 { + 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 {}; +} diff --git a/docs/output/insert-identity.txt b/docs/output/insert-identity.txt new file mode 100755 index 0000000..434dae5 --- /dev/null +++ b/docs/output/insert-identity.txt @@ -0,0 +1,71 @@ +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence widget_seq start with 1 increment by 25 +--- inserting 30 WidgetIdentity rows (GenerationType.IDENTITY) --- +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-1] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-2] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-3] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-4] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-5] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-6] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-7] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-8] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-9] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-10] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-11] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-12] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-13] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-14] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-15] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-16] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-17] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-18] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-19] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-20] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-21] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-22] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-23] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-24] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-25] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-26] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-27] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-28] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-29] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [identity-30] +entityInsertCount = 30 +prepareStatementCount = 30 +(with IDENTITY, expect prepareStatementCount to land close to entityInsertCount -- each insert has to go to the database immediately to hand back the generated key, so there is nothing left for hibernate.jdbc.batch_size to batch) diff --git a/docs/output/insert-sequence.txt b/docs/output/insert-sequence.txt new file mode 100755 index 0000000..5971a11 --- /dev/null +++ b/docs/output/insert-sequence.txt @@ -0,0 +1,104 @@ +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence widget_seq start with 1 increment by 25 +--- inserting 30 WidgetSequence rows (GenerationType.SEQUENCE, allocationSize=25) --- +Hibernate: select next value for widget_seq +Hibernate: select next value for widget_seq +Hibernate: select next value for widget_seq +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-1] +binding parameter (2:BIGINT) <- [1] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-2] +binding parameter (2:BIGINT) <- [2] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-3] +binding parameter (2:BIGINT) <- [3] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-4] +binding parameter (2:BIGINT) <- [4] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-5] +binding parameter (2:BIGINT) <- [5] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-6] +binding parameter (2:BIGINT) <- [6] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-7] +binding parameter (2:BIGINT) <- [7] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-8] +binding parameter (2:BIGINT) <- [8] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-9] +binding parameter (2:BIGINT) <- [9] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-10] +binding parameter (2:BIGINT) <- [10] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-11] +binding parameter (2:BIGINT) <- [11] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-12] +binding parameter (2:BIGINT) <- [12] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-13] +binding parameter (2:BIGINT) <- [13] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-14] +binding parameter (2:BIGINT) <- [14] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-15] +binding parameter (2:BIGINT) <- [15] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-16] +binding parameter (2:BIGINT) <- [16] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-17] +binding parameter (2:BIGINT) <- [17] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-18] +binding parameter (2:BIGINT) <- [18] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-19] +binding parameter (2:BIGINT) <- [19] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-20] +binding parameter (2:BIGINT) <- [20] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-21] +binding parameter (2:BIGINT) <- [21] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-22] +binding parameter (2:BIGINT) <- [22] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-23] +binding parameter (2:BIGINT) <- [23] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-24] +binding parameter (2:BIGINT) <- [24] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-25] +binding parameter (2:BIGINT) <- [25] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-26] +binding parameter (2:BIGINT) <- [26] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-27] +binding parameter (2:BIGINT) <- [27] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-28] +binding parameter (2:BIGINT) <- [28] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-29] +binding parameter (2:BIGINT) <- [29] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [sequence-30] +binding parameter (2:BIGINT) <- [30] +entityInsertCount = 30 +prepareStatementCount = 4 +(with SEQUENCE, the id is known before the row is written, so Hibernate can defer and batch the inserts -- expect prepareStatementCount well below entityInsertCount) diff --git a/docs/output/jndi-boot-autoconfig-javap.txt b/docs/output/jndi-boot-autoconfig-javap.txt new file mode 100755 index 0000000..4e61b1a --- /dev/null +++ b/docs/output/jndi-boot-autoconfig-javap.txt @@ -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(); diff --git a/docs/output/jndi-full-run.txt b/docs/output/jndi-full-run.txt new file mode 100755 index 0000000..e91d86c --- /dev/null +++ b/docs/output/jndi-full-run.txt @@ -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 diff --git a/docs/output/jndi-hibernate-datasource-setting-javap.txt b/docs/output/jndi-hibernate-datasource-setting-javap.txt new file mode 100755 index 0000000..e7f43ff --- /dev/null +++ b/docs/output/jndi-hibernate-datasource-setting-javap.txt @@ -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; diff --git a/docs/output/jndi-simplejndi-jar-listing.txt b/docs/output/jndi-simplejndi-jar-listing.txt new file mode 100755 index 0000000..756c254 --- /dev/null +++ b/docs/output/jndi-simplejndi-jar-listing.txt @@ -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 diff --git a/docs/output/jndi-simplenamingcontextbuilder-removal.txt b/docs/output/jndi-simplenamingcontextbuilder-removal.txt new file mode 100755 index 0000000..e50445b --- /dev/null +++ b/docs/output/jndi-simplenamingcontextbuilder-removal.txt @@ -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) diff --git a/docs/output/jndi-tests-run.txt b/docs/output/jndi-tests-run.txt new file mode 100755 index 0000000..4e82f1b --- /dev/null +++ b/docs/output/jndi-tests-run.txt @@ -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 diff --git a/docs/output/mappingstyle-xml-vs-annotations.txt b/docs/output/mappingstyle-xml-vs-annotations.txt new file mode 100755 index 0000000..d0169ff --- /dev/null +++ b/docs/output/mappingstyle-xml-vs-annotations.txt @@ -0,0 +1,298 @@ +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 +Failed to initialize JPA EntityManagerFactory: Entity 'com.ankurm.hibernatedemo.mappingstyle.OverrideEntity' has no identifier (every '@Entity' class must declare or inherit at least one '@Id' or '@EmbeddedId' property) +Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]: Entity 'com.ankurm.hibernatedemo.mappingstyle.OverrideEntity' has no identifier (every '@Entity' class must declare or inherit at least one '@Id' or '@EmbeddedId' property) +Application run failed +org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]: Entity 'com.ankurm.hibernatedemo.mappingstyle.OverrideEntity' has no identifier (every '@Entity' class must declare or inherit at least one '@Id' or '@EmbeddedId' property) + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1815) + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:603) + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:525) + at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:333) + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:371) + at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:331) + at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:201) + at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:977) + at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:621) + at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:756) + at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:445) + at org.springframework.boot.SpringApplication.run(SpringApplication.java:321) + at org.springframework.boot.builder.SpringApplicationBuilder.run(SpringApplicationBuilder.java:154) + at com.ankurm.hibernatedemo.mappingstyle.XmlMappingMetadataCompleteTest.lambda$metadataComplete_ignoresAtIdAnnotation_bootFailsWithNoIdentifier$0(XmlMappingMetadataCompleteTest.java:32) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:54) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3223) + at com.ankurm.hibernatedemo.mappingstyle.XmlMappingMetadataCompleteTest.metadataComplete_ignoresAtIdAnnotation_bootFailsWithNoIdentifier(XmlMappingMetadataCompleteTest.java:31) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: org.hibernate.AnnotationException: Entity 'com.ankurm.hibernatedemo.mappingstyle.OverrideEntity' has no identifier (every '@Entity' class must declare or inherit at least one '@Id' or '@EmbeddedId' property) + at org.hibernate.boot.model.internal.InheritanceState.getElementsToProcess(InheritanceState.java:248) + at org.hibernate.boot.model.internal.InheritanceState.postProcess(InheritanceState.java:165) + at org.hibernate.boot.model.internal.EntityBinder.handleIdentifier(EntityBinder.java:434) + at org.hibernate.boot.model.internal.EntityBinder.bindEntityClass(EntityBinder.java:259) + at org.hibernate.boot.model.internal.AnnotationBinder.bindClass(AnnotationBinder.java:247) + at org.hibernate.boot.model.source.internal.annotations.AnnotationMetadataSourceProcessorImpl.processEntityHierarchies(AnnotationMetadataSourceProcessorImpl.java:197) + at org.hibernate.boot.model.process.spi.MetadataBuildingProcess$1.processEntityHierarchies(MetadataBuildingProcess.java:323) + at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.coordinateProcessors(MetadataBuildingProcess.java:356) + at org.hibernate.boot.model.process.spi.MetadataBuildingProcess.complete(MetadataBuildingProcess.java:209) + at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.metadata(EntityManagerFactoryBuilderImpl.java:1388) + at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.populateSessionFactoryBuilder(EntityManagerFactoryBuilderImpl.java:1468) + at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1450) + at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:93) + at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:443) + at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:436) + at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:411) + at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:419) + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1862) + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1811) + ... 98 common frames omitted +RESULT[metadata-complete]: boot FAILED as predicted: org.hibernate.AnnotationException: Entity 'com.ankurm.hibernatedemo.mappingstyle.OverrideEntity' has no identifier (every '@Entity' class must declare or inherit at least one '@Id' or '@EmbeddedId' property) +HHH10001002: Using built-in connection pool (not intended for production use) +HHH90000028: Support for `` is deprecated [RESOURCE : com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml]; migrate to orm.xml or mapping.xml, or enable `hibernate.transform_hbm_xml.enabled` for on the fly transformation +drop table if exists hbm_employees cascade +create table hbm_employees (id bigint generated by default as identity, first_name varchar(100) not null, email_address varchar(255) unique, primary key (id)) +insert into hbm_employees (email_address,first_name,id) values (?,?,default) +binding parameter (1:VARCHAR) <- [ada@example.com] +binding parameter (2:VARCHAR) <- [Ada] +select he1_0.id,he1_0.email_address,he1_0.first_name from hbm_employees he1_0 where he1_0.id=? +binding parameter (1:BIGINT) <- [1] +RESULT[hbm-default-runtime]: persisted+loaded id=1 firstName=Ada email=ada@example.com -- NO hibernate.transform_hbm_xml.enabled setting was applied. +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +Hibernate: create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +create table override_entity (id bigint not null, xml_name varchar(255), primary key (id)) +Hibernate: create table override_entity (id bigint not null, xml_name varchar(255), primary key (id)) +create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence book_seq start with 1 increment by 50 +create sequence override_entity_seq start with 1 increment by 50 +Hibernate: create sequence override_entity_seq start with 1 increment by 50 +create sequence widget_alloc10_seq start with 1 increment by 10 +Hibernate: create sequence widget_alloc10_seq start with 1 increment by 10 +create sequence widget_alloc1_seq start with 1 increment by 1 +Hibernate: create sequence widget_alloc1_seq start with 1 increment by 1 +create sequence widget_alloc25_seq start with 1 increment by 25 +Hibernate: create sequence widget_alloc25_seq start with 1 increment by 25 +create sequence widget_alloc50_seq start with 1 increment by 50 +Hibernate: create sequence widget_alloc50_seq start with 1 increment by 50 +create sequence widget_batch_sweep1_seq start with 1 increment by 50 +Hibernate: create sequence widget_batch_sweep1_seq start with 1 increment by 50 +create sequence widget_seq start with 1 increment by 25 +Hibernate: create sequence widget_seq start with 1 increment by 25 +alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Hibernate: alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +WARNING: A Java agent has been loaded dynamically (/sessions/intelligent-loving-cori/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +RESULT[override]: runtime column name for OverrideEntity.value = xml_name (annotation said 'annotation_name', orm.xml said 'xml_name') +select next value for override_entity_seq +Hibernate: select next value for override_entity_seq +/* insert for com.ankurm.hibernatedemo.mappingstyle.OverrideEntity */insert into override_entity (xml_name,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.mappingstyle.OverrideEntity */insert into override_entity (xml_name,id) values (?,?) +binding parameter (1:VARCHAR) <- [hello] +binding parameter (2:BIGINT) <- [1] +/* dynamic native SQL query */ select xml_name from override_entity where id = 1 +Hibernate: /* dynamic native SQL query */ select xml_name from override_entity where id = 1 +RESULT[override]: native query against column 'xml_name' returned: hello +HHH10001002: Using built-in connection pool (not intended for production use) +drop table if exists hbm_employees cascade +create table hbm_employees (id bigint generated by default as identity, first_name varchar(100) not null, email_address varchar(255) unique, primary key (id)) +RESULT[transform=true]: BOOT SUCCEEDED, SessionFactory built WITH hibernate.transform_hbm_xml.enabled=true +HHH10001002: Using built-in connection pool (not intended for production use) +HHH90000028: Support for `` is deprecated [RESOURCE : com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml]; migrate to orm.xml or mapping.xml, or enable `hibernate.transform_hbm_xml.enabled` for on the fly transformation +drop table if exists hbm_employees cascade +create table hbm_employees (id bigint generated by default as identity, first_name varchar(100) not null, email_address varchar(255) unique, primary key (id)) +RESULT[default]: BOOT SUCCEEDED, SessionFactory built without hibernate.transform_hbm_xml.enabled +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create table mapping_xml_naturalid_widgets (id bigint generated by default as identity, name varchar(255), sku varchar(255) not null, primary key (id)) +Hibernate: create table mapping_xml_naturalid_widgets (id bigint generated by default as identity, name varchar(255), sku varchar(255) not null, primary key (id)) +alter table if exists override_entity add column annotation_name varchar(255) +Hibernate: alter table if exists override_entity add column annotation_name varchar(255) +alter table if exists mapping_xml_naturalid_widgets drop constraint if exists UKgsgrv3pg0aypajhwtinus0j90 +Hibernate: alter table if exists mapping_xml_naturalid_widgets drop constraint if exists UKgsgrv3pg0aypajhwtinus0j90 +alter table if exists mapping_xml_naturalid_widgets add constraint UKgsgrv3pg0aypajhwtinus0j90 unique (sku) +Hibernate: alter table if exists mapping_xml_naturalid_widgets add constraint UKgsgrv3pg0aypajhwtinus0j90 unique (sku) +/* insert for com.ankurm.hibernatedemo.mappingstyle.MappingXmlNaturalIdEntity */insert into mapping_xml_naturalid_widgets (name,sku,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.mappingstyle.MappingXmlNaturalIdEntity */insert into mapping_xml_naturalid_widgets (name,sku,id) values (?,?,default) +binding parameter (1:VARCHAR) <- [XML Widget] +binding parameter (2:VARCHAR) <- [SKU-XML-1] +select mxnie1_0.id,mxnie1_0.name,mxnie1_0.sku from mapping_xml_naturalid_widgets mxnie1_0 where mxnie1_0.sku=? +Hibernate: select mxnie1_0.id,mxnie1_0.name,mxnie1_0.sku from mapping_xml_naturalid_widgets mxnie1_0 where mxnie1_0.sku=? +binding parameter (1:VARCHAR) <- [SKU-XML-1] +RESULT[mapping-xml-natural-id]: session.byNaturalId() resolved an entity whose @NaturalId-equivalent was declared ENTIRELY in Hibernate's native mapping.xml dialect, zero Java annotations. name=XML Widget +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create table xml_only_widgets (id bigint generated by default as identity, label_text varchar(80), primary key (id)) +Hibernate: create table xml_only_widgets (id bigint generated by default as identity, label_text varchar(80), primary key (id)) +/* insert for com.ankurm.hibernatedemo.mappingstyle.OrmXmlOnlyEntity */insert into xml_only_widgets (label_text,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.mappingstyle.OrmXmlOnlyEntity */insert into xml_only_widgets (label_text,id) values (?,default) +binding parameter (1:VARCHAR) <- [mapped-by-orm-xml-only] +/* select o from OrmXmlOnlyEntity o where o.label = :label */ select oxoe1_0.id,oxoe1_0.label_text from xml_only_widgets oxoe1_0 where oxoe1_0.label_text=? +Hibernate: /* select o from OrmXmlOnlyEntity o where o.label = :label */ select oxoe1_0.id,oxoe1_0.label_text from xml_only_widgets oxoe1_0 where oxoe1_0.label_text=? +binding parameter (1:VARCHAR) <- [mapped-by-orm-xml-only] +RESULT[orm-xml-only]: persisted+queried id=1 via JPQL against entity mapped ENTIRELY by orm.xml (table xml_only_widgets), zero annotations on the Java class. +HHH10001002: Using built-in connection pool (not intended for production use) +HHH90000028: Support for `` is deprecated [RESOURCE : com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml]; migrate to orm.xml or mapping.xml, or enable `hibernate.transform_hbm_xml.enabled` for on the fly transformation +drop table if exists hbm_employees cascade +create table hbm_employees (id bigint generated by default as identity, first_name varchar(100) not null, email_address varchar(255) unique, primary key (id)) +RESULT[transform=false]: BOOT SUCCEEDED even with transform_hbm_xml.enabled=false diff --git a/docs/output/merge-vs-refresh-tests.txt b/docs/output/merge-vs-refresh-tests.txt new file mode 100755 index 0000000..ae9bf9b --- /dev/null +++ b/docs/output/merge-vs-refresh-tests.txt @@ -0,0 +1,82 @@ +Hibernate: select next value for book_seq +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [USER_EDIT] +binding parameter (3:VARCHAR) <- [Silent Overwrite] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [1] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=? +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [ADMIN_EDIT] +binding parameter (3:VARCHAR) <- [Silent Overwrite] +binding parameter (4:BIGINT) <- [1] +binding parameter (5:BIGINT) <- [1] +binding parameter (6:BIGINT) <- [0] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +Hibernate: select next value for book_seq +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Lazy Collection Book] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [2] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Note */insert into note (book_id,text,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [first note] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [2] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version,n1_0.book_id,n1_0.id,n1_0.text from book b1_0 left join note n1_0 on b1_0.id=n1_0.book_id where b1_0.id=? +binding parameter (1:BIGINT) <- [2] +Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=? +binding parameter (1:VARCHAR) <- [Edited While Detached] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Lazy Collection Book] +binding parameter (4:BIGINT) <- [1] +binding parameter (5:BIGINT) <- [2] +binding parameter (6:BIGINT) <- [0] +merge() initialized the LAZY notes collection because CascadeType.MERGE forces traversal of it +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Test Author] +binding parameter (2:VARCHAR) <- [DRAFT] +binding parameter (3:VARCHAR) <- [Managed + Detached] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [3] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [3] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [3] +Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=? +binding parameter (1:VARCHAR) <- [Changed On The Detached Copy] +binding parameter (2:VARCHAR) <- [DRAFT] +binding parameter (3:VARCHAR) <- [Managed + Detached] +binding parameter (4:BIGINT) <- [1] +binding parameter (5:BIGINT) <- [3] +binding parameter (6:BIGINT) <- [0] +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?) +binding parameter (1:VARCHAR) <- [Robert C. Martin] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Clean Code] +binding parameter (4:BIGINT) <- [0] +binding parameter (5:BIGINT) <- [4] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [4] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [4] +Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=? +binding parameter (1:VARCHAR) <- [Robert C. Martin] +binding parameter (2:VARCHAR) <- [null] +binding parameter (3:VARCHAR) <- [Clean Code (2nd Edition)] +binding parameter (4:BIGINT) <- [1] +binding parameter (5:BIGINT) <- [4] +binding parameter (6:BIGINT) <- [0] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version,n1_0.book_id,n1_0.id,n1_0.text from book b1_0 left join note n1_0 on b1_0.id=n1_0.book_id where b1_0.id=? +binding parameter (1:BIGINT) <- [4] +OptimisticLockException surfaced directly from the merge() call. +exception: jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '4'] +Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [4] diff --git a/docs/output/merge-vs-refresh.txt b/docs/output/merge-vs-refresh.txt new file mode 100755 index 0000000..60112a0 --- /dev/null +++ b/docs/output/merge-vs-refresh.txt @@ -0,0 +1,41 @@ +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence widget_seq start with 1 increment by 25 +Hibernate: select next value for book_seq +Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,title,version,id) values (?,?,?,?) +binding parameter (1:VARCHAR) <- [Robert C. Martin] +binding parameter (2:VARCHAR) <- [Clean Code] +binding parameter (3:BIGINT) <- [0] +binding parameter (4:BIGINT) <- [1] +SEED: inserted Book{id=1, title=Clean Code, author=Robert C. Martin, version=0} +--- Step 1: load the row, then close the session (entity is now detached) --- +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +detached instance in hand: Book{id=1, title=Clean Code, author=Robert C. Martin, version=0} +--- Step 2: a second, independent session edits the same row and commits --- +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,title=?,version=? where id=? and version=? +binding parameter (1:VARCHAR) <- [Robert C. Martin] +binding parameter (2:VARCHAR) <- [Clean Code (2nd Edition)] +binding parameter (3:BIGINT) <- [1] +binding parameter (4:BIGINT) <- [1] +binding parameter (5:BIGINT) <- [0] +second session committed: Book{id=1, title=Clean Code (2nd Edition), author=Robert C. Martin, version=1} -- version column has now advanced in the database +--- Step 3: mutate the ORIGINAL detached instance (still holding the OLD version) and merge() it --- +detached instance before merge (note the version and title are both stale): Book{id=1, title=Clean Code, author=Robert C. Martin (Uncle Bob), version=0} +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +merge() threw jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '1'] +the title change from Step 2 survives untouched -- merge() refused to apply a write built on a stale version +--- Step 4: refresh() on a MANAGED entity with an unflushed local edit --- +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +before refresh(): Book{id=1, title=Clean Code (2nd Edition), author=SOMEONE ELSE ENTIRELY (never flushed), version=1} +Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=? +binding parameter (1:BIGINT) <- [1] +after refresh(): Book{id=1, title=Clean Code (2nd Edition), author=Robert C. Martin, version=1} -- the local edit is gone, no exception was thrown diff --git a/docs/output/namedquery-execution-and-projections.txt b/docs/output/namedquery-execution-and-projections.txt new file mode 100755 index 0000000..39e290f --- /dev/null +++ b/docs/output/namedquery-execution-and-projections.txt @@ -0,0 +1,6 @@ +cacheable=true named query: puts after 1st run = 1, cache hits after 2nd run = 1 +Employee.byNativeDto(ACTIVE): [EmployeeDto{id=1, firstName=Native1}] +JPQL constructor expression into a record: [EmployeeRecordDto[id=3, firstName=RecordTest]] +Employee.findByName(Ankur): 1 rows +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] diff --git a/docs/output/namedquery-ormxml.txt b/docs/output/namedquery-ormxml.txt new file mode 100755 index 0000000..de4d609 --- /dev/null +++ b/docs/output/namedquery-ormxml.txt @@ -0,0 +1,3 @@ +annotation-defined named query result: 1 rows +orm.xml-defined named query result: 1 rows +XmlQueryEmployee.overridden (annotation says salary<0, orm.xml says salary>:min): 1 rows diff --git a/docs/output/namedquery-preparse-performance.txt b/docs/output/namedquery-preparse-performance.txt new file mode 100755 index 0000000..d76dd19 --- /dev/null +++ b/docs/output/namedquery-preparse-performance.txt @@ -0,0 +1,9 @@ +PERF (shared sandbox container, indicative only): 5000 iterations after 500 warmup each +PERF named query : total=620.878923 ms, avg=124.17578459999999 us/call +PERF inline JPQL : total=568.874652 ms, avg=113.7749304 us/call +PERF ratio (named/inline) = 1.0914160453751416 +--- second run for consistency --- +PERF (shared sandbox container, indicative only): 5000 iterations after 500 warmup each +PERF named query : total=683.679163 ms, avg=136.73583259999998 us/call +PERF inline JPQL : total=641.327846 ms, avg=128.26556920000002 us/call +PERF ratio (named/inline) = 1.066036922089299 diff --git a/docs/output/namedquery-startup-validation.txt b/docs/output/namedquery-startup-validation.txt new file mode 100755 index 0000000..5ed130b --- /dev/null +++ b/docs/output/namedquery-startup-validation.txt @@ -0,0 +1,8 @@ +22:59:19.100 [main] INFO DEMO -- startup_check=false: SessionFactory built successfully with the broken named query still inside it: true +22:59:19.332 [main] INFO DEMO -- startup_check=false: query only fails when actually CALLED -- class: java.lang.IllegalArgumentException, message: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'firsNam' of 'com.ankurm.brokenprobe.BrokenNamedQueryEmployee' [SELECT e FROM BrokenNamedQueryEmployee e WHERE e.firsNam = :name] +22:59:19.367 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +22:59:19.373 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: +-- +22:59:19.414 [main] INFO DEMO -- startup_check=true (default): bootstrap failure -- wrapper class: org.hibernate.query.NamedQueryValidationException +22:59:19.414 [main] INFO DEMO -- startup_check=true (default): 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] diff --git a/docs/output/naturalid-javap-session.txt b/docs/output/naturalid-javap-session.txt new file mode 100644 index 0000000..1158481 --- /dev/null +++ b/docs/output/naturalid-javap-session.txt @@ -0,0 +1,15 @@ +$ javap -cp org.hibernate.Session | grep -i naturalid + public abstract org.hibernate.NaturalIdLoadAccess byNaturalId(java.lang.Class); + public abstract org.hibernate.NaturalIdLoadAccess byNaturalId(java.lang.String); + public abstract org.hibernate.SimpleNaturalIdLoadAccess bySimpleNaturalId(java.lang.Class); + public abstract org.hibernate.SimpleNaturalIdLoadAccess bySimpleNaturalId(java.lang.String); + public abstract org.hibernate.NaturalIdMultiLoadAccess byMultipleNaturalId(java.lang.Class); + public abstract org.hibernate.NaturalIdMultiLoadAccess byMultipleNaturalId(java.lang.String); + +$ javap -cp org.hibernate.annotations.TimeZoneStorageType # for comparison in ch.13 + public static final org.hibernate.annotations.TimeZoneStorageType NATIVE; + public static final org.hibernate.annotations.TimeZoneStorageType NORMALIZE; + public static final org.hibernate.annotations.TimeZoneStorageType NORMALIZE_UTC; + public static final org.hibernate.annotations.TimeZoneStorageType COLUMN; + public static final org.hibernate.annotations.TimeZoneStorageType AUTO; + public static final org.hibernate.annotations.TimeZoneStorageType DEFAULT; diff --git a/docs/output/naturalid-tests.txt b/docs/output/naturalid-tests.txt new file mode 100755 index 0000000..f4792c2 --- /dev/null +++ b/docs/output/naturalid-tests.txt @@ -0,0 +1,365 @@ +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:06:11.225 [main] INFO org.hibernate.orm.core -- HHH000001: Hibernate ORM core version 7.4.5.Final +23:06:11.265 [main] INFO org.hibernate.orm.cache -- HHH90001028: Second-level cache region factory [org.hibernate.cache.jcache.internal.JCacheRegionFactory] +23:06:11.512 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:06:11.710 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:naturalidl2;DB_CLOSE_DELAY=-1] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: NATURALIDL2/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +WARNING: A terminally deprecated method in sun.misc.Unsafe has been called +WARNING: sun.misc.Unsafe::objectFieldOffset has been called by org.ehcache.impl.internal.concurrent.ThreadLocalRandomUtil (file:/sessions/intelligent-loving-cori/.m2/repository/org/ehcache/ehcache/3.10.8/ehcache-3.10.8-jakarta.jar) +WARNING: Please consider reporting this to the maintainers of class org.ehcache.impl.internal.concurrent.ThreadLocalRandomUtil +WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release +23:06:12.413 [main] WARN org.hibernate.orm.cache -- HHH90001006: Missing cache region [com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct] 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'. +23:06:12.478 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct' created in EhcacheManager. +23:06:12.489 [main] WARN org.hibernate.orm.cache -- HHH90001006: Missing cache region [com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct##NaturalId] 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'. +23:06:12.492 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct##NaturalId' created in EhcacheManager. +23:06:12.992 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +RESULT[naturalid-l2-cache-real-miss-then-hit]: first lookup (genuine cold row) queries=1, naturalId miss=1, naturalId put=1 | second lookup (new session) cumulative queries=1, naturalId hits=1 +23:06:13.193 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct##NaturalId' removed from EhcacheManager. +23:06:13.194 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct' removed from EhcacheManager. +23:06:13.218 [main] INFO org.hibernate.orm.cache -- HHH90001028: Second-level cache region factory [org.hibernate.cache.jcache.internal.JCacheRegionFactory] +23:06:13.257 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:06:13.263 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:naturalidl2;DB_CLOSE_DELAY=-1] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: NATURALIDL2/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:06:13.306 [main] WARN org.hibernate.orm.cache -- HHH90001006: Missing cache region [com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct] 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'. +23:06:13.310 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct' created in EhcacheManager. +23:06:13.311 [main] WARN org.hibernate.orm.cache -- HHH90001006: Missing cache region [com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct##NaturalId] 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'. +23:06:13.323 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct##NaturalId' created in EhcacheManager. +23:06:13.335 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +RESULT[naturalid-l2-cache]: immediately after persist()+commit(): queries=2, naturalId cache put=1, naturalId cache hit=0 -- @NaturalIdCache populates the L2 region on INSERT, before anyone ever looked it up. +RESULT[naturalid-l2-cache]: session1 (post-insert) cumulative queries=0 | session2 (new session, same natural id) cumulative queries=0, naturalId cache hits=2 +23:06:13.375 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct##NaturalId' removed from EhcacheManager. +23:06:13.375 [main] INFO org.ehcache.core.EhcacheManager -- Cache 'com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct' removed from EhcacheManager. +HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] at com.ankurm.hibernatedemo.persistenceannotations.TemporalOnJavaTimeEntity.eventDate. +HHH90001006: Missing cache region [com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct##NaturalId] 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'. +HHH90001006: Missing cache region [com.ankurm.hibernatedemo.naturalid.CachedNaturalIdProduct] 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'. +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_natural_id_product(rn_ integer not null, id bigint, name varchar(255), sku varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_natural_id_product(rn_ integer not null, id bigint, name varchar(255), sku varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_identity_hash_set_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_identity_hash_set_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +create global temporary table HTE_cached_natural_id_product(rn_ integer not null, id bigint, name varchar(255), sku varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_cached_natural_id_product(rn_ integer not null, id bigint, name varchar(255), sku varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_mixed_access_entity(rn_ integer not null, id bigint, computedLabel varchar(255), rawValue varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_mixed_access_entity(rn_ integer not null, id bigint, computedLabel varchar(255), rawValue varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_json_column_entity(rn_ integer not null, id bigint, details json, primary key (rn_)) transactional +Hibernate: create global temporary table HTE_json_column_entity(rn_ integer not null, id bigint, details json, primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_temporal_on_java_time_entity(eventDate date, rn_ integer not null, id bigint, primary key (rn_)) transactional +Hibernate: create global temporary table HTE_temporal_on_java_time_entity(eventDate date, rn_ integer not null, id bigint, primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_company(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_company(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_immutable_natural_id_entity(rn_ integer not null, id bigint, code varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_immutable_natural_id_entity(rn_ integer not null, id bigint, code varchar(255), primary key (rn_)) transactional +create global temporary table HTE_natural_id_equals_entity(rn_ integer not null, id bigint, name varchar(255), sku varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_natural_id_equals_entity(rn_ integer not null, id bigint, name varchar(255), sku varchar(255), primary key (rn_)) transactional +create global temporary table HTE_department(rn_ integer not null, company_id bigint, id bigint, deptCode varchar(255), name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_department(rn_ integer not null, company_id bigint, id bigint, deptCode varchar(255), name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_enumerated_value_entity(rn_ integer not null, id bigint, priority varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_enumerated_value_entity(rn_ integer not null, id bigint, priority varchar(255), primary key (rn_)) transactional +create global temporary table HTE_mutable_natural_id_entity(rn_ integer not null, id bigint, code varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_mutable_natural_id_entity(rn_ integer not null, id bigint, code varchar(255), primary key (rn_)) transactional +create global temporary table HTE_id_based_equals_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_id_based_equals_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +create global temporary table HTE_enum_default_ordinal_entity(rn_ integer not null, status tinyint, id bigint, primary key (rn_)) transactional +Hibernate: create global temporary table HTE_enum_default_ordinal_entity(rn_ integer not null, status tinyint, id bigint, primary key (rn_)) transactional +create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +create table cached_natural_id_product (id bigint not null, name varchar(255), sku varchar(255) not null, primary key (id)) +Hibernate: create table cached_natural_id_product (id bigint not null, name varchar(255), sku varchar(255) not null, primary key (id)) +create table company (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table company (id bigint not null, name varchar(255), primary key (id)) +create table department (id bigint not null, dept_code varchar(255), name varchar(255), company_id bigint, primary key (id)) +Hibernate: create table department (id bigint not null, dept_code varchar(255), name varchar(255), company_id bigint, primary key (id)) +create table enum_default_ordinal_entity (id bigint not null, status tinyint check ((status between 0 and 2)), primary key (id)) +Hibernate: create table enum_default_ordinal_entity (id bigint not null, status tinyint check ((status between 0 and 2)), primary key (id)) +create table enumerated_value_entity (id bigint not null, priority varchar(255) check ((priority in ('H','L','M'))), primary key (id)) +Hibernate: create table enumerated_value_entity (id bigint not null, priority varchar(255) check ((priority in ('H','L','M'))), primary key (id)) +create table id_based_equals_entity (id bigint not null, label varchar(255), primary key (id)) +Hibernate: create table id_based_equals_entity (id bigint not null, label varchar(255), primary key (id)) +create table identity_hash_set_entity (id bigint not null, label varchar(255), primary key (id)) +Hibernate: create table identity_hash_set_entity (id bigint not null, label varchar(255), primary key (id)) +create table immutable_natural_id_entity (id bigint not null, code varchar(255), primary key (id)) +Hibernate: create table immutable_natural_id_entity (id bigint not null, code varchar(255), primary key (id)) +create table json_column_entity (id bigint not null, details json, primary key (id)) +Hibernate: create table json_column_entity (id bigint not null, details json, primary key (id)) +create table mixed_access_entity (id bigint not null, computed_label varchar(255), raw_value varchar(255), primary key (id)) +Hibernate: create table mixed_access_entity (id bigint not null, computed_label varchar(255), raw_value varchar(255), primary key (id)) +create table mutable_natural_id_entity (id bigint not null, code varchar(255), primary key (id)) +Hibernate: create table mutable_natural_id_entity (id bigint not null, code varchar(255), primary key (id)) +create table natural_id_equals_entity (id bigint not null, name varchar(255), sku varchar(255) not null, primary key (id)) +Hibernate: create table natural_id_equals_entity (id bigint not null, name varchar(255), sku varchar(255) not null, primary key (id)) +create table natural_id_product (id bigint not null, name varchar(255), sku varchar(255) not null, primary key (id)) +Hibernate: create table natural_id_product (id bigint not null, name varchar(255), sku varchar(255) not null, primary key (id)) +create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +Hibernate: create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +create table override_entity (id bigint not null, annotation_name varchar(255), primary key (id)) +Hibernate: create table override_entity (id bigint not null, annotation_name varchar(255), primary key (id)) +create table temporal_on_java_time_entity (id bigint not null, event_date date, primary key (id)) +Hibernate: create table temporal_on_java_time_entity (id bigint not null, event_date date, primary key (id)) +create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +alter table if exists cached_natural_id_product drop constraint if exists UKje04jryb5drgla4ee8f9wb09h +Hibernate: alter table if exists cached_natural_id_product drop constraint if exists UKje04jryb5drgla4ee8f9wb09h +alter table if exists cached_natural_id_product add constraint UKje04jryb5drgla4ee8f9wb09h unique (sku) +Hibernate: alter table if exists cached_natural_id_product add constraint UKje04jryb5drgla4ee8f9wb09h unique (sku) +alter table if exists department drop constraint if exists UKshij4bp4ym2hmov81mkrwn0c8 +Hibernate: alter table if exists department drop constraint if exists UKshij4bp4ym2hmov81mkrwn0c8 +alter table if exists department add constraint UKshij4bp4ym2hmov81mkrwn0c8 unique (company_id, dept_code) +Hibernate: alter table if exists department add constraint UKshij4bp4ym2hmov81mkrwn0c8 unique (company_id, dept_code) +alter table if exists immutable_natural_id_entity drop constraint if exists UK5gvhgvwmwj20jj1uh486alunu +Hibernate: alter table if exists immutable_natural_id_entity drop constraint if exists UK5gvhgvwmwj20jj1uh486alunu +alter table if exists immutable_natural_id_entity add constraint UK5gvhgvwmwj20jj1uh486alunu unique (code) +Hibernate: alter table if exists immutable_natural_id_entity add constraint UK5gvhgvwmwj20jj1uh486alunu unique (code) +alter table if exists mutable_natural_id_entity drop constraint if exists UK27w8jwneoik5sx2gt8q9rduf5 +Hibernate: alter table if exists mutable_natural_id_entity drop constraint if exists UK27w8jwneoik5sx2gt8q9rduf5 +alter table if exists mutable_natural_id_entity add constraint UK27w8jwneoik5sx2gt8q9rduf5 unique (code) +Hibernate: alter table if exists mutable_natural_id_entity add constraint UK27w8jwneoik5sx2gt8q9rduf5 unique (code) +alter table if exists natural_id_equals_entity drop constraint if exists UKs5mlj556xgu0u0dl8jq93xvi1 +Hibernate: alter table if exists natural_id_equals_entity drop constraint if exists UKs5mlj556xgu0u0dl8jq93xvi1 +alter table if exists natural_id_equals_entity add constraint UKs5mlj556xgu0u0dl8jq93xvi1 unique (sku) +Hibernate: alter table if exists natural_id_equals_entity add constraint UKs5mlj556xgu0u0dl8jq93xvi1 unique (sku) +alter table if exists natural_id_product drop constraint if exists UK4uilk3eo365mgcetnh0da4n3b +Hibernate: alter table if exists natural_id_product drop constraint if exists UK4uilk3eo365mgcetnh0da4n3b +alter table if exists natural_id_product add constraint UK4uilk3eo365mgcetnh0da4n3b unique (sku) +Hibernate: alter table if exists natural_id_product add constraint UK4uilk3eo365mgcetnh0da4n3b unique (sku) +create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence book_seq start with 1 increment by 50 +create sequence cached_natural_id_product_seq start with 1 increment by 50 +Hibernate: create sequence cached_natural_id_product_seq start with 1 increment by 50 +create sequence company_seq start with 1 increment by 50 +Hibernate: create sequence company_seq start with 1 increment by 50 +create sequence department_seq start with 1 increment by 50 +Hibernate: create sequence department_seq start with 1 increment by 50 +create sequence enum_default_ordinal_entity_seq start with 1 increment by 50 +Hibernate: create sequence enum_default_ordinal_entity_seq start with 1 increment by 50 +create sequence enumerated_value_entity_seq start with 1 increment by 50 +Hibernate: create sequence enumerated_value_entity_seq start with 1 increment by 50 +create sequence id_based_equals_entity_seq start with 1 increment by 50 +Hibernate: create sequence id_based_equals_entity_seq start with 1 increment by 50 +create sequence identity_hash_set_entity_seq start with 1 increment by 50 +Hibernate: create sequence identity_hash_set_entity_seq start with 1 increment by 50 +create sequence immutable_natural_id_entity_seq start with 1 increment by 50 +Hibernate: create sequence immutable_natural_id_entity_seq start with 1 increment by 50 +create sequence json_column_entity_seq start with 1 increment by 50 +Hibernate: create sequence json_column_entity_seq start with 1 increment by 50 +create sequence mixed_access_entity_seq start with 1 increment by 50 +Hibernate: create sequence mixed_access_entity_seq start with 1 increment by 50 +create sequence mutable_natural_id_entity_seq start with 1 increment by 50 +Hibernate: create sequence mutable_natural_id_entity_seq start with 1 increment by 50 +create sequence natural_id_equals_entity_seq start with 1 increment by 50 +Hibernate: create sequence natural_id_equals_entity_seq start with 1 increment by 50 +create sequence natural_id_product_seq start with 1 increment by 50 +Hibernate: create sequence natural_id_product_seq start with 1 increment by 50 +create sequence override_entity_seq start with 1 increment by 50 +Hibernate: create sequence override_entity_seq start with 1 increment by 50 +create sequence temporal_on_java_time_entity_seq start with 1 increment by 50 +Hibernate: create sequence temporal_on_java_time_entity_seq start with 1 increment by 50 +create sequence widget_alloc10_seq start with 1 increment by 10 +Hibernate: create sequence widget_alloc10_seq start with 1 increment by 10 +create sequence widget_alloc1_seq start with 1 increment by 1 +Hibernate: create sequence widget_alloc1_seq start with 1 increment by 1 +create sequence widget_alloc25_seq start with 1 increment by 25 +Hibernate: create sequence widget_alloc25_seq start with 1 increment by 25 +create sequence widget_alloc50_seq start with 1 increment by 50 +Hibernate: create sequence widget_alloc50_seq start with 1 increment by 50 +create sequence widget_batch_sweep1_seq start with 1 increment by 50 +Hibernate: create sequence widget_batch_sweep1_seq start with 1 increment by 50 +create sequence widget_seq start with 1 increment by 25 +Hibernate: create sequence widget_seq start with 1 increment by 25 +alter table if exists department add constraint FKh1m88q0f7sc0mk76kju4kcn6f foreign key (company_id) references company +Hibernate: alter table if exists department add constraint FKh1m88q0f7sc0mk76kju4kcn6f foreign key (company_id) references company +alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Hibernate: alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +WARNING: A Java agent has been loaded dynamically (/sessions/intelligent-loving-cori/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +select next value for company_seq +Hibernate: select next value for company_seq +select next value for company_seq +Hibernate: select next value for company_seq +select next value for department_seq +Hibernate: select next value for department_seq +select next value for department_seq +Hibernate: select next value for department_seq +/* insert for com.ankurm.hibernatedemo.naturalid.Company */insert into company (name,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.Company */insert into company (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [Acme] +binding parameter (2:BIGINT) <- [1] +/* insert for com.ankurm.hibernatedemo.naturalid.Company */insert into company (name,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.Company */insert into company (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [Other Co] +binding parameter (2:BIGINT) <- [2] +/* insert for com.ankurm.hibernatedemo.naturalid.Department */insert into department (company_id,dept_code,name,id) values (?,?,?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.Department */insert into department (company_id,dept_code,name,id) values (?,?,?,?) +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [ENG-01] +binding parameter (3:VARCHAR) <- [Acme Engineering] +binding parameter (4:BIGINT) <- [1] +/* insert for com.ankurm.hibernatedemo.naturalid.Department */insert into department (company_id,dept_code,name,id) values (?,?,?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.Department */insert into department (company_id,dept_code,name,id) values (?,?,?,?) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [ENG-01] +binding parameter (3:VARCHAR) <- [Other Co Engineering] +binding parameter (4:BIGINT) <- [2] +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=? +Hibernate: 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=? +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [ENG-01] +RESULT[composite-naturalid]: byNaturalId(company=Acme, deptCode=ENG-01) resolved to 'Acme Engineering' in 1 query/queries +/* insert for com.ankurm.hibernatedemo.naturalid.Company */insert into company (name,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.Company */insert into company (name,id) values (?,?) +binding parameter (1:VARCHAR) <- [SQL-Capture Co] +binding parameter (2:BIGINT) <- [3] +/* insert for com.ankurm.hibernatedemo.naturalid.Department */insert into department (company_id,dept_code,name,id) values (?,?,?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.Department */insert into department (company_id,dept_code,name,id) values (?,?,?,?) +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [OPS-1] +binding parameter (3:VARCHAR) <- [Operations] +binding parameter (4:BIGINT) <- [3] +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=? +Hibernate: 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=? +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [OPS-1] +select next value for mutable_natural_id_entity_seq +Hibernate: select next value for mutable_natural_id_entity_seq +/* insert for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */insert into mutable_natural_id_entity (code,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */insert into mutable_natural_id_entity (code,id) values (?,?) +binding parameter (1:VARCHAR) <- [CODE-X] +binding parameter (2:BIGINT) <- [1] +select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.id=? +Hibernate: select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.id=? +binding parameter (1:BIGINT) <- [1] +/* update for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */update mutable_natural_id_entity set code=? where id=? +Hibernate: /* update for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */update mutable_natural_id_entity set code=? where id=? +binding parameter (1:VARCHAR) <- [CODE-Y] +binding parameter (2:BIGINT) <- [1] +/* dynamic native SQL query */ select code from mutable_natural_id_entity where id = 1 +Hibernate: /* dynamic native SQL query */ select code from mutable_natural_id_entity where id = 1 +RESULT[naturalid-mutable-mutation]: flush succeeded, DB column now = CODE-Y +select next value for mutable_natural_id_entity_seq +Hibernate: select next value for mutable_natural_id_entity_seq +/* insert for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */insert into mutable_natural_id_entity (code,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */insert into mutable_natural_id_entity (code,id) values (?,?) +binding parameter (1:VARCHAR) <- [CODE-OLD] +binding parameter (2:BIGINT) <- [2] +select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.id=? +Hibernate: select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.id=? +binding parameter (1:BIGINT) <- [2] +/* update for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */update mutable_natural_id_entity set code=? where id=? +Hibernate: /* update for com.ankurm.hibernatedemo.naturalid.MutableNaturalIdEntity */update mutable_natural_id_entity set code=? where id=? +binding parameter (1:VARCHAR) <- [CODE-NEW] +binding parameter (2:BIGINT) <- [2] +select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.code=? +Hibernate: select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.code=? +binding parameter (1:VARCHAR) <- [CODE-OLD] +select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.code=? +Hibernate: select mnie1_0.id,mnie1_0.code from mutable_natural_id_entity mnie1_0 where mnie1_0.code=? +binding parameter (1:VARCHAR) <- [CODE-NEW] +RESULT[naturalid-mutable-stale-lookup]: byNaturalId("CODE-OLD") = null, byNaturalId("CODE-NEW") = 2 +select next value for immutable_natural_id_entity_seq +Hibernate: select next value for immutable_natural_id_entity_seq +/* insert for com.ankurm.hibernatedemo.naturalid.ImmutableNaturalIdEntity */insert into immutable_natural_id_entity (code,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.ImmutableNaturalIdEntity */insert into immutable_natural_id_entity (code,id) values (?,?) +binding parameter (1:VARCHAR) <- [CODE-A] +binding parameter (2:BIGINT) <- [1] +select inie1_0.id,inie1_0.code from immutable_natural_id_entity inie1_0 where inie1_0.id=? +Hibernate: select inie1_0.id,inie1_0.code from immutable_natural_id_entity inie1_0 where inie1_0.id=? +binding parameter (1:BIGINT) <- [1] +RESULT[naturalid-immutable-mutation]: flushing a changed IMMUTABLE natural id threw: org.hibernate.HibernateException: An immutable natural identifier of entity com.ankurm.hibernatedemo.naturalid.ImmutableNaturalIdEntity was altered from `CODE-A` to `CODE-B` +select next value for natural_id_product_seq +Hibernate: select next value for natural_id_product_seq +/* insert for com.ankurm.hibernatedemo.naturalid.NaturalIdProduct */insert into natural_id_product (name,sku,id) values (?,?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.NaturalIdProduct */insert into natural_id_product (name,sku,id) values (?,?,?) +binding parameter (1:VARCHAR) <- [Widget] +binding parameter (2:VARCHAR) <- [SKU-L1-1] +binding parameter (3:BIGINT) <- [1] +select nip1_0.id,nip1_0.name,nip1_0.sku from natural_id_product nip1_0 where nip1_0.sku=? +Hibernate: select nip1_0.id,nip1_0.name,nip1_0.sku from natural_id_product nip1_0 where nip1_0.sku=? +binding parameter (1:VARCHAR) <- [SKU-L1-1] +RESULT[naturalid-l1-no-l2]: queries after 1st bySimpleNaturalId=1, after 2nd (same session)=1 (no @NaturalIdCache, no L2 cache provider configured) +select next value for natural_id_product_seq +Hibernate: select next value for natural_id_product_seq +/* insert for com.ankurm.hibernatedemo.naturalid.NaturalIdProduct */insert into natural_id_product (name,sku,id) values (?,?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.NaturalIdProduct */insert into natural_id_product (name,sku,id) values (?,?,?) +binding parameter (1:VARCHAR) <- [Gadget] +binding parameter (2:VARCHAR) <- [SKU-L1-2] +binding parameter (3:BIGINT) <- [2] +select nip1_0.id,nip1_0.name,nip1_0.sku from natural_id_product nip1_0 where nip1_0.sku=? +Hibernate: select nip1_0.id,nip1_0.name,nip1_0.sku from natural_id_product nip1_0 where nip1_0.sku=? +binding parameter (1:VARCHAR) <- [SKU-L1-2] +select nip1_0.id,nip1_0.name,nip1_0.sku from natural_id_product nip1_0 where nip1_0.sku=? +Hibernate: select nip1_0.id,nip1_0.name,nip1_0.sku from natural_id_product nip1_0 where nip1_0.sku=? +binding parameter (1:VARCHAR) <- [SKU-L1-2] +RESULT[naturalid-cross-session-no-l2]: queries after session 1 lookup=1, cumulative after a NEW session repeats the same lookup=2 (no L2 cache -- the L1 natural-id map dies with the session) +select next value for natural_id_equals_entity_seq +Hibernate: select next value for natural_id_equals_entity_seq +/* insert for com.ankurm.hibernatedemo.naturalid.NaturalIdEqualsEntity */insert into natural_id_equals_entity (name,sku,id) values (?,?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.naturalid.NaturalIdEqualsEntity */insert into natural_id_equals_entity (name,sku,id) values (?,?,?) +binding parameter (1:VARCHAR) <- [widget] +binding parameter (2:VARCHAR) <- [SKU-EQ-1] +binding parameter (3:BIGINT) <- [1] +RESULT[naturalid-equals-hashset]: after persist(), e.getId()=1, e.getSku()=SKU-EQ-1, set.contains(e) = true (equals/hashCode based on the immutable natural id, NOT the surrogate id) +RESULT[naturalid-equals-transient-dup]: a.equals(b)=true for two transient instances sharing sku='SKU-EQ-DUP' but different names; HashSet.add(b) rejected it as a duplicate = true +RESULT[naturalid-equals-transient]: a.equals(b) for two DIFFERENT transient instances = false (both have null surrogate ids, but different natural ids) diff --git a/docs/output/persistenceannotations-json-no-formatmapper.txt b/docs/output/persistenceannotations-json-no-formatmapper.txt new file mode 100644 index 0000000..49f95b1 --- /dev/null +++ b/docs/output/persistenceannotations-json-no-formatmapper.txt @@ -0,0 +1,17 @@ +@JdbcTypeCode(SqlTypes.JSON) with NO JSON provider on the classpath. + +Reproduced by removing tools.jackson.core:jackson-databind and excluding +spring-boot-starter-jackson from spring-boot-starter-web, leaving zero JSON +providers on the test classpath: + +$ mvn -o -B dependency:list -DincludeScope=test | grep -icE 'jackson-databind|yasson|johnzon' +0 + +$ mvn -o -B test -Dtest=JsonColumnOnH2Test +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. + +Put either Jackson or a JSONB implementation back on the classpath and the same +test passes. spring-boot-starter-data-jpa on its own does not bring one: + +$ mvn -o -B test -Dtest=JsonColumnOnH2Test # with tools.jackson.core:jackson-databind present +[INFO] BUILD SUCCESS diff --git a/docs/output/persistenceannotations-tests.txt b/docs/output/persistenceannotations-tests.txt new file mode 100755 index 0000000..3c3699d --- /dev/null +++ b/docs/output/persistenceannotations-tests.txt @@ -0,0 +1,208 @@ +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 +HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] at com.ankurm.hibernatedemo.persistenceannotations.TemporalOnLocalDateEntity.eventDate. +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_identity_hash_set_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_identity_hash_set_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_mixed_access_entity(rn_ integer not null, id bigint, computedLabel varchar(255), rawValue varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_mixed_access_entity(rn_ integer not null, id bigint, computedLabel varchar(255), rawValue varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_enumerated_value_entity(rn_ integer not null, id bigint, priority varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_enumerated_value_entity(rn_ integer not null, id bigint, priority varchar(255), primary key (rn_)) transactional +create global temporary table HTE_json_column_entity(rn_ integer not null, id bigint, details json, primary key (rn_)) transactional +Hibernate: create global temporary table HTE_json_column_entity(rn_ integer not null, id bigint, details json, primary key (rn_)) transactional +create global temporary table HTE_id_based_equals_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_id_based_equals_entity(rn_ integer not null, id bigint, label varchar(255), primary key (rn_)) transactional +create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_override_entity(rn_ integer not null, id bigint, value varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_enum_default_ordinal_entity(rn_ integer not null, status tinyint, id bigint, primary key (rn_)) transactional +Hibernate: create global temporary table HTE_enum_default_ordinal_entity(rn_ integer not null, status tinyint, id bigint, primary key (rn_)) transactional +create global temporary table HTE_temporal_on_java_time_entity(eventDate date, rn_ integer not null, id bigint, primary key (rn_)) transactional +Hibernate: create global temporary table HTE_temporal_on_java_time_entity(eventDate date, rn_ integer not null, id bigint, primary key (rn_)) transactional +create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +create table enum_default_ordinal_entity (id bigint not null, status tinyint check ((status between 0 and 2)), primary key (id)) +Hibernate: create table enum_default_ordinal_entity (id bigint not null, status tinyint check ((status between 0 and 2)), primary key (id)) +create table enumerated_value_entity (id bigint not null, priority varchar(255) check ((priority in ('H','L','M'))), primary key (id)) +Hibernate: create table enumerated_value_entity (id bigint not null, priority varchar(255) check ((priority in ('H','L','M'))), primary key (id)) +create table id_based_equals_entity (id bigint not null, label varchar(255), primary key (id)) +Hibernate: create table id_based_equals_entity (id bigint not null, label varchar(255), primary key (id)) +create table identity_hash_set_entity (id bigint not null, label varchar(255), primary key (id)) +Hibernate: create table identity_hash_set_entity (id bigint not null, label varchar(255), primary key (id)) +create table json_column_entity (id bigint not null, details json, primary key (id)) +Hibernate: create table json_column_entity (id bigint not null, details json, primary key (id)) +create table mixed_access_entity (id bigint not null, computed_label varchar(255), raw_value varchar(255), primary key (id)) +Hibernate: create table mixed_access_entity (id bigint not null, computed_label varchar(255), raw_value varchar(255), primary key (id)) +create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +Hibernate: create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +create table override_entity (id bigint not null, annotation_name varchar(255), primary key (id)) +Hibernate: create table override_entity (id bigint not null, annotation_name varchar(255), primary key (id)) +create table temporal_on_java_time_entity (id bigint not null, event_date date, primary key (id)) +Hibernate: create table temporal_on_java_time_entity (id bigint not null, event_date date, primary key (id)) +create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence book_seq start with 1 increment by 50 +create sequence enum_default_ordinal_entity_seq start with 1 increment by 50 +Hibernate: create sequence enum_default_ordinal_entity_seq start with 1 increment by 50 +create sequence enumerated_value_entity_seq start with 1 increment by 50 +Hibernate: create sequence enumerated_value_entity_seq start with 1 increment by 50 +create sequence id_based_equals_entity_seq start with 1 increment by 50 +Hibernate: create sequence id_based_equals_entity_seq start with 1 increment by 50 +create sequence identity_hash_set_entity_seq start with 1 increment by 50 +Hibernate: create sequence identity_hash_set_entity_seq start with 1 increment by 50 +create sequence json_column_entity_seq start with 1 increment by 50 +Hibernate: create sequence json_column_entity_seq start with 1 increment by 50 +create sequence mixed_access_entity_seq start with 1 increment by 50 +Hibernate: create sequence mixed_access_entity_seq start with 1 increment by 50 +create sequence override_entity_seq start with 1 increment by 50 +Hibernate: create sequence override_entity_seq start with 1 increment by 50 +create sequence temporal_on_java_time_entity_seq start with 1 increment by 50 +Hibernate: create sequence temporal_on_java_time_entity_seq start with 1 increment by 50 +create sequence widget_alloc10_seq start with 1 increment by 10 +Hibernate: create sequence widget_alloc10_seq start with 1 increment by 10 +create sequence widget_alloc1_seq start with 1 increment by 1 +Hibernate: create sequence widget_alloc1_seq start with 1 increment by 1 +create sequence widget_alloc25_seq start with 1 increment by 25 +Hibernate: create sequence widget_alloc25_seq start with 1 increment by 25 +create sequence widget_alloc50_seq start with 1 increment by 50 +Hibernate: create sequence widget_alloc50_seq start with 1 increment by 50 +create sequence widget_batch_sweep1_seq start with 1 increment by 50 +Hibernate: create sequence widget_batch_sweep1_seq start with 1 increment by 50 +create sequence widget_seq start with 1 increment by 25 +Hibernate: create sequence widget_seq start with 1 increment by 25 +alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Hibernate: alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +WARNING: A Java agent has been loaded dynamically (/sessions/intelligent-loving-cori/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +select next value for id_based_equals_entity_seq +Hibernate: select next value for id_based_equals_entity_seq +/* insert for com.ankurm.hibernatedemo.persistenceannotations.IdBasedEqualsEntity */insert into id_based_equals_entity (label,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.IdBasedEqualsEntity */insert into id_based_equals_entity (label,id) values (?,?) +binding parameter (1:VARCHAR) <- [widget] +binding parameter (2:BIGINT) <- [1] +RESULT[id-based-equals-hashset-trap]: after persist(), e.getId()=1, set.contains(e) = false (same reference, same set, only the hash code changed) +RESULT[id-based-equals-hashset-trap]: manual iteration foundByIteration = true -- confirms equals() itself still works; it's HashSet's bucket indexing that is now wrong. +select next value for mixed_access_entity_seq +Hibernate: select next value for mixed_access_entity_seq +/* insert for com.ankurm.hibernatedemo.persistenceannotations.MixedAccessEntity */insert into mixed_access_entity (computed_label,raw_value,id) values (?,?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.MixedAccessEntity */insert into mixed_access_entity (computed_label,raw_value,id) values (?,?,?) +binding parameter (1:VARCHAR) <- [WIDGET] +binding parameter (2:VARCHAR) <- [widget] +binding parameter (3:BIGINT) <- [1] +RESULT[mixed-access]: getComputedLabel() call count before persist=0, after commit/flush=2 (PROPERTY-access attributes are read via the getter at flush time, not via a backing field) +/* dynamic native SQL query */ select computed_label from mixed_access_entity where id = 1 +Hibernate: /* dynamic native SQL query */ select computed_label from mixed_access_entity where id = 1 +RESULT[mixed-access]: DB column computed_label = WIDGET +select next value for temporal_on_java_time_entity_seq +Hibernate: select next value for temporal_on_java_time_entity_seq +/* insert for com.ankurm.hibernatedemo.persistenceannotations.TemporalOnLocalDateEntity */insert into temporal_on_java_time_entity (event_date,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.TemporalOnLocalDateEntity */insert into temporal_on_java_time_entity (event_date,id) values (?,?) +binding parameter (1:DATE) <- [2026-01-15] +binding parameter (2:BIGINT) <- [1] +select tojte1_0.id,tojte1_0.event_date from temporal_on_java_time_entity tojte1_0 where tojte1_0.id=? +Hibernate: select tojte1_0.id,tojte1_0.event_date from temporal_on_java_time_entity tojte1_0 where tojte1_0.id=? +binding parameter (1:BIGINT) <- [1] +RESULT[temporal-on-localdate]: boot succeeded (not silent -- Hibernate logs HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] at boot time, WARN level, one line per annotated field); round-tripped eventDate=2026-01-15. The mapping itself is unaffected -- LocalDate maps the same with or without @Temporal. +HHH10001002: Using built-in connection pool (not intended for production use) +create global temporary table HTE_enum_default_ordinal_entity(rn_ integer not null, status tinyint, id bigint, primary key (rn_)) transactional +drop table if exists enum_default_ordinal_entity cascade +drop sequence if exists enum_default_ordinal_entity_SEQ +create sequence enum_default_ordinal_entity_SEQ start with 1 increment by 50 +create table enum_default_ordinal_entity (status tinyint check ((status between 0 and 2)), id bigint not null, primary key (id)) +select next value for enum_default_ordinal_entity_SEQ +insert into enum_default_ordinal_entity (status,id) values (?,?) +binding parameter (1:TINYINT) <- [SHIPPED] +binding parameter (2:BIGINT) <- [1] +select status from enum_default_ordinal_entity where id = 1 +RESULT[enum-ordinal-default]: stored ordinal for SHIPPED (V1 ordering) = 1 +HHH10001002: Using built-in connection pool (not intended for production use) +select erve1_0.id,erve1_0.status from enum_default_ordinal_entity erve1_0 where erve1_0.id=? +binding parameter (1:BIGINT) <- [1] +RESULT[enum-ordinal-default]: same row re-read through V2 (PENDING_REVIEW inserted before SHIPPED) enum ordering = PENDING_REVIEW -- no exception thrown, silently resolves to the WRONG constant. +select next value for json_column_entity_seq +Hibernate: select next value for json_column_entity_seq +/* insert for com.ankurm.hibernatedemo.persistenceannotations.JsonColumnEntity */insert into json_column_entity (details,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.JsonColumnEntity */insert into json_column_entity (details,id) values (?,?) +binding parameter (1:JSON) <- [{color=red, qty=5}] +binding parameter (2:BIGINT) <- [1] +select jce1_0.id,jce1_0.details from json_column_entity jce1_0 where jce1_0.id=? +Hibernate: select jce1_0.id,jce1_0.details from json_column_entity jce1_0 where jce1_0.id=? +binding parameter (1:BIGINT) <- [1] +RESULT[jdbctypecode-json-h2]: persisted+loaded details={color=red, qty=5} +/* dynamic native SQL query */ select data_type from information_schema.columns where table_name = 'JSON_COLUMN_ENTITY' and column_name = 'DETAILS' +Hibernate: /* dynamic native SQL query */ select data_type from information_schema.columns where table_name = 'JSON_COLUMN_ENTITY' and column_name = 'DETAILS' +RESULT[jdbctypecode-json-h2]: H2 column type for the JSON field = JSON +select next value for enumerated_value_entity_seq +Hibernate: select next value for enumerated_value_entity_seq +select next value for enumerated_value_entity_seq +Hibernate: select next value for enumerated_value_entity_seq +/* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +binding parameter (1:VARCHAR) <- [H] +binding parameter (2:BIGINT) <- [1] +/* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +binding parameter (1:VARCHAR) <- [H] +binding parameter (2:BIGINT) <- [2] +/* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +binding parameter (1:VARCHAR) <- [L] +binding parameter (2:BIGINT) <- [3] +/* select new com.ankurm.hibernatedemo.persistenceannotations.PriorityCountView(e.priority, count(e)) from EnumeratedValueEntity e group by e.priority order by e.priority */ select eve1_0.priority,count(eve1_0.id) from enumerated_value_entity eve1_0 group by eve1_0.priority order by eve1_0.priority +Hibernate: /* select new com.ankurm.hibernatedemo.persistenceannotations.PriorityCountView(e.priority, count(e)) from EnumeratedValueEntity e group by e.priority order by e.priority */ select eve1_0.priority,count(eve1_0.id) from enumerated_value_entity eve1_0 group by eve1_0.priority order by eve1_0.priority +RESULT[jpa32-record-constructor-expression]: [PriorityCountView[priority=HIGH, total=2], PriorityCountView[priority=LOW, total=1]] +/* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +Hibernate: /* insert for com.ankurm.hibernatedemo.persistenceannotations.EnumeratedValueEntity */insert into enumerated_value_entity (priority,id) values (?,?) +binding parameter (1:VARCHAR) <- [H] +binding parameter (2:BIGINT) <- [4] +/* dynamic native SQL query */ select priority from enumerated_value_entity where id = 4 +Hibernate: /* dynamic native SQL query */ select priority from enumerated_value_entity where id = 4 +select eve1_0.id,eve1_0.priority from enumerated_value_entity eve1_0 where eve1_0.id=? +Hibernate: select eve1_0.id,eve1_0.priority from enumerated_value_entity eve1_0 where eve1_0.id=? +binding parameter (1:BIGINT) <- [4] +RESULT[jpa32-enumeratedvalue]: raw DB value for HIGH = 'H' (neither ordinal '2' nor name 'HIGH' -- the @EnumeratedValue-annotated code 'H') +/* select e from EnumeratedValueEntity e where e.id = :id */ select eve1_0.id,eve1_0.priority from enumerated_value_entity eve1_0 where eve1_0.id=? +Hibernate: /* select e from EnumeratedValueEntity e where e.id = :id */ select eve1_0.id,eve1_0.priority from enumerated_value_entity eve1_0 where eve1_0.id=? +binding parameter (1:BIGINT) <- [-999] +RESULT[jpa32-getsingleresultornull]: query matching zero rows via getSingleResultOrNull() = null (getSingleResult() would have thrown NoResultException here) diff --git a/docs/output/procedure-failure-modes.txt b/docs/output/procedure-failure-modes.txt new file mode 100755 index 0000000..f6c0835 --- /dev/null +++ b/docs/output/procedure-failure-modes.txt @@ -0,0 +1,9 @@ +wrong parameter name 'employee_id' (procedure expects 'emp_id') threw: NOTHING -- bound positionally regardless of the name: tax_amount output = 7500.00 +swapped IN/OUT positional registration on GET_TAX threw: org.hibernate.exception.GenericJDBCException: Unable to register CallableStatement OUT parameter [Invalid argument in JDBC call: Not OUT or INOUT mode for parameter: 1] [n/a] +emp_id (really IN) registered as ParameterMode.OUT threw: org.hibernate.exception.GenericJDBCException: Unable to register CallableStatement OUT parameter [Invalid argument in JDBC call: Not OUT or INOUT mode for parameter: 1] [n/a] +getResultList() on a no-result-set procedure threw: java.lang.IllegalStateException: Current CallableStatement was not a ResultSet, but getResultList was called +getOutputParameterValue() WITHOUT calling execute() first threw: NOTHING -- getOutputParameterValue() triggered execution implicitly (value=7500.00) +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 +ProcedureCall with addSynchronizedEntityClass(ProcEmployee.class), unflushed Dave NOT counted: COUNT_EMPLOYEES = 2 (still just Alice+Bob -- addSynchronizedEntityClass had NO auto-flush effect here) diff --git a/docs/output/procedure-happy-path.txt b/docs/output/procedure-happy-path.txt new file mode 100755 index 0000000..108c183 --- /dev/null +++ b/docs/output/procedure-happy-path.txt @@ -0,0 +1,7 @@ +@NamedStoredProcedureQuery ProcEmployee.getTax(emp_id=1) execute()=false tax_amount=7500.00 +EntityManager.createStoredProcedureQuery("GET_TAX") for emp 2, tax_amount=9000.00 +Session.createStoredProcedureQuery("GET_TAX") for emp 1, tax_amount=7500.00 +INOUT parameter 'sal' after ADJUST_SALARY(1000.00, 10.00) = 1100.00 +ProcEmployee.listAll execute() returned false (HSQLDB misreports this as false) +getResultList() on the (mis-reported) result-set procedure threw: java.lang.IllegalStateException: Current CallableStatement was not a ResultSet, but getResultList was called +DTO-mapped getResultList() threw: java.lang.IllegalStateException: Current CallableStatement was not a ResultSet, but getResultList was called diff --git a/docs/output/procedure-hsqldb-jdbc-driver-quirk.txt b/docs/output/procedure-hsqldb-jdbc-driver-quirk.txt new file mode 100755 index 0000000..6d84639 --- /dev/null +++ b/docs/output/procedure-hsqldb-jdbc-driver-quirk.txt @@ -0,0 +1,56 @@ +=== Raw JDBC probe (no Hibernate), HSQLDB 2.7.3, DYNAMIC RESULT SETS procedure === +--- CallableStatement.execute() then getUpdateCount()/getResultSet() --- +execute() returned=false +getUpdateCount=0 +getResultSet() = org.hsqldb.jdbc.JDBCResultSet@5649fd9b +row: 1 Alice 50000.00 +row: 2 Bob 60000.00 +getMoreResults=false + +--- Statement.execute("CALL ...") vs CallableStatement.executeQuery() --- +--- via plain Statement.execute(CALL ...) --- +Statement.execute returned=false +getResultSet=null +--- via CallableStatement.executeQuery() --- +executeQuery ok, rs=org.hsqldb.jdbc.JDBCResultSet@5649fd9b +row via executeQuery: 1 +row via executeQuery: 2 +--- metadata: getMetaData() on CallableStatement before execute --- +getMetaData()=null + +CONCLUSION: CallableStatement.execute() returns false (per JDBC spec this should mean +'no ResultSet, check update count'), and getUpdateCount() also returns 0 (not -1). +Yet CallableStatement.getResultSet() DOES return a live, iterable ResultSet with the +cursor's rows, and CallableStatement.executeQuery() works correctly end-to-end. +Statement.execute("CALL ...") is worse: execute()=false AND getResultSet()=null (the +result set is only reachable through the CallableStatement form). +Hibernate 7.4.5's ProcedureCallImpl (org.hibernate.procedure.internal.ProcedureCallImpl +/ StandardCallableStatementSupport) drives the call via execute() and trusts its boolean +to decide whether to attach a ResultSetOutput. Since HSQLDB's driver misreports that +boolean, getResultList() on a DYNAMIC RESULT SETS procedure fails with: + java.lang.IllegalStateException: Current CallableStatement was not a ResultSet, but getResultList was called +This reproduces identically through @NamedStoredProcedureQuery(resultClasses=...) and +through createStoredProcedureQuery(name, sqlResultSetMappingName) -- see +StoredProcedureHappyPathTest#resultSetProcedure_mappedToEntity_hitsHsqldbDriverIncompatibility +and #resultSetProcedure_mappedToDto_alsoHitsHsqldbDriverIncompatibility. + +=== OUT parameter vs result-set consumption ORDER (raw JDBC, procedure with BOTH) === +Procedure: combo(IN emp_id, OUT tax_amount) READS SQL DATA DYNAMIC RESULT SETS 1, opens a cursor +AND sets the OUT parameter. + +Reading the OUT parameter BEFORE consuming the ResultSet: + execute()=false + OUT tax_amount = 7500.00 <- succeeds, no exception + (then) getResultSet() still returns the cursor with its rows intact + (then) OUT tax_amount read AGAIN = 7500.00 <- still succeeds + +Reading the OUT parameter AFTER fully consuming the ResultSet: + rows consumed first + OUT tax_amount = 9000.00 <- also succeeds + +CONCLUSION: unlike some JDBC drivers (historically SQL Server's, and some Oracle configurations) +that require a stored procedure's result set(s) to be fully consumed before OUT parameters +become readable, HSQLDB 2.7.3's driver imposes NO such ordering constraint. Reading the OUT +value before, interleaved with, or after draining the cursor all work identically. The +"ordering trap" described in stored-procedure folklore is real on SOME databases/drivers but +is NOT reproducible on HSQLDB -- worth stating explicitly rather than assuming it's universal. diff --git a/docs/output/procedure-javap-api-surface.txt b/docs/output/procedure-javap-api-surface.txt new file mode 100755 index 0000000..b214eb4 --- /dev/null +++ b/docs/output/procedure-javap-api-surface.txt @@ -0,0 +1,139 @@ +=== jakarta.persistence-api 3.2.0: NamedStoredProcedureQuery === +Compiled from "NamedStoredProcedureQuery.java" +public interface jakarta.persistence.NamedStoredProcedureQuery extends java.lang.annotation.Annotation { + public abstract java.lang.String name(); + public abstract java.lang.String procedureName(); + public abstract jakarta.persistence.StoredProcedureParameter[] parameters(); + public abstract java.lang.Class[] resultClasses(); + public abstract java.lang.String[] resultSetMappings(); + public abstract jakarta.persistence.QueryHint[] hints(); +} + +=== jakarta.persistence-api 3.2.0: StoredProcedureParameter === +Compiled from "StoredProcedureParameter.java" +public interface jakarta.persistence.StoredProcedureParameter extends java.lang.annotation.Annotation { + public abstract java.lang.String name(); + public abstract jakarta.persistence.ParameterMode mode(); + public abstract java.lang.Class type(); +} + +=== jakarta.persistence-api 3.2.0: StoredProcedureQuery === +Compiled from "StoredProcedureQuery.java" +public interface jakarta.persistence.StoredProcedureQuery extends jakarta.persistence.Query { + public abstract jakarta.persistence.StoredProcedureQuery setHint(java.lang.String, java.lang.Object); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(jakarta.persistence.Parameter, T); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(jakarta.persistence.Parameter, java.util.Calendar, jakarta.persistence.TemporalType); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(jakarta.persistence.Parameter, java.util.Date, jakarta.persistence.TemporalType); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(java.lang.String, java.lang.Object); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(java.lang.String, java.util.Calendar, jakarta.persistence.TemporalType); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(java.lang.String, java.util.Date, jakarta.persistence.TemporalType); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(int, java.lang.Object); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(int, java.util.Calendar, jakarta.persistence.TemporalType); + public abstract jakarta.persistence.StoredProcedureQuery setParameter(int, java.util.Date, jakarta.persistence.TemporalType); + public abstract jakarta.persistence.StoredProcedureQuery setFlushMode(jakarta.persistence.FlushModeType); + public abstract jakarta.persistence.StoredProcedureQuery setCacheRetrieveMode(jakarta.persistence.CacheRetrieveMode); + public abstract jakarta.persistence.StoredProcedureQuery setCacheStoreMode(jakarta.persistence.CacheStoreMode); + public abstract jakarta.persistence.StoredProcedureQuery setTimeout(java.lang.Integer); + public abstract jakarta.persistence.StoredProcedureQuery registerStoredProcedureParameter(int, java.lang.Class, jakarta.persistence.ParameterMode); + public abstract jakarta.persistence.StoredProcedureQuery registerStoredProcedureParameter(java.lang.String, java.lang.Class, jakarta.persistence.ParameterMode); + public abstract java.lang.Object getOutputParameterValue(int); + public abstract java.lang.Object getOutputParameterValue(java.lang.String); + public abstract boolean execute(); + public abstract int executeUpdate(); + public abstract java.util.List getResultList(); + public abstract java.lang.Object getSingleResult(); + public abstract java.lang.Object getSingleResultOrNull(); + public abstract boolean hasMoreResults(); + public abstract int getUpdateCount(); + public default jakarta.persistence.Query setTimeout(java.lang.Integer); + public default jakarta.persistence.Query setCacheStoreMode(jakarta.persistence.CacheStoreMode); + public default jakarta.persistence.Query setCacheRetrieveMode(jakarta.persistence.CacheRetrieveMode); + public default jakarta.persistence.Query setFlushMode(jakarta.persistence.FlushModeType); + public default jakarta.persistence.Query setParameter(int, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(int, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(int, java.lang.Object); + public default jakarta.persistence.Query setParameter(java.lang.String, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(java.lang.String, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(java.lang.String, java.lang.Object); + public default jakarta.persistence.Query setParameter(jakarta.persistence.Parameter, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(jakarta.persistence.Parameter, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(jakarta.persistence.Parameter, java.lang.Object); + public default jakarta.persistence.Query setHint(java.lang.String, java.lang.Object); +} + +=== hibernate-core 7.4.5.Final: org.hibernate.procedure.ProcedureCall (native, JPA-superset) === +Compiled from "ProcedureCall.java" +public interface org.hibernate.procedure.ProcedureCall extends org.hibernate.query.CommonQueryContract,org.hibernate.query.SynchronizeableQuery,jakarta.persistence.StoredProcedureQuery,java.lang.AutoCloseable { + public static final java.lang.String FUNCTION_RETURN_TYPE_HINT; + public abstract java.lang.String getProcedureName(); + public abstract boolean isFunctionCall(); + public abstract org.hibernate.procedure.ProcedureCall markAsFunctionCall(int); + public abstract org.hibernate.procedure.ProcedureCall markAsFunctionCall(java.lang.Class); + public abstract org.hibernate.procedure.ProcedureCall markAsFunctionCall(jakarta.persistence.metamodel.Type); + public abstract org.hibernate.procedure.ProcedureParameter registerParameter(int, java.lang.Class, jakarta.persistence.ParameterMode); + public abstract org.hibernate.procedure.ProcedureParameter registerParameter(int, jakarta.persistence.metamodel.Type, jakarta.persistence.ParameterMode); + public abstract org.hibernate.procedure.ProcedureCall registerStoredProcedureParameter(int, jakarta.persistence.metamodel.Type, jakarta.persistence.ParameterMode); + public abstract org.hibernate.procedure.ProcedureParameter getParameterRegistration(int); + public abstract org.hibernate.procedure.ProcedureParameter registerParameter(java.lang.String, java.lang.Class, jakarta.persistence.ParameterMode) throws org.hibernate.procedure.NamedParametersNotSupportedException; + public abstract org.hibernate.procedure.ProcedureParameter registerParameter(java.lang.String, jakarta.persistence.metamodel.Type, jakarta.persistence.ParameterMode) throws org.hibernate.procedure.NamedParametersNotSupportedException; + public abstract org.hibernate.procedure.ProcedureCall registerStoredProcedureParameter(java.lang.String, jakarta.persistence.metamodel.Type, jakarta.persistence.ParameterMode); + public abstract org.hibernate.procedure.ProcedureParameter getParameterRegistration(java.lang.String); + public abstract java.util.List> getRegisteredParameters(); + public abstract org.hibernate.procedure.ProcedureOutputs getOutputs(); + public abstract org.hibernate.procedure.FunctionReturn getFunctionReturn(); + public default void close(); + public abstract org.hibernate.procedure.ProcedureCall addSynchronizedQuerySpace(java.lang.String); + public abstract org.hibernate.procedure.ProcedureCall addSynchronizedEntityName(java.lang.String) throws org.hibernate.MappingException; + public abstract org.hibernate.procedure.ProcedureCall addSynchronizedEntityClass(java.lang.Class) throws org.hibernate.MappingException; + public abstract org.hibernate.procedure.ProcedureCall setHint(java.lang.String, java.lang.Object); + public abstract org.hibernate.procedure.ProcedureCall setParameter(jakarta.persistence.Parameter, T); + public abstract org.hibernate.procedure.ProcedureCall setParameter(jakarta.persistence.Parameter, java.util.Calendar, jakarta.persistence.TemporalType); + public abstract org.hibernate.procedure.ProcedureCall setParameter(jakarta.persistence.Parameter, java.util.Date, jakarta.persistence.TemporalType); + public abstract org.hibernate.procedure.ProcedureCall setParameter(java.lang.String, java.lang.Object); + public abstract org.hibernate.procedure.ProcedureCall setParameter(java.lang.String, java.util.Calendar, jakarta.persistence.TemporalType); + public abstract org.hibernate.procedure.ProcedureCall setParameter(java.lang.String, java.util.Date, jakarta.persistence.TemporalType); + public abstract org.hibernate.procedure.ProcedureCall setParameter(int, java.lang.Object); + public abstract org.hibernate.procedure.ProcedureCall setParameter(int, java.util.Calendar, jakarta.persistence.TemporalType); + public abstract org.hibernate.procedure.ProcedureCall setParameter(int, java.util.Date, jakarta.persistence.TemporalType); + public abstract org.hibernate.procedure.ProcedureCall setFlushMode(jakarta.persistence.FlushModeType); + public abstract org.hibernate.procedure.ProcedureCall registerStoredProcedureParameter(int, java.lang.Class, jakarta.persistence.ParameterMode); + public abstract org.hibernate.procedure.ProcedureCall registerStoredProcedureParameter(java.lang.String, java.lang.Class, jakarta.persistence.ParameterMode); + public default org.hibernate.query.CommonQueryContract setParameter(jakarta.persistence.Parameter, java.util.Date, jakarta.persistence.TemporalType); + public default org.hibernate.query.CommonQueryContract setParameter(jakarta.persistence.Parameter, java.util.Calendar, jakarta.persistence.TemporalType); + public default org.hibernate.query.CommonQueryContract setParameter(jakarta.persistence.Parameter, java.lang.Object); + public default org.hibernate.query.CommonQueryContract setParameter(int, java.util.Calendar, jakarta.persistence.TemporalType); + public default org.hibernate.query.CommonQueryContract setParameter(int, java.util.Date, jakarta.persistence.TemporalType); + public default org.hibernate.query.CommonQueryContract setParameter(int, java.lang.Object); + public default org.hibernate.query.CommonQueryContract setParameter(java.lang.String, java.util.Date, jakarta.persistence.TemporalType); + public default org.hibernate.query.CommonQueryContract setParameter(java.lang.String, java.util.Calendar, jakarta.persistence.TemporalType); + public default org.hibernate.query.CommonQueryContract setParameter(java.lang.String, java.lang.Object); + public default org.hibernate.query.CommonQueryContract setHint(java.lang.String, java.lang.Object); + public default org.hibernate.query.CommonQueryContract setFlushMode(jakarta.persistence.FlushModeType); + public default org.hibernate.query.SynchronizeableQuery addSynchronizedEntityClass(java.lang.Class) throws org.hibernate.MappingException; + public default org.hibernate.query.SynchronizeableQuery addSynchronizedEntityName(java.lang.String) throws org.hibernate.MappingException; + public default org.hibernate.query.SynchronizeableQuery addSynchronizedQuerySpace(java.lang.String); + public default jakarta.persistence.StoredProcedureQuery registerStoredProcedureParameter(java.lang.String, java.lang.Class, jakarta.persistence.ParameterMode); + public default jakarta.persistence.StoredProcedureQuery registerStoredProcedureParameter(int, java.lang.Class, jakarta.persistence.ParameterMode); + public default jakarta.persistence.StoredProcedureQuery setFlushMode(jakarta.persistence.FlushModeType); + public default jakarta.persistence.StoredProcedureQuery setParameter(int, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.StoredProcedureQuery setParameter(int, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.StoredProcedureQuery setParameter(int, java.lang.Object); + public default jakarta.persistence.StoredProcedureQuery setParameter(java.lang.String, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.StoredProcedureQuery setParameter(java.lang.String, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.StoredProcedureQuery setParameter(java.lang.String, java.lang.Object); + public default jakarta.persistence.StoredProcedureQuery setParameter(jakarta.persistence.Parameter, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.StoredProcedureQuery setParameter(jakarta.persistence.Parameter, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.StoredProcedureQuery setParameter(jakarta.persistence.Parameter, java.lang.Object); + public default jakarta.persistence.StoredProcedureQuery setHint(java.lang.String, java.lang.Object); + public default jakarta.persistence.Query setFlushMode(jakarta.persistence.FlushModeType); + public default jakarta.persistence.Query setParameter(int, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(int, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(int, java.lang.Object); + public default jakarta.persistence.Query setParameter(java.lang.String, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(java.lang.String, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(java.lang.String, java.lang.Object); + public default jakarta.persistence.Query setParameter(jakarta.persistence.Parameter, java.util.Date, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(jakarta.persistence.Parameter, java.util.Calendar, jakarta.persistence.TemporalType); + public default jakarta.persistence.Query setParameter(jakarta.persistence.Parameter, java.lang.Object); + public default jakarta.persistence.Query setHint(java.lang.String, java.lang.Object); +} diff --git a/docs/output/proxy-entitygraph-fetch-vs-load.txt b/docs/output/proxy-entitygraph-fetch-vs-load.txt new file mode 100755 index 0000000..608a6fd --- /dev/null +++ b/docs/output/proxy-entitygraph-fetch-vs-load.txt @@ -0,0 +1,54 @@ +# EntityGraphFetchTest -- filtered run output. JUnit does not run @Test methods in +# declaration order, so match SQL shape to test by the FK columns selected, not by position: +# +# left join proxy_review only (no proxy_publisher columns) -> fetchgraph: +# named attribute (reviews) joined; publisher forced to LAZY despite its EAGER mapping. +# Confirmed by the very next line: "book.getPublisher() runtime class = ...HibernateProxy". +# left join proxy_publisher only (no proxy_review columns) -> no graph at all: +# plain find() honours the mapping as declared: publisher EAGER (joined), reviews LAZY (not joined). +# left join proxy_publisher AND left join proxy_review -> loadgraph: +# named attribute (reviews) joined AND the mapped-EAGER publisher stays joined too -- +# loadgraph only ADDS to the mapping's defaults, it never takes anything away. + +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Graph Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [Graph Book] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [Nice graph] +select pb1_0.id,pb1_0.publisher_id,pb1_0.title,r1_0.book_id,r1_0.id,r1_0.comment from proxy_book pb1_0 left join proxy_review r1_0 on pb1_0.id=r1_0.book_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [1] +fetchgraph: book.getPublisher() runtime class = com.ankurm.hibernatedemo.proxy.ProxyPublisher$HibernateProxy +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Graph Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [Graph Book] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [Nice graph] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [2] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Graph Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [Graph Book] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [Nice graph] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title,r1_0.book_id,r1_0.id,r1_0.comment from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id left join proxy_review r1_0 on pb1_0.id=r1_0.book_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [3] + +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- EntityGraphFetchTest diff --git a/docs/output/proxy-entitygraph-run.txt b/docs/output/proxy-entitygraph-run.txt new file mode 100755 index 0000000..bdba4ca --- /dev/null +++ b/docs/output/proxy-entitygraph-run.txt @@ -0,0 +1,125 @@ +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 +22:53:28.029 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hibernatedemo.proxy.EntityGraphFetchTest]: EntityGraphFetchTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration. +22:53:28.169 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hibernatedemo.HibernateDemoApplication for test class com.ankurm.hibernatedemo.proxy.EntityGraphFetchTest +22:53:28.247 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hibernatedemo.proxy.EntityGraphFetchTest]: EntityGraphFetchTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration. +22:53:28.249 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hibernatedemo.HibernateDemoApplication for test class com.ankurm.hibernatedemo.proxy.EntityGraphFetchTest +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +Hibernate: create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +create table proxy_book (id bigint generated by default as identity, title varchar(255), publisher_id bigint, primary key (id)) +Hibernate: create table proxy_book (id bigint generated by default as identity, title varchar(255), publisher_id bigint, primary key (id)) +create table proxy_publisher (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table proxy_publisher (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table proxy_review (id bigint generated by default as identity, comment varchar(255), book_id bigint, primary key (id)) +Hibernate: create table proxy_review (id bigint generated by default as identity, comment varchar(255), book_id bigint, primary key (id)) +create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence book_seq start with 1 increment by 50 +create sequence widget_alloc10_seq start with 1 increment by 10 +Hibernate: create sequence widget_alloc10_seq start with 1 increment by 10 +create sequence widget_alloc1_seq start with 1 increment by 1 +Hibernate: create sequence widget_alloc1_seq start with 1 increment by 1 +create sequence widget_alloc25_seq start with 1 increment by 25 +Hibernate: create sequence widget_alloc25_seq start with 1 increment by 25 +create sequence widget_alloc50_seq start with 1 increment by 50 +Hibernate: create sequence widget_alloc50_seq start with 1 increment by 50 +create sequence widget_batch_sweep1_seq start with 1 increment by 50 +Hibernate: create sequence widget_batch_sweep1_seq start with 1 increment by 50 +create sequence widget_seq start with 1 increment by 25 +Hibernate: create sequence widget_seq start with 1 increment by 25 +alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Hibernate: alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +alter table if exists proxy_book add constraint FKdwgf9ornbhstryty5erv6yks3 foreign key (publisher_id) references proxy_publisher +Hibernate: alter table if exists proxy_book add constraint FKdwgf9ornbhstryty5erv6yks3 foreign key (publisher_id) references proxy_publisher +alter table if exists proxy_review add constraint FKt4m0nd9t6swvakhmcnm0xclio foreign key (book_id) references proxy_book +Hibernate: alter table if exists proxy_review add constraint FKt4m0nd9t6swvakhmcnm0xclio foreign key (book_id) references proxy_book +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +WARNING: A Java agent has been loaded dynamically (/sessions/intelligent-loving-cori/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Graph Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [Graph Book] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [Nice graph] +select pb1_0.id,pb1_0.publisher_id,pb1_0.title,r1_0.book_id,r1_0.id,r1_0.comment from proxy_book pb1_0 left join proxy_review r1_0 on pb1_0.id=r1_0.book_id where pb1_0.id=? +Hibernate: select pb1_0.id,pb1_0.publisher_id,pb1_0.title,r1_0.book_id,r1_0.id,r1_0.comment from proxy_book pb1_0 left join proxy_review r1_0 on pb1_0.id=r1_0.book_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [1] +fetchgraph: book.getPublisher() runtime class = com.ankurm.hibernatedemo.proxy.ProxyPublisher$HibernateProxy +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Graph Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [Graph Book] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [Nice graph] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [2] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Graph Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [Graph Book] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [Nice graph] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title,r1_0.book_id,r1_0.id,r1_0.comment from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id left join proxy_review r1_0 on pb1_0.id=r1_0.book_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title,r1_0.book_id,r1_0.id,r1_0.comment from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id left join proxy_review r1_0 on pb1_0.id=r1_0.book_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [3] diff --git a/docs/output/proxy-lazy-and-identity-run.txt b/docs/output/proxy-lazy-and-identity-run.txt new file mode 100755 index 0000000..0508197 --- /dev/null +++ b/docs/output/proxy-lazy-and-identity-run.txt @@ -0,0 +1,156 @@ +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 +22:53:20.581 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hibernatedemo.proxy.ProxyIdentityTest]: ProxyIdentityTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration. +22:53:20.749 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hibernatedemo.HibernateDemoApplication for test class com.ankurm.hibernatedemo.proxy.ProxyIdentityTest +22:53:20.823 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hibernatedemo.proxy.ProxyIdentityTest]: ProxyIdentityTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration. +22:53:20.825 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hibernatedemo.HibernateDemoApplication for test class com.ankurm.hibernatedemo.proxy.ProxyIdentityTest +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +Hibernate: create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +create table proxy_book (id bigint generated by default as identity, title varchar(255), publisher_id bigint, primary key (id)) +Hibernate: create table proxy_book (id bigint generated by default as identity, title varchar(255), publisher_id bigint, primary key (id)) +create table proxy_publisher (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table proxy_publisher (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table proxy_review (id bigint generated by default as identity, comment varchar(255), book_id bigint, primary key (id)) +Hibernate: create table proxy_review (id bigint generated by default as identity, comment varchar(255), book_id bigint, primary key (id)) +create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence book_seq start with 1 increment by 50 +create sequence widget_alloc10_seq start with 1 increment by 10 +Hibernate: create sequence widget_alloc10_seq start with 1 increment by 10 +create sequence widget_alloc1_seq start with 1 increment by 1 +Hibernate: create sequence widget_alloc1_seq start with 1 increment by 1 +create sequence widget_alloc25_seq start with 1 increment by 25 +Hibernate: create sequence widget_alloc25_seq start with 1 increment by 25 +create sequence widget_alloc50_seq start with 1 increment by 50 +Hibernate: create sequence widget_alloc50_seq start with 1 increment by 50 +create sequence widget_batch_sweep1_seq start with 1 increment by 50 +Hibernate: create sequence widget_batch_sweep1_seq start with 1 increment by 50 +create sequence widget_seq start with 1 increment by 25 +Hibernate: create sequence widget_seq start with 1 increment by 25 +alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Hibernate: alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +alter table if exists proxy_book add constraint FKdwgf9ornbhstryty5erv6yks3 foreign key (publisher_id) references proxy_publisher +Hibernate: alter table if exists proxy_book add constraint FKdwgf9ornbhstryty5erv6yks3 foreign key (publisher_id) references proxy_publisher +alter table if exists proxy_review add constraint FKt4m0nd9t6swvakhmcnm0xclio foreign key (book_id) references proxy_book +Hibernate: alter table if exists proxy_review add constraint FKt4m0nd9t6swvakhmcnm0xclio foreign key (book_id) references proxy_book +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +WARNING: A Java agent has been loaded dynamically (/sessions/intelligent-loving-cori/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Identity Press] +getReference() runtime class: com.ankurm.hibernatedemo.proxy.ProxyPublisher$HibernateProxy +select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +Hibernate: select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +binding parameter (1:BIGINT) <- [1] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [Naive Equals Press] +select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +Hibernate: select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +binding parameter (1:BIGINT) <- [2] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [O'Reilly] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [Effective Hibernate] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [Great book] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [1] +verbatim exception class: org.hibernate.LazyInitializationException +verbatim exception message: Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews' with key '1' (no session) +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [O'Reilly] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [4] +binding parameter (2:VARCHAR) <- [Initialize Me] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [Great book] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [2] +select r1_0.book_id,r1_0.id,r1_0.comment from proxy_review r1_0 where r1_0.book_id=? +Hibernate: select r1_0.book_id,r1_0.id,r1_0.comment from proxy_review r1_0 where r1_0.book_id=? +binding parameter (1:BIGINT) <- [2] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [O'Reilly] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [5] +binding parameter (2:VARCHAR) <- [To-One Proxy Message] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [3] +binding parameter (2:VARCHAR) <- [Great book] +to-one proxy verbatim message: Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#3] - no session +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [O'Reilly] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [6] +binding parameter (2:VARCHAR) <- [Unproxy Me] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [4] +binding parameter (2:VARCHAR) <- [Great book] +getReference() proxy class: com.ankurm.hibernatedemo.proxy.ProxyBook$HibernateProxy +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [4] +Hibernate.unproxy(proxy) class: com.ankurm.hibernatedemo.proxy.ProxyBook diff --git a/docs/output/proxy-lazy-init-and-identity.txt b/docs/output/proxy-lazy-init-and-identity.txt new file mode 100755 index 0000000..9a538fd --- /dev/null +++ b/docs/output/proxy-lazy-init-and-identity.txt @@ -0,0 +1,22 @@ +# LazyInitializationTest + ProxyIdentityTest -- filtered run output (DEMO log lines only) +# Full raw run: proxy-lazy-and-identity-run.txt + +getReference() runtime class: com.ankurm.hibernatedemo.proxy.ProxyPublisher$HibernateProxy +select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +Hibernate: select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +Hibernate: select pp1_0.id,pp1_0.name from proxy_publisher pp1_0 where pp1_0.id=? +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +verbatim exception class: org.hibernate.LazyInitializationException +verbatim exception message: Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews' with key '1' (no session) +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +to-one proxy verbatim message: Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#3] - no session +getReference() proxy class: com.ankurm.hibernatedemo.proxy.ProxyBook$HibernateProxy +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate.unproxy(proxy) class: com.ankurm.hibernatedemo.proxy.ProxyBook + +Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- LazyInitializationTest +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- ProxyIdentityTest diff --git a/docs/output/proxy-osiv-and-no-trans.txt b/docs/output/proxy-osiv-and-no-trans.txt new file mode 100755 index 0000000..1739c52 --- /dev/null +++ b/docs/output/proxy-osiv-and-no-trans.txt @@ -0,0 +1,13 @@ +# OpenInViewAndLazyLoadNoTransTest + OsivDefaultWarningTest + OsivDisabledExceptionTest +# filtered run output + +enable_lazy_load_no_trans=true: proxy.getTitle() after close returned 'No-Trans Book' with no exception +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 +checked at context-startup time: the OSIV warning line is present in the captured log +default OSIV (true): GET /osiv/books/1 -> status 200, body {"title":"OSIV Default Book","publisher":{"name":"OSIV Default Press","id":1},"id":1,"reviews":[{"comment":"Rendered fine","id":1}]} +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)] +open-in-view=false: GET /osiv/books/2 -> status 500, body {"timestamp":"2026-09-05T17:23:41.574Z","status":500,"error":"Internal Server Error","path":"/osiv/books/2"} + +Tests run: 1 -- OpenInViewAndLazyLoadNoTransTest (enable_lazy_load_no_trans=true) +Tests run: 1 -- OsivDefaultWarningTest (open-in-view left unset -> Boot default true) +Tests run: 1 -- OsivDisabledExceptionTest (open-in-view=false) diff --git a/docs/output/proxy-osiv-run.txt b/docs/output/proxy-osiv-run.txt new file mode 100755 index 0000000..bfedfeb --- /dev/null +++ b/docs/output/proxy-osiv-run.txt @@ -0,0 +1,179 @@ +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 +22:53:35.358 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hibernatedemo.proxy.OpenInViewAndLazyLoadNoTransTest]: OpenInViewAndLazyLoadNoTransTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration. +22:53:35.483 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hibernatedemo.HibernateDemoApplication for test class com.ankurm.hibernatedemo.proxy.OpenInViewAndLazyLoadNoTransTest +22:53:35.551 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hibernatedemo.proxy.OpenInViewAndLazyLoadNoTransTest]: OpenInViewAndLazyLoadNoTransTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration. +22:53:35.553 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hibernatedemo.HibernateDemoApplication for test class com.ankurm.hibernatedemo.proxy.OpenInViewAndLazyLoadNoTransTest +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +Hibernate: create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +create table proxy_book (id bigint generated by default as identity, title varchar(255), publisher_id bigint, primary key (id)) +Hibernate: create table proxy_book (id bigint generated by default as identity, title varchar(255), publisher_id bigint, primary key (id)) +create table proxy_publisher (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table proxy_publisher (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table proxy_review (id bigint generated by default as identity, comment varchar(255), book_id bigint, primary key (id)) +Hibernate: create table proxy_review (id bigint generated by default as identity, comment varchar(255), book_id bigint, primary key (id)) +create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep50 (id bigint not null, name varchar(255), primary key (id)) +create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id)) +create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id)) +create sequence book_seq start with 1 increment by 50 +Hibernate: create sequence book_seq start with 1 increment by 50 +create sequence widget_alloc10_seq start with 1 increment by 10 +Hibernate: create sequence widget_alloc10_seq start with 1 increment by 10 +create sequence widget_alloc1_seq start with 1 increment by 1 +Hibernate: create sequence widget_alloc1_seq start with 1 increment by 1 +create sequence widget_alloc25_seq start with 1 increment by 25 +Hibernate: create sequence widget_alloc25_seq start with 1 increment by 25 +create sequence widget_alloc50_seq start with 1 increment by 50 +Hibernate: create sequence widget_alloc50_seq start with 1 increment by 50 +create sequence widget_batch_sweep1_seq start with 1 increment by 50 +Hibernate: create sequence widget_batch_sweep1_seq start with 1 increment by 50 +create sequence widget_seq start with 1 increment by 25 +Hibernate: create sequence widget_seq start with 1 increment by 25 +alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Hibernate: alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +alter table if exists proxy_book add constraint FKdwgf9ornbhstryty5erv6yks3 foreign key (publisher_id) references proxy_publisher +Hibernate: alter table if exists proxy_book add constraint FKdwgf9ornbhstryty5erv6yks3 foreign key (publisher_id) references proxy_publisher +alter table if exists proxy_review add constraint FKt4m0nd9t6swvakhmcnm0xclio foreign key (book_id) references proxy_book +Hibernate: alter table if exists proxy_review add constraint FKt4m0nd9t6swvakhmcnm0xclio foreign key (book_id) references proxy_book +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +WARNING: A Java agent has been loaded dynamically (/sessions/intelligent-loving-cori/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [No-Trans Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [1] +binding parameter (2:VARCHAR) <- [No-Trans Book] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [1] +enable_lazy_load_no_trans=true: proxy.getTitle() after close returned 'No-Trans Book' with no exception + + . ____ _ __ _ _ + /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ +( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ + \\/ ___)| |_)| | | | | || (_| | ) ) ) ) + ' |____| .__|_| |_|_| |_\__, | / / / / + =========|_|==============|___/=/_/_/_/ + + :: Spring Boot :: (v4.1.1) + +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name 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 global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create table book (id bigint not null, author varchar(255), status varchar(255), title varchar(255), version bigint not null, primary key (id)) +Hibernate: create table note (id bigint generated by default as identity, text varchar(255), book_id bigint, primary key (id)) +Hibernate: create table proxy_book (id bigint generated by default as identity, title varchar(255), publisher_id bigint, primary key (id)) +Hibernate: create table proxy_publisher (id bigint generated by default as identity, name varchar(255), primary key (id)) +Hibernate: create table proxy_review (id bigint generated by default as identity, comment varchar(255), book_id bigint, primary key (id)) +Hibernate: create table widget_alloc1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_alloc50 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep1 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep10 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep25 (id bigint not null, name varchar(255), primary key (id)) +Hibernate: create table widget_batch_sweep50 (id bigint not null, name varchar(255), 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_alloc10_seq start with 1 increment by 10 +Hibernate: create sequence widget_alloc1_seq start with 1 increment by 1 +Hibernate: create sequence widget_alloc25_seq start with 1 increment by 25 +Hibernate: create sequence widget_alloc50_seq start with 1 increment by 50 +Hibernate: create sequence widget_batch_sweep1_seq start with 1 increment by 50 +Hibernate: create sequence widget_seq start with 1 increment by 25 +Hibernate: alter table if exists note add constraint FKrussm9y4vwyp0x6gl8n288ovv foreign key (book_id) references book +Hibernate: alter table if exists proxy_book add constraint FKdwgf9ornbhstryty5erv6yks3 foreign key (publisher_id) references proxy_publisher +Hibernate: alter table if exists proxy_review add constraint FKt4m0nd9t6swvakhmcnm0xclio foreign key (book_id) references proxy_book +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 +checked at context-startup time: the OSIV warning line is present in the captured log +Hibernate: insert into proxy_publisher (name,id) values (?,default) +Hibernate: insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select r1_0.book_id,r1_0.id,r1_0.comment from proxy_review r1_0 where r1_0.book_id=? +default OSIV (true): GET /osiv/books/1 -> status 200, body {"title":"OSIV Default Book","publisher":{"name":"OSIV Default Press","id":1},"id":1,"reviews":[{"comment":"Rendered fine","id":1}]} +create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep1(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), status varchar(255), title varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc25(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name 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 +create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_alloc10(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +Hibernate: create global temporary table HTE_widget_batch_sweep50(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional +/* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyPublisher */insert into proxy_publisher (name,id) values (?,default) +binding parameter (1:VARCHAR) <- [OSIV Disabled Press] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyBook */insert into proxy_book (publisher_id,title,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [OSIV Disabled Book] +/* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +Hibernate: /* insert for com.ankurm.hibernatedemo.proxy.ProxyReview */insert into proxy_review (book_id,comment,id) values (?,?,default) +binding parameter (1:BIGINT) <- [2] +binding parameter (2:VARCHAR) <- [Never rendered] +select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +Hibernate: select pb1_0.id,p1_0.id,p1_0.name,pb1_0.title from proxy_book pb1_0 left join proxy_publisher p1_0 on p1_0.id=pb1_0.publisher_id where pb1_0.id=? +binding parameter (1:BIGINT) <- [2] +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)] +open-in-view=false: GET /osiv/books/2 -> status 500, body {"timestamp":"2026-09-05T17:23:41.574Z","status":500,"error":"Internal Server Error","path":"/osiv/books/2"} diff --git a/docs/output/proxy-settings-javap.txt b/docs/output/proxy-settings-javap.txt new file mode 100755 index 0000000..00edb22 --- /dev/null +++ b/docs/output/proxy-settings-javap.txt @@ -0,0 +1,62 @@ +# javap org.hibernate.Hibernate (hibernate-core-7.4.5.Final.jar) +Compiled from "Hibernate.java" +public final class org.hibernate.Hibernate { + public static void initialize(java.lang.Object) throws org.hibernate.HibernateException; + public static boolean isInitialized(java.lang.Object); + public static int size(java.util.Collection); + public static boolean isEmpty(java.util.Collection); + public static boolean contains(java.util.Collection, T); + public static V get(java.util.Map, K); + public static T get(java.util.List, int); + public static java.lang.Class getClass(T); + public static java.lang.Class getClassLazy(T); + public static boolean isInstance(java.lang.Object, java.lang.Class); + public static boolean isPropertyInitialized(E, jakarta.persistence.metamodel.Attribute); + public static boolean isPropertyInitialized(java.lang.Object, java.lang.String); + public static void initializeProperty(E, jakarta.persistence.metamodel.Attribute); + public static void initializeProperty(java.lang.Object, java.lang.String); + public static java.lang.Object unproxy(java.lang.Object); + public static T unproxy(T, java.lang.Class); + public static E createDetachedProxy(org.hibernate.SessionFactory, java.lang.Class, java.lang.Object); + public static org.hibernate.Hibernate$CollectionInterface> bag(); + public static org.hibernate.Hibernate$CollectionInterface> set(); + public static org.hibernate.Hibernate$CollectionInterface> list(); + public static org.hibernate.Hibernate$CollectionInterface> map(); + public static org.hibernate.Hibernate$CollectionInterface> sortedSet(); + public static org.hibernate.Hibernate$CollectionInterface> sortedMap(); + public static org.hibernate.Hibernate$CollectionInterface collection(java.lang.Class); + public static org.hibernate.LobHelper getLobHelper(); + static {}; +} + +# javap org.hibernate.cfg.TransactionSettings -- confirms hibernate.enable_lazy_load_no_trans still exists, annotated @Unsafe +Compiled from "TransactionSettings.java" +public interface org.hibernate.cfg.TransactionSettings { + public static final java.lang.String TRANSACTION_COORDINATOR_STRATEGY; + public static final java.lang.String JTA_PLATFORM; + public static final java.lang.String JTA_PLATFORM_RESOLVER; + public static final java.lang.String PREFER_USER_TRANSACTION; + public static final java.lang.String JTA_CACHE_TM; + public static final java.lang.String JTA_CACHE_UT; + public static final java.lang.String JTA_TRACK_BY_THREAD; + public static final java.lang.String ALLOW_JTA_TRANSACTION_ACCESS; + public static final java.lang.String AUTO_CLOSE_SESSION; + public static final java.lang.String FLUSH_BEFORE_COMPLETION; + public static final java.lang.String ENABLE_LAZY_LOAD_NO_TRANS; + public static final java.lang.String ALLOW_UPDATE_OUTSIDE_TRANSACTION; +} + +# javap org.hibernate.cfg.Unsafe -- marker annotation, no members +Compiled from "Unsafe.java" +public interface org.hibernate.cfg.Unsafe extends java.lang.annotation.Annotation { +} + +# javap org.hibernate.cfg.BytecodeSettings +Compiled from "BytecodeSettings.java" +public interface org.hibernate.cfg.BytecodeSettings { + public static final java.lang.String BYTECODE_PROVIDER; + public static final java.lang.String BYTECODE_PROVIDER_INSTANCE; + public static final java.lang.String ENHANCER_ENABLE_ASSOCIATION_MANAGEMENT; + public static final java.lang.String ENHANCER_ENABLE_DIRTY_TRACKING; + public static final java.lang.String ENHANCER_ENABLE_LAZY_INITIALIZATION; +} diff --git a/docs/output/testdb-create-table-ddl.txt b/docs/output/testdb-create-table-ddl.txt new file mode 100755 index 0000000..cb461c6 --- /dev/null +++ b/docs/output/testdb-create-table-ddl.txt @@ -0,0 +1,18 @@ +# Same TestDbWidget mapping, actual create table/sequence DDL captured per database +# (H2 plain, H2 MODE=PostgreSQL, H2 MODE=Oracle, HSQLDB, Derby) + +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:46.378 [main] INFO DEMO -- db=H2 -> resolved dialect = org.hibernate.dialect.H2Dialect +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.094 [main] INFO DEMO -- db=DERBY -> resolved dialect = org.hibernate.community.dialect.DerbyDialect +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.175 [main] INFO DEMO -- db=H2_POSTGRES_MODE -> resolved dialect = org.hibernate.dialect.H2Dialect +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.409 [main] INFO DEMO -- db=HSQLDB -> resolved dialect = org.hibernate.dialect.HSQLDialect +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.458 [main] INFO DEMO -- db=H2_ORACLE_MODE -> resolved dialect = org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/docs/output/testdb-crossdb-behavior.txt b/docs/output/testdb-crossdb-behavior.txt new file mode 100755 index 0000000..311f8ac --- /dev/null +++ b/docs/output/testdb-crossdb-behavior.txt @@ -0,0 +1,10 @@ +# CrossDatabaseBehaviorTest -- filtered run output + +23:20:04.009 [main] INFO DEMO -- unquoted 'value' column, db=H2 -> succeeded=false, detail=JdbcSQLSyntaxErrorException: Syntax error in SQL statement "create table reserved_word_test (id integer, [*]value integer)"; expected "identifier"; SQL statement: +23:20:04.011 [main] INFO DEMO -- unquoted 'value' column, db=HSQLDB -> succeeded=true, detail=CREATE TABLE succeeded with an unquoted 'value' column +23:20:04.011 [main] INFO DEMO -- unquoted 'value' column, db=DERBY -> succeeded=true, detail=CREATE TABLE succeeded with an unquoted 'value' column +23:20:04.242 [main] INFO DEMO -- CHAR(10) holding 'AB', db=H2 -> getString() = [AB ], length=10 +23:20:04.242 [main] INFO DEMO -- CHAR(10) holding 'AB', db=HSQLDB -> getString() = [AB ], length=10 +23:20:04.242 [main] INFO DEMO -- CHAR(10) holding 'AB', db=DERBY -> getString() = [AB ], length=10 + +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 diff --git a/docs/output/testdb-crossdb-run.txt b/docs/output/testdb-crossdb-run.txt new file mode 100755 index 0000000..e3649e3 --- /dev/null +++ b/docs/output/testdb-crossdb-run.txt @@ -0,0 +1,11 @@ +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:20:04.009 [main] INFO DEMO -- unquoted 'value' column, db=H2 -> succeeded=false, detail=JdbcSQLSyntaxErrorException: Syntax error in SQL statement "create table reserved_word_test (id integer, [*]value integer)"; expected "identifier"; SQL statement: +create table reserved_word_test (id integer, value integer) [42001-240] +23:20:04.011 [main] INFO DEMO -- unquoted 'value' column, db=HSQLDB -> succeeded=true, detail=CREATE TABLE succeeded with an unquoted 'value' column +23:20:04.011 [main] INFO DEMO -- unquoted 'value' column, db=DERBY -> succeeded=true, detail=CREATE TABLE succeeded with an unquoted 'value' column +23:20:04.242 [main] INFO DEMO -- CHAR(10) holding 'AB', db=H2 -> getString() = [AB ], length=10 +23:20:04.242 [main] INFO DEMO -- CHAR(10) holding 'AB', db=HSQLDB -> getString() = [AB ], length=10 +23:20:04.242 [main] INFO DEMO -- CHAR(10) holding 'AB', db=DERBY -> getString() = [AB ], length=10 diff --git a/docs/output/testdb-derby-dialect-not-found.txt b/docs/output/testdb-derby-dialect-not-found.txt new file mode 100755 index 0000000..5480281 --- /dev/null +++ b/docs/output/testdb-derby-dialect-not-found.txt @@ -0,0 +1,34 @@ +# Two real failures hit while wiring up Derby 10.16.1.1 against Hibernate ORM 7.4.5.Final. +# Both captured verbatim from actual test/debug runs in this sandbox. + +## Failure 1: dialect auto-detection refuses to guess for Derby +# (hibernate.dialect NOT set -- relying on JDBC metadata auto-detection, which works fine for +# both H2 and HSQLDB in this same test suite) + +org.hibernate.HibernateException: Unable to determine Dialect for Apache Derby 10.16 (please set 'hibernate.dialect' or register a Dialect resolver) + at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.determineDialect(DialectFactoryImpl.java:202) + at org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl.buildDialect(DialectFactoryImpl.java:86) + at org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator$1.execute(JdbcEnvironmentInitiator.java:398) + +# Note: the JDBC connection itself succeeded (product name/version WAS read: "Apache Derby +# 10.16") -- this is not a connectivity problem, it's Hibernate's dialect resolver chain simply +# not recognizing that product/version pair anymore. + +## Failure 2: the "obvious" fix (set hibernate.dialect explicitly to the old FQCN) also fails +# hibernate.dialect=org.hibernate.dialect.DerbyDialect + +Caused by: org.hibernate.boot.registry.classloading.spi.ClassLoadingException: Unable to load class [org.hibernate.dialect.DerbyDialect] +Caused by: java.lang.ClassNotFoundException: Could not load requested class: org.hibernate.dialect.DerbyDialect + +# org.hibernate.dialect.DerbyDialect does not exist anywhere in hibernate-core-7.4.5.Final.jar +# (confirmed: unzip -l hibernate-core-7.4.5.Final.jar | grep -i derby -> zero matches). + +## The actual fix + +# Add org.hibernate.orm:hibernate-community-dialects:7.4.5.Final (a SEPARATE artifact, +# NOT pulled in by hibernate-core, spring-boot-starter-data-jpa, or any Spring Boot starter) +# and set: +# hibernate.dialect=org.hibernate.community.dialect.DerbyDialect +# Confirmed present: unzip -l hibernate-community-dialects-7.4.5.Final.jar | grep -i derby +# -> org/hibernate/community/dialect/DerbyDialect.class (and DerbyLegacyDialect, for older +# Derby versions, also in this module). diff --git a/docs/output/testdb-dialect-run.txt b/docs/output/testdb-dialect-run.txt new file mode 100755 index 0000000..536f59c --- /dev/null +++ b/docs/output/testdb-dialect-run.txt @@ -0,0 +1,369 @@ +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:16:44.846 [main] INFO org.hibernate.orm.core -- HHH000001: Hibernate ORM core version 7.4.5.Final +23:16:45.153 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:16:45.346 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:testdb-plain;DB_CLOSE_DELAY=-1] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: TESTDB-PLAIN/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +Hibernate: create global temporary table HTE_TestDbWidget(active boolean, order integer, rn_ integer not null, sku varchar(5), id bigint, description clob, primary key (rn_)) transactional +23:16:46.348 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:46.378 [main] INFO DEMO -- db=H2 -> resolved dialect = org.hibernate.dialect.H2Dialect +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +23:16:46.444 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:16:46.955 [main] WARN org.hibernate.orm.jdbc -- HHH100123: Low default JDBC fetch size: 1 (consider setting 'hibernate.jdbc.fetch_size') +23:16:46.955 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:derby:memory:testdb-plain;create=true] + Database driver: Apache Derby Embedded JDBC Driver + Database dialect: DerbyDialect + Database version: 10.16.1 + Default catalog/schema: undefined/APP + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 1 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:16:46.986 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table TestDbWidget +23:16:47.041 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropTables(SchemaDropperImpl.java:376) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:248) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.DialectAndDdlTest.resolvedDialect(DialectAndDdlTest.java:23) + at com.ankurm.hibernatedemo.testdb.DialectAndDdlTest.derby_resolvesDerbyDialect_fromTheCommunityDialectsModule_notHibernateCore(DialectAndDdlTest.java:65) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DDLStatementNode.justGetDescriptor(DDLStatementNode.java:366) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:337) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:289) + at org.apache.derby.impl.sql.compile.DropTableNode.bindStatement(DropTableNode.java:98) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:16:47.046 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropSequences(SchemaDropperImpl.java:335) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:261) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.DialectAndDdlTest.resolvedDialect(DialectAndDdlTest.java:23) + at com.ankurm.hibernatedemo.testdb.DialectAndDdlTest.derby_resolvesDerbyDialect_fromTheCommunityDialectsModule_notHibernateCore(DialectAndDdlTest.java:65) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DropSequenceNode.bindStatement(DropSequenceNode.java:74) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.094 [main] INFO DEMO -- db=DERBY -> resolved dialect = org.hibernate.community.dialect.DerbyDialect +Hibernate: drop table TestDbWidget +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:16:47.144 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:16:47.149 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:testdb-pgmode;DB_CLOSE_DELAY=-1;MODE=PostgreSQL] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: TESTDB-PGMODE/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +Hibernate: create global temporary table HTE_TestDbWidget(active boolean, order integer, rn_ integer not null, sku varchar(5), id bigint, description clob, primary key (rn_)) transactional +23:16:47.171 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.175 [main] INFO DEMO -- db=H2_POSTGRES_MODE -> resolved dialect = org.hibernate.dialect.H2Dialect +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +23:16:47.198 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:16:47.372 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:hsqldb:mem:testdb-plain] + Database driver: HSQL Database Engine Driver + Database dialect: HSQLDialect + Database version: 2.7.3 + Default catalog/schema: PUBLIC/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: none + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:16:47.405 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.409 [main] INFO DEMO -- db=HSQLDB -> resolved dialect = org.hibernate.dialect.HSQLDialect +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +23:16:47.424 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:16:47.428 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:testdb-oraclemode;DB_CLOSE_DELAY=-1;MODE=Oracle] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: TESTDB-ORACLEMODE/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +Hibernate: create global temporary table HTE_TestDbWidget(active boolean, order integer, rn_ integer not null, sku varchar(5), id bigint, description clob, primary key (rn_)) transactional +23:16:47.453 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +23:16:47.458 [main] INFO DEMO -- db=H2_ORACLE_MODE -> resolved dialect = org.hibernate.dialect.H2Dialect +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ diff --git a/docs/output/testdb-jcache-classpath-pollution.txt b/docs/output/testdb-jcache-classpath-pollution.txt new file mode 100755 index 0000000..18bb3b4 --- /dev/null +++ b/docs/output/testdb-jcache-classpath-pollution.txt @@ -0,0 +1,17 @@ +The failure as it actually appeared in a full 'mvn -B test' run of this repository, +before hibernate.cache.use_second_level_cache was pinned to false in application.yml. +It is order-dependent: running the two classes on their own passes. + +[ERROR] ImmutableEntityTest.nativeSqlUpdate_onImmutableEntity_alwaysWorks:184 » Rollback Error while committing the transaction [Unable to perform afterTransactionCompletion callback: Cache[com.ank + +[ERROR] Tests run: 140, Failures: 0, Errors: 1, Skipped: 0 + +Why every Spring context in the project had a second-level cache at all: +$ mvn -o -B test -Dtest=JCacheOnClasspathAutoEnablesL2Test +second-level cache enabled = true +region factory = org.hibernate.cache.jcache.internal.JCacheRegionFactory +explicitly configured? = true + +Nothing in src/main/resources/application.yml mentions caching: +$ grep -ic cache src/main/resources/application.yml +0 diff --git a/docs/output/testdb-reserved-word-survey.txt b/docs/output/testdb-reserved-word-survey.txt new file mode 100755 index 0000000..e737edc --- /dev/null +++ b/docs/output/testdb-reserved-word-survey.txt @@ -0,0 +1,25 @@ +# Quick survey: 22 candidate column names, tried unquoted against H2 2.4.240, HSQLDB 2.7.3, Derby 10.16.1.1 +# via a raw 'create table'. Looking for a word where the three engines actually diverge. + +value: H2=FAIL HSQLDB=OK DERBY=OK +key: H2=FAIL HSQLDB=OK DERBY=FAIL +user: H2=FAIL HSQLDB=OK DERBY=FAIL +size: H2=OK HSQLDB=OK DERBY=OK +time: H2=OK HSQLDB=OK DERBY=OK +date: H2=OK HSQLDB=OK DERBY=OK +level: H2=OK HSQLDB=OK DERBY=OK +row: H2=FAIL HSQLDB=OK DERBY=OK +limit: H2=FAIL HSQLDB=OK DERBY=OK +role: H2=OK HSQLDB=OK DERBY=OK +count: H2=OK HSQLDB=OK DERBY=OK +year: H2=FAIL HSQLDB=OK DERBY=FAIL +type: H2=OK HSQLDB=OK DERBY=OK +text: H2=OK HSQLDB=OK DERBY=OK +data: H2=OK HSQLDB=OK DERBY=OK +name: H2=OK HSQLDB=OK DERBY=OK +number: H2=OK HSQLDB=OK DERBY=OK +index: H2=OK HSQLDB=OK DERBY=OK +state: H2=OK HSQLDB=OK DERBY=OK +status: H2=OK HSQLDB=OK DERBY=OK +group: H2=FAIL HSQLDB=FAIL DERBY=FAIL +check: H2=FAIL HSQLDB=FAIL DERBY=FAIL diff --git a/docs/output/testdb-startup-timing-run.txt b/docs/output/testdb-startup-timing-run.txt new file mode 100755 index 0000000..87c31ab --- /dev/null +++ b/docs/output/testdb-startup-timing-run.txt @@ -0,0 +1,959 @@ +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:22:21.650 [main] INFO org.hibernate.orm.core -- HHH000001: Hibernate ORM core version 7.4.5.Final +23:22:21.953 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:22.150 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:testdb-timing0;DB_CLOSE_DELAY=-1] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: TESTDB-TIMING0/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +Hibernate: create global temporary table HTE_TestDbWidget(active boolean, order integer, rn_ integer not null, sku varchar(5), id bigint, description clob, primary key (rn_)) transactional +23:22:23.166 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +23:22:23.235 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:23.240 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:testdb-timing1;DB_CLOSE_DELAY=-1] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: TESTDB-TIMING1/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +Hibernate: create global temporary table HTE_TestDbWidget(active boolean, order integer, rn_ integer not null, sku varchar(5), id bigint, description clob, primary key (rn_)) transactional +23:22:23.289 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +23:22:23.304 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:23.310 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:h2:mem:testdb-timing2;DB_CLOSE_DELAY=-1] + Database driver: H2 JDBC Driver + Database dialect: H2Dialect + Database version: 2.4.240 + Default catalog/schema: TESTDB-TIMING2/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 100 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +Hibernate: create global temporary table HTE_TestDbWidget(active boolean, order integer, rn_ integer not null, sku varchar(5), id bigint, description clob, primary key (rn_)) transactional +23:22:23.351 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence if exists TestDbWidget_SEQ +23:22:23.361 [main] INFO DEMO -- startup ms for H2 over 3 runs: 1628, 83, 62 (sandbox container -- indicative only, not a benchmark) +23:22:23.377 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:23.545 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:hsqldb:mem:testdb-timing0] + Database driver: HSQL Database Engine Driver + Database dialect: HSQLDialect + Database version: 2.7.3 + Default catalog/schema: PUBLIC/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: none + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:22:23.595 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +23:22:23.614 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:23.632 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:hsqldb:mem:testdb-timing1] + Database driver: HSQL Database Engine Driver + Database dialect: HSQLDialect + Database version: 2.7.3 + Default catalog/schema: PUBLIC/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: none + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:22:23.676 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +23:22:23.694 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:23.728 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:hsqldb:mem:testdb-timing2] + Database driver: HSQL Database Engine Driver + Database dialect: HSQLDialect + Database version: 2.7.3 + Default catalog/schema: PUBLIC/PUBLIC + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: none + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:22:23.754 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table if exists TestDbWidget cascade +Hibernate: drop sequence TestDbWidget_SEQ if exists +23:22:23.757 [main] INFO DEMO -- startup ms for HSQLDB over 3 runs: 238, 79, 74 (sandbox container -- indicative only, not a benchmark) +23:22:23.768 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:24.265 [main] WARN org.hibernate.orm.jdbc -- HHH100123: Low default JDBC fetch size: 1 (consider setting 'hibernate.jdbc.fetch_size') +23:22:24.266 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:derby:memory:testdb-timing0;create=true] + Database driver: Apache Derby Embedded JDBC Driver + Database dialect: DerbyDialect + Database version: 10.16.1 + Default catalog/schema: undefined/APP + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 1 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:22:24.301 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table TestDbWidget +23:22:24.353 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropTables(SchemaDropperImpl.java:376) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:248) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.timeOneBuild(StartupTimingTest.java:20) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.measureStartupAcrossThreeRunsPerDatabase(StartupTimingTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DDLStatementNode.justGetDescriptor(DDLStatementNode.java:366) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:337) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:289) + at org.apache.derby.impl.sql.compile.DropTableNode.bindStatement(DropTableNode.java:98) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:22:24.359 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropSequences(SchemaDropperImpl.java:335) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:261) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.timeOneBuild(StartupTimingTest.java:20) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.measureStartupAcrossThreeRunsPerDatabase(StartupTimingTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DropSequenceNode.bindStatement(DropSequenceNode.java:74) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table TestDbWidget +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:22:24.425 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:24.509 [main] WARN org.hibernate.orm.jdbc -- HHH100123: Low default JDBC fetch size: 1 (consider setting 'hibernate.jdbc.fetch_size') +23:22:24.509 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:derby:memory:testdb-timing1;create=true] + Database driver: Apache Derby Embedded JDBC Driver + Database dialect: DerbyDialect + Database version: 10.16.1 + Default catalog/schema: undefined/APP + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 1 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:22:24.562 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table TestDbWidget +23:22:24.575 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropTables(SchemaDropperImpl.java:376) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:248) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.timeOneBuild(StartupTimingTest.java:20) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.measureStartupAcrossThreeRunsPerDatabase(StartupTimingTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DDLStatementNode.justGetDescriptor(DDLStatementNode.java:366) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:337) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:289) + at org.apache.derby.impl.sql.compile.DropTableNode.bindStatement(DropTableNode.java:98) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:22:24.581 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropSequences(SchemaDropperImpl.java:335) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:261) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.timeOneBuild(StartupTimingTest.java:20) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.measureStartupAcrossThreeRunsPerDatabase(StartupTimingTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DropSequenceNode.bindStatement(DropSequenceNode.java:74) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table TestDbWidget +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:22:24.607 [main] WARN org.hibernate.orm.connections.pooling -- HHH10001002: Using built-in connection pool (not intended for production use) +23:22:24.686 [main] WARN org.hibernate.orm.jdbc -- HHH100123: Low default JDBC fetch size: 1 (consider setting 'hibernate.jdbc.fetch_size') +23:22:24.686 [main] INFO org.hibernate.orm.connections.pooling -- HHH10001005: Database info: + Database JDBC URL [jdbc:derby:memory:testdb-timing2;create=true] + Database driver: Apache Derby Embedded JDBC Driver + Database dialect: DerbyDialect + Database version: 10.16.1 + Default catalog/schema: undefined/APP + Autocommit mode: false + Isolation level: READ_COMMITTED + JDBC fetch size: 1 + Pool: DriverManagerConnectionProvider + Minimum pool size: 1 + Maximum pool size: 20 +23:22:24.709 [main] INFO org.hibernate.orm.core -- HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +Hibernate: drop table TestDbWidget +23:22:24.710 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop table TestDbWidget" via JDBC ['DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropTables(SchemaDropperImpl.java:376) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:248) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.timeOneBuild(StartupTimingTest.java:20) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.measureStartupAcrossThreeRunsPerDatabase(StartupTimingTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TABLE' cannot be performed on 'TESTDBWIDGET' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DDLStatementNode.justGetDescriptor(DDLStatementNode.java:366) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:337) + at org.apache.derby.impl.sql.compile.DDLStatementNode.getTableDescriptor(DDLStatementNode.java:289) + at org.apache.derby.impl.sql.compile.DropTableNode.bindStatement(DropTableNode.java:98) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:22:24.712 [main] WARN org.hibernate.tool.schema.internal.ExceptionHandlerLoggedImpl -- GenerationTarget encountered exception accepting command : Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] +org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "drop sequence TestDbWidget_SEQ restrict" via JDBC ['DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist.] + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:91) + at org.hibernate.tool.schema.internal.Helper.applySqlString(Helper.java:218) + at org.hibernate.tool.schema.internal.Helper.applySqlStrings(Helper.java:204) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropSequences(SchemaDropperImpl.java:335) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropConstraintsTablesSequences(SchemaDropperImpl.java:261) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.dropFromMetadata(SchemaDropperImpl.java:210) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.performDrop(SchemaDropperImpl.java:182) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:152) + at org.hibernate.tool.schema.internal.SchemaDropperImpl.doDrop(SchemaDropperImpl.java:112) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:227) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.lambda$process$1(SchemaManagementToolCoordinator.java:101) + at java.base/java.util.HashMap.forEach(HashMap.java:1430) + at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:100) + at org.hibernate.boot.internal.SessionFactoryObserverForSchemaExport.sessionFactoryCreated(SessionFactoryObserverForSchemaExport.java:35) + at org.hibernate.internal.SessionFactoryObserverChain.sessionFactoryCreated(SessionFactoryObserverChain.java:33) + at org.hibernate.internal.SessionFactoryImpl.(SessionFactoryImpl.java:327) + at org.hibernate.internal.SessionFactoryRegistry.instantiateSessionFactory(SessionFactoryRegistry.java:64) + at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:458) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:200) + at org.hibernate.boot.internal.MetadataImpl.buildSessionFactory(MetadataImpl.java:72) + at com.ankurm.hibernatedemo.testdb.TestDbSupport.buildSessionFactory(TestDbSupport.java:73) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.timeOneBuild(StartupTimingTest.java:20) + at com.ankurm.hibernatedemo.testdb.StartupTimingTest.measureStartupAcrossThreeRunsPerDatabase(StartupTimingTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:701) + at org.junit.platform.commons.support.ReflectionSupport.invokeMethod(ReflectionSupport.java:502) + at org.junit.jupiter.engine.support.MethodReflectionUtils.invoke(MethodReflectionUtils.java:45) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:61) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:124) + at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:163) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:148) + at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:123) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:105) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:99) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:66) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:47) + at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:39) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:104) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:98) + at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invokeVoid(InterceptingExecutableInvoker.java:71) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$0(TestMethodTestDescriptor.java:219) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:215) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:157) + at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:70) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:176) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:42) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$2(NodeTestTask.java:180) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$1(NodeTestTask.java:166) + at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:138) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$0(NodeTestTask.java:164) + at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:74) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:163) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:116) + at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:36) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:52) + at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:58) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.executeEngine(EngineExecutionOrchestrator.java:246) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.failOrExecuteEngine(EngineExecutionOrchestrator.java:218) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:179) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:66) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:157) + at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:65) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:125) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at org.junit.platform.launcher.core.InterceptingLauncher.lambda$execute$2(InterceptingLauncher.java:57) + at org.junit.platform.launcher.core.ClasspathAlignmentCheckingLauncherInterceptor.intercept(ClasspathAlignmentCheckingLauncherInterceptor.java:25) + at org.junit.platform.launcher.core.InterceptingLauncher.execute(InterceptingLauncher.java:56) + at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:58) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.apache.maven.surefire.api.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:125) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.executeWithCancellationToken(LauncherAdapter.java:68) + at org.apache.maven.surefire.junitplatform.LauncherAdapter.execute(LauncherAdapter.java:54) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:203) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:168) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:136) + at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) + at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) + at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) +Caused by: java.sql.SQLSyntaxErrorException: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.impl.jdbc.SQLExceptionFactory.getSQLException(SQLExceptionFactory.java:103) + at org.apache.derby.impl.jdbc.Util.generateCsSQLException(Util.java:230) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.wrapInSQLException(TransactionResourceImpl.java:431) + at org.apache.derby.impl.jdbc.TransactionResourceImpl.handleException(TransactionResourceImpl.java:360) + at org.apache.derby.impl.jdbc.EmbedConnection.handleException(EmbedConnection.java:2400) + at org.apache.derby.impl.jdbc.ConnectionChild.handleException(ConnectionChild.java:86) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:697) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:637) + at org.hibernate.tool.schema.internal.exec.GenerationTargetToDatabase.accept(GenerationTargetToDatabase.java:86) + ... 103 common frames omitted +Caused by: ERROR 42Y55: 'DROP TESTDBWIDGET_SEQ' cannot be performed on 'TESTDBWIDGET_SEQ' because it does not exist. + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:299) + at org.apache.derby.shared.common.error.StandardException.newException(StandardException.java:294) + at org.apache.derby.impl.sql.compile.DropSequenceNode.bindStatement(DropSequenceNode.java:74) + at org.apache.derby.impl.sql.GenericStatement.prepMinion(GenericStatement.java:401) + at org.apache.derby.impl.sql.GenericStatement.prepare(GenericStatement.java:99) + at org.apache.derby.impl.sql.conn.GenericLanguageConnectionContext.prepareInternalStatement(GenericLanguageConnectionContext.java:1114) + at org.apache.derby.impl.jdbc.EmbedStatement.execute(EmbedStatement.java:689) + ... 105 common frames omitted +Hibernate: create sequence TestDbWidget_SEQ start with 1 increment by 50 +Hibernate: create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id)) +Hibernate: drop table TestDbWidget +Hibernate: drop sequence TestDbWidget_SEQ restrict +23:22:24.728 [main] INFO DEMO -- startup ms for DERBY over 3 runs: 637, 176, 125 (sandbox container -- indicative only, not a benchmark) diff --git a/docs/output/testdb-startup-timing.txt b/docs/output/testdb-startup-timing.txt new file mode 100755 index 0000000..2905788 --- /dev/null +++ b/docs/output/testdb-startup-timing.txt @@ -0,0 +1,8 @@ +# Startup timing: SessionFactory build time (nanoTime, ms), 3 runs per database. +# Measured in a shared sandbox container -- NOT a benchmark, indicative of relative order of +# magnitude only. The first H2 run includes JVM/driver class-loading overhead common to +# whichever database happens to run first in the JVM. + +23:22:23.361 [main] INFO DEMO -- startup ms for H2 over 3 runs: 1628, 83, 62 (sandbox container -- indicative only, not a benchmark) +23:22:23.757 [main] INFO DEMO -- startup ms for HSQLDB over 3 runs: 238, 79, 74 (sandbox container -- indicative only, not a benchmark) +23:22:24.728 [main] INFO DEMO -- startup ms for DERBY over 3 runs: 637, 176, 125 (sandbox container -- indicative only, not a benchmark) diff --git a/pom.xml b/pom.xml new file mode 100755 index 0000000..f3983f0 --- /dev/null +++ b/pom.xml @@ -0,0 +1,235 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + hibernate-demo + 1.0.0 + hibernate-demo + + Companion repository for the ankurm.com Hibernate 7 batch: get() vs load(), merge() vs + refresh(), and inserting objects efficiently. + + + + 25 + + 7.4.5.Final + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.hsqldb + hsqldb + runtime + + + org.apache.derby + derby + runtime + + + org.apache.derby + derbytools + runtime + + + com.github.h-thurow + simple-jndi + 0.25.0 + test + + + tools.jackson.core + jackson-databind + + + + jakarta.validation + jakarta.validation-api + + + jakarta.inject + jakarta.inject-api + 2.0.1 + + + jakarta.enterprise + jakarta.enterprise.cdi-api + 4.1.0 + + + org.hibernate.orm + hibernate-jcache + + + javax.cache + cache-api + 1.1.1 + + + org.ehcache + ehcache + 3.10.8 + jakarta + + + org.glassfish.jaxb + jaxb-runtime + + + + + org.hibernate.orm + hibernate-community-dialects + runtime + + + org.springframework.boot + spring-boot-starter-web + test + + + + org.hibernate.orm + hibernate-hikaricp + test + + + + org.hibernate.validator + hibernate-validator-cdi + 9.1.3.Final + test + + + + org.jboss.weld.se + weld-se-core + 6.0.4.Final + test + + + + org.glassfish.expressly + expressly + 6.0.0 + test + + + + org.hibernate.search + hibernate-search-mapper-orm + 8.4.0.Final + + + + org.hibernate.search + hibernate-search-backend-lucene + 8.4.0.Final + runtime + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + org.hibernate.orm + hibernate-jpamodelgen + ${hibernate.version} + + + + + + + + diff --git a/scripts/check_links.py b/scripts/check_links.py new file mode 100644 index 0000000..e48004d --- /dev/null +++ b/scripts/check_links.py @@ -0,0 +1,38 @@ +import re, sys, os + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DOCS = os.path.join(ROOT, "docs") + +link_re = re.compile(r'\[[^\]]*\]\(([^)]+)\)') + +errors = [] +checked = 0 + +for dirpath, _, filenames in os.walk(DOCS): + for fn in filenames: + if not fn.endswith(".md"): + continue + path = os.path.join(dirpath, fn) + with open(path, encoding="utf-8") as f: + content = f.read() + for m in link_re.finditer(content): + target = m.group(1).strip() + if target.startswith(("http://", "https://", "mailto:")): + continue + # strip fragment + target_path = target.split("#", 1)[0] + if not target_path: + continue + resolved = os.path.normpath(os.path.join(dirpath, target_path)) + checked += 1 + if not os.path.exists(resolved): + errors.append(f"{os.path.relpath(path, ROOT)}: broken link -> {target} (resolved: {os.path.relpath(resolved, ROOT)})") + +print(f"Checked {checked} relative links across docs/*.md") +if errors: + print(f"\n{len(errors)} BROKEN LINK(S):") + for e in errors: + print(" " + e) + sys.exit(1) +else: + print("All relative links resolve to existing files.") diff --git a/scripts/clean_output.py b/scripts/clean_output.py new file mode 100755 index 0000000..5834a1d --- /dev/null +++ b/scripts/clean_output.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Turn a raw mvn spring-boot:run capture into a clean, reproducible transcript. + +Strips JVM/build noise (sun.misc.Unsafe warnings, JAVA_TOOL_OPTIONS proxy banners) and the +duplicate un-prefixed echo of each SQL statement that org.hibernate.SQL's show_sql=true prints +to stdout in addition to the "Hibernate: ..." line the logger emits -- same text twice, so only +the logger-prefixed copy is kept. +""" +import re +import sys + +DROP_PREFIXES = ( + "WARNING:", + "Picked up JAVA_TOOL_OPTIONS", +) + + +def clean(lines): + out = [] + for line in lines: + stripped = line.rstrip("\n") + if any(stripped.startswith(p) for p in DROP_PREFIXES): + continue + # Drop the bare SQL echo line that show_sql=true prints without the "Hibernate: " prefix + # -- it is always immediately followed by the same text WITH the prefix. + out.append(stripped) + deduped = [] + i = 0 + while i < len(out): + cur = out[i] + nxt = out[i + 1] if i + 1 < len(out) else None + if nxt is not None and nxt == "Hibernate: " + cur: + i += 1 # skip the bare echo, keep the prefixed one on the next iteration + continue + deduped.append(out[i]) + i += 1 + return deduped + + +if __name__ == "__main__": + src, dst = sys.argv[1], sys.argv[2] + with open(src, encoding="utf-8") as f: + lines = f.readlines() + cleaned = clean(lines) + with open(dst, "w", encoding="utf-8") as f: + f.write("\n".join(cleaned) + "\n") + print(f"{src}: {len(lines)} -> {dst}: {len(cleaned)} lines") diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..78b1fc9 --- /dev/null +++ b/scripts/run-all.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Regenerate every file in docs/output/ from a real run. This is the script referenced by +# "regenerated by one command" in the post and the README -- if it stops producing the same +# shape of output, the docs are wrong until it's fixed, not the other way around. +set -eu + +cd "$(dirname "$0")/.." +RAW_DIR="$(mktemp -d)" +trap 'rm -rf "$RAW_DIR"' EXIT + +run_one() { + local profile="$1" outfile="$2" + echo "=== running profile: $profile ===" >&2 + mvn -q -B org.springframework.boot:spring-boot-maven-plugin:run \ + -Dspring-boot.run.profiles="$profile" 2>&1 \ + | grep -v '^Picked up JAVA_TOOL_OPTIONS' \ + | grep -v '^WARNING:' \ + > "$RAW_DIR/$profile.raw.txt" + python3 scripts/clean_output.py "$RAW_DIR/$profile.raw.txt" "docs/output/$outfile" +} + +run_one getvsload get-vs-load.txt +run_one mergerefresh merge-vs-refresh.txt +run_one insert-identity insert-identity.txt +run_one insert-sequence insert-sequence.txt + +echo "docs/output/ regenerated." >&2 diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..bac59f9 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Run one scenario in the foreground and exit. +# +# ./scripts/run.sh getvsload +# ./scripts/run.sh mergerefresh +# ./scripts/run.sh insert-identity +# ./scripts/run.sh insert-sequence +# +# Every scenario here is a CommandLineRunner against an in-memory H2 database with +# spring.main.web-application-type=none, so there is no server to keep alive and nothing to +# kill afterwards -- the process runs the scenario and exits on its own. +set -eu + +PROFILE="${1:?usage: run.sh }" + +cd "$(dirname "$0")/.." +mvn -q -B org.springframework.boot:spring-boot-maven-plugin:run \ + -Dspring-boot.run.profiles="$PROFILE" diff --git a/src/main/java/com/ankurm/hibernatedemo/HibernateDemoApplication.java b/src/main/java/com/ankurm/hibernatedemo/HibernateDemoApplication.java new file mode 100755 index 0000000..ec20c8e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/HibernateDemoApplication.java @@ -0,0 +1,27 @@ +package com.ankurm.hibernatedemo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Entry point for the companion demos behind three ankurm.com Hibernate 7 posts: + * + *
    + *
  • {@code get-vs-load} — docs/01-get-vs-load.md, {@link com.ankurm.hibernatedemo.scenario.GetVsLoadRunner}
  • + *
  • {@code merge-vs-refresh} — docs/02-merge-vs-refresh.md, {@link com.ankurm.hibernatedemo.scenario.MergeVsRefreshRunner}
  • + *
  • {@code insert-identity} / {@code insert-sequence} — docs/03-inserting-objects.md, + * {@link com.ankurm.hibernatedemo.scenario.InsertIdentityRunner} and + * {@link com.ankurm.hibernatedemo.scenario.InsertSequenceRunner}
  • + *
+ * + * Each scenario is a profile-gated {@link org.springframework.boot.CommandLineRunner} that runs + * once against an in-memory H2 database and exits — there is no web server to keep alive, + * so {@code scripts/run.sh <profile>} is a plain foreground {@code mvn spring-boot:run} call. + */ +@SpringBootApplication +public class HibernateDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(HibernateDemoApplication.class, args); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/aggregate/CategorySummary.java b/src/main/java/com/ankurm/hibernatedemo/aggregate/CategorySummary.java new file mode 100644 index 0000000..707046e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/aggregate/CategorySummary.java @@ -0,0 +1,11 @@ +package com.ankurm.hibernatedemo.aggregate; + +/** + * A Java record used as a {@code select new} constructor-expression target -- Hibernate 7 accepts + * a record's canonical constructor here exactly like it accepts a class constructor, so a + * GROUP BY summary can come back as a real typed record instead of an {@code Object[]}. + * + *

Docs: docs/21-aggregate-functions.md. + */ +public record CategorySummary(String category, long productCount, double averagePrice) { +} diff --git a/src/main/java/com/ankurm/hibernatedemo/aggregate/Product.java b/src/main/java/com/ankurm/hibernatedemo/aggregate/Product.java new file mode 100644 index 0000000..be0b683 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/aggregate/Product.java @@ -0,0 +1,61 @@ +package com.ankurm.hibernatedemo.aggregate; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * A deliberately plain entity for chapter 21's aggregate-function tests: one numeric column + * ({@code price}) and one nullable numeric column ({@code stockQuantity}) so the null-handling + * behavior of {@code AVG}/{@code SUM} over a partially-null column has something real to bite on. + * + *

Docs: docs/21-aggregate-functions.md. + */ +@Entity +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String category; + + private String name; + + private Double price; + + /** Deliberately nullable -- a discontinued product with unknown stock is modelled as NULL, not 0. */ + private Integer stockQuantity; + + protected Product() { + // for Hibernate + } + + public Product(String category, String name, Double price, Integer stockQuantity) { + this.category = category; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + } + + public Long getId() { + return id; + } + + public String getCategory() { + return category; + } + + public String getName() { + return name; + } + + public Double getPrice() { + return price; + } + + public Integer getStockQuantity() { + return stockQuantity; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/AssocAuthor.java b/src/main/java/com/ankurm/hibernatedemo/association/AssocAuthor.java new file mode 100755 index 0000000..6cee124 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/AssocAuthor.java @@ -0,0 +1,53 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.NamedEntityGraph; +import jakarta.persistence.NamedAttributeNode; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; + +/** + * Plain (non-batched) author used for the N+1 / fetch-join / entity-graph comparison in + * docs/12-association-mappings.md, chapter "Counting the N+1". No {@code @BatchSize} here on + * purpose — {@link BatchAuthor} is the batched twin used for the fourth number. + */ +@Entity +@Table(name = "assoc_author") +@NamedEntityGraph(name = "AssocAuthor.books", attributeNodes = @NamedAttributeNode("books")) +public class AssocAuthor { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private List books = new ArrayList<>(); + + protected AssocAuthor() { + } + + public AssocAuthor(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public List getBooks() { + return books; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/AssocBook.java b/src/main/java/com/ankurm/hibernatedemo/association/AssocBook.java new file mode 100755 index 0000000..03f8db1 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/AssocBook.java @@ -0,0 +1,50 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +/** Owning side (holds the FK) of {@link AssocAuthor#getBooks()}. Docs: 12-association-mappings.md. */ +@Entity +@Table(name = "assoc_book") +public class AssocBook { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private AssocAuthor author; + + protected AssocBook() { + } + + public AssocBook(String title, AssocAuthor author) { + this.title = title; + this.author = author; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public AssocAuthor getAuthor() { + return author; + } + + public void setAuthor(AssocAuthor author) { + this.author = author; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BagAuthorList.java b/src/main/java/com/ankurm/hibernatedemo/association/BagAuthorList.java new file mode 100755 index 0000000..12b2e3a --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BagAuthorList.java @@ -0,0 +1,53 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; + +/** + * Deliberately has TWO {@code List} (bag) collections so that fetch-joining both in one JPQL + * query reproduces {@code org.hibernate.loader.MultipleBagFetchException}. Docs: 12-association-mappings.md, + * chapter "MultipleBagFetchException". + */ +@Entity +@Table(name = "bag_author_list") +public class BagAuthorList { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private List books = new ArrayList<>(); + + @OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private List awards = new ArrayList<>(); + + protected BagAuthorList() { + } + + public BagAuthorList(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public List getBooks() { + return books; + } + + public List getAwards() { + return awards; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BagAuthorSet.java b/src/main/java/com/ankurm/hibernatedemo/association/BagAuthorSet.java new file mode 100755 index 0000000..b984218 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BagAuthorSet.java @@ -0,0 +1,53 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.HashSet; +import java.util.Set; + +/** + * Same shape as {@link BagAuthorList} but with {@code Set} collections — fetch-joining + * both does NOT throw {@code MultipleBagFetchException} (that is Fix #1), but it does reproduce + * the cartesian-product row explosion: one SQL row per (book, award) pair per author. + */ +@Entity +@Table(name = "bag_author_set") +public class BagAuthorSet { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private Set books = new HashSet<>(); + + @OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private Set awards = new HashSet<>(); + + protected BagAuthorSet() { + } + + public BagAuthorSet(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public Set getBooks() { + return books; + } + + public Set getAwards() { + return awards; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BagAwardL.java b/src/main/java/com/ankurm/hibernatedemo/association/BagAwardL.java new file mode 100755 index 0000000..089e7fd --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BagAwardL.java @@ -0,0 +1,37 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "bag_award_l") +public class BagAwardL { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private BagAuthorList author; + + protected BagAwardL() { + } + + public BagAwardL(String name, BagAuthorList author) { + this.name = name; + this.author = author; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BagAwardS.java b/src/main/java/com/ankurm/hibernatedemo/association/BagAwardS.java new file mode 100755 index 0000000..be7073d --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BagAwardS.java @@ -0,0 +1,37 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "bag_award_s") +public class BagAwardS { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private BagAuthorSet author; + + protected BagAwardS() { + } + + public BagAwardS(String name, BagAuthorSet author) { + this.name = name; + this.author = author; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BagBookL.java b/src/main/java/com/ankurm/hibernatedemo/association/BagBookL.java new file mode 100755 index 0000000..cfb5b6f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BagBookL.java @@ -0,0 +1,37 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "bag_book_l") +public class BagBookL { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private BagAuthorList author; + + protected BagBookL() { + } + + public BagBookL(String title, BagAuthorList author) { + this.title = title; + this.author = author; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BagBookS.java b/src/main/java/com/ankurm/hibernatedemo/association/BagBookS.java new file mode 100755 index 0000000..e37cf44 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BagBookS.java @@ -0,0 +1,37 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "bag_book_s") +public class BagBookS { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private BagAuthorSet author; + + protected BagBookS() { + } + + public BagBookS(String title, BagAuthorSet author) { + this.title = title; + this.author = author; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BatchAuthor.java b/src/main/java/com/ankurm/hibernatedemo/association/BatchAuthor.java new file mode 100755 index 0000000..e1aca8f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BatchAuthor.java @@ -0,0 +1,47 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import org.hibernate.annotations.BatchSize; + +/** + * Same shape as {@link AssocAuthor} but the {@code books} collection carries + * {@code @BatchSize(size = 10)} — the fourth number in the N+1 comparison table. + */ +@Entity +@Table(name = "batch_author") +public class BatchAuthor { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @BatchSize(size = 10) + @OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private List books = new ArrayList<>(); + + protected BatchAuthor() { + } + + public BatchAuthor(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public List getBooks() { + return books; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/BatchBook.java b/src/main/java/com/ankurm/hibernatedemo/association/BatchBook.java new file mode 100755 index 0000000..4775f46 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/BatchBook.java @@ -0,0 +1,37 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "batch_book") +public class BatchBook { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private BatchAuthor author; + + protected BatchBook() { + } + + public BatchBook(String title, BatchAuthor author) { + this.title = title; + this.author = author; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/CascadeAuthor.java b/src/main/java/com/ankurm/hibernatedemo/association/CascadeAuthor.java new file mode 100755 index 0000000..d9c6aba --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/CascadeAuthor.java @@ -0,0 +1,55 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; + +/** + * {@code cascade = ALL, orphanRemoval = true}: the "developer did not expect this" cascade + * trap. Docs: 12-association-mappings.md, chapter "Cascade and orphanRemoval". + */ +@Entity +@Table(name = "cascade_author") +public class CascadeAuthor { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List books = new ArrayList<>(); + + protected CascadeAuthor() { + } + + public CascadeAuthor(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public List getBooks() { + return books; + } + + /** + * The bug: a developer "resets" the list by assigning a brand new collection instead of + * mutating the existing one (a common pattern when mapping from a DTO). With + * {@code orphanRemoval = true}, Hibernate sees every previously-owned book missing from the + * new collection and deletes all of them on flush. + */ + public void replaceBooksWithNewList(List newBooks) { + this.books = new ArrayList<>(newBooks); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/CascadeBook.java b/src/main/java/com/ankurm/hibernatedemo/association/CascadeBook.java new file mode 100755 index 0000000..1052c86 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/CascadeBook.java @@ -0,0 +1,41 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "cascade_book") +public class CascadeBook { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private CascadeAuthor author; + + protected CascadeBook() { + } + + public CascadeBook(String title, CascadeAuthor author) { + this.title = title; + this.author = author; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/LazyProfile.java b/src/main/java/com/ankurm/hibernatedemo/association/LazyProfile.java new file mode 100755 index 0000000..2ac7648 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/LazyProfile.java @@ -0,0 +1,38 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +/** Owning side (holds {@code user_id} FK) of {@link LazyUser#getProfile()}. */ +@Entity +@Table(name = "lazy_profile") +public class LazyProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String bio; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private LazyUser user; + + protected LazyProfile() { + } + + public LazyProfile(String bio, LazyUser user) { + this.bio = bio; + this.user = user; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/LazyUser.java b/src/main/java/com/ankurm/hibernatedemo/association/LazyUser.java new file mode 100755 index 0000000..b6bf5ce --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/LazyUser.java @@ -0,0 +1,50 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +/** + * Non-owning ("mappedBy") side of an optional {@code @OneToOne}. Docs: 12-association-mappings.md, + * chapter "The @OneToOne lazy trap". Even though {@link #profile} is declared + * {@code FetchType.LAZY}, Hibernate cannot build a proxy for it here without bytecode + * enhancement: it does not hold the foreign key, so it cannot know whether a profile row + * exists without querying. The result is an eager extra SELECT on every load of LazyUser. + */ +@Entity +@Table(name = "lazy_user") +public class LazyUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; + + @OneToOne(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL, optional = true) + private LazyProfile profile; + + protected LazyUser() { + } + + public LazyUser(String username) { + this.username = username; + } + + public Long getId() { + return id; + } + + public LazyProfile getProfile() { + return profile; + } + + public void setProfile(LazyProfile profile) { + this.profile = profile; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/MiProfile.java b/src/main/java/com/ankurm/hibernatedemo/association/MiProfile.java new file mode 100755 index 0000000..95aafc2 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/MiProfile.java @@ -0,0 +1,36 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +/** Owning side using {@code @MapsId}: its {@code @Id} IS the {@code user_id} FK value. */ +@Entity +@Table(name = "mi_profile") +public class MiProfile { + + @Id + private Long id; + + private String bio; + + @MapsId + @OneToOne + @JoinColumn(name = "id") + private MiUser user; + + protected MiProfile() { + } + + public MiProfile(String bio, MiUser user) { + this.bio = bio; + this.user = user; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/MiUser.java b/src/main/java/com/ankurm/hibernatedemo/association/MiUser.java new file mode 100755 index 0000000..4756cd7 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/MiUser.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * The @MapsId fix: this entity carries NO inverse {@code @OneToOne} field at all. A profile is + * looked up on demand with {@code session.find(MiProfile.class, userId)} because it shares the + * same primary key value as the user (see {@link MiProfile}), so there is nothing to proxy and + * nothing forces an eager join when loading a user. + */ +@Entity +@Table(name = "mi_user") +public class MiUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; + + protected MiUser() { + } + + public MiUser(String username) { + this.username = username; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/NoOrphanAuthor.java b/src/main/java/com/ankurm/hibernatedemo/association/NoOrphanAuthor.java new file mode 100755 index 0000000..37e55f6 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/NoOrphanAuthor.java @@ -0,0 +1,47 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; + +/** + * {@code cascade = {PERSIST, MERGE}}, no {@code orphanRemoval}. Used for two things: + * (1) removing a child from the in-memory collection and flushing does nothing to the row + * (docs: "orphanRemoval off"), and (2) mutating only this inverse side (the collection) without + * touching {@link NoOrphanBook#setAuthor} never writes the FK (docs: "owning side"). + */ +@Entity +@Table(name = "no_orphan_author") +public class NoOrphanAuthor { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @OneToMany(mappedBy = "author", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY) + private List books = new ArrayList<>(); + + protected NoOrphanAuthor() { + } + + public NoOrphanAuthor(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public List getBooks() { + return books; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/association/NoOrphanBook.java b/src/main/java/com/ankurm/hibernatedemo/association/NoOrphanBook.java new file mode 100755 index 0000000..f7843eb --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/association/NoOrphanBook.java @@ -0,0 +1,45 @@ +package com.ankurm.hibernatedemo.association; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "no_orphan_book") +public class NoOrphanBook { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private NoOrphanAuthor author; + + protected NoOrphanBook() { + } + + public NoOrphanBook(String title, NoOrphanAuthor author) { + this.title = title; + this.author = author; + } + + public Long getId() { + return id; + } + + public NoOrphanAuthor getAuthor() { + return author; + } + + public void setAuthor(NoOrphanAuthor author) { + this.author = author; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/bootstrap/BootstrapUser.java b/src/main/java/com/ankurm/hibernatedemo/bootstrap/BootstrapUser.java new file mode 100644 index 0000000..b4ec4c5 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/bootstrap/BootstrapUser.java @@ -0,0 +1,54 @@ +package com.ankurm.hibernatedemo.bootstrap; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * Backs ankurm.com post 4855 (bootstrapping EntityManager). Docs: docs/17-entitymanager-bootstrap.md. + * + *

Deliberately NOT scanned by Spring's own auto-configured {@code EntityManagerFactory} + * (see {@code META-INF/persistence.xml} under {@code src/test/resources}, which is what + * {@link EntityManagerBootstrapTest} actually bootstraps against) -- this chapter is about raw + * JPA bootstrapping, deliberately bypassing Spring Boot's autoconfiguration entirely so the two + * paths the fictional original article described (XML vs {@code PersistenceConfiguration}) are + * both exercised for real. + */ +@Entity +public class BootstrapUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private String email; + + protected BootstrapUser() { + // JPA + } + + public BootstrapUser(String name, String email) { + this.name = name; + this.email = email; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + + @Override + public String toString() { + return "BootstrapUser{id=%s, name=%s, email=%s}".formatted(id, name, email); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/cache/CacheProduct.java b/src/main/java/com/ankurm/hibernatedemo/cache/CacheProduct.java new file mode 100644 index 0000000..2def01f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/cache/CacheProduct.java @@ -0,0 +1,59 @@ +package com.ankurm.hibernatedemo.cache; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +/** + * Entity-level L2 cache target for the Ehcache 3 configuration chapter. Unlike + * {@code CachedNaturalIdProduct} (chapter 06), this entity has no natural id at all -- + * {@code @Cacheable}/{@code @Cache} here caches lookups by primary key, the ordinary case the + * original article's {@code Product} class demonstrated. + * + *

Region name matches the {@code productCache} alias configured in + * {@code src/test/resources/ehcache-chapter18.xml}, deliberately, so a typo here shows up as a + * cache miss rather than a silent fallback to Ehcache's programmatic default. + * + *

Docs: docs/18-ehcache-l2-configuration.md + */ +@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; + + public CacheProduct() { + } + + public CacheProduct(String name, Double price) { + this.name = name; + this.price = price; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Double getPrice() { + return price; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/cache/UncachedProduct.java b/src/main/java/com/ankurm/hibernatedemo/cache/UncachedProduct.java new file mode 100644 index 0000000..518db35 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/cache/UncachedProduct.java @@ -0,0 +1,40 @@ +package com.ankurm.hibernatedemo.cache; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * Same shape as {@link CacheProduct}, deliberately with no {@code @Cacheable}/{@code @Cache} at + * all. Used only by {@code QueryCacheWithoutEntityCacheTest} to demonstrate the "Query Cache + * without Entity Cache causes N+1 selects" pitfall the original article listed but never + * measured. + * + *

Docs: docs/18-ehcache-l2-configuration.md + */ +@Entity +public class UncachedProduct { + + @Id + @GeneratedValue + private Long id; + + @Column(nullable = false) + private String name; + + public UncachedProduct() { + } + + public UncachedProduct(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/datetime/NanoPrecisionEntity.java b/src/main/java/com/ankurm/hibernatedemo/datetime/NanoPrecisionEntity.java new file mode 100755 index 0000000..63dfc3b --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/datetime/NanoPrecisionEntity.java @@ -0,0 +1,58 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.time.LocalDateTime; + +/** Docs: 13-date-and-time-mapping.md, chapter "Second-precision / truncation". */ +@Entity +@Table(name = "nano_precision") +public class NanoPrecisionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDateTime plainLocalDateTime; + + @Column(precision = 9) + private LocalDateTime highPrecisionLocalDateTime; + + private Instant plainInstant; + + protected NanoPrecisionEntity() { + } + + public Long getId() { + return id; + } + + public LocalDateTime getPlainLocalDateTime() { + return plainLocalDateTime; + } + + public void setPlainLocalDateTime(LocalDateTime v) { + this.plainLocalDateTime = v; + } + + public LocalDateTime getHighPrecisionLocalDateTime() { + return highPrecisionLocalDateTime; + } + + public void setHighPrecisionLocalDateTime(LocalDateTime v) { + this.highPrecisionLocalDateTime = v; + } + + public Instant getPlainInstant() { + return plainInstant; + } + + public void setPlainInstant(Instant v) { + this.plainInstant = v; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/datetime/TemporalOnJavaTimeEntity.java b/src/main/java/com/ankurm/hibernatedemo/datetime/TemporalOnJavaTimeEntity.java new file mode 100755 index 0000000..63de93e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/datetime/TemporalOnJavaTimeEntity.java @@ -0,0 +1,42 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import java.time.Instant; + +/** + * Deliberately misuses the deprecated {@code @Temporal} annotation on a {@code java.time.Instant} + * field -- not portable per the Jakarta Persistence 3.2 spec, but does Hibernate 7.4.5 actually + * reject it? Docs: 13-date-and-time-mapping.md, chapter "@Temporal verified". + */ +@Entity +@Table(name = "temporal_on_java_time") +public class TemporalOnJavaTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Temporal(TemporalType.TIMESTAMP) + private Instant instantWithTemporalAnnotation; + + protected TemporalOnJavaTimeEntity() { + } + + public Long getId() { + return id; + } + + public Instant getInstantWithTemporalAnnotation() { + return instantWithTemporalAnnotation; + } + + public void setInstantWithTemporalAnnotation(Instant v) { + this.instantWithTemporalAnnotation = v; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/datetime/TemporalTypesEntity.java b/src/main/java/com/ankurm/hibernatedemo/datetime/TemporalTypesEntity.java new file mode 100755 index 0000000..548ac7c --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/datetime/TemporalTypesEntity.java @@ -0,0 +1,137 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Calendar; +import java.util.Date; + +/** + * Every basic temporal type in one entity so a single {@code show-create-table} run captures + * the DDL Hibernate 7.4.5 generates for each. Docs: 13-date-and-time-mapping.md, chapter "Basic temporal + * types round trip". {@code legacyDateNoTemporal} deliberately has NO {@code @Temporal} + * annotation to show what Hibernate does by default for {@code java.util.Date}. + */ +@Entity +@Table(name = "temporal_types") +public class TemporalTypesEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDate localDate; + private LocalDateTime localDateTime; + private LocalTime localTime; + private Instant instant; + private OffsetDateTime offsetDateTime; + private ZonedDateTime zonedDateTime; + + @Temporal(TemporalType.DATE) + private Date legacyDateAsDate; + + @Temporal(TemporalType.TIMESTAMP) + private Date legacyDateAsTimestamp; + + private Date legacyDateNoTemporal; + + @Temporal(TemporalType.TIMESTAMP) + private Calendar legacyCalendar; + + protected TemporalTypesEntity() { + } + + public Long getId() { + return id; + } + + public LocalDate getLocalDate() { + return localDate; + } + + public void setLocalDate(LocalDate localDate) { + this.localDate = localDate; + } + + public LocalDateTime getLocalDateTime() { + return localDateTime; + } + + public void setLocalDateTime(LocalDateTime localDateTime) { + this.localDateTime = localDateTime; + } + + public LocalTime getLocalTime() { + return localTime; + } + + public void setLocalTime(LocalTime localTime) { + this.localTime = localTime; + } + + public Instant getInstant() { + return instant; + } + + public void setInstant(Instant instant) { + this.instant = instant; + } + + public OffsetDateTime getOffsetDateTime() { + return offsetDateTime; + } + + public void setOffsetDateTime(OffsetDateTime offsetDateTime) { + this.offsetDateTime = offsetDateTime; + } + + public ZonedDateTime getZonedDateTime() { + return zonedDateTime; + } + + public void setZonedDateTime(ZonedDateTime zonedDateTime) { + this.zonedDateTime = zonedDateTime; + } + + public Date getLegacyDateAsDate() { + return legacyDateAsDate; + } + + public void setLegacyDateAsDate(Date legacyDateAsDate) { + this.legacyDateAsDate = legacyDateAsDate; + } + + public Date getLegacyDateAsTimestamp() { + return legacyDateAsTimestamp; + } + + public void setLegacyDateAsTimestamp(Date legacyDateAsTimestamp) { + this.legacyDateAsTimestamp = legacyDateAsTimestamp; + } + + public Date getLegacyDateNoTemporal() { + return legacyDateNoTemporal; + } + + public void setLegacyDateNoTemporal(Date legacyDateNoTemporal) { + this.legacyDateNoTemporal = legacyDateNoTemporal; + } + + public Calendar getLegacyCalendar() { + return legacyCalendar; + } + + public void setLegacyCalendar(Calendar legacyCalendar) { + this.legacyCalendar = legacyCalendar; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageEntity.java b/src/main/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageEntity.java new file mode 100755 index 0000000..9e189ce --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageEntity.java @@ -0,0 +1,106 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import org.hibernate.annotations.TimeZoneStorage; +import org.hibernate.annotations.TimeZoneStorageType; + +/** + * One {@code OffsetDateTime} column per {@code @TimeZoneStorage} mode plus one with NO + * annotation at all (to observe Hibernate 7.4.5's actual default). Docs: 13-date-and-time-mapping.md, + * chapter "The central experiment". Run alongside {@code javap + * org.hibernate.annotations.TimeZoneStorageType} to confirm the enum constants independently + * of this entity. + */ +@Entity +@Table(name = "tz_storage") +public class TimeZoneStorageEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // No @TimeZoneStorage annotation at all -- whatever Hibernate 7.4.5 defaults to. + @Column(name = "no_annotation_col") + private OffsetDateTime noAnnotation; + + @TimeZoneStorage(TimeZoneStorageType.NATIVE) + @Column(name = "native_col") + private OffsetDateTime nativeMode; + + @TimeZoneStorage(TimeZoneStorageType.NORMALIZE) + @Column(name = "normalize_col") + private OffsetDateTime normalizeMode; + + @TimeZoneStorage(TimeZoneStorageType.NORMALIZE_UTC) + @Column(name = "normalize_utc_col") + private OffsetDateTime normalizeUtcMode; + + @TimeZoneStorage(TimeZoneStorageType.COLUMN) + @Column(name = "column_mode_col") + private OffsetDateTime columnMode; + + @TimeZoneStorage(TimeZoneStorageType.AUTO) + @Column(name = "auto_col") + private OffsetDateTime autoMode; + + protected TimeZoneStorageEntity() { + } + + public Long getId() { + return id; + } + + public OffsetDateTime getNoAnnotation() { + return noAnnotation; + } + + public void setNoAnnotation(OffsetDateTime v) { + this.noAnnotation = v; + } + + public OffsetDateTime getNativeMode() { + return nativeMode; + } + + public void setNativeMode(OffsetDateTime v) { + this.nativeMode = v; + } + + public OffsetDateTime getNormalizeMode() { + return normalizeMode; + } + + public void setNormalizeMode(OffsetDateTime v) { + this.normalizeMode = v; + } + + public OffsetDateTime getNormalizeUtcMode() { + return normalizeUtcMode; + } + + public void setNormalizeUtcMode(OffsetDateTime v) { + this.normalizeUtcMode = v; + } + + public OffsetDateTime getColumnMode() { + return columnMode; + } + + public void setColumnMode(OffsetDateTime v) { + this.columnMode = v; + } + + public OffsetDateTime getAutoMode() { + return autoMode; + } + + public void setAutoMode(OffsetDateTime v) { + this.autoMode = v; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/hikari/PoolProbe.java b/src/main/java/com/ankurm/hibernatedemo/hikari/PoolProbe.java new file mode 100644 index 0000000..a73a287 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/hikari/PoolProbe.java @@ -0,0 +1,39 @@ +package com.ankurm.hibernatedemo.hikari; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * A minimal entity used only to force a real JDBC connection checkout through whichever + * connection pool is configured -- this chapter is about the pool, not the mapping, so the + * entity itself is deliberately trivial. + * + *

Docs: docs/19-hikaricp-connection-pooling.md + */ +@Entity +public class PoolProbe { + + @Id + @GeneratedValue + private Long id; + + @Column(nullable = false) + private String label; + + public PoolProbe() { + } + + public PoolProbe(String label) { + this.label = label; + } + + public Long getId() { + return id; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/immutable/AuditTrail.java b/src/main/java/com/ankurm/hibernatedemo/immutable/AuditTrail.java new file mode 100755 index 0000000..c93fdfa --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/immutable/AuditTrail.java @@ -0,0 +1,44 @@ +package com.ankurm.hibernatedemo.immutable; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +/** Child row for {@link RateWithAuditTrail}'s @Immutable collection. */ +@Entity +public class AuditTrail { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "audit_seq") + private Long id; + + private String note; + + @ManyToOne + @JoinColumn(name = "rate_with_audit_id") + private RateWithAuditTrail rateWithAuditTrail; + + protected AuditTrail() { + // JPA + } + + public AuditTrail(String note) { + this.note = note; + } + + public Long getId() { + return id; + } + + public String getNote() { + return note; + } + + @Override + public String toString() { + return "AuditTrail{id=%s, note=%s}".formatted(id, note); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/immutable/ExchangeRate.java b/src/main/java/com/ankurm/hibernatedemo/immutable/ExchangeRate.java new file mode 100755 index 0000000..e77dfc3 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/immutable/ExchangeRate.java @@ -0,0 +1,58 @@ +package com.ankurm.hibernatedemo.immutable; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.math.BigDecimal; +import org.hibernate.annotations.Immutable; + +/** + * The headline case for docs/07-immutable-entities.md: an {@code @Immutable} entity with a mutable + * Java setter. Nothing stops the field mutation in the JVM -- the point is what happens (or + * doesn't) when the mutated instance is flushed. + */ +@Entity +@Immutable +public class ExchangeRate { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rate_seq") + private Long id; + + private String pair; + + @jakarta.persistence.Column(precision = 19, scale = 4) + private BigDecimal rate; + + protected ExchangeRate() { + // JPA + } + + public ExchangeRate(String pair, BigDecimal rate) { + this.pair = pair; + this.rate = rate; + } + + public Long getId() { + return id; + } + + public String getPair() { + return pair; + } + + public BigDecimal getRate() { + return rate; + } + + /** Ordinary setter -- @Immutable is a Hibernate-engine concept, not a Java one. */ + public void setRate(BigDecimal rate) { + this.rate = rate; + } + + @Override + public String toString() { + return "ExchangeRate{id=%s, pair=%s, rate=%s}".formatted(id, pair, rate); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/immutable/ExchangeRateVersioned.java b/src/main/java/com/ankurm/hibernatedemo/immutable/ExchangeRateVersioned.java new file mode 100755 index 0000000..59fec3e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/immutable/ExchangeRateVersioned.java @@ -0,0 +1,56 @@ +package com.ankurm.hibernatedemo.immutable; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import org.hibernate.annotations.Immutable; + +/** + * Same shape as {@link ExchangeRate} but carries a {@code @Version} column, to check whether + * Hibernate 7.4.5 rejects the {@code @Immutable} + {@code @Version} combination outright, and + * whether the version column ever increments if it doesn't. + */ +@Entity +@Immutable +public class ExchangeRateVersioned { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rate_v_seq") + private Long id; + + private String pair; + + @jakarta.persistence.Column(precision = 19, scale = 4) + private BigDecimal rate; + + @Version + private Long version; + + protected ExchangeRateVersioned() { + // JPA + } + + public ExchangeRateVersioned(String pair, BigDecimal rate) { + this.pair = pair; + this.rate = rate; + } + + public Long getId() { + return id; + } + + public BigDecimal getRate() { + return rate; + } + + public void setRate(BigDecimal rate) { + this.rate = rate; + } + + public Long getVersion() { + return version; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/immutable/PlainRate.java b/src/main/java/com/ankurm/hibernatedemo/immutable/PlainRate.java new file mode 100755 index 0000000..f57ada6 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/immutable/PlainRate.java @@ -0,0 +1,47 @@ +package com.ankurm.hibernatedemo.immutable; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.math.BigDecimal; + +/** + * A perfectly ordinary, non-{@code @Immutable} entity, used as the control group when + * comparing {@code @Immutable} against {@code Session.setDefaultReadOnly(true)} and + * {@code Session.setReadOnly(entity, true)} -- those are Session/query-scoped read-only knobs + * that apply to entities that were never annotated at all. + */ +@Entity +public class PlainRate { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "plain_rate_seq") + private Long id; + + private String pair; + + @jakarta.persistence.Column(precision = 19, scale = 4) + private BigDecimal rate; + + protected PlainRate() { + // JPA + } + + public PlainRate(String pair, BigDecimal rate) { + this.pair = pair; + this.rate = rate; + } + + public Long getId() { + return id; + } + + public BigDecimal getRate() { + return rate; + } + + public void setRate(BigDecimal rate) { + this.rate = rate; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/immutable/RateWithAuditTrail.java b/src/main/java/com/ankurm/hibernatedemo/immutable/RateWithAuditTrail.java new file mode 100755 index 0000000..2e7a627 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/immutable/RateWithAuditTrail.java @@ -0,0 +1,51 @@ +package com.ankurm.hibernatedemo.immutable; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import org.hibernate.annotations.Immutable; + +/** + * A MUTABLE parent entity (no {@code @Immutable} on the class) whose collection is marked + * {@code @Immutable}. This isolates the collection-level annotation's own behaviour, per the + * article's "Advanced Usage: Immutable Collections" section, from the entity-level one. + */ +@Entity +public class RateWithAuditTrail { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rate_audit_seq") + private Long id; + + private String pair; + + @Immutable + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List auditTrails = new ArrayList<>(); + + protected RateWithAuditTrail() { + // JPA + } + + public RateWithAuditTrail(String pair) { + this.pair = pair; + } + + public Long getId() { + return id; + } + + public String getPair() { + return pair; + } + + public List getAuditTrails() { + return auditTrails; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/immutable/WideImmutableRow.java b/src/main/java/com/ankurm/hibernatedemo/immutable/WideImmutableRow.java new file mode 100755 index 0000000..a1abca7 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/immutable/WideImmutableRow.java @@ -0,0 +1,90 @@ +package com.ankurm.hibernatedemo.immutable; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import org.hibernate.annotations.Immutable; + +/** + * Same 12-column shape as {@link WideMutableRow}, but @Immutable, for the flush-cost + * comparison in docs/07-immutable-entities.md. + */ +@Entity +@Immutable +public class WideImmutableRow { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "wide_immutable_seq") + private Long id; + + private String f1; + private String f2; + private String f3; + private String f4; + private String f5; + private String f6; + private String f7; + private String f8; + private String f9; + private String f10; + private String f11; + private String f12; + + protected WideImmutableRow() { + // JPA + } + + public WideImmutableRow(String seed) { + this.f1 = seed; + this.f2 = seed; + this.f3 = seed; + this.f4 = seed; + this.f5 = seed; + this.f6 = seed; + this.f7 = seed; + this.f8 = seed; + this.f9 = seed; + this.f10 = seed; + this.f11 = seed; + this.f12 = seed; + } + + public Long getId() { return id; } + + public String getF1() { return f1; } + public void setF1(String v) { this.f1 = v; } + + public String getF2() { return f2; } + public void setF2(String v) { this.f2 = v; } + + public String getF3() { return f3; } + public void setF3(String v) { this.f3 = v; } + + public String getF4() { return f4; } + public void setF4(String v) { this.f4 = v; } + + public String getF5() { return f5; } + public void setF5(String v) { this.f5 = v; } + + public String getF6() { return f6; } + public void setF6(String v) { this.f6 = v; } + + public String getF7() { return f7; } + public void setF7(String v) { this.f7 = v; } + + public String getF8() { return f8; } + public void setF8(String v) { this.f8 = v; } + + public String getF9() { return f9; } + public void setF9(String v) { this.f9 = v; } + + public String getF10() { return f10; } + public void setF10(String v) { this.f10 = v; } + + public String getF11() { return f11; } + public void setF11(String v) { this.f11 = v; } + + public String getF12() { return f12; } + public void setF12(String v) { this.f12 = v; } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/immutable/WideMutableRow.java b/src/main/java/com/ankurm/hibernatedemo/immutable/WideMutableRow.java new file mode 100755 index 0000000..4179fc3 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/immutable/WideMutableRow.java @@ -0,0 +1,90 @@ +package com.ankurm.hibernatedemo.immutable; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * A wide (12-column) MUTABLE entity, used only to give per-entity dirty checking something + * non-trivial to compare on a full flush -- with 1-2 fields the per-field snapshot comparison + * cost is too small to distinguish from measurement noise. See docs/07-immutable-entities.md, + * "@Version and dirty-check cost". + */ +@Entity +public class WideMutableRow { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "wide_mutable_seq") + private Long id; + + private String f1; + private String f2; + private String f3; + private String f4; + private String f5; + private String f6; + private String f7; + private String f8; + private String f9; + private String f10; + private String f11; + private String f12; + + protected WideMutableRow() { + // JPA + } + + public WideMutableRow(String seed) { + this.f1 = seed; + this.f2 = seed; + this.f3 = seed; + this.f4 = seed; + this.f5 = seed; + this.f6 = seed; + this.f7 = seed; + this.f8 = seed; + this.f9 = seed; + this.f10 = seed; + this.f11 = seed; + this.f12 = seed; + } + + public Long getId() { return id; } + + public String getF1() { return f1; } + public void setF1(String v) { this.f1 = v; } + + public String getF2() { return f2; } + public void setF2(String v) { this.f2 = v; } + + public String getF3() { return f3; } + public void setF3(String v) { this.f3 = v; } + + public String getF4() { return f4; } + public void setF4(String v) { this.f4 = v; } + + public String getF5() { return f5; } + public void setF5(String v) { this.f5 = v; } + + public String getF6() { return f6; } + public void setF6(String v) { this.f6 = v; } + + public String getF7() { return f7; } + public void setF7(String v) { this.f7 = v; } + + public String getF8() { return f8; } + public void setF8(String v) { this.f8 = v; } + + public String getF9() { return f9; } + public void setF9(String v) { this.f9 = v; } + + public String getF10() { return f10; } + public void setF10(String v) { this.f10 = v; } + + public String getF11() { return f11; } + public void setF11(String v) { this.f11 = v; } + + public String getF12() { return f12; } + public void setF12(String v) { this.f12 = v; } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/interceptor/Task.java b/src/main/java/com/ankurm/hibernatedemo/interceptor/Task.java new file mode 100644 index 0000000..1caa305 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/interceptor/Task.java @@ -0,0 +1,49 @@ +package com.ankurm.hibernatedemo.interceptor; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * Chapter 24's interceptor playground -- a plain entity whose {@code name} field an + * {@code Interceptor} mutates in place before it hits the database. + * + *

Docs: docs/24-interceptors.md. + */ +@Entity +public class Task { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private int priority; + + protected Task() { + // for Hibernate + } + + public Task(String name, int priority) { + this.name = name; + this.priority = priority; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getPriority() { + return priority; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/interceptor/UppercasingInterceptor.java b/src/main/java/com/ankurm/hibernatedemo/interceptor/UppercasingInterceptor.java new file mode 100644 index 0000000..bb6265c --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/interceptor/UppercasingInterceptor.java @@ -0,0 +1,64 @@ +package com.ankurm.hibernatedemo.interceptor; + +import java.util.concurrent.atomic.AtomicInteger; +import org.hibernate.Interceptor; +import org.hibernate.type.Type; + +/** + * Implements {@link Interceptor} DIRECTLY rather than extending {@code org.hibernate. + * EmptyInterceptor} -- there is no longer a reason to extend anything. Every method on + * {@code Interceptor} is a {@code default} method as of Hibernate 6+ (verified with {@code + * javap} against the 7.4.5.Final jar before writing this class), so overriding just the two + * callbacks this class actually needs is enough; the old {@code org.hibernate.EmptyInterceptor} + * base class still exists in the 7.4.5.Final jar, but only as {@code org.hibernate.internal. + * EmptyInterceptor} -- a package-private-looking, {@code final}, singleton-only class that isn't + * meant to be extended by application code any more. + * + *

Both {@link #onSave} and {@link #onFlushDirty} mutate the {@code state} array in place and + * return {@code true} -- that {@code true} is the contract: it tells Hibernate the state array + * was actually changed, so the (possibly mutated) values get flushed, not silently dropped. + * + *

Docs: docs/24-interceptors.md. + */ +public class UppercasingInterceptor implements Interceptor { + + private final AtomicInteger onSaveCalls = new AtomicInteger(); + private final AtomicInteger onFlushDirtyCalls = new AtomicInteger(); + + @Override + public boolean onSave(Object entity, Object id, Object[] state, String[] propertyNames, Type[] types) { + // 'id' is typed java.lang.Object here, not java.io.Serializable -- Hibernate 6 widened + // every identifier parameter on this interface from Serializable to Object, since an + // application is free to use a non-Serializable identifier type. + onSaveCalls.incrementAndGet(); + return uppercaseNameIfPresent(state, propertyNames); + } + + @Override + public boolean onFlushDirty(Object entity, Object id, Object[] currentState, Object[] previousState, + String[] propertyNames, Type[] types) { + onFlushDirtyCalls.incrementAndGet(); + return uppercaseNameIfPresent(currentState, propertyNames); + } + + 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(java.util.Locale.ROOT); + if (!upper.equals(s)) { + state[i] = upper; + return true; // tells Hibernate: yes, I changed the state array, flush it + } + } + } + return false; + } + + public int getOnSaveCalls() { + return onSaveCalls.get(); + } + + public int getOnFlushDirtyCalls() { + return onFlushDirtyCalls.get(); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.java b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.java new file mode 100755 index 0000000..3006ba3 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.java @@ -0,0 +1,46 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +/** + * Deliberately annotation-free POJO, mapped only through + * {@code HbmEmployee.hbm.xml} (legacy Hibernate mapping format). + * + *

Backs the empirical hbm.xml probe in {@code HbmXmlBootTest}. Docs: docs/04-annotations-vs-xml.md. + */ +public class HbmEmployee { + + private Long id; + private String firstName; + private String email; + + public HbmEmployee() { + } + + public HbmEmployee(String firstName, String email) { + this.firstName = firstName; + this.email = email; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdEntity.java b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdEntity.java new file mode 100755 index 0000000..86b2529 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdEntity.java @@ -0,0 +1,39 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +/** + * Zero annotations. Mapped entirely by {@code mapping-xml-natural-id.xml}, using Hibernate's + * native "mapping.xml" XML dialect (namespace {@code http://www.hibernate.org/xsd/orm/mapping}, + * schema {@code mapping-7.0.xsd}) -- NOT the JPA-standard orm.xml dialect, which has no + * <natural-id> element at all. Proves a Hibernate-only concept (@NaturalId) can be expressed + * in XML, just not in the portable JPA orm.xml XSD. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1 ("what XML can do that annotations cannot" -- + * inverted: what one XML dialect can do that the other XML dialect and annotations both cannot + * express the same way). + */ +public class MappingXmlNaturalIdEntity { + + private Long id; + private String sku; + private String name; + + public MappingXmlNaturalIdEntity() { + } + + public MappingXmlNaturalIdEntity(String sku, String name) { + this.sku = sku; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getSku() { + return sku; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlOnlyEntity.java b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlOnlyEntity.java new file mode 100755 index 0000000..a510835 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlOnlyEntity.java @@ -0,0 +1,38 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +/** + * Deliberately carries NO JPA/Hibernate annotations at all -- not even {@code @Entity}. + * Its only mapping is {@code orm-xml-only-mapping.xml}, registered via + * {@code spring.jpa.mapping-resources}. If a query against {@code xml_only_widgets} succeeds, + * orm.xml alone is enough to make this a managed entity. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1. + */ +public class OrmXmlOnlyEntity { + + private Long id; + private String label; + + public OrmXmlOnlyEntity() { + } + + public OrmXmlOnlyEntity(String label) { + this.label = label; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/mappingstyle/OverrideEntity.java b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/OverrideEntity.java new file mode 100755 index 0000000..5312002 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/mappingstyle/OverrideEntity.java @@ -0,0 +1,39 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * Annotated with {@code @Column(name = "annotation_name")}. A matching orm.xml entry + * (see {@code orm-xml-override-mapping.xml}) maps the SAME field to {@code xml_name} instead. + * Whichever name shows up in the generated DDL/SQL is the winner. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1 (merge/override semantics). + */ +@Entity +public class OverrideEntity { + + @Id + @GeneratedValue + private Long id; + + @Column(name = "annotation_name") + private String value; + + public OverrideEntity() { + } + + public OverrideEntity(String value) { + this.value = value; + } + + public Long getId() { + return id; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/Book.java b/src/main/java/com/ankurm/hibernatedemo/model/Book.java new file mode 100755 index 0000000..64c1692 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/Book.java @@ -0,0 +1,97 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Version; +import java.util.ArrayList; +import java.util.List; + +/** + * The entity used by {@code get-vs-load} and {@code merge-vs-refresh}. + * + *

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

Carries a {@code @Version} column on purpose — the merge/refresh tests need a real + * optimistic-lock field to show what merge() does when the version it is holding is stale, not + * just what it does to a plain column. The {@code status} field exists specifically for + * {@code MergeRefreshTest#refreshDiscardsUnflushedEditSilently}, framed as the + * "USER_EDIT" vs "ADMIN_EDIT" scenario in docs/02-merge-vs-refresh.md. The + * {@code notes} collection is LAZY and cascades MERGE only — it exists solely for + * {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized}, which shows that an + * unfetched collection is never navigated during merge(). + */ +@Entity +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq") + private Long id; + + private String title; + + private String author; + + private String status; + + @Version + private Long version; + + @OneToMany(mappedBy = "book", cascade = CascadeType.MERGE, fetch = FetchType.LAZY) + private List notes = new ArrayList<>(); + + protected Book() { + // JPA + } + + public Book(String title, String author) { + this.title = title; + this.author = author; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Long getVersion() { + return version; + } + + public List getNotes() { + return notes; + } + + @Override + public String toString() { + return "Book{id=%s, title=%s, author=%s, status=%s, version=%s}" + .formatted(id, title, author, status, version); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/Note.java b/src/main/java/com/ankurm/hibernatedemo/model/Note.java new file mode 100755 index 0000000..a30f099 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/Note.java @@ -0,0 +1,45 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +/** + * Child of {@link Book}, used only by {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized} + * (docs/02-merge-vs-refresh.md) to show that an unfetched {@code LAZY} collection is not + * navigated -- and therefore cannot fail with {@code LazyInitializationException} -- during + * {@code merge()} of the owning detached entity. + */ +@Entity +public class Note { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String text; + + @ManyToOne + @JoinColumn(name = "book_id") + private Book book; + + protected Note() { + // JPA + } + + public Note(String text, Book book) { + this.text = text; + this.book = book; + } + + public Long getId() { + return id; + } + + public String getText() { + return text; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc1.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc1.java new file mode 100755 index 0000000..d772a4a --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc1.java @@ -0,0 +1,37 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * One of four otherwise-identical entities ({@link WidgetAlloc1}, {@link WidgetAlloc10}, + * {@link WidgetAlloc25}, {@link WidgetAlloc50}) used only by + * {@code AllocationSizeSweepTest} to isolate the effect of {@code allocationSize} on + * {@code prepareStatementCount} while {@code hibernate.jdbc.batch_size} is held fixed at 25. + * Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetAlloc1 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc1_seq") + @SequenceGenerator(name = "widget_alloc1_seq", sequenceName = "widget_alloc1_seq", allocationSize = 1) + private Long id; + + private String name; + + protected WidgetAlloc1() { + // JPA + } + + public WidgetAlloc1(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc10.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc10.java new file mode 100755 index 0000000..cecd681 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc10.java @@ -0,0 +1,34 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * See {@link WidgetAlloc1} — same purpose, {@code allocationSize = 10}. + * Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetAlloc10 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc10_seq") + @SequenceGenerator(name = "widget_alloc10_seq", sequenceName = "widget_alloc10_seq", allocationSize = 10) + private Long id; + + private String name; + + protected WidgetAlloc10() { + // JPA + } + + public WidgetAlloc10(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc25.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc25.java new file mode 100755 index 0000000..18f2fa5 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc25.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * See {@link WidgetAlloc1} — same purpose, {@code allocationSize = 25} (matches + * {@code hibernate.jdbc.batch_size} in the sweep, same as {@link WidgetSequence}). + * Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetAlloc25 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc25_seq") + @SequenceGenerator(name = "widget_alloc25_seq", sequenceName = "widget_alloc25_seq", allocationSize = 25) + private Long id; + + private String name; + + protected WidgetAlloc25() { + // JPA + } + + public WidgetAlloc25(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc50.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc50.java new file mode 100755 index 0000000..4efc6e5 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetAlloc50.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * See {@link WidgetAlloc1} — same purpose, {@code allocationSize = 50} (JPA's default). + * Reused by {@code BatchSizeSweepTest} to hold {@code allocationSize} fixed at 50 while + * {@code hibernate.jdbc.batch_size} varies. Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetAlloc50 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc50_seq") + @SequenceGenerator(name = "widget_alloc50_seq", sequenceName = "widget_alloc50_seq", allocationSize = 50) + private Long id; + + private String name; + + protected WidgetAlloc50() { + // JPA + } + + public WidgetAlloc50(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep1.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep1.java new file mode 100755 index 0000000..019827d --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep1.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * Used exclusively by {@code BatchSizeSweepTest.BatchSize1}. See {@link WidgetBatchSweep50}'s + * Javadoc for why each batch_size sweep point gets its own entity and sequence rather than + * sharing one across nested test classes. Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetBatchSweep1 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq") + @SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50) + private Long id; + + private String name; + + protected WidgetBatchSweep1() { + // JPA + } + + public WidgetBatchSweep1(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep10.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep10.java new file mode 100755 index 0000000..6c80d9f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep10.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * Used exclusively by {@code BatchSizeSweepTest.BatchSize10}. See {@link WidgetBatchSweep50}'s + * Javadoc for why each batch_size sweep point gets its own entity and sequence rather than + * sharing one across nested test classes. Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetBatchSweep10 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq") + @SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50) + private Long id; + + private String name; + + protected WidgetBatchSweep10() { + // JPA + } + + public WidgetBatchSweep10(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep25.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep25.java new file mode 100755 index 0000000..e5d2355 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep25.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * Used exclusively by {@code BatchSizeSweepTest.BatchSize25}. See {@link WidgetBatchSweep50}'s + * Javadoc for why each batch_size sweep point gets its own entity and sequence rather than + * sharing one across nested test classes. Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetBatchSweep25 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq") + @SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50) + private Long id; + + private String name; + + protected WidgetBatchSweep25() { + // JPA + } + + public WidgetBatchSweep25(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep50.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep50.java new file mode 100755 index 0000000..fb96965 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetBatchSweep50.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * Used exclusively by {@code BatchSizeSweepTest.BatchSize50}. See {@link WidgetBatchSweep50}'s + * Javadoc for why each batch_size sweep point gets its own entity and sequence rather than + * sharing one across nested test classes. Docs: docs/03-inserting-objects.md. + */ +@Entity +public class WidgetBatchSweep50 { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq") + @SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50) + private Long id; + + private String name; + + protected WidgetBatchSweep50() { + // JPA + } + + public WidgetBatchSweep50(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java new file mode 100755 index 0000000..650d4dc --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java @@ -0,0 +1,41 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * Insert-scenario twin of {@link WidgetSequence}, identical except for the id generation + * strategy. Docs: docs/03-inserting-objects.md. + * + *

{@code IDENTITY} requires the database to hand back the generated key on every single + * insert, which is exactly why it defeats JDBC batching — see the captured output in + * docs/output/insert-identity.txt versus docs/output/insert-sequence.txt for the same + * {@code hibernate.jdbc.batch_size} setting producing very different behaviour. + */ +@Entity +public class WidgetIdentity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + protected WidgetIdentity() { + // JPA + } + + public WidgetIdentity(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java b/src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java new file mode 100755 index 0000000..9cbaf30 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java @@ -0,0 +1,42 @@ +package com.ankurm.hibernatedemo.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; + +/** + * Insert-scenario twin of {@link WidgetIdentity}. Docs: docs/03-inserting-objects.md. + * + *

{@code allocationSize} matches {@code hibernate.jdbc.batch_size} in + * {@code application-insert-sequence.yml} on purpose: a mismatched allocation size is its own + * classic footgun (extra round trips to refill the sequence pool mid-batch) and not one this + * repo is trying to demonstrate here. + */ +@Entity +public class WidgetSequence { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_seq") + @SequenceGenerator(name = "widget_seq", sequenceName = "widget_seq", allocationSize = 25) + private Long id; + + private String name; + + protected WidgetSequence() { + // JPA + } + + public WidgetSequence(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeDto.java b/src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeDto.java new file mode 100755 index 0000000..ce7e477 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeDto.java @@ -0,0 +1,25 @@ +package com.ankurm.hibernatedemo.namedquery; + +/** JPQL/native constructor-result projection target. Plain class version. */ +public class EmployeeDto { + private final Long id; + private final String firstName; + + public EmployeeDto(Long id, String firstName) { + this.id = id; + this.firstName = firstName; + } + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + @Override + public String toString() { + return "EmployeeDto{id=%s, firstName=%s}".formatted(id, firstName); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeRecordDto.java b/src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeRecordDto.java new file mode 100755 index 0000000..7ac1c72 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/namedquery/EmployeeRecordDto.java @@ -0,0 +1,8 @@ +package com.ankurm.hibernatedemo.namedquery; + +/** + * Jakarta Persistence 3.2 alternative to a hand-written DTO class: does a Java {@code record} + * work directly as a JPQL constructor-expression result? Tested in NamedQueryExecutionTest. + */ +public record EmployeeRecordDto(Long id, String firstName) { +} diff --git a/src/main/java/com/ankurm/hibernatedemo/namedquery/HibernateExtraEmployee.java b/src/main/java/com/ankurm/hibernatedemo/namedquery/HibernateExtraEmployee.java new file mode 100755 index 0000000..b91dda9 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/namedquery/HibernateExtraEmployee.java @@ -0,0 +1,38 @@ +package com.ankurm.hibernatedemo.namedquery; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.NamedQuery; + +/** + * Uses {@code org.hibernate.annotations.NamedQuery} (the Hibernate extension, not the JPA + * standard one) specifically to exercise an extra it offers that JPA's does not: + * {@code cacheable = true}. Docs: 14-named-queries.md, chapter "jakarta vs hibernate NamedQuery". + */ +@Entity +@Table(name = "hib_extra_employee") +@NamedQuery(name = "HibernateExtraEmployee.cacheableFindAll", + query = "SELECT e FROM HibernateExtraEmployee e", + cacheable = true) +public class HibernateExtraEmployee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + protected HibernateExtraEmployee() { + } + + public HibernateExtraEmployee(String name) { + this.name = name; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/namedquery/NqEmployee.java b/src/main/java/com/ankurm/hibernatedemo/namedquery/NqEmployee.java new file mode 100755 index 0000000..ac8be06 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/namedquery/NqEmployee.java @@ -0,0 +1,72 @@ +package com.ankurm.hibernatedemo.namedquery; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.NamedNativeQueries; +import jakarta.persistence.NamedNativeQuery; +import jakarta.persistence.SqlResultSetMapping; +import jakarta.persistence.ConstructorResult; +import jakarta.persistence.ColumnResult; +import jakarta.persistence.Table; + +/** + * Backs ankurm.com post 4877. Docs: 14-named-queries.md. + * + *

{@code Employee.findByName} is a valid JPA {@code @NamedQuery}, {@code Employee.byNativeDto} + * is a {@code @NamedNativeQuery} + {@code @SqlResultSetMapping} into {@link EmployeeDto} via + * {@code @ConstructorResult}. + */ +@Entity +@Table(name = "nq_employee") +@NamedQueries({ + @NamedQuery(name = "Employee.findByName", query = "SELECT e FROM NqEmployee e WHERE e.firstName = :name"), + @NamedQuery(name = "Employee.findAllActive", query = "SELECT e FROM NqEmployee e WHERE e.status = 'ACTIVE' ORDER BY e.id DESC") +}) +@NamedNativeQueries({ + @NamedNativeQuery( + name = "Employee.byNativeDto", + query = "SELECT id, first_name AS firstName FROM nq_employee WHERE status = :status", + resultSetMapping = "EmployeeDtoMapping") +}) +@SqlResultSetMapping( + name = "EmployeeDtoMapping", + classes = @ConstructorResult( + targetClass = EmployeeDto.class, + columns = { + @ColumnResult(name = "id", type = Long.class), + @ColumnResult(name = "firstName", type = String.class) + })) +public class NqEmployee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String firstName; + + private String status; + + protected NqEmployee() { + } + + public NqEmployee(String firstName, String status) { + this.firstName = firstName; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getStatus() { + return status; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/namedquery/XmlQueryEmployee.java b/src/main/java/com/ankurm/hibernatedemo/namedquery/XmlQueryEmployee.java new file mode 100755 index 0000000..015028c --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/namedquery/XmlQueryEmployee.java @@ -0,0 +1,46 @@ +package com.ankurm.hibernatedemo.namedquery; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; + +/** + * Has one {@code @NamedQuery} defined via annotation ({@code XmlQueryEmployee.findBySalaryAbove}) + * AND one defined via {@code orm.xml} ({@code XmlQueryEmployee.findBySalaryAboveXml}), plus an + * XML-defined query that OVERRIDES a same-named annotated one + * ({@code XmlQueryEmployee.overridden}) -- see src/main/resources/META-INF/orm.xml. Docs: + * 14-named-queries.md, chapter "Named queries in orm.xml". + */ +@Entity +@Table(name = "xml_query_employee") +@NamedQuery(name = "XmlQueryEmployee.findBySalaryAbove", query = "SELECT e FROM XmlQueryEmployee e WHERE e.salary > :min") +@NamedQuery(name = "XmlQueryEmployee.overridden", query = "SELECT e FROM XmlQueryEmployee e WHERE e.salary < 0") // deliberately wrong; orm.xml should win +public class XmlQueryEmployee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private double salary; + + protected XmlQueryEmployee() { + } + + public XmlQueryEmployee(String name, double salary) { + this.name = name; + this.salary = salary; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/naturalid/CachedNaturalIdProduct.java b/src/main/java/com/ankurm/hibernatedemo/naturalid/CachedNaturalIdProduct.java new file mode 100755 index 0000000..f716a1b --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/naturalid/CachedNaturalIdProduct.java @@ -0,0 +1,54 @@ +package com.ankurm.hibernatedemo.naturalid; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.NaturalId; +import org.hibernate.annotations.NaturalIdCache; + +/** + * Same shape as {@link NaturalIdProduct}, but with {@code @Cacheable} + {@code @Cache} (entity + * L2 cache) AND {@code @NaturalIdCache} (the SEPARATE natural-id-to-PK resolution L2 cache + * region -- these are two different cache regions, not one). + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@Entity +@jakarta.persistence.Cacheable +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) +@NaturalIdCache +public class CachedNaturalIdProduct { + + @Id + @GeneratedValue + private Long id; + + @NaturalId + @Column(nullable = false, unique = true, updatable = false) + private String sku; + + private String name; + + public CachedNaturalIdProduct() { + } + + public CachedNaturalIdProduct(String sku, String name) { + this.sku = sku; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getSku() { + return sku; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/naturalid/Company.java b/src/main/java/com/ankurm/hibernatedemo/naturalid/Company.java new file mode 100755 index 0000000..d83e3ab --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/naturalid/Company.java @@ -0,0 +1,35 @@ +package com.ankurm.hibernatedemo.naturalid; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * Parent side of the composite natural id demo -- see {@link Department}. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@Entity +public class Company { + + @Id + @GeneratedValue + private Long id; + + private String name; + + public Company() { + } + + public Company(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/naturalid/Department.java b/src/main/java/com/ankurm/hibernatedemo/naturalid/Department.java new file mode 100755 index 0000000..f7f8536 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/naturalid/Department.java @@ -0,0 +1,58 @@ +package com.ankurm.hibernatedemo.naturalid; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import org.hibernate.annotations.NaturalId; + +/** + * A COMPOSITE natural id: (company, deptCode) together must be unique, not either field alone. + * Matches the article's example -- verified to actually load via + * {@code session.byNaturalId(Department.class).using(...).using(...).load()}. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@Entity +public class Department { + + @Id + @GeneratedValue + private Long id; + + @NaturalId + @ManyToOne + @JoinColumn(name = "company_id") + private Company company; + + @NaturalId + private String deptCode; + + private String name; + + public Department() { + } + + public Department(Company company, String deptCode, String name) { + this.company = company; + this.deptCode = deptCode; + this.name = name; + } + + public Long getId() { + return id; + } + + public Company getCompany() { + return company; + } + + public String getDeptCode() { + return deptCode; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/naturalid/ImmutableNaturalIdEntity.java b/src/main/java/com/ankurm/hibernatedemo/naturalid/ImmutableNaturalIdEntity.java new file mode 100755 index 0000000..62df90d --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/naturalid/ImmutableNaturalIdEntity.java @@ -0,0 +1,44 @@ +package com.ankurm.hibernatedemo.naturalid; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import org.hibernate.annotations.NaturalId; + +/** + * {@code @NaturalId} with NO {@code mutable} attribute -- defaults to {@code mutable = false} + * (immutable). Deliberately does NOT add {@code @Column(updatable = false)}, so if Hibernate + * lets an UPDATE through at the SQL level, that is Hibernate's own natural-id immutability + * enforcement failing to stop it, not a JPA column-level guard doing the stopping. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@Entity +public class ImmutableNaturalIdEntity { + + @Id + @GeneratedValue + private Long id; + + @NaturalId // mutable defaults to false + private String code; + + public ImmutableNaturalIdEntity() { + } + + public ImmutableNaturalIdEntity(String code) { + this.code = code; + } + + public Long getId() { + return id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/naturalid/MutableNaturalIdEntity.java b/src/main/java/com/ankurm/hibernatedemo/naturalid/MutableNaturalIdEntity.java new file mode 100755 index 0000000..dffff3b --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/naturalid/MutableNaturalIdEntity.java @@ -0,0 +1,42 @@ +package com.ankurm.hibernatedemo.naturalid; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import org.hibernate.annotations.NaturalId; + +/** + * {@code @NaturalId(mutable = true)} -- the explicit opt-in for a natural id that IS allowed + * to change (e.g. an email address). + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@Entity +public class MutableNaturalIdEntity { + + @Id + @GeneratedValue + private Long id; + + @NaturalId(mutable = true) + private String code; + + public MutableNaturalIdEntity() { + } + + public MutableNaturalIdEntity(String code) { + this.code = code; + } + + public Long getId() { + return id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsEntity.java b/src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsEntity.java new file mode 100755 index 0000000..139076f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsEntity.java @@ -0,0 +1,62 @@ +package com.ankurm.hibernatedemo.naturalid; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import java.util.Objects; +import org.hibernate.Hibernate; +import org.hibernate.annotations.NaturalId; + +/** + * equals()/hashCode() based on the IMMUTABLE natural id (sku), assigned in the constructor -- + * exactly the pattern posts 4864/4865 recommend. Since sku is set before the object ever enters + * a Set/Map (unlike a surrogate id, which is null until flush), the hash code should be STABLE + * across persist(). This entity exists to test whether that advice actually holds up. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@Entity +public class NaturalIdEqualsEntity { + + @Id + @GeneratedValue + private Long id; + + @NaturalId + @Column(nullable = false, unique = true, updatable = false) + private final String sku; + + private String name; + + protected NaturalIdEqualsEntity() { + this.sku = null; // JPA no-arg constructor requirement + } + + public NaturalIdEqualsEntity(String sku, String name) { + this.sku = sku; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getSku() { + return sku; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + if (Hibernate.getClass(this) != Hibernate.getClass(o)) return false; + NaturalIdEqualsEntity that = (NaturalIdEqualsEntity) o; + return Objects.equals(sku, that.sku); + } + + @Override + public int hashCode() { + return Objects.hash(sku); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdProduct.java b/src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdProduct.java new file mode 100755 index 0000000..cf3e6f9 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdProduct.java @@ -0,0 +1,52 @@ +package com.ankurm.hibernatedemo.naturalid; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import org.hibernate.annotations.NaturalId; + +/** + * A single-field, IMMUTABLE natural id (the JPA/Hibernate default for {@code @NaturalId}). + * No {@code @Cache}/{@code @NaturalIdCache} here -- this is the baseline entity for the + * "does bySimpleNaturalId save a query without L2 cache" experiment. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@Entity +public class NaturalIdProduct { + + @Id + @GeneratedValue + private Long id; + + @NaturalId + @Column(nullable = false, unique = true, updatable = false) + private String sku; + + private String name; + + public NaturalIdProduct() { + } + + public NaturalIdProduct(String sku, String name) { + this.sku = sku; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getSku() { + return sku; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/pagination/Article.java b/src/main/java/com/ankurm/hibernatedemo/pagination/Article.java new file mode 100644 index 0000000..812ebe8 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/pagination/Article.java @@ -0,0 +1,62 @@ +package com.ankurm.hibernatedemo.pagination; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; + +/** + * Chapter 23's pagination playground. {@code comments} exists specifically so a {@code join + * fetch} + pagination combination has a real collection to trigger Hibernate's in-memory + * fallback warning against. + * + *

Docs: docs/23-pagination.md. + */ +@Entity +public class Article { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private int sequence; + + @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true) + private List comments = new ArrayList<>(); + + protected Article() { + // for Hibernate + } + + public Article(String title, int sequence) { + this.title = title; + this.sequence = sequence; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public int getSequence() { + return sequence; + } + + public List getComments() { + return comments; + } + + public void addComment(Comment comment) { + comment.setArticle(this); + comments.add(comment); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/pagination/Comment.java b/src/main/java/com/ankurm/hibernatedemo/pagination/Comment.java new file mode 100644 index 0000000..aa48e49 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/pagination/Comment.java @@ -0,0 +1,49 @@ +package com.ankurm.hibernatedemo.pagination; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +/** + * Docs: docs/23-pagination.md. + */ +@Entity +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String body; + + @ManyToOne + @JoinColumn(name = "article_id") + private Article article; + + protected Comment() { + // for Hibernate + } + + public Comment(String body) { + this.body = body; + } + + public Long getId() { + return id; + } + + public String getBody() { + return body; + } + + public Article getArticle() { + return article; + } + + public void setArticle(Article article) { + this.article = article; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumDefaultOrdinalEntity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumDefaultOrdinalEntity.java new file mode 100755 index 0000000..b50af9e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumDefaultOrdinalEntity.java @@ -0,0 +1,49 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * {@code status} has NO {@code @Enumerated} at all -- JPA's default is ORDINAL. This entity + * exists purely to demonstrate what breaks: reordering (or inserting into the middle of) the + * enum silently repoints every stored ordinal at the wrong constant. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +@Table(name = "enum_default_ordinal_entity") +public class EnumDefaultOrdinalEntity { + + @Id + @GeneratedValue + private Long id; + + // No @Enumerated -- JPA default is ORDINAL. + private OrderStatus status; + + public EnumDefaultOrdinalEntity() { + } + + public EnumDefaultOrdinalEntity(OrderStatus status) { + this.status = status; + } + + public Long getId() { + return id; + } + + public OrderStatus getStatus() { + return status; + } + + public void setStatus(OrderStatus status) { + this.status = status; + } + + /** V1 order of constants: NEW=0, SHIPPED=1, DELIVERED=2. */ + public enum OrderStatus { + NEW, SHIPPED, DELIVERED + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumReorderedV2Entity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumReorderedV2Entity.java new file mode 100755 index 0000000..06bf51d --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumReorderedV2Entity.java @@ -0,0 +1,40 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Maps the SAME table ({@code enum_default_ordinal_entity}) as {@link EnumDefaultOrdinalEntity}, + * but with a status enum that has an EXTRA constant inserted before {@code SHIPPED}. This + * simulates "someone added a constant to the middle of the enum" without a migration -- + * the classic ORDINAL trap. No @Enumerated here either (default ORDINAL), matching the + * original mapping exactly. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +@Table(name = "enum_default_ordinal_entity") +public class EnumReorderedV2Entity { + + @Id + private Long id; + + @Column(name = "status") + private ReorderedStatus status; + + public Long getId() { + return id; + } + + public ReorderedStatus getStatus() { + return status; + } + + /** V2: a constant (PENDING_REVIEW) was inserted BEFORE SHIPPED -- ordinal 1 now means something else. */ + public enum ReorderedStatus { + NEW, PENDING_REVIEW, SHIPPED, DELIVERED + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumeratedValueEntity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumeratedValueEntity.java new file mode 100755 index 0000000..9977f02 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumeratedValueEntity.java @@ -0,0 +1,61 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EnumType; +import jakarta.persistence.EnumeratedValue; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * {@code @EnumeratedValue} is NEW in Jakarta Persistence 3.2 (confirmed present via javap on + * jakarta.persistence-api 3.2.0 -- it does not exist in 3.1). It lets an enum control its own + * persisted representation (a custom code), instead of ORDINAL or STRING. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +public class EnumeratedValueEntity { + + @Id + @GeneratedValue + private Long id; + + // @EnumeratedValue's own field type (String) determines the persisted column type, but + // Hibernate still needs to be told this is a STRING-shaped enum, not the ORDINAL default -- + // otherwise boot fails with "@EnumeratedValue for EnumType.ORDINAL must be placed on a + // field whose type is byte, short, or int". + @Enumerated(EnumType.STRING) + private Priority priority; + + public EnumeratedValueEntity() { + } + + public EnumeratedValueEntity(Priority priority) { + this.priority = priority; + } + + public Long getId() { + return id; + } + + public Priority getPriority() { + return priority; + } + + /** Persisted representation is the "code" string, NOT the enum's ordinal or name(). */ + public enum Priority { + LOW("L"), MEDIUM("M"), HIGH("H"); + + @EnumeratedValue + private final String code; + + Priority(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsEntity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsEntity.java new file mode 100755 index 0000000..0992ed1 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsEntity.java @@ -0,0 +1,47 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import java.util.Objects; + +/** + * The anti-pattern the article warns about in Q5: {@code equals()}/{@code hashCode()} based on + * the SURROGATE {@code @GeneratedValue} id. Before the first flush, {@code id} is null, so the + * object's hash code is fixed at "hash of null" -- then flush() mutates {@code id}, changing the + * hash code out from under any HashSet/HashMap the object is already sitting in. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +public class IdBasedEqualsEntity { + + @Id + @GeneratedValue + private Long id; + + private String label; + + public IdBasedEqualsEntity() { + } + + public IdBasedEqualsEntity(String label) { + this.label = label; + } + + public Long getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IdBasedEqualsEntity that)) return false; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdentityHashSetEntity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdentityHashSetEntity.java new file mode 100755 index 0000000..9777338 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdentityHashSetEntity.java @@ -0,0 +1,41 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * Deliberately relies on the JPA/Hibernate DEFAULT {@code equals()}/{@code hashCode()} + * (identity-based, inherited from Object) with a surrogate {@code @GeneratedValue} id that is + * null until the first flush. This is the entity used to reproduce the classic + * "HashSet.contains() returns false after persist()" symptom. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +public class IdentityHashSetEntity { + + @Id + @GeneratedValue + private Long id; + + private String label; + + public IdentityHashSetEntity() { + } + + public IdentityHashSetEntity(String label) { + this.label = label; + } + + public Long getId() { + return id; + } + + public String getLabel() { + return label; + } + + // Deliberately NOT overriding equals()/hashCode() -- uses Object identity. + // A second class below overrides them using the surrogate id, to show that trap too. +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnEntity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnEntity.java new file mode 100755 index 0000000..394e6b2 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnEntity.java @@ -0,0 +1,40 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * {@code @JdbcTypeCode(SqlTypes.JSON)} on a {@code Map} field, tested against + * H2 2.4.240 -- the article recommends it without saying whether it actually works on H2. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +public class JsonColumnEntity { + + @Id + @GeneratedValue + private Long id; + + @JdbcTypeCode(SqlTypes.JSON) + private Map details; + + public JsonColumnEntity() { + } + + public JsonColumnEntity(Map details) { + this.details = details; + } + + public Long getId() { + return id; + } + + public Map getDetails() { + return details; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessEntity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessEntity.java new file mode 100755 index 0000000..a21d73d --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessEntity.java @@ -0,0 +1,76 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Transient; + +/** + * Default access is FIELD (because {@code @Id} is annotated on the field). One property, + * {@code computedLabel}, is explicitly switched to PROPERTY access via {@code @Access} on its + * getter, with a derivation the FIELD side does not know about. This reproduces the classic + * "mixed access silently reads the wrong value" symptom: a raw field mutation is invisible to + * Hibernate once a property is PROPERTY-access, and vice versa. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +public class MixedAccessEntity { + + @Id + @GeneratedValue + private Long id; + + private String rawValue; + + // FIELD access is the entity default (because @Id is on a field). This backing field is + // deliberately never read directly by Hibernate for the "label" property -- only the + // PROPERTY-access getter below is. + @Transient + private int getterCallCount = 0; + + public MixedAccessEntity() { + } + + public MixedAccessEntity(String rawValue) { + this.rawValue = rawValue; + } + + public Long getId() { + return id; + } + + public String getRawValue() { + return rawValue; + } + + public void setRawValue(String rawValue) { + this.rawValue = rawValue; + } + + /** + * Explicitly PROPERTY access: Hibernate calls this getter (not a field read) to determine + * the persisted value for the "computedLabel" mapped property. + */ + @Access(AccessType.PROPERTY) + @Column(name = "computed_label") + public String getComputedLabel() { + getterCallCount++; + return rawValue == null ? null : rawValue.toUpperCase(); + } + + // Hibernate's default PROPERTY-access strategy REQUIRES a setter even for a logically + // read-only derived column -- omitting it throws PropertyNotFoundException at boot + // ("Could not locate setter method for property 'computedLabel'"). This setter is a + // deliberate no-op: it exists purely to satisfy that requirement. + public void setComputedLabel(String ignored) { + // no-op: computedLabel is derived from rawValue, never written directly + } + + public int getGetterCallCount() { + return getterCallCount; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/PriorityCountView.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/PriorityCountView.java new file mode 100755 index 0000000..c1f01a2 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/PriorityCountView.java @@ -0,0 +1,11 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +/** + * A Java record used as a JPQL constructor-expression result type -- new in Jakarta + * Persistence 3.2 (JPQL constructor expressions may target a record, matching a canonical + * constructor by position/type). + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +public record PriorityCountView(EnumeratedValueEntity.Priority priority, long total) { +} diff --git a/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnLocalDateEntity.java b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnLocalDateEntity.java new file mode 100755 index 0000000..20f2e0e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnLocalDateEntity.java @@ -0,0 +1,42 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import java.time.LocalDate; + +/** + * {@code @Temporal} is {@code @Deprecated(since = "3.2")} in jakarta.persistence-api 3.2.0 + * (confirmed via javap on the annotation class file). This entity puts it on a + * {@code java.time.LocalDate} field anyway, to see whether Hibernate 7.4.5 ignores it, + * warns, or throws at boot. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@Entity +public class TemporalOnLocalDateEntity { + + @Id + @GeneratedValue + private Long id; + + @Temporal(TemporalType.DATE) + private LocalDate eventDate; + + public TemporalOnLocalDateEntity() { + } + + public TemporalOnLocalDateEntity(LocalDate eventDate) { + this.eventDate = eventDate; + } + + public Long getId() { + return id; + } + + public LocalDate getEventDate() { + return eventDate; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/procedure/EmployeeSummary.java b/src/main/java/com/ankurm/hibernatedemo/procedure/EmployeeSummary.java new file mode 100755 index 0000000..758d294 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/procedure/EmployeeSummary.java @@ -0,0 +1,30 @@ +package com.ankurm.hibernatedemo.procedure; + +/** + * Plain DTO (NOT an @Entity) used as the {@code @ConstructorResult} target for + * {@code EmployeeSummaryMapping} in {@link ProcEmployee}, backing the "result set mapped to a + * DTO via @SqlResultSetMapping" requirement in docs/08-stored-procedures.md. + */ +public class EmployeeSummary { + + private final Integer id; + private final String name; + + public EmployeeSummary(Integer id, String name) { + this.id = id; + this.name = name; + } + + public Integer getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "EmployeeSummary{id=%s, name=%s}".formatted(id, name); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/procedure/ProcEmployee.java b/src/main/java/com/ankurm/hibernatedemo/procedure/ProcEmployee.java new file mode 100755 index 0000000..7238328 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/procedure/ProcEmployee.java @@ -0,0 +1,87 @@ +package com.ankurm.hibernatedemo.procedure; + +import jakarta.persistence.Column; +import jakarta.persistence.ColumnResult; +import jakarta.persistence.ConstructorResult; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.NamedStoredProcedureQueries; +import jakarta.persistence.NamedStoredProcedureQuery; +import jakarta.persistence.ParameterMode; +import jakarta.persistence.SqlResultSetMapping; +import jakarta.persistence.SqlResultSetMappings; +import jakarta.persistence.StoredProcedureParameter; +import jakarta.persistence.Table; +import java.math.BigDecimal; + +/** + * Entity for docs/08-stored-procedures.md (merged posts 4867 + 4881). Backed by HSQLDB 2.7.3 + * real SQL/PSM stored procedures created in {@code ProcedureSchemaSupport} -- these are not + * described in prose, they are compiled and executed. + */ +@Entity +@Table(name = "PROC_EMPLOYEES") +@NamedStoredProcedureQueries({ + @NamedStoredProcedureQuery( + name = "ProcEmployee.getTax", + procedureName = "GET_TAX", + parameters = { + @StoredProcedureParameter(mode = ParameterMode.IN, name = "emp_id", type = Integer.class), + @StoredProcedureParameter(mode = ParameterMode.OUT, name = "tax_amount", type = BigDecimal.class) + } + ), + @NamedStoredProcedureQuery( + name = "ProcEmployee.listAll", + procedureName = "LIST_EMPLOYEES", + resultClasses = ProcEmployee.class + ) +}) +@SqlResultSetMappings({ + @SqlResultSetMapping( + name = "EmployeeSummaryMapping", + classes = @ConstructorResult( + targetClass = EmployeeSummary.class, + columns = { + @ColumnResult(name = "ID", type = Integer.class), + @ColumnResult(name = "NAME", type = String.class) + } + ) + ) +}) +public class ProcEmployee { + + @Id + private Integer id; + + private String name; + + @Column(precision = 10, scale = 2) + private BigDecimal salary; + + protected ProcEmployee() { + // JPA + } + + public ProcEmployee(Integer id, String name, BigDecimal salary) { + this.id = id; + this.name = name; + this.salary = salary; + } + + public Integer getId() { + return id; + } + + public String getName() { + return name; + } + + public BigDecimal getSalary() { + return salary; + } + + @Override + public String toString() { + return "ProcEmployee{id=%s, name=%s, salary=%s}".formatted(id, name, salary); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyBook.java b/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyBook.java new file mode 100755 index 0000000..bf06b0b --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyBook.java @@ -0,0 +1,76 @@ +package com.ankurm.hibernatedemo.proxy; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.NamedAttributeNode; +import jakarta.persistence.NamedEntityGraph; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; + +/** + * Backs docs/11-proxies-and-lazy-initialization.md (post 4870 rewrite). Two associations on purpose: + * + *

    + *
  • {@code publisher} -- {@code @ManyToOne} with the JPA-default fetch type, EAGER. + * Nothing in this class overrides it.
  • + *
  • {@code reviews} -- {@code @OneToMany}, explicitly LAZY, and the only attribute named + * in {@code Book.reviews-only}. This is the association the fetchgraph/loadgraph demo + * turns on and off.
  • + *
+ * + *

With a {@code jakarta.persistence.loadgraph} hint carrying {@code Book.reviews-only}, + * {@code reviews} gets join-fetched AND {@code publisher} still gets its default EAGER join -- + * loadgraph only promotes attributes, it never demotes ones the mapping already marks EAGER. + * With a {@code jakarta.persistence.fetchgraph} hint carrying the same named graph, + * {@code reviews} still gets join-fetched, but {@code publisher} is forced down to LAZY (a + * proxy, no join) even though the mapping says EAGER -- fetchgraph treats the graph as the + * complete fetch plan, not an addition to the mapping's own defaults. + */ +@Entity +@NamedEntityGraph(name = "Book.reviews-only", attributeNodes = @NamedAttributeNode("reviews")) +public class ProxyBook { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne + @JoinColumn(name = "publisher_id") + private ProxyPublisher publisher; + + @OneToMany(mappedBy = "book", fetch = FetchType.LAZY) + private List reviews = new ArrayList<>(); + + protected ProxyBook() { + // JPA + } + + public ProxyBook(String title, ProxyPublisher publisher) { + this.title = title; + this.publisher = publisher; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public ProxyPublisher getPublisher() { + return publisher; + } + + public List getReviews() { + return reviews; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyPublisher.java b/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyPublisher.java new file mode 100755 index 0000000..a2303a2 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyPublisher.java @@ -0,0 +1,39 @@ +package com.ankurm.hibernatedemo.proxy; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * The default-fetch-type EAGER side of the entity-graph demo in docs/11-proxies-and-lazy-initialization.md + * (fetchgraph vs loadgraph). {@link ProxyBook#publisher} points here with the default + * {@code @ManyToOne} fetch type, which is EAGER -- deliberately, so that a + * {@code jakarta.persistence.fetchgraph} hint has something to force back to LAZY that a + * {@code jakarta.persistence.loadgraph} hint leaves alone. + */ +@Entity +public class ProxyPublisher { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + protected ProxyPublisher() { + // JPA + } + + public ProxyPublisher(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyReview.java b/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyReview.java new file mode 100755 index 0000000..baf5290 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/proxy/ProxyReview.java @@ -0,0 +1,44 @@ +package com.ankurm.hibernatedemo.proxy; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +/** + * Child side of {@link ProxyBook#reviews}. See {@link ProxyBook} for why this association + * exists and how the entity-graph demo in docs/11-proxies-and-lazy-initialization.md uses it. + */ +@Entity +public class ProxyReview { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "book_id") + private ProxyBook book; + + protected ProxyReview() { + // JPA + } + + public ProxyReview(String comment, ProxyBook book) { + this.comment = comment; + this.book = book; + } + + public Long getId() { + return id; + } + + public String getComment() { + return comment; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/query/Department.java b/src/main/java/com/ankurm/hibernatedemo/query/Department.java new file mode 100644 index 0000000..4b2ad0e --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/query/Department.java @@ -0,0 +1,49 @@ +package com.ankurm.hibernatedemo.query; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * Shared by the HQL chapter (15) and the Criteria API chapter (16) — both posts use the + * same Employee/Department pair in their examples, so one real mapping backs both rather than + * two near-duplicates. + * + *

Named {@code QueryDept} (entity name, via {@code @Entity(name = ...)}), not {@code + * Department}, because chapter 06's {@code naturalid.Department} already claims that entity + * name in this same persistence unit -- Hibernate requires distinct entity names project-wide, + * not just distinct class names. + * + *

Docs: docs/15-hql-queries.md, docs/16-criteria-queries.md. + */ +@Entity(name = "QueryDept") +public class Department { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + protected Department() { + // JPA + } + + public Department(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "Department{id=%s, name=%s}".formatted(id, name); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/query/Employee.java b/src/main/java/com/ankurm/hibernatedemo/query/Employee.java new file mode 100644 index 0000000..061618a --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/query/Employee.java @@ -0,0 +1,97 @@ +package com.ankurm.hibernatedemo.query; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDate; + +/** + * Shared by the HQL chapter (15) and the Criteria API chapter (16). + * + *

Docs: docs/15-hql-queries.md, docs/16-criteria-queries.md. + * + *

{@code status} and {@code hireDate} exist specifically for the bulk-update/bulk-delete + * scenarios both chapters cover ({@code UPDATE ... WHERE lastLogin/hireDate < :cutoff}-shaped + * queries), and {@code department} is LAZY so the fetch-join chapter has a real N+1 to avoid + * rather than an asserted one. + */ +@Entity +public class Employee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String firstName; + + private String lastName; + + private Double salary; + + private String status; + + private LocalDate hireDate; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_id") + private Department department; + + protected Employee() { + // JPA + } + + public Employee(String firstName, String lastName, Double salary, String status, LocalDate hireDate, Department department) { + this.firstName = firstName; + this.lastName = lastName; + this.salary = salary; + this.status = status; + this.hireDate = hireDate; + this.department = department; + } + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + public Double getSalary() { + return salary; + } + + public void setSalary(Double salary) { + this.salary = salary; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDate getHireDate() { + return hireDate; + } + + public Department getDepartment() { + return department; + } + + @Override + public String toString() { + return "Employee{id=%s, firstName=%s, lastName=%s, salary=%s, status=%s}" + .formatted(id, firstName, lastName, salary, status); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/scenario/GetVsLoadRunner.java b/src/main/java/com/ankurm/hibernatedemo/scenario/GetVsLoadRunner.java new file mode 100755 index 0000000..e9de341 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/scenario/GetVsLoadRunner.java @@ -0,0 +1,154 @@ +package com.ankurm.hibernatedemo.scenario; + +import com.ankurm.hibernatedemo.model.Book; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.Session; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * Backs ankurm.com post 4859 ("Hibernate 7: get() vs load()") and + * docs/01-get-vs-load.md. Captured verbatim into docs/output/get-vs-load.txt by + * {@code scripts/run.sh getvsload}. + * + *

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

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

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

{@link Book} carries a {@code @Version} column specifically so this scenario can show what + * {@code merge()} does when the detached instance it is given is holding a version older than + * what is currently in the database — not just what it does to an un-versioned row. + */ +@Component +@Profile("mergerefresh") +public class MergeVsRefreshRunner implements CommandLineRunner { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + private final EntityManagerFactory emf; + + public MergeVsRefreshRunner(EntityManagerFactory emf) { + this.emf = emf; + } + + @Override + public void run(String... args) { + Long id = seedOneBook(); + Book detached = loadThenDetach(id); + simulateAnotherProcessEditingTheRow(id); + mergeStaleDetachedInstance(detached); + refreshSilentlyDiscardsUnflushedEdit(id); + } + + private Long seedOneBook() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Book book = new Book("Clean Code", "Robert C. Martin"); + em.persist(book); + em.getTransaction().commit(); + Long id = book.getId(); + em.close(); + DEMO.info("SEED: inserted {}", book); + return id; + } + + private Book loadThenDetach(Long id) { + DEMO.info("--- Step 1: load the row, then close the session (entity is now detached) ---"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + Book book = session.get(Book.class, id); + em.getTransaction().commit(); + em.close(); + DEMO.info("detached instance in hand: {}", book); + return book; + } + + private void simulateAnotherProcessEditingTheRow(Long id) { + DEMO.info("--- Step 2: a second, independent session edits the same row and commits ---"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + Book book = session.get(Book.class, id); + book.setTitle("Clean Code (2nd Edition)"); + em.getTransaction().commit(); + em.close(); + DEMO.info("second session committed: {} -- version column has now advanced in the database", book); + } + + private void mergeStaleDetachedInstance(Book detached) { + DEMO.info("--- Step 3: mutate the ORIGINAL detached instance (still holding the OLD version) and merge() it ---"); + detached.setAuthor("Robert C. Martin (Uncle Bob)"); + DEMO.info("detached instance before merge (note the version and title are both stale): {}", detached); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + try { + Book merged = session.merge(detached); + em.getTransaction().commit(); + DEMO.info("merge() succeeded, returned managed instance: {}", merged); + } catch (OptimisticLockException e) { + em.getTransaction().rollback(); + DEMO.info("merge() threw {}: {}", e.getClass().getName(), e.getMessage()); + DEMO.info("the title change from Step 2 survives untouched -- merge() refused to apply a write built on a stale version"); + } finally { + em.close(); + } + } + + private void refreshSilentlyDiscardsUnflushedEdit(Long id) { + DEMO.info("--- Step 4: refresh() on a MANAGED entity with an unflushed local edit ---"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + Book book = session.get(Book.class, id); + book.setAuthor("SOMEONE ELSE ENTIRELY (never flushed)"); + DEMO.info("before refresh(): {}", book); + session.refresh(book); + DEMO.info("after refresh(): {} -- the local edit is gone, no exception was thrown", book); + em.getTransaction().commit(); + em.close(); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/search/Director.java b/src/main/java/com/ankurm/hibernatedemo/search/Director.java new file mode 100644 index 0000000..59a45d9 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/search/Director.java @@ -0,0 +1,38 @@ +package com.ankurm.hibernatedemo.search; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField; + +/** + * Not itself {@code @Indexed} -- it is only ever indexed as an embedded part of {@link Movie}, + * via {@code @IndexedEmbedded}. Docs: docs/25-hibernate-search.md. + */ +@Entity +public class Director { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @KeywordField + private String name; + + protected Director() { + // for Hibernate + } + + public Director(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/search/Movie.java b/src/main/java/com/ankurm/hibernatedemo/search/Movie.java new file mode 100644 index 0000000..ebddf91 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/search/Movie.java @@ -0,0 +1,96 @@ +package com.ankurm.hibernatedemo.search; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import org.hibernate.search.engine.backend.types.Sortable; +import org.hibernate.search.mapper.pojo.automaticindexing.ReindexOnUpdate; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.GenericField; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.IndexedEmbedded; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.IndexingDependency; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField; + +/** + * Chapter 25's Hibernate Search entity, pinned to {@code hibernate-search-mapper-orm} + * {@code 8.4.0.Final} (see {@code pom.xml} for why -- verified against Maven Central, NOT the + * {@code 7.3.2.Final} this repo's blog post previously claimed). + * + *

{@code title} is {@code @FullTextField} (analyzed, tokenized, fuzzy-matchable), + * {@code genre} is {@code @KeywordField} (stored whole, for exact-match filtering, never + * tokenized), {@code releaseYear} is a plain {@code @GenericField} marked sortable, and + * {@code director} is {@code @IndexedEmbedded} so a search on the director's name matches the + * movie without {@code Director} itself needing to be {@code @Indexed}. + * + *

Docs: docs/25-hibernate-search.md. + */ +@Entity +@Indexed +public class Movie { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // No explicit analyzer name: the Lucene backend ships no predefined "english" analyzer out + // of the box (confirmed the hard way -- naming one here without registering it via a + // LuceneAnalysisConfigurer bean fails the whole application context at startup with + // HSEARCH000353 "Unknown analyzer"). Omitting the attribute uses Hibernate Search's own + // built-in default full-text analyzer, which is enough for this chapter's examples. + @FullTextField + private String title; + + @KeywordField + private String genre; + + @GenericField(sortable = Sortable.YES) + private int releaseYear; + + // @IndexedEmbedded on a @ManyToOne with no inverse side fails bootstrap outright + // (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 that Director changes. Director has no @OneToMany back-reference in this repo's + // model, and reindexing-on-director-update isn't something this chapter's tests exercise, so + // @IndexingDependency(reindexOnUpdate = SHALLOW) opts out of that automatic reindexing + // instead of adding a back-reference this model doesn't otherwise need. + @ManyToOne + @JoinColumn(name = "director_id") + @IndexedEmbedded + @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.SHALLOW) + private Director director; + + protected Movie() { + // for Hibernate + } + + public Movie(String title, String genre, int releaseYear, Director director) { + this.title = title; + this.genre = genre; + this.releaseYear = releaseYear; + this.director = director; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getGenre() { + return genre; + } + + public int getReleaseYear() { + return releaseYear; + } + + public Director getDirector() { + return director; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/sorting/LengthThenAlphaComparator.java b/src/main/java/com/ankurm/hibernatedemo/sorting/LengthThenAlphaComparator.java new file mode 100644 index 0000000..08c0da3 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/sorting/LengthThenAlphaComparator.java @@ -0,0 +1,24 @@ +package com.ankurm.hibernatedemo.sorting; + +import java.io.Serializable; +import java.util.Comparator; + +/** + * A custom ordering for {@code @SortComparator} on {@link Playlist#getGenres()}: shortest name + * first, alphabetical as the tiebreaker. Hibernate instantiates this with a no-arg constructor + * via reflection, so it needs one (implicit here) and needs to be {@link Serializable} the same + * way any object that might end up in a Hibernate second-level cache entry does. + * + *

Docs: docs/22-sorting.md. + */ +public class LengthThenAlphaComparator implements Comparator, Serializable { + + @Override + public int compare(String a, String b) { + int byLength = Integer.compare(a.length(), b.length()); + if (byLength != 0) { + return byLength; + } + return a.compareTo(b); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/sorting/Playlist.java b/src/main/java/com/ankurm/hibernatedemo/sorting/Playlist.java new file mode 100644 index 0000000..a50bbca --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/sorting/Playlist.java @@ -0,0 +1,101 @@ +package com.ankurm.hibernatedemo.sorting; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import java.util.ArrayList; +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; +import org.hibernate.annotations.SortComparator; +import org.hibernate.annotations.SortNatural; + +/** + * Chapter 22's sorting playground: a {@code @OrderBy}-sorted list of {@link Song}, one + * {@code SortedSet} sorted by natural ordering, and one sorted by a custom comparator. + * + *

Docs: docs/22-sorting.md. + */ +@Entity +public class Playlist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + /** + * {@code @OrderBy("title asc")} names the ENTITY PROPERTY {@code title}, not the mapped + * column {@link Song#getTitle()} is stored under ({@code song_title} -- deliberately + * different, see {@link Song}). Hibernate translates the property name to the right column + * itself; a raw column name here would be a coincidence at best and wrong the moment the + * column is renamed. + */ + @OneToMany(mappedBy = "playlist", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("title asc") + private List songs = new ArrayList<>(); + + /** + * Natural ordering (plain {@code String.compareTo}) -- {@code @SortNatural} tells Hibernate + * to keep this as a real, server-independent {@code TreeSet} in memory, not to add an + * {@code ORDER BY} to the collection's own fetch (there is no single "row order" for a + * many-valued element collection to sort by until it's loaded). + */ + @ElementCollection + @CollectionTable(name = "playlist_tag", joinColumns = @JoinColumn(name = "playlist_id")) + @Column(name = "tag") + @SortNatural + private SortedSet tags = new TreeSet<>(); + + /** + * Same mechanism, a caller-supplied ordering instead of natural ordering: shortest name + * first, alphabetical as the tiebreaker. + */ + @ElementCollection + @CollectionTable(name = "playlist_genre", joinColumns = @JoinColumn(name = "playlist_id")) + @Column(name = "genre") + @SortComparator(LengthThenAlphaComparator.class) + private SortedSet genres = new TreeSet<>(new LengthThenAlphaComparator()); + + protected Playlist() { + // for Hibernate + } + + public Playlist(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public List getSongs() { + return songs; + } + + public void addSong(Song song) { + song.setPlaylist(this); + songs.add(song); + } + + public SortedSet getTags() { + return tags; + } + + public SortedSet getGenres() { + return genres; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/sorting/Song.java b/src/main/java/com/ankurm/hibernatedemo/sorting/Song.java new file mode 100644 index 0000000..9f6dff9 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/sorting/Song.java @@ -0,0 +1,73 @@ +package com.ankurm.hibernatedemo.sorting; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +/** + * The entity's property is {@code title}; the column it's stored under is {@code song_title}, + * on purpose -- this is what proves {@code @OrderBy("title asc")} on {@link Playlist} names the + * property, not the column. + * + *

{@code rating} is nullable: some songs are unrated, which is what chapter 22's null- + * precedence section needs a real column for. + * + *

Docs: docs/22-sorting.md. + */ +@Entity +public class Song { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "song_title") + private String title; + + private String artist; + + /** Nullable on purpose -- an unrated song is NULL, not 0. */ + private Integer rating; + + @ManyToOne + @JoinColumn(name = "playlist_id") + private Playlist playlist; + + protected Song() { + // for Hibernate + } + + public Song(String title, String artist, Integer rating) { + this.title = title; + this.artist = artist; + this.rating = rating; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getArtist() { + return artist; + } + + public Integer getRating() { + return rating; + } + + public Playlist getPlaylist() { + return playlist; + } + + public void setPlaylist(Playlist playlist) { + this.playlist = playlist; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/sorting/SongSortField.java b/src/main/java/com/ankurm/hibernatedemo/sorting/SongSortField.java new file mode 100644 index 0000000..7cbf59f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/sorting/SongSortField.java @@ -0,0 +1,33 @@ +package com.ankurm.hibernatedemo.sorting; + +import java.util.Set; + +/** + * The whitelist chapter 22's "dynamic sorting" section builds against. Concatenating an + * unvalidated caller-supplied string directly into an HQL {@code order by} clause hands that + * caller a way to inject arbitrary HQL (a path onto an unrelated entity, a nested {@code case} + * expression, or simply a string that breaks the query outright as a denial-of-service). The + * fix is not to sanitize the string -- it's to never let it reach the query at all except + * through a fixed, known-safe set of property names. + * + *

Docs: docs/22-sorting.md. + */ +public final class SongSortField { + + private static final Set ALLOWED = Set.of("title", "artist", "rating"); + + private SongSortField() { + } + + /** + * @return the exact HQL property name to sort by + * @throws IllegalArgumentException if {@code requested} is not one of the known-safe fields + */ + public static String toHqlPropertyOrThrow(String requested) { + if (!ALLOWED.contains(requested)) { + throw new IllegalArgumentException( + "'" + requested + "' is not a sortable field; allowed values are " + ALLOWED); + } + return requested; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/testdb/TestDbWidget.java b/src/main/java/com/ankurm/hibernatedemo/testdb/TestDbWidget.java new file mode 100755 index 0000000..a43c0d4 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/testdb/TestDbWidget.java @@ -0,0 +1,76 @@ +package com.ankurm.hibernatedemo.testdb; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; + +/** + * Backs docs/09-testing-in-memory-databases.md (post 4868 rewrite). One entity, run through the SAME mapping + * against H2 2.4.240, HSQLDB 2.7.3 and Apache Derby 10.16.1.1 via {@link TestDbBootstrap} -- + * the DDL differences, dialect selection, and cross-database failure case are all read off + * this exact class. + * + *

    + *
  • {@code id} -- {@code GenerationType.AUTO}, deliberately not IDENTITY or SEQUENCE, to + * see what each dialect resolves AUTO to.
  • + *
  • {@code sku} -- {@code varchar(5)}, short enough that an over-length value is a real + * constraint violation on at least one of the three databases.
  • + *
  • {@code order} -- a column named after a SQL reserved word on purpose (see + * {@code @Column(name = "\"order\"")}), the classic "works on some databases, breaks on + * others" trap.
  • + *
  • {@code active} -- a plain {@code boolean}, to see how each dialect maps and prints it.
  • + *
  • {@code description} -- {@code @Lob}, to see clob/text mapping differences.
  • + *
+ */ +@Entity +public class TestDbWidget { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @Column(length = 5) + private String sku; + + @Column(name = "\"order\"") + private Integer order; + + private boolean active; + + @Lob + private String description; + + protected TestDbWidget() { + // JPA + } + + public TestDbWidget(String sku, Integer order, boolean active, String description) { + this.sku = sku; + this.order = order; + this.active = active; + this.description = description; + } + + public Long getId() { + return id; + } + + public String getSku() { + return sku; + } + + public Integer getOrder() { + return order; + } + + public boolean isActive() { + return active; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/validation/InventoryPolicy.java b/src/main/java/com/ankurm/hibernatedemo/validation/InventoryPolicy.java new file mode 100644 index 0000000..40a5256 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/validation/InventoryPolicy.java @@ -0,0 +1,18 @@ +package com.ankurm.hibernatedemo.validation; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * A CDI-managed policy bean, deliberately not a constant. {@link PositiveInventoryValidator} + * depends on it via {@code @Inject} rather than hardcoding a threshold, so injection either + * genuinely happens or the validator has nothing usable to call. + * + *

Docs: docs/20-hibernate-validator-cdi.md + */ +@ApplicationScoped +public class InventoryPolicy { + + public int minimumThreshold() { + return 5; + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/validation/PositiveInventory.java b/src/main/java/com/ankurm/hibernatedemo/validation/PositiveInventory.java new file mode 100644 index 0000000..99658b8 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/validation/PositiveInventory.java @@ -0,0 +1,27 @@ +package com.ankurm.hibernatedemo.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A stock quantity must be at or above {@link InventoryPolicy#minimumThreshold()} -- a policy + * value, not a hardcoded number, which is the whole point: {@link PositiveInventoryValidator} + * needs that bean injected to do its job at all. + * + *

Docs: docs/20-hibernate-validator-cdi.md + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PositiveInventoryValidator.class) +public @interface PositiveInventory { + + String message() default "quantity is below the minimum inventory threshold"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/ankurm/hibernatedemo/validation/PositiveInventoryValidator.java b/src/main/java/com/ankurm/hibernatedemo/validation/PositiveInventoryValidator.java new file mode 100644 index 0000000..1b3504f --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/validation/PositiveInventoryValidator.java @@ -0,0 +1,28 @@ +package com.ankurm.hibernatedemo.validation; + +import jakarta.inject.Inject; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * The article's central claim, measured directly: does {@code @Inject} actually work inside a + * {@code ConstraintValidator}? The field below is never null-checked defensively on purpose -- + * the {@code NullPointerException} it throws when {@code policy} was never injected is itself + * the evidence chapter 20 measures. + * + *

Docs: docs/20-hibernate-validator-cdi.md + */ +public class PositiveInventoryValidator implements ConstraintValidator { + + @Inject + private InventoryPolicy policy; + + @Override + public boolean isValid(Integer quantity, ConstraintValidatorContext context) { + if (quantity == null) { + return true; // let @NotNull handle nullness; this constraint is about the value + } + // No null-guard on `policy` here, deliberately -- see the class Javadoc. + return quantity >= policy.minimumThreshold(); + } +} diff --git a/src/main/java/com/ankurm/hibernatedemo/validation/StockLevel.java b/src/main/java/com/ankurm/hibernatedemo/validation/StockLevel.java new file mode 100644 index 0000000..cfd2768 --- /dev/null +++ b/src/main/java/com/ankurm/hibernatedemo/validation/StockLevel.java @@ -0,0 +1,21 @@ +package com.ankurm.hibernatedemo.validation; + +/** + * A plain POJO, deliberately not a JPA entity -- this chapter is about Bean Validation and CDI, + * not persistence. + * + *

Docs: docs/20-hibernate-validator-cdi.md + */ +public class StockLevel { + + @PositiveInventory + private final Integer quantity; + + public StockLevel(Integer quantity) { + this.quantity = quantity; + } + + public Integer getQuantity() { + return quantity; + } +} diff --git a/src/main/resources/META-INF/beans.xml b/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..ec11b08 --- /dev/null +++ b/src/main/resources/META-INF/beans.xml @@ -0,0 +1,13 @@ + + + + diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml new file mode 100755 index 0000000..0548af3 --- /dev/null +++ b/src/main/resources/META-INF/orm.xml @@ -0,0 +1,18 @@ + + + + + + + SELECT e FROM XmlQueryEmployee e WHERE e.salary > :min ORDER BY e.salary DESC + + + + + SELECT e FROM XmlQueryEmployee e WHERE e.salary > :min + + + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100755 index 0000000..be9b3aa --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,58 @@ +spring: + main: + web-application-type: none + banner-mode: off + datasource: + url: jdbc:h2:mem:hibernate-demo;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + open-in-view: false + properties: + hibernate: + show_sql: true + format_sql: false + use_sql_comments: true + generate_statistics: true + # Pinned OFF deliberately. hibernate-jcache is on this project's classpath (the + # natural-id L2 chapter needs it), and Hibernate 7.4.5 will resolve a RegionFactory + # through the service loader and turn the second-level cache ON by itself when it + # finds one -- with nothing configured here. That is fine until a standalone test + # closes the shared Ehcache CacheManager, at which point unrelated Spring tests fail + # their commit with "Cache[...] is closed". See docs/output/testdb-jcache-classpath-pollution.txt + # and JCacheOnClasspathAutoEnablesL2Test, which pins the auto-enable down. + cache: + use_second_level_cache: false + jdbc: + batch_size: 25 + order_inserts: true + order_updates: true + search: + # Chapter 25's @Indexed entity (com.ankurm.hibernatedemo.search.Movie) lives under + # this application's normally-scanned package tree, so Hibernate Search bootstraps + # for EVERY test in this repo that boots the shared HibernateDemoApplication context, + # not only chapter 25's own tests -- confirmed the hard way: adding @Indexed here with + # zero backend configuration broke every other @SpringBootTest in the whole suite, + # because Hibernate Search has no backend to start against otherwise. "target/" is + # writable, gets removed by `mvn clean`, and Spring's test-context caching means this + # backend starts exactly once per test JVM, not once per test class. + backend: + type: lucene + directory: + root: target/lucene-indexes + schema_management: + strategy: drop-and-create + +logging: + level: + root: WARN + DEMO: INFO + org.hibernate.SQL: DEBUG + org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.engine.jdbc.batch.internal.BatchingBatch: DEBUG + org.hibernate.stat: INFO + pattern: + console: "%msg%n" diff --git a/src/test/java/com/ankurm/brokenprobe/BrokenNamedQueryEmployee.java b/src/test/java/com/ankurm/brokenprobe/BrokenNamedQueryEmployee.java new file mode 100755 index 0000000..8914bc9 --- /dev/null +++ b/src/test/java/com/ankurm/brokenprobe/BrokenNamedQueryEmployee.java @@ -0,0 +1,37 @@ +package com.ankurm.brokenprobe; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; + +/** + * DELIBERATELY BROKEN entity used ONLY by + * {@code com.ankurm.hibernatedemo.namedquery.NamedQueryStartupValidationTest}, via a fully + * standalone Hibernate bootstrap (StandardServiceRegistryBuilder + MetadataSources) that never + * touches the shared Spring Boot application context. + * + *

Deliberately placed OUTSIDE the {@code com.ankurm.hibernatedemo} package tree. Spring + * Boot's default JPA entity scanning walks every subpackage of the {@code @SpringBootApplication} + * class's package ({@code com.ankurm.hibernatedemo}), so a broken {@code @NamedQuery} anywhere + * under that tree would fail {@code @SpringBootTest} context bootstrap for EVERY test in this + * shared repository, not just this one. Keeping it here means only the standalone bootstrap + * below ever sees it. + */ +@Entity +@Table(name = "broken_nq_employee") +@NamedQuery(name = "BrokenNamedQueryEmployee.badProperty", + query = "SELECT e FROM BrokenNamedQueryEmployee e WHERE e.firsNam = :name") // typo: firsNam +public class BrokenNamedQueryEmployee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String firstName; + + protected BrokenNamedQueryEmployee() { + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java b/src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java new file mode 100755 index 0000000..faf774e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java @@ -0,0 +1,93 @@ +package com.ankurm.hibernatedemo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.model.WidgetAlloc1; +import com.ankurm.hibernatedemo.model.WidgetAlloc10; +import com.ankurm.hibernatedemo.model.WidgetAlloc25; +import com.ankurm.hibernatedemo.model.WidgetAlloc50; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.function.Function; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md. + * Run with {@code ./mvnw -Dtest=AllocationSizeSweepTest test}. + * + *

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

The companion sweep to {@code AllocationSizeSweepTest}: holds {@code allocationSize=50} + * fixed and sweeps {@code hibernate.jdbc.batch_size} across 1, 10, 25, 50 -- each as its own + * {@code @Nested @SpringBootTest} so each gets a genuinely separate Hibernate configuration + * rather than one mutated at runtime. + * + *

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

Run with {@code ./mvnw -Dtest=GetVsGetReferenceTest test}. Every assertion here was first + * observed by running the same code and reading the log, then pinned down as an assertion -- + * none of the outcomes below were assumed going in. + */ +@SpringBootTest +class GetVsGetReferenceTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + private Long seedBook(String title) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Book book = new Book(title, "Test Author"); + em.persist(book); + em.getTransaction().commit(); + Long id = book.getId(); + em.close(); + return id; + } + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + // ---- Part 1: four calls, four outcomes ---- + + @Test + void getOnExistingId_firesSelect_returnsRealEntity() { + Long id = seedBook("Effective Java"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + stats().clear(); + Book book = session.get(Book.class, id); + assertThat(stats().getPrepareStatementCount()).as("get() on an existing id must fire a SELECT").isEqualTo(1); + assertThat(book).isNotNull(); + assertThat(book.getClass()).isEqualTo(Book.class); + em.getTransaction().commit(); + em.close(); + } + + @Test + void getOnMissingId_firesSelect_returnsNull() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + stats().clear(); + Book book = session.get(Book.class, 999_111_222L); + assertThat(stats().getPrepareStatementCount()).as("get() on a missing id still fires a SELECT").isEqualTo(1); + assertThat(book).isNull(); + em.getTransaction().commit(); + em.close(); + } + + @Test + void getReferenceOnExistingId_noSelectUntilPropertyAccessed() { + Long id = seedBook("Domain-Driven Design"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + stats().clear(); + Book proxy = session.getReference(Book.class, id); + assertThat(stats().getPrepareStatementCount()) + .as("getReference() must not fire a SELECT at the call site") + .isEqualTo(0); + + String title = proxy.getTitle(); + + assertThat(stats().getPrepareStatementCount()) + .as("the SELECT is deferred until a non-id accessor is called") + .isEqualTo(1); + assertThat(title).isEqualTo("Domain-Driven Design"); + em.getTransaction().commit(); + em.close(); + } + + @Test + void getReferenceOnMissingId_noExceptionUntilAccessed() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + Book proxy = session.getReference(Book.class, 999_333_444L); + // No exception yet -- constructing the proxy never touched the database. + assertThat(proxy).isNotNull(); + + EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, proxy::getTitle); + DEMO.info("getReference() on a missing id, once accessed, threw: {}: {}", ex.getClass().getName(), ex.getMessage()); + em.getTransaction().rollback(); + em.close(); + } + + // ---- Part 2: same-session matrix ---- + // For each combination, both calls target the SAME id in the SAME session. + + @Test + void sessionMatrix_getThenGet_secondCallHitsL1Cache_sameInstance() { + Long id = seedBook("Matrix: get/get"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + + Book first = session.get(Book.class, id); + stats().clear(); + Book second = session.get(Book.class, id); + + assertThat(stats().getPrepareStatementCount()) + .as("second get() in the same session must NOT re-fire a SELECT (L1 cache hit)") + .isEqualTo(0); + assertThat(second).isSameAs(first); + em.getTransaction().commit(); + em.close(); + } + + @Test + void sessionMatrix_getReferenceThenGetReference_secondCallHitsL1Cache_sameInstance() { + Long id = seedBook("Matrix: getReference/getReference"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + + Book first = session.getReference(Book.class, id); + stats().clear(); + Book second = session.getReference(Book.class, id); + + assertThat(stats().getPrepareStatementCount()) + .as("second getReference() in the same session must not fire anything either -- still just a reference") + .isEqualTo(0); + assertThat(second).isSameAs(first); + em.getTransaction().commit(); + em.close(); + } + + @Test + void sessionMatrix_getThenGetReference_returnsTheSameAlreadyInitializedInstance() { + Long id = seedBook("Matrix: get/getReference"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + + Book real = session.get(Book.class, id); + stats().clear(); + Book second = session.getReference(Book.class, id); + + assertThat(stats().getPrepareStatementCount()) + .as("getReference() after get() must not fire a SELECT -- the real entity is already in the L1 cache") + .isEqualTo(0); + assertThat(second) + .as("getReference() returns the SAME already-managed real instance, not a new proxy, once one exists in this session") + .isSameAs(real); + assertThat(second.getClass()).isEqualTo(Book.class); + em.getTransaction().commit(); + em.close(); + } + + @Test + void sessionMatrix_getReferenceThenGet_getReturnsTheExistingProxyAndDoesNotForceInitialization() { + Long id = seedBook("Matrix: getReference/get"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + + Book proxy = session.getReference(Book.class, id); + stats().clear(); + Book second = session.get(Book.class, id); + + assertThat(second) + .as("get() after getReference() returns the SAME proxy already sitting in the L1 cache") + .isSameAs(proxy); + DEMO.info("get() after getReference(): prepareStatementCount for this call = {}, returned class = {}", + stats().getPrepareStatementCount(), second.getClass().getName()); + em.getTransaction().commit(); + em.close(); + } + + // ---- Part 3: proxy identity experiment ---- + + @Test + void proxyIdentity_instanceofSurvives_equalsDoesNot() { + Long id = seedBook("Proxy Identity"); + EntityManager em1 = emf.createEntityManager(); + em1.getTransaction().begin(); + Book real = em1.unwrap(Session.class).get(Book.class, id); + + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + Book proxy = em2.unwrap(Session.class).getReference(Book.class, id); + + assertThat(proxy).isInstanceOf(Book.class); + assertThat(org.hibernate.Hibernate.getClass(proxy)).isEqualTo(Book.class); + assertThat(proxy.getClass()).isNotEqualTo(Book.class); + + // Book does not override equals()/hashCode() -- this is the point of the test. + assertThat(real.equals(proxy)).isFalse(); + assertThat(proxy.equals(real)).isFalse(); + + Set set = new HashSet<>(); + set.add(real); + assertThat(set.contains(proxy)) + .as("a HashSet built on default equals()/hashCode() cannot recognise the proxy and the real instance as the same row") + .isFalse(); + + em1.getTransaction().commit(); + em1.close(); + em2.getTransaction().commit(); + em2.close(); + } + + @Test + void proxyOutlivesItsSession_throwsLazyInitializationExceptionOnAccess() { + Long id = seedBook("Outlives Session"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Book proxy = em.unwrap(Session.class).getReference(Book.class, id); + em.getTransaction().commit(); + em.close(); + + assertThrows(LazyInitializationException.class, proxy::getTitle); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java b/src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java new file mode 100755 index 0000000..dc2b072 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java @@ -0,0 +1,47 @@ +package com.ankurm.hibernatedemo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.model.WidgetIdentity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md. + * Run with {@code ./mvnw -Dtest=IdentityBatchTest test}. + */ +@SpringBootTest +class IdentityBatchTest { + + private static final int ROW_COUNT = 30; + + @Autowired + private EntityManagerFactory emf; + + @Test + void identityGeneratorDisablesBatching_despiteBatchSizeBeingSet() { + SessionFactory sessionFactory = emf.unwrap(SessionFactory.class); + Statistics stats = sessionFactory.getStatistics(); + stats.clear(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= ROW_COUNT; i++) { + em.persist(new WidgetIdentity("identity-" + i)); + } + em.getTransaction().commit(); + em.close(); + + assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT); + assertThat(stats.getPrepareStatementCount()) + .as("with GenerationType.IDENTITY, hibernate.jdbc.batch_size has nothing to batch -- " + + "every insert is its own round trip because the generated key is only " + + "known after the row is written") + .isEqualTo(ROW_COUNT); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/ImmutableBulkUpdateAllowedTest.java b/src/test/java/com/ankurm/hibernatedemo/ImmutableBulkUpdateAllowedTest.java new file mode 100755 index 0000000..2abd2a4 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/ImmutableBulkUpdateAllowedTest.java @@ -0,0 +1,60 @@ +package com.ankurm.hibernatedemo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.immutable.ExchangeRate; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs docs/07-immutable-entities.md. A SEPARATE Spring context (own EntityManagerFactory) with + * {@code hibernate.query.immutable_entity_update_query_handling_mode=allow} set, to prove the + * escape hatch from {@link ImmutableEntityTest#bulkHqlUpdate_onImmutableEntity_isRejectedAtTranslationTime_byDefault} + * really lets the bulk HQL UPDATE run -- and that when it does, the UPDATE is issued for real. + * This is a SessionFactory-wide setting, not a per-query hint, hence the separate context. + */ +@SpringBootTest(properties = { + "spring.jpa.properties.hibernate.query.immutable_entity_update_query_handling_mode=allow" +}) +class ImmutableBulkUpdateAllowedTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void withHandlingModeSetToAllow_bulkHqlUpdate_actuallyRunsAgainstImmutableEntity() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + ExchangeRate rate = new ExchangeRate("ZAR/USD", new BigDecimal("18.0000")); + seed.persist(rate); + seed.getTransaction().commit(); + Long id = rate.getId(); + seed.close(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + int updated = em.createQuery("update ExchangeRate set rate = :r where id = :id") + .setParameter("r", new BigDecimal("1.0000")) + .setParameter("id", id) + .executeUpdate(); + em.getTransaction().commit(); + em.close(); + DEMO.info("with handling-mode=allow, bulk HQL UPDATE rowsAffected={}", updated); + + EntityManager verify = emf.createEntityManager(); + ExchangeRate reloaded = verify.find(ExchangeRate.class, id); + DEMO.info("row after allowed bulk HQL update: {}", reloaded); + verify.close(); + + assertThat(updated).isEqualTo(1); + assertThat(reloaded.getRate()).isEqualByComparingTo("1.0000"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/ImmutableEntityTest.java b/src/test/java/com/ankurm/hibernatedemo/ImmutableEntityTest.java new file mode 100755 index 0000000..4bea460 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/ImmutableEntityTest.java @@ -0,0 +1,330 @@ +package com.ankurm.hibernatedemo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.immutable.AuditTrail; +import com.ankurm.hibernatedemo.immutable.ExchangeRate; +import com.ankurm.hibernatedemo.immutable.ExchangeRateVersioned; +import com.ankurm.hibernatedemo.immutable.PlainRate; +import com.ankurm.hibernatedemo.immutable.RateWithAuditTrail; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import org.hibernate.HibernateException; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs docs/07-immutable-entities.md ({@code @Immutable}, post 4866). + * Run with {@code ./mvnw -Dtest=ImmutableEntityTest test}. + * + *

Verified on Hibernate ORM 7.4.5.Final / jakarta.persistence-api 3.2.0 / H2 2.4.240. + */ +@SpringBootTest +class ImmutableEntityTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + /** + * THE headline claim: mutate a field on an @Immutable entity, flush inside a transaction -- + * no UPDATE is issued, AND no exception is thrown. The silence is the story. + */ + @Test + void mutatingAndFlushing_immutableEntity_issuesNoUpdate_andThrowsNothing() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + ExchangeRate rate = new ExchangeRate("USD/EUR", new BigDecimal("0.9200")); + seed.persist(rate); + seed.getTransaction().commit(); + Long id = rate.getId(); + seed.close(); + + stats().clear(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ExchangeRate managed = em.find(ExchangeRate.class, id); + managed.setRate(new BigDecimal("999.9999")); + DEMO.info("in-memory field mutated to {}, about to flush inside a transaction", managed.getRate()); + em.getTransaction().commit(); // flush + commit; must not throw + em.close(); + + long updateCount = stats().getEntityUpdateCount(); + DEMO.info("Statistics.getEntityUpdateCount() after mutate+flush = {}", updateCount); + assertThat(updateCount).isZero(); + + EntityManager verify = emf.createEntityManager(); + ExchangeRate reloaded = verify.find(ExchangeRate.class, id); + DEMO.info("reloaded from DB: {}", reloaded); + assertThat(reloaded.getRate()).isEqualByComparingTo("0.9200"); + verify.close(); + } + + /** @Immutable does not protect against EntityManager.remove() -- the DELETE goes through. */ + @Test + void immutableEntity_canStillBeDeleted_viaEntityManagerRemove() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + ExchangeRate rate = new ExchangeRate("GBP/USD", new BigDecimal("1.2500")); + seed.persist(rate); + seed.getTransaction().commit(); + Long id = rate.getId(); + seed.close(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ExchangeRate managed = em.find(ExchangeRate.class, id); + em.remove(managed); + em.getTransaction().commit(); + em.close(); + + EntityManager verify = emf.createEntityManager(); + ExchangeRate gone = verify.find(ExchangeRate.class, id); + DEMO.info("after EntityManager.remove() on an @Immutable entity, find() returns: {}", gone); + assertThat(gone).isNull(); + verify.close(); + } + + /** + * Does a bulk HQL "update ... set" statement respect @Immutable? By DEFAULT in 7.4.5, + * Hibernate refuses to even translate it -- it fails at query-compile time, before hitting + * the database, with an InterpretationException naming the offending entity and the exact + * setting that would allow it through. + */ + @Test + void bulkHqlUpdate_onImmutableEntity_isRejectedAtTranslationTime_byDefault() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + ExchangeRate rate = new ExchangeRate("AUD/USD", new BigDecimal("0.6500")); + seed.persist(rate); + seed.getTransaction().commit(); + Long id = rate.getId(); + seed.close(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Exception caught = null; + try { + em.createQuery("update ExchangeRate set rate = :r where id = :id") + .setParameter("r", new BigDecimal("42.0000")) + .setParameter("id", id) + .executeUpdate(); + } catch (Exception e) { + caught = e; + } + em.getTransaction().rollback(); + em.close(); + + DEMO.info("bulk HQL 'update ExchangeRate set ...' on an @Immutable entity threw: {}: {}", + caught == null ? "NOTHING" : caught.getClass().getName(), + caught == null ? "" : caught.getMessage()); + + assertThat(caught).isInstanceOf(org.hibernate.query.sqm.InterpretationException.class); + assertThat(caught.getMessage()).contains("attempts to update an immutable entity"); + assertThat(caught.getMessage()).contains("immutable_entity_update_query_handling_mode"); + } + + + + /** Does a bulk HQL "delete" statement respect @Immutable? */ + @Test + void bulkHqlDelete_onImmutableEntity_behaviour() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + ExchangeRate rate = new ExchangeRate("NZD/USD", new BigDecimal("0.6100")); + seed.persist(rate); + seed.getTransaction().commit(); + Long id = rate.getId(); + seed.close(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + int deleted = em.createQuery("delete from ExchangeRate where id = :id") + .setParameter("id", id) + .executeUpdate(); + em.getTransaction().commit(); + em.close(); + DEMO.info("bulk HQL 'delete from ExchangeRate' executeUpdate() returned rowsAffected={}", deleted); + assertThat(deleted).isEqualTo(1); + + EntityManager verify = emf.createEntityManager(); + assertThat(verify.find(ExchangeRate.class, id)).isNull(); + verify.close(); + } + + /** Native SQL is below Hibernate's tracking entirely -- @Immutable cannot see it. */ + @Test + void nativeSqlUpdate_onImmutableEntity_alwaysWorks() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + ExchangeRate rate = new ExchangeRate("CHF/USD", new BigDecimal("1.1000")); + seed.persist(rate); + seed.getTransaction().commit(); + Long id = rate.getId(); + seed.close(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + int rows = em.createNativeQuery("update exchange_rate set rate = 7.7 where id = " + id) + .executeUpdate(); + em.getTransaction().commit(); + em.close(); + DEMO.info("native SQL UPDATE rows={}", rows); + assertThat(rows).isEqualTo(1); + + EntityManager verify = emf.createEntityManager(); + ExchangeRate reloaded = verify.find(ExchangeRate.class, id); + DEMO.info("row after native SQL update: {}", reloaded); + assertThat(reloaded.getRate()).isEqualByComparingTo("7.7"); + verify.close(); + } + + /** + * @Immutable on a @OneToMany collection: adding to it and flushing has historically thrown a + * specific exception. Capture the exact class and message in Hibernate 7.4.5. + */ + @Test + void addingToImmutableCollection_andFlushing_throwsSpecificException() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + RateWithAuditTrail parent = new RateWithAuditTrail("EUR/JPY"); + seed.persist(parent); + seed.getTransaction().commit(); + Long id = parent.getId(); + seed.close(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + RateWithAuditTrail managed = em.find(RateWithAuditTrail.class, id); + managed.getAuditTrails().add(new AuditTrail("added after load")); + + Exception caught = null; + try { + em.getTransaction().commit(); + } catch (Exception e) { + caught = e; + } + + Throwable root = caught; + while (root != null && root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + DEMO.info("adding to an @Immutable collection and flushing threw: {}", + caught == null ? "NOTHING" : caught.getClass().getName() + ": " + caught.getMessage()); + DEMO.info("root cause class: {} message: {}", + root == null ? "NONE" : root.getClass().getName(), + root == null ? "" : root.getMessage()); + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + em.close(); + + assertThat(caught).isNotNull(); + } + + /** Does Hibernate accept @Immutable + @Version, and does the version column ever increment? */ + @Test + void immutablePlusVersion_isAccepted_butVersionNeverIncrements() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + ExchangeRateVersioned rate = new ExchangeRateVersioned("SEK/USD", new BigDecimal("0.0950")); + seed.persist(rate); + seed.getTransaction().commit(); + Long id = rate.getId(); + Long initialVersion = rate.getVersion(); + seed.close(); + DEMO.info("persisted @Immutable+@Version entity, version after insert = {}", initialVersion); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ExchangeRateVersioned managed = em.find(ExchangeRateVersioned.class, id); + managed.setRate(new BigDecimal("0.1234")); + em.getTransaction().commit(); // must not throw + em.close(); + + EntityManager verify = emf.createEntityManager(); + ExchangeRateVersioned reloaded = verify.find(ExchangeRateVersioned.class, id); + DEMO.info("after mutate+flush, version = {}, rate = {}", reloaded.getVersion(), reloaded.getRate()); + verify.close(); + + assertThat(reloaded.getVersion()).isEqualTo(initialVersion); + assertThat(reloaded.getRate()).isEqualByComparingTo("0.0950"); + } + + /** + * Session.setReadOnly(entity, true) on a PLAIN (non-@Immutable) entity: same silent-no-UPDATE + * outcome as @Immutable, but it is a per-entity-instance runtime toggle rather than a + * class-wide, DDL-agnostic mapping annotation. + */ + @Test + void sessionSetReadOnlyOnPlainEntity_alsoSuppressesUpdate_perInstance() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + PlainRate p = new PlainRate("PLN/USD", new BigDecimal("4.0")); + seed.persist(p); + seed.getTransaction().commit(); + Long id = p.getId(); + seed.close(); + + stats().clear(); + + EntityManager em = emf.createEntityManager(); + Session session = em.unwrap(Session.class); + em.getTransaction().begin(); + PlainRate managed = session.find(PlainRate.class, id); + session.setReadOnly(managed, true); + managed.setRate(new BigDecimal("999")); + em.getTransaction().commit(); + em.close(); + + long updateCount = stats().getEntityUpdateCount(); + DEMO.info("Session.setReadOnly(entity,true) then mutate+flush -> entityUpdateCount = {}", updateCount); + assertThat(updateCount).isZero(); + + EntityManager verify = emf.createEntityManager(); + PlainRate reloaded = verify.find(PlainRate.class, id); + assertThat(reloaded.getRate()).isEqualByComparingTo("4.0"); + verify.close(); + } + + /** Session.setDefaultReadOnly(true) applies the read-only flag to every entity loaded afterwards. */ + @Test + void sessionSetDefaultReadOnly_appliesToAllSubsequentLoads() { + EntityManager seed = emf.createEntityManager(); + seed.getTransaction().begin(); + PlainRate p = new PlainRate("DKK/USD", new BigDecimal("6.9")); + seed.persist(p); + seed.getTransaction().commit(); + Long id = p.getId(); + seed.close(); + + EntityManager em = emf.createEntityManager(); + Session session = em.unwrap(Session.class); + session.setDefaultReadOnly(true); + em.getTransaction().begin(); + PlainRate managed = session.find(PlainRate.class, id); + managed.setRate(new BigDecimal("111")); + em.getTransaction().commit(); + em.close(); + + EntityManager verify = emf.createEntityManager(); + PlainRate reloaded = verify.find(PlainRate.class, id); + DEMO.info("after setDefaultReadOnly(true) + mutate + flush, rate = {}", reloaded.getRate()); + assertThat(reloaded.getRate()).isEqualByComparingTo("6.9"); + verify.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/ImmutableFlushCostTest.java b/src/test/java/com/ankurm/hibernatedemo/ImmutableFlushCostTest.java new file mode 100755 index 0000000..5d376dd --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/ImmutableFlushCostTest.java @@ -0,0 +1,100 @@ +package com.ankurm.hibernatedemo; + +import com.ankurm.hibernatedemo.immutable.WideImmutableRow; +import com.ankurm.hibernatedemo.immutable.WideMutableRow; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.List; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs docs/07-immutable-entities.md, "Does @Immutable actually cost less at flush time?". + * + *

This is NOT a rigorous benchmark: it is one Spring context, in a shared container, timed + * with {@code System.nanoTime()} around a single {@code flush()} call per entity type, no JMH, + * no warm-up isolation, no forked JVM. Treat the numbers as indicative of the right order of + * magnitude and direction, not as a citable throughput claim. Rows: 4000 per entity type, + * 12 String columns each, loaded fully into the persistence context, then flushed with NO + * pending changes -- so any time difference is purely Hibernate deciding "does this entity need + * an UPDATE", not the cost of writing one. + */ +@SpringBootTest +class ImmutableFlushCostTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static final int ROW_COUNT = 4000; + + @Autowired + private EntityManagerFactory emf; + + @Test + void flushingManyLoadedEntities_immutableSkipsDirtyCheck_mutableDoesNot() { + seedMutable(ROW_COUNT); + seedImmutable(ROW_COUNT); + + // Warm-up pass (JIT, connection pool, buffer pool) -- discarded. + timeMutableFlush(); + timeImmutableFlush(); + + long mutableNanos = timeMutableFlush(); + long immutableNanos = timeImmutableFlush(); + + DEMO.info("flush() over {} loaded MUTABLE rows (12 cols, no pending changes): {} ms", + ROW_COUNT, mutableNanos / 1_000_000.0); + DEMO.info("flush() over {} loaded @Immutable rows (12 cols, no pending changes): {} ms", + ROW_COUNT, immutableNanos / 1_000_000.0); + DEMO.info("ratio (mutable / immutable) = {}", (double) mutableNanos / immutableNanos); + DEMO.info("CAVEAT: single-run, shared-container timing -- indicative only, not a benchmark result."); + } + + private long timeMutableFlush() { + EntityManager em = emf.createEntityManager(); + Session session = em.unwrap(Session.class); + em.getTransaction().begin(); + List rows = session.createQuery("from WideMutableRow", WideMutableRow.class).list(); + long start = System.nanoTime(); + em.flush(); + long elapsed = System.nanoTime() - start; + em.getTransaction().rollback(); + em.close(); + return elapsed; + } + + private long timeImmutableFlush() { + EntityManager em = emf.createEntityManager(); + Session session = em.unwrap(Session.class); + em.getTransaction().begin(); + List rows = session.createQuery("from WideImmutableRow", WideImmutableRow.class).list(); + long start = System.nanoTime(); + em.flush(); + long elapsed = System.nanoTime() - start; + em.getTransaction().rollback(); + em.close(); + return elapsed; + } + + private void seedMutable(int count) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 0; i < count; i++) { + em.persist(new WideMutableRow("seed-" + i)); + } + em.getTransaction().commit(); + em.close(); + } + + private void seedImmutable(int count) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 0; i < count; i++) { + em.persist(new WideImmutableRow("seed-" + i)); + } + em.getTransaction().commit(); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java b/src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java new file mode 100755 index 0000000..fe4a57f --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java @@ -0,0 +1,160 @@ +package com.ankurm.hibernatedemo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.model.Book; +import com.ankurm.hibernatedemo.model.Note; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.Hibernate; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4860. Docs: docs/02-merge-vs-refresh.md. + * Run with {@code ./mvnw -Dtest=MergeRefreshTest test}. + * + *

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

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

{@link WidgetSequence} sets {@code allocationSize = 25}, matching + * {@code hibernate.jdbc.batch_size} in application.yml. See {@code AllocationSizeSweepTest} for + * what happens when the two are deliberately mismatched. + */ +@SpringBootTest +class SequenceBatchTest { + + private static final int ROW_COUNT = 30; + + @Autowired + private EntityManagerFactory emf; + + @Test + void sequenceGeneratorAllowsBatching_fourPreparedStatementsForThirtyRows() { + SessionFactory sessionFactory = emf.unwrap(SessionFactory.class); + Statistics stats = sessionFactory.getStatistics(); + stats.clear(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= ROW_COUNT; i++) { + em.persist(new WidgetSequence("sequence-" + i)); + } + em.getTransaction().commit(); + em.close(); + + assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT); + assertThat(stats.getPrepareStatementCount()) + .as("30 rows at batch_size=25 is 2 insert batches (25 + 5); allocationSize=25 " + + "means the first 25 ids come from one sequence call and the remaining " + + "5 force a second -- 2 insert batches + 2 sequence calls = 4") + .isEqualTo(4); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/aggregate/AggregateFunctionsTest.java b/src/test/java/com/ankurm/hibernatedemo/aggregate/AggregateFunctionsTest.java new file mode 100644 index 0000000..1324660 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/aggregate/AggregateFunctionsTest.java @@ -0,0 +1,156 @@ +package com.ankurm.hibernatedemo.aggregate; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Proves the article's aggregate-function claims against a real H2 database: what an aggregate + * query returns over an empty table, {@code select new} record construction under GROUP BY / + * HAVING, the Criteria API equivalent, and HQL's {@code row_number()} window function. + * + *

Docs: docs/21-aggregate-functions.md. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class AggregateFunctionsTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void emptyResultSet_countIsZero_sumAndAvgAreNull_neitherThrows() { + // Scoped to a category that matches zero rows -- rather than relying on the whole table + // being empty, which would make this test's outcome depend on execution order against + // the other test methods in this class that share the same EntityManagerFactory/database. + String noSuchCategory = "no-such-category-zzz"; + EntityManager em = emf.createEntityManager(); + + Long count = em.createQuery( + "select count(p) from Product p where p.category = :c", Long.class) + .setParameter("c", noSuchCategory) + .getSingleResult(); + Double sum = em.createQuery( + "select sum(p.price) from Product p where p.category = :c", Double.class) + .setParameter("c", noSuchCategory) + .getSingleResult(); + Double avg = em.createQuery( + "select avg(p.price) from Product p where p.category = :c", Double.class) + .setParameter("c", noSuchCategory) + .getSingleResult(); + + em.close(); + + System.out.println("RESULT[aggregate-empty-result-set]: over 0 matching rows -- " + + "count(p)=" + count + " (never null) | sum(p.price)=" + sum + " | avg(p.price)=" + + avg + " -- getSingleResult() returned normally for all three, no " + + "NoResultException, because SQL's aggregate functions over zero rows still " + + "produce exactly one result row."); + + assertThat(count).isZero(); + assertThat(sum).isNull(); + assertThat(avg).isNull(); + } + + @Test + void groupByHaving_selectNewRecord_producesTypedSummaries() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new Product("Keyboards", "Compact 65%", 89.0, 40)); + em.persist(new Product("Keyboards", "Full-size", 59.0, 15)); + em.persist(new Product("Keyboards", "Ergonomic split", 149.0, 5)); + em.persist(new Product("Monitors", "27in 4K", 399.0, 8)); + em.getTransaction().commit(); + + // Scoped to this test's own two categories -- the entity manager factory's H2 database is + // shared across every test method in this class, so an unscoped query here would also + // pick up Cables/Mice from the other test methods and make this assertion order-dependent. + List summaries = em.createQuery( + "select new com.ankurm.hibernatedemo.aggregate.CategorySummary(" + + "p.category, count(p), avg(p.price)) " + + "from Product p where p.category in ('Keyboards', 'Monitors') " + + "group by p.category having count(p) > 1 " + + "order by p.category", + CategorySummary.class).getResultList(); + em.close(); + + String rendered = summaries.stream() + .map(s -> s.category() + "(count=" + s.productCount() + ", avg=" + s.averagePrice() + ")") + .reduce((a, b) -> a + ", " + b) + .orElse(""); + System.out.println("RESULT[aggregate-groupby-having-record]: HAVING count(p) > 1 kept only " + + "categories with more than one product -- " + rendered + + " -- Monitors (1 product) was correctly excluded by HAVING, not just by GROUP BY."); + + assertThat(summaries).hasSize(1); + assertThat(summaries.get(0).category()).isEqualTo("Keyboards"); + assertThat(summaries.get(0).productCount()).isEqualTo(3); + } + + @Test + void criteriaApi_avgWithGroupBy_matchesHqlEquivalent() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new Product("Cables", "USB-C 1m", 9.0, 200)); + em.persist(new Product("Cables", "USB-C 2m", 13.0, 120)); + em.getTransaction().commit(); + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Object[].class); + Root root = cq.from(Product.class); + cq.multiselect(root.get("category"), cb.avg(root.get("price"))) + .where(cb.equal(root.get("category"), "Cables")) + .groupBy(root.get("category")); + + TypedQuery query = em.createQuery(cq); + Object[] row = query.getSingleResult(); + em.close(); + + System.out.println("RESULT[aggregate-criteria-avg]: Criteria API cb.avg(root.get(\"price\")) " + + "for category='" + row[0] + "' -- average=" + row[1] + + " -- same numeric result as the equivalent HQL avg(p.price), just built without a" + + " string query."); + + assertThat((Double) row[1]).isEqualTo(11.0); + } + + @Test + void windowFunction_rowNumberOverPartitionByCategory() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new Product("Mice", "Wireless A", 49.0, 30)); + em.persist(new Product("Mice", "Wireless B", 79.0, 12)); + em.persist(new Product("Mice", "Wired C", 19.0, 60)); + em.getTransaction().commit(); + + List ranked = em.createQuery( + "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", + Object[].class).getResultList(); + em.close(); + + StringBuilder rendered = new StringBuilder(); + for (Object[] row : ranked) { + rendered.append(row[0]).append("=rank").append(row[2]).append(" "); + } + System.out.println("RESULT[aggregate-window-row-number]: row_number() over " + + "(partition by category order by price desc) for the Mice category -- " + + rendered.toString().trim() + + " -- 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."); + + assertThat(ranked).hasSize(3); + assertThat(((Number) ranked.get(0)[2]).intValue()).isEqualTo(1); + assertThat(ranked.get(0)[0]).isEqualTo("Wireless B"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/association/BagFetchTest.java b/src/test/java/com/ankurm/hibernatedemo/association/BagFetchTest.java new file mode 100755 index 0000000..a607fc4 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/association/BagFetchTest.java @@ -0,0 +1,181 @@ +package com.ankurm.hibernatedemo.association; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.List; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4872, docs/12-association-mappings.md chapters "MultipleBagFetchException" + * and "The cartesian-product trap". + */ +@SpringBootTest +class BagFetchTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @BeforeEach + void cleanTables() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM BagBookL").executeUpdate(); + em.createQuery("DELETE FROM BagAwardL").executeUpdate(); + em.createQuery("DELETE FROM BagAuthorList").executeUpdate(); + em.createQuery("DELETE FROM BagBookS").executeUpdate(); + em.createQuery("DELETE FROM BagAwardS").executeUpdate(); + em.createQuery("DELETE FROM BagAuthorSet").executeUpdate(); + em.getTransaction().commit(); + em.close(); + } + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + private Long seedListAuthor(int nBooks, int nAwards) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + BagAuthorList author = new BagAuthorList("List Author"); + em.persist(author); + for (int i = 0; i < nBooks; i++) { + em.persist(new BagBookL("Book" + i, author)); + } + for (int i = 0; i < nAwards; i++) { + em.persist(new BagAwardL("Award" + i, author)); + } + em.getTransaction().commit(); + Long id = author.getId(); + em.close(); + return id; + } + + private Long seedSetAuthor(int nBooks, int nAwards) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + BagAuthorSet author = new BagAuthorSet("Set Author"); + em.persist(author); + for (int i = 0; i < nBooks; i++) { + em.persist(new BagBookS("Book" + i, author)); + } + for (int i = 0; i < nAwards; i++) { + em.persist(new BagAwardS("Award" + i, author)); + } + em.getTransaction().commit(); + Long id = author.getId(); + em.close(); + return id; + } + + @Test + void fetchJoiningTwoListsInOneQuery_throwsMultipleBagFetchException() { + seedListAuthor(4, 3); + EntityManager em = emf.createEntityManager(); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + em.createQuery( + "SELECT a FROM BagAuthorList a JOIN FETCH a.books JOIN FETCH a.awards", + BagAuthorList.class).getResultList()); + + Throwable root = ex; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + DEMO.info("MultipleBagFetchException reproduction -- wrapper class: {}", ex.getClass().getName()); + DEMO.info("MultipleBagFetchException reproduction -- root cause class: {}", root.getClass().getName()); + DEMO.info("MultipleBagFetchException reproduction -- verbatim message: {}", root.getMessage()); + + assertThat(ex.getClass().getName()).as("EntityManager.createQuery(...).getResultList() wraps it as IllegalArgumentException, NOT PersistenceException") + .isEqualTo("java.lang.IllegalArgumentException"); + assertThat(root.getClass().getName()).isEqualTo("org.hibernate.loader.MultipleBagFetchException"); + em.close(); + } + + @Test + void fix1_useSetsInsteadOfLists_noExceptionOneQuery() { + seedSetAuthor(4, 3); + EntityManager em = emf.createEntityManager(); + stats().clear(); + + List authors = em.createQuery( + "SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards", + BagAuthorSet.class) + .getResultList(); + + long queries = stats().getPrepareStatementCount(); + DEMO.info("Fix #1 (Set instead of List): {} distinct authors returned, {} queries fired", authors.size(), queries); + + assertThat(authors).hasSize(1); + assertThat(authors.get(0).getBooks()).hasSize(4); + assertThat(authors.get(0).getAwards()).hasSize(3); + assertThat(queries).isEqualTo(1); + em.close(); + } + + @Test + void fix2_twoSeparateQueries_avoidsBagFetchExceptionEntirely() { + Long id = seedListAuthor(4, 3); + EntityManager em = emf.createEntityManager(); + stats().clear(); + + BagAuthorList author = em.createQuery( + "SELECT a FROM BagAuthorList a JOIN FETCH a.books WHERE a.id = :id", BagAuthorList.class) + .setParameter("id", id) + .getSingleResult(); + // second, separate query for the other bag -- no exception because only one JOIN FETCH per query + author = em.createQuery( + "SELECT a FROM BagAuthorList a JOIN FETCH a.awards WHERE a.id = :id", BagAuthorList.class) + .setParameter("id", id) + .getSingleResult(); + + long queries = stats().getPrepareStatementCount(); + DEMO.info("Fix #2 (two queries): {} queries fired, books={}, awards={}", + queries, author.getBooks().size(), author.getAwards().size()); + + assertThat(author.getBooks()).hasSize(4); + assertThat(author.getAwards()).hasSize(3); + assertThat(queries).as("one JOIN FETCH per query, run twice").isEqualTo(2); + em.close(); + } + + @Test + void cartesianProduct_fetchJoiningTwoAllowedSetsExplodesRowCount() { + seedSetAuthor(4, 3); + EntityManager em = emf.createEntityManager(); + + // Raw SQL join without DISTINCT to see the true row count the database returns. + Object rawCount = em.createNativeQuery( + "SELECT COUNT(*) FROM bag_author_set a " + + "JOIN bag_book_s b ON b.author_id = a.id " + + "JOIN bag_award_s w ON w.author_id = a.id") + .getSingleResult(); + long rawRowCount = ((Number) rawCount).longValue(); + + List entities = em.createQuery( + "SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards", + BagAuthorSet.class) + .getResultList(); + + DEMO.info("Cartesian product: 4 books x 3 awards for 1 author -> raw SQL join rows = {}, distinct entities returned = {}", + rawRowCount, entities.size()); + + assertThat(rawRowCount).as("SQL returns one row per (book, award) pair: 4 x 3").isEqualTo(12); + assertThat(entities).as("Hibernate's DISTINCT root-entity de-duplication collapses this back to 1 entity") + .hasSize(1); + assertThat(entities.get(0).getBooks()).hasSize(4); + assertThat(entities.get(0).getAwards()).hasSize(3); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/association/CascadeOrphanTest.java b/src/test/java/com/ankurm/hibernatedemo/association/CascadeOrphanTest.java new file mode 100755 index 0000000..4c29c48 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/association/CascadeOrphanTest.java @@ -0,0 +1,188 @@ +package com.ankurm.hibernatedemo.association; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4872, docs/12-association-mappings.md chapters "Cascade and orphanRemoval" + * and "The owning side". + */ +@SpringBootTest +class CascadeOrphanTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @BeforeEach + void cleanTables() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM CascadeBook").executeUpdate(); + em.createQuery("DELETE FROM CascadeAuthor").executeUpdate(); + em.createQuery("DELETE FROM NoOrphanBook").executeUpdate(); + em.createQuery("DELETE FROM NoOrphanAuthor").executeUpdate(); + em.getTransaction().commit(); + em.close(); + } + + @Test + void cascadeAllPlusOrphanRemoval_reassigningTheCollectionThrowsInsteadOfSilentlyDeleting() { + // CORRECTION vs the common blog claim ("silently deletes what you did not expect"): + // assigning a brand new List instance to a CascadeType.ALL + orphanRemoval=true + // collection does NOT silently delete anything -- Hibernate detects the managed + // collection was dereferenced and throws at flush/commit time instead. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + CascadeAuthor author = new CascadeAuthor("Prolific Author"); + em.persist(author); + em.persist(new CascadeBook("Book A", author)); + em.persist(new CascadeBook("Book B", author)); + em.getTransaction().commit(); + Long id = author.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + CascadeAuthor managed = em2.find(CascadeAuthor.class, id); + CascadeBook keep = managed.getBooks().get(0); + managed.replaceBooksWithNewList(new ArrayList<>(List.of(keep))); // assigns a NEW ArrayList + jakarta.persistence.RollbackException ex = org.junit.jupiter.api.Assertions.assertThrows( + jakarta.persistence.RollbackException.class, () -> em2.getTransaction().commit()); + + Throwable root = ex.getCause(); + DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- wrapper: {}", ex.getClass().getName()); + DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- root cause: {}: {}", + root.getClass().getName(), root.getMessage()); + em2.close(); + + assertThat(root.getClass().getName()).isEqualTo("org.hibernate.HibernateException"); + assertThat(root.getMessage()).contains("no longer referenced by the owning entity instance"); + } + + @Test + void cascadeAllPlusOrphanRemoval_mutatingTheExistingCollectionDoesDeleteUnexpectedly() { + // The scenario that DOES silently delete: mutating the SAME managed collection instance + // (clear() then re-add the ones to keep) rather than reassigning a new List. This is + // the realistic version of the "developer did not expect this" cascade trap. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + CascadeAuthor author = new CascadeAuthor("Prolific Author 2"); + em.persist(author); + em.persist(new CascadeBook("Book A", author)); + em.persist(new CascadeBook("Book B", author)); + em.persist(new CascadeBook("Book C", author)); + em.getTransaction().commit(); + Long id = author.getId(); + em.close(); + + EntityManager check1 = emf.createEntityManager(); + long before = ((Number) check1.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id") + .setParameter("id", id).getSingleResult()).longValue(); + check1.close(); + assertThat(before).isEqualTo(3); + + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + CascadeAuthor managed = em2.find(CascadeAuthor.class, id); + CascadeBook keep = managed.getBooks().stream().filter(b -> b.getTitle().equals("Book A")).findFirst().orElseThrow(); + // Mutate the EXISTING Hibernate-managed collection in place -- this is the pattern that + // actually reaches production: "keep only Book A" implemented via removeIf. + managed.getBooks().removeIf(b -> !b.getTitle().equals("Book A")); + em2.getTransaction().commit(); + em2.close(); + + EntityManager check2 = emf.createEntityManager(); + long after = ((Number) check2.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id") + .setParameter("id", id).getSingleResult()).longValue(); + DEMO.info("cascade=ALL + orphanRemoval=true, in-place removeIf(): books before={}, books after={}", before, after); + check2.close(); + + assertThat(after) + .as("orphanRemoval=true DELETEd Book B and Book C as soon as they left the managed collection") + .isEqualTo(1); + } + + @Test + void noOrphanRemoval_removingFromCollectionAndFlushingDoesNothingToTheRow() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + NoOrphanAuthor author = new NoOrphanAuthor("Careful Author"); + em.persist(author); + NoOrphanBook book1 = new NoOrphanBook("Keep Me", author); + NoOrphanBook book2 = new NoOrphanBook("Remove Me From Collection", author); + em.persist(book1); + em.persist(book2); + em.getTransaction().commit(); + Long authorId = author.getId(); + Long book2Id = book2.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + NoOrphanAuthor managed = em2.find(NoOrphanAuthor.class, authorId); + managed.getBooks().removeIf(b -> b.getId().equals(book2Id)); + em2.getTransaction().commit(); // no orphanRemoval, no cascade REMOVE -- nothing should happen to book2's row + em2.close(); + + EntityManager check = emf.createEntityManager(); + NoOrphanBook stillThere = check.find(NoOrphanBook.class, book2Id); + DEMO.info("orphanRemoval=false: after removing book2 from author.books and flushing, book2 row still exists = {}, author_id still = {}", + stillThere != null, stillThere == null ? null : stillThere.getAuthor().getId()); + check.close(); + + assertThat(stillThere) + .as("without orphanRemoval or cascade REMOVE, removing a child from the collection does not delete or unlink its row") + .isNotNull(); + assertThat(stillThere.getAuthor().getId()) + .as("its FK is untouched -- still points at the same author") + .isEqualTo(authorId); + } + + @Test + void owningSide_mutatingOnlyTheInverseCollectionNeverWritesTheForeignKey() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + NoOrphanAuthor author1 = new NoOrphanAuthor("Author One"); + NoOrphanAuthor author2 = new NoOrphanAuthor("Author Two"); + em.persist(author1); + em.persist(author2); + NoOrphanBook orphanBook = new NoOrphanBook("Unassigned Book", null); + em.persist(orphanBook); + em.getTransaction().commit(); + Long bookId = orphanBook.getId(); + Long author2Id = author2.getId(); + em.close(); + + // Mutate ONLY the inverse (mappedBy) side: add the book to author2's collection, but + // never call book.setAuthor(author2) -- the collection is not the owning side. + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + NoOrphanAuthor managedAuthor2 = em2.find(NoOrphanAuthor.class, author2Id); + NoOrphanBook managedBook = em2.find(NoOrphanBook.class, bookId); + managedAuthor2.getBooks().add(managedBook); // inverse side only + em2.getTransaction().commit(); + em2.close(); + + EntityManager check = emf.createEntityManager(); + NoOrphanBook reloaded = check.find(NoOrphanBook.class, bookId); + DEMO.info("owning side test: mutated only author2.getBooks().add(book) (inverse side), book.author after flush = {}", + reloaded.getAuthor() == null ? "null (FK not written)" : reloaded.getAuthor().getId()); + check.close(); + + assertThat(reloaded.getAuthor()) + .as("mutating only the inverse (mappedBy) collection never persists the association -- the owning side (Book.author) decides the FK") + .isNull(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/association/NPlusOneTest.java b/src/test/java/com/ankurm/hibernatedemo/association/NPlusOneTest.java new file mode 100755 index 0000000..2895a31 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/association/NPlusOneTest.java @@ -0,0 +1,218 @@ +package com.ankurm.hibernatedemo.association; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.TypedQuery; +import java.util.List; +import java.util.Map; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +/** + * Backs ankurm.com post 4872, docs/12-association-mappings.md chapter "Counting the N+1". + * + *

Seeds 100 authors with 3 books each (301 rows total incl. authors) and counts real + * {@code Statistics.getPrepareStatementCount()} for four strategies: naive lazy iteration, + * JPQL fetch join, {@code @EntityGraph}, and {@code @BatchSize(10)}. Run with + * {@code mvn -Dtest=NPlusOneTest test}. + */ +@SpringBootTest +class NPlusOneTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static final int AUTHOR_COUNT = 100; + private static final int BOOKS_PER_AUTHOR = 3; + + @Autowired + private EntityManagerFactory emf; + + @BeforeEach + void cleanTables() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM AssocBook").executeUpdate(); + em.createQuery("DELETE FROM AssocAuthor").executeUpdate(); + em.createQuery("DELETE FROM BatchBook").executeUpdate(); + em.createQuery("DELETE FROM BatchAuthor").executeUpdate(); + em.getTransaction().commit(); + em.close(); + } + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + private void seedPlainAuthors() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int a = 0; a < AUTHOR_COUNT; a++) { + AssocAuthor author = new AssocAuthor("Author-" + a); + em.persist(author); + for (int b = 0; b < BOOKS_PER_AUTHOR; b++) { + em.persist(new AssocBook("Book-" + a + "-" + b, author)); + } + } + em.getTransaction().commit(); + em.close(); + } + + private void seedBatchAuthors() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int a = 0; a < AUTHOR_COUNT; a++) { + BatchAuthor author = new BatchAuthor("BatchAuthor-" + a); + em.persist(author); + for (int b = 0; b < BOOKS_PER_AUTHOR; b++) { + em.persist(new BatchBook("BatchBook-" + a + "-" + b, author)); + } + } + em.getTransaction().commit(); + em.close(); + } + + @Test + void naiveLazyIteration_firesOneQueryPerAuthor_theClassicNPlusOne() { + seedPlainAuthors(); + EntityManager em = emf.createEntityManager(); + stats().clear(); + + List authors = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList(); + long booksTouched = 0; + for (AssocAuthor author : authors) { + booksTouched += author.getBooks().size(); // triggers one SELECT per author + } + + long queries = stats().getPrepareStatementCount(); + DEMO.info("NAIVE lazy iteration: {} authors, {} queries (1 for authors + {} for their book collections), books touched = {}", + authors.size(), queries, authors.size(), booksTouched); + + assertThat(authors).hasSize(AUTHOR_COUNT); + assertThat(queries) + .as("1 query to load authors + 1 per author to lazily load its books == N+1") + .isEqualTo(1 + AUTHOR_COUNT); + em.close(); + } + + @Test + void jpqlFetchJoin_firesExactlyOneQuery() { + seedPlainAuthors(); + EntityManager em = emf.createEntityManager(); + stats().clear(); + + List authors = em.createQuery( + "SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class) + .getResultList(); + long booksTouched = 0; + for (AssocAuthor author : authors) { + booksTouched += author.getBooks().size(); // already initialised, no extra SELECT + } + + long queries = stats().getPrepareStatementCount(); + DEMO.info("JPQL JOIN FETCH: {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched); + + assertThat(authors).hasSize(AUTHOR_COUNT); + assertThat(queries).as("fetch join collapses N+1 into a single query").isEqualTo(1); + em.close(); + } + + @Test + void entityGraph_firesExactlyOneQuery() { + seedPlainAuthors(); + EntityManager em = emf.createEntityManager(); + stats().clear(); + + EntityGraph graph = em.getEntityGraph("AssocAuthor.books"); + TypedQuery query = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class); + query.setHint("jakarta.persistence.fetchgraph", graph); + List authors = query.getResultList(); + long booksTouched = 0; + for (AssocAuthor author : authors) { + booksTouched += author.getBooks().size(); + } + + long queries = stats().getPrepareStatementCount(); + DEMO.info("@EntityGraph (fetchgraph hint): {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched); + + assertThat(authors).hasSize(AUTHOR_COUNT); + assertThat(queries).as("entity graph fetch also collapses to a single query").isEqualTo(1); + em.close(); + } + + @Test + void batchSize10_collapsesNPlusOneIntoCeilNOverBatchSizePlusOne() { + seedBatchAuthors(); + EntityManager em = emf.createEntityManager(); + stats().clear(); + + List authors = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList(); + long booksTouched = 0; + for (BatchAuthor author : authors) { + booksTouched += author.getBooks().size(); // triggers 1 SELECT per 10 authors (batch fetch) + } + + long queries = stats().getPrepareStatementCount(); + // ceil(100 / 10) = 10 batch selects + 1 for the authors themselves = 11 + long expected = 1 + (long) Math.ceil(AUTHOR_COUNT / 10.0); + DEMO.info("@BatchSize(10): {} authors, {} queries (expected {} = 1 + ceil({}/10)), books touched = {}", + authors.size(), queries, expected, AUTHOR_COUNT, booksTouched); + + assertThat(authors).hasSize(AUTHOR_COUNT); + assertThat(queries).as("BatchSize(10) turns 101 queries into ceil(100/10)+1 = 11").isEqualTo(expected); + em.close(); + } + + @Test + void printSideBySideComparisonTable() { + // Re-derive each number in one place so the article table and the assertions above can + // never silently drift apart. + seedPlainAuthors(); + seedBatchAuthors(); + + EntityManager em = emf.createEntityManager(); + + stats().clear(); + List naive = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList(); + naive.forEach(a -> a.getBooks().size()); + long naiveQ = stats().getPrepareStatementCount(); + + stats().clear(); + List fetchJoin = em.createQuery( + "SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class) + .getResultList(); + fetchJoin.forEach(a -> a.getBooks().size()); + long fetchJoinQ = stats().getPrepareStatementCount(); + + stats().clear(); + EntityGraph graph = em.getEntityGraph("AssocAuthor.books"); + TypedQuery egQuery = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class); + egQuery.setHint("jakarta.persistence.fetchgraph", graph); + List eg = egQuery.getResultList(); + eg.forEach(a -> a.getBooks().size()); + long egQ = stats().getPrepareStatementCount(); + + stats().clear(); + List batched = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList(); + batched.forEach(a -> a.getBooks().size()); + long batchQ = stats().getPrepareStatementCount(); + + DEMO.info("=== Side-by-side query counts for {} authors x {} books each ===", AUTHOR_COUNT, BOOKS_PER_AUTHOR); + DEMO.info("naive lazy iteration : {} queries", naiveQ); + DEMO.info("JPQL JOIN FETCH : {} queries", fetchJoinQ); + DEMO.info("@EntityGraph : {} queries", egQ); + DEMO.info("@BatchSize(10) : {} queries", batchQ); + + assertThat(Map.of("naive", naiveQ, "fetchJoin", fetchJoinQ, "entityGraph", egQ, "batch10", batchQ)) + .isEqualTo(Map.of("naive", 101L, "fetchJoin", 1L, "entityGraph", 1L, "batch10", 11L)); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/association/OneToOneLazyTest.java b/src/test/java/com/ankurm/hibernatedemo/association/OneToOneLazyTest.java new file mode 100755 index 0000000..38ce5b8 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/association/OneToOneLazyTest.java @@ -0,0 +1,115 @@ +package com.ankurm.hibernatedemo.association; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4872, docs/12-association-mappings.md chapter "The @OneToOne lazy trap". + * + *

{@link LazyUser#getProfile()} is the non-owning ("mappedBy") side of an optional + * {@code @OneToOne}, declared {@code FetchType.LAZY}. Without bytecode enhancement, Hibernate + * cannot build a lazy proxy for it (it has no FK column to defer against), so it fires an + * eager extra SELECT regardless of the annotation. {@link MiUser}/{@link MiProfile} show the + * {@code @MapsId} fix: don't map the inverse side at all, look the child up on demand by the + * shared primary key. + */ +@SpringBootTest +class OneToOneLazyTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @BeforeEach + void cleanTables() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM LazyProfile").executeUpdate(); + em.createQuery("DELETE FROM LazyUser").executeUpdate(); + em.createQuery("DELETE FROM MiProfile").executeUpdate(); + em.createQuery("DELETE FROM MiUser").executeUpdate(); + em.getTransaction().commit(); + em.close(); + } + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + @Test + void mappedBySideOneToOne_firesExtraSelectEvenThoughItIsDeclaredLazy() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + LazyUser user = new LazyUser("alice"); + em.persist(user); + LazyProfile profile = new LazyProfile("bio text", user); + user.setProfile(profile); + em.persist(profile); + em.getTransaction().commit(); + Long id = user.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + stats().clear(); + LazyUser loaded = em2.find(LazyUser.class, id); + long queriesAfterFind = stats().getPrepareStatementCount(); + DEMO.info("LazyUser.find(): {} queries fired BEFORE touching getProfile() at all (expected 2: user + eager profile join/select)", queriesAfterFind); + + // Note: the extra SELECT for profile already happened during find(), not here. + String bio = loaded.getProfile() == null ? null : "loaded"; + long queriesAfterAccess = stats().getPrepareStatementCount(); + DEMO.info("after touching getProfile(): {} queries total (profile={})", queriesAfterAccess, bio); + + assertThat(queriesAfterFind) + .as("mappedBy @OneToOne(LAZY) still fires the profile SELECT immediately during find(), not on first access") + .isEqualTo(2); + assertThat(queriesAfterAccess).as("no further query needed -- it already ran eagerly").isEqualTo(2); + em2.close(); + } + + @Test + void mapsIdFix_loadingUserAloneFiresOnlyOneQuery_profileFetchedOnDemand() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + MiUser user = new MiUser("bob"); + em.persist(user); + em.flush(); // need the generated id before building the shared-PK child + MiProfile profile = new MiProfile("bob's bio", user); + em.persist(profile); + em.getTransaction().commit(); + Long id = user.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + stats().clear(); + MiUser loaded = em2.find(MiUser.class, id); + long queriesForUserOnly = stats().getPrepareStatementCount(); + DEMO.info("MiUser.find() (no mappedBy field at all): {} query", queriesForUserOnly); + + // Fetch profile ONLY when actually needed, using the shared PK -- no proxy required. + MiProfile profileOnDemand = em2.find(MiProfile.class, id); + long queriesAfterExplicitProfileFetch = stats().getPrepareStatementCount(); + DEMO.info("explicit MiProfile.find() by shared PK when actually needed: {} total queries", queriesAfterExplicitProfileFetch); + + assertThat(loaded).isNotNull(); + assertThat(queriesForUserOnly) + .as("with @MapsId and no inverse mappedBy field, loading the user alone costs exactly 1 query") + .isEqualTo(1); + assertThat(profileOnDemand.getId()).isEqualTo(id); + assertThat(queriesAfterExplicitProfileFetch) + .as("fetching the profile only happens when the code actually asks for it") + .isEqualTo(2); + em2.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java b/src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java new file mode 100644 index 0000000..cad725d --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/bootstrap/EntityManagerBootstrapTest.java @@ -0,0 +1,152 @@ +package com.ankurm.hibernatedemo.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.PersistenceConfiguration; +import jakarta.persistence.PersistenceException; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs ankurm.com post 4855 (bootstrapping EntityManager). Docs: docs/17-entitymanager-bootstrap.md. + * + *

Deliberately plain JUnit, NOT {@code @SpringBootTest} -- the whole point of this chapter is + * bootstrapping a raw JPA {@link EntityManagerFactory} with no Spring involved, exactly as a + * Java SE application or a unit test outside a Spring context would. The XML path reads + * {@code src/test/resources/META-INF/persistence.xml}; the programmatic path uses Jakarta + * Persistence 3.2's {@link PersistenceConfiguration}, new in this version. + * + *

Run with {@code ./mvnw -Dtest=EntityManagerBootstrapTest test}. + */ +class EntityManagerBootstrapTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Test + void xmlBootstrap_createsFactoryFromPersistenceXmlAndPersistsAUser() { + // No properties map passed here at all -- everything comes from + // META-INF/persistence.xml on the test classpath, resolved purely by unit name. + try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("XmlBootstrapPU")) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + BootstrapUser user = new BootstrapUser("Ankur", "admin@ankurm.com"); + em.persist(user); + em.getTransaction().commit(); + Long id = user.getId(); + em.close(); + + EntityManager reader = emf.createEntityManager(); + BootstrapUser reloaded = reader.find(BootstrapUser.class, id); + assertThat(reloaded).isNotNull(); + assertThat(reloaded.getEmail()).isEqualTo("admin@ankurm.com"); + reader.close(); + DEMO.info("xmlBootstrap: persisted and reloaded user id={}", id); + } + } + + @Test + void programmaticBootstrap_needsNoPersistenceXmlUnitOnClasspath() { + // "ProgrammaticPU" has NO matching anywhere in persistence.xml -- + // this only works at all if PersistenceConfiguration genuinely bypasses XML lookup. + 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()) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + BootstrapUser user = new BootstrapUser("Programmatic", "prog@ankurm.com"); + em.persist(user); + em.getTransaction().commit(); + Long id = user.getId(); + em.close(); + + EntityManager reader = emf.createEntityManager(); + assertThat(reader.find(BootstrapUser.class, id)).isNotNull(); + reader.close(); + DEMO.info("programmaticBootstrap: persisted user id={} with zero persistence.xml units named 'ProgrammaticPU'", id); + } + } + + @Test + void persistenceUnitName_inProgrammaticConfig_doesNotTriggerXmlSearch() { + // Reusing "XmlBootstrapPU" -- the EXACT SAME name as the real XML unit -- but with + // completely different properties built purely in code, pointed at a third database. + // If the name alone triggered an XML lookup/merge, this factory would end up + // connected to bootstrap-xml (the XML unit's database) instead. It doesn't. + PersistenceConfiguration config = new PersistenceConfiguration("XmlBootstrapPU") + .provider("org.hibernate.jpa.HibernatePersistenceProvider") + .managedClass(BootstrapUser.class) + .property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver") + .property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-namecollision;DB_CLOSE_DELAY=-1") + .property(PersistenceConfiguration.JDBC_USER, "sa") + .property(PersistenceConfiguration.JDBC_PASSWORD, "") + .property("hibernate.hbm2ddl.auto", "create-drop"); + + try (EntityManagerFactory emf = config.createEntityManagerFactory()) { + EntityManager em = emf.createEntityManager(); + @SuppressWarnings("unchecked") + java.util.List row = em.createNativeQuery("SELECT DATABASE()").getResultList(); + String actualDb = (String) row.get(0); + assertThat(actualDb).as("the programmatic config's own JDBC URL won, not the XML unit's") + .isEqualToIgnoringCase("BOOTSTRAP-NAMECOLLISION"); + em.close(); + DEMO.info("persistenceUnitNameCollision: connected database = {} (unit name 'XmlBootstrapPU' reused on purpose)", actualDb); + } + } + + @Test + void unconfiguredUnitName_failsWithPersistenceException_notSilently() { + // "TotallyUnknownPU" exists neither in persistence.xml nor as a PersistenceConfiguration + // -- Persistence.createEntityManagerFactory(name) with no config map has nowhere left + // to look. + assertThatThrownBy(() -> Persistence.createEntityManagerFactory("TotallyUnknownPU")) + .isInstanceOf(PersistenceException.class) + .satisfies(ex -> { + DEMO.info("unconfiguredUnitName: {}: {}", ex.getClass().getName(), ex.getMessage()); + assertThat(ex.getMessage()).contains("TotallyUnknownPU"); + }); + } + + @Test + void repeatedFactoryCreation_isMeasurablyExpensive() { + // Not a Metaspace OOM reproduction -- that needs sustained, uncollectable class-loader + // growth over many thousands of factories and isn't something to actually trigger in a + // shared CI sandbox. What IS safely measurable: EntityManagerFactory creation is not + // cheap, which is the whole reason "never create one per request" is the rule in the + // first place. + PersistenceConfiguration config = new PersistenceConfiguration("TimingPU") + .provider("org.hibernate.jpa.HibernatePersistenceProvider") + .managedClass(BootstrapUser.class) + .property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver") + .property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-timing;DB_CLOSE_DELAY=-1") + .property(PersistenceConfiguration.JDBC_USER, "sa") + .property(PersistenceConfiguration.JDBC_PASSWORD, "") + .property("hibernate.hbm2ddl.auto", "create-drop"); + + long factoryStart = System.nanoTime(); + EntityManagerFactory emf = config.createEntityManagerFactory(); + long factoryMillis = (System.nanoTime() - factoryStart) / 1_000_000; + + long emStart = System.nanoTime(); + EntityManager em = emf.createEntityManager(); + long emMillis = (System.nanoTime() - emStart) / 1_000_000; + em.close(); + emf.close(); + + assertThat(factoryMillis).as("factory creation happened and was timed").isGreaterThanOrEqualTo(0); + DEMO.info("repeatedFactoryCreation: createEntityManagerFactory() took {} ms, createEntityManager() took {} ms -- " + + "the factory call is the one doing schema validation, service registry bootstrap, and metadata scanning; " + + "the EntityManager call is comparatively trivial", factoryMillis, emMillis); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/cache/BulkUpdateBypassesCacheTest.java b/src/test/java/com/ankurm/hibernatedemo/cache/BulkUpdateBypassesCacheTest.java new file mode 100644 index 0000000..e787162 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/cache/BulkUpdateBypassesCacheTest.java @@ -0,0 +1,195 @@ +package com.ankurm.hibernatedemo.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Checks the original article's claim -- "Bulk HQL mutations bypass cache: a bulk update leaves + * an already-cached entity stale until it is manually evicted" -- against what actually happens. + * The claim is FALSE both for HQL/JPQL bulk statements and, once actually measured, for native + * SQL bulk statements too: a fresh session sees the new value immediately in both cases, with no + * manual eviction step needed for either. + * + *

For {@code Session.createMutationQuery(...).executeUpdate()}, verified by disassembling + * {@code hibernate-core-7.4.5.Final.jar}: every bulk HQL/JPQL {@code update}/{@code delete} + * registers a {@code org.hibernate.action.internal.BulkOperationCleanupAction} as an + * after-transaction-completion process. Its {@code EntityCleanup} inner class calls {@code + * EntityDataAccess.lockRegion()} then {@code EntityDataAccess.removeAll(session)} for every + * entity type the statement's "query spaces" (tables) touch. Hibernate knows which entity types + * are affected because {@code createMutationQuery} parses the HQL/JPQL itself. + * + *

The natural next question -- and the original hypothesis behind the second test below -- + * was whether a bulk update fired as raw/native SQL escapes this, since Hibernate's HQL parser + * never sees it and so cannot name the affected "query spaces" for {@code + * BulkOperationCleanupAction} to key off of. Measuring it disproves the hypothesis: Hibernate's + * actual default for an unqualified native DML statement is the conservative opposite of "do + * nothing" -- unable to prove which regions are safe, it invalidates every region it knows about. + * The entity cache still ends up empty after the statement, just reached by a different path. + * + *

Docs: docs/18-ehcache-l2-configuration.md + */ +class BulkUpdateBypassesCacheTest { + + private StandardServiceRegistry registry; + private SessionFactory sessionFactory; + + private void boot() { + registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:bulkbypass;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.generate_statistics", "true") + .applySetting("hibernate.cache.use_second_level_cache", "true") + .applySetting("hibernate.cache.region.factory_class", "jcache") + .applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider") + .applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml") + .build(); + Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(CacheProduct.class) + .buildMetadata(); + sessionFactory = metadata.buildSessionFactory(); + } + + @AfterEach + void tearDown() { + if (sessionFactory != null) { + sessionFactory.close(); + } + if (registry != null) { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void bulkHqlUpdate_doesNotLeaveTheL2EntityCacheStale_becauseHibernateAutoEvictsTheRegion() { + boot(); + + Long id; + try (Session seed = sessionFactory.openSession()) { + Transaction tx = seed.beginTransaction(); + CacheProduct p = new CacheProduct("Laptop Pro", 1199.0); + seed.persist(p); + tx.commit(); + id = p.getId(); + } + + // Populate/confirm the L2 region with the original price via a plain get() in its own session. + try (Session warm = sessionFactory.openSession()) { + CacheProduct p = warm.get(CacheProduct.class, id); + assertThat(p.getPrice()).isEqualTo(1199.0); + } + + // Bulk HQL/JPQL UPDATE: Hibernate parses this itself, knows CacheProduct's table is a + // "query space" the statement touches, and registers a BulkOperationCleanupAction that + // evicts CacheProduct's entire L2 region right after this transaction commits. + try (Session bulk = sessionFactory.openSession()) { + Transaction tx = bulk.beginTransaction(); + int updated = bulk.createMutationQuery("update CacheProduct set price = :newPrice where id = :id") + .setParameter("newPrice", 999.0) + .setParameter("id", id) + .executeUpdate(); + tx.commit(); + assertThat(updated).isEqualTo(1); + } + + // A brand-new session's get() -- does it see the new price, or the stale cached one? + Double priceSeenAfterBulkUpdate; + try (Session after = sessionFactory.openSession()) { + priceSeenAfterBulkUpdate = after.get(CacheProduct.class, id).getPrice(); + } + + System.out.println("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=" + priceSeenAfterBulkUpdate + + " -- 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."); + + assertThat(priceSeenAfterBulkUpdate) + .as("corrected finding: a fresh session sees the real 999.0 immediately -- the bulk HQL " + + "update did not leave a stale cache entry behind") + .isEqualTo(999.0); + } + + @Test + void bulkNativeSqlUpdate_alsoDoesNotLeaveTheCacheStale_hibernateInvalidatesEverythingItCannotParse() { + boot(); + Statistics stats = sessionFactory.getStatistics(); + + Long id; + try (Session seed = sessionFactory.openSession()) { + Transaction tx = seed.beginTransaction(); + CacheProduct p = new CacheProduct("Laptop Pro", 1199.0); + seed.persist(p); + tx.commit(); + id = p.getId(); + } + + // Populate/confirm the L2 region with the original price via a plain get() in its own session. + try (Session warm = sessionFactory.openSession()) { + CacheProduct p = warm.get(CacheProduct.class, id); + assertThat(p.getPrice()).isEqualTo(1199.0); + } + long l2PutsAfterWarm = stats.getSecondLevelCachePutCount(); + + // Raw/native SQL UPDATE: Hibernate's HQL parser never sees this statement, so it cannot + // name the specific "query spaces" (tables) it touches the way createMutationQuery does. + // The hypothesis going in was that this means NO cache eviction happens at all -- that + // hypothesis is wrong, and disproven by the assertions below. Hibernate's actual behavior + // for an unqualified native DML statement is the conservative opposite: since it cannot + // prove which regions are safe, it treats every known cache region as a possibly-affected + // query space and invalidates all of them, exactly as if the statement had touched + // everything. This is confirmed here by getSecondLevelCacheHitCount() staying at zero on + // the read after the native update, even though the entity was demonstrably cached going + // in (l2PutsAfterWarm is 1, not 0). + try (Session bulk = sessionFactory.openSession()) { + Transaction tx = bulk.beginTransaction(); + int updated = bulk.createNativeQuery("update CacheProduct set price = :newPrice where id = :id") + .setParameter("newPrice", 999.0) + .setParameter("id", id) + .executeUpdate(); + tx.commit(); + assertThat(updated).isEqualTo(1); + } + + long l2HitsBeforeRead = stats.getSecondLevelCacheHitCount(); + Double priceSeenAfterNativeUpdate; + try (Session after = sessionFactory.openSession()) { + priceSeenAfterNativeUpdate = after.get(CacheProduct.class, id).getPrice(); + } + long l2HitsAfterRead = stats.getSecondLevelCacheHitCount(); + + System.out.println("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=" + + priceSeenAfterNativeUpdate + " | L2 puts recorded before the native update=" + l2PutsAfterWarm + + " | L2 hits before/after the post-update read=" + l2HitsBeforeRead + "/" + l2HitsAfterRead + + " -- 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."); + + assertThat(l2PutsAfterWarm) + .as("the entity really was put into the L2 region by the warm-up get()") + .isEqualTo(1L); + assertThat(priceSeenAfterNativeUpdate) + .as("corrected finding: a native SQL update does not leave the cache stale either -- " + + "Hibernate's conservative default invalidates the region instead") + .isEqualTo(999.0); + assertThat(l2HitsAfterRead) + .as("and the read after the native update is not served from L2 at all -- it is a " + + "genuine re-query, not a lucky freshly-recomputed cache entry") + .isEqualTo(l2HitsBeforeRead); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/cache/CacheApiNamespaceTest.java b/src/test/java/com/ankurm/hibernatedemo/cache/CacheApiNamespaceTest.java new file mode 100644 index 0000000..ecaddd1 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/cache/CacheApiNamespaceTest.java @@ -0,0 +1,63 @@ +package com.ankurm.hibernatedemo.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * The original article claimed: "Without the jakarta classifier, Ehcache 3 ships with the older + * javax.cache JCache API. Hibernate 7 requires the jakarta.cache namespace. Using the wrong + * artifact causes a ClassNotFoundException or NoSuchMethodError at startup." That claim is + * checked here directly against the classpath rather than repeated. + * + *

JSR-107 (JCache) was never migrated to the Jakarta namespace by its spec maintainers -- + * unlike JPA, Bean Validation, or Servlet. {@code javax.cache.Caching} is the one and only API + * class, with or without Ehcache's own "jakarta" classifier. That classifier is Ehcache's own + * internal choice of JAXB runtime major version (used to parse its own {@code ehcache.xml}), not + * a JCache API namespace switch -- confirmed by comparing the two classifier jars' Gradle module + * metadata (one depends on {@code jaxb-runtime [2.2,3)}, the other on {@code [3,3.1)}) and by + * disassembling {@code ConfigurationParser.class} in both jars, which import + * {@code javax.xml.bind} and {@code jakarta.xml.bind} respectively -- never {@code javax.cache} + * or a {@code jakarta.cache} package, because the latter does not exist. + * + *

Docs: docs/18-ehcache-l2-configuration.md + */ +class CacheApiNamespaceTest { + + @Test + void javaxCacheApi_isOnTheClasspath_regardlessOfEhcachesJakartaClassifier() throws ClassNotFoundException { + Class caching = Class.forName("javax.cache.Caching"); + Class cacheManager = Class.forName("javax.cache.CacheManager"); + + System.out.println("RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath " + + "(jar: " + caching.getProtectionDomain().getCodeSource().getLocation() + ")"); + + assertThat(caching).isNotNull(); + assertThat(cacheManager).isNotNull(); + } + + @Test + void jakartaCacheNamespace_doesNotExist_onThisClasspathOrAnyOther() { + Throwable thrown = catchClassNotFound("jakarta.cache.Cache"); + + System.out.println("RESULT[cache-api-no-jakarta-namespace]: Class.forName(\"jakarta.cache.Cache\") -> " + + thrown.getClass().getSimpleName() + + " -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's " + + "own \"jakarta\" classifier on org.ehcache:ehcache."); + + assertThatThrownBy(() -> Class.forName("jakarta.cache.Cache")) + .as("no jakarta.cache package has ever existed -- JCache (JSR-107) kept the javax.cache " + + "namespace even after Jakarta EE 9's javax->jakarta rename") + .isInstanceOf(ClassNotFoundException.class); + } + + private static Throwable catchClassNotFound(String className) { + try { + Class.forName(className); + return null; + } catch (Throwable t) { + return t; + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/cache/EntityL2CacheTest.java b/src/test/java/com/ankurm/hibernatedemo/cache/EntityL2CacheTest.java new file mode 100644 index 0000000..6e35483 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/cache/EntityL2CacheTest.java @@ -0,0 +1,105 @@ +package com.ankurm.hibernatedemo.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The ordinary case the original article's {@code Product} class was built around: PK lookups + * of a {@code @Cacheable}/{@code @Cache}-annotated entity, served from Ehcache after the first + * session closes, following the same isolated-SessionFactory pattern as chapter 06's + * {@code NaturalIdL2CacheTest} so this never touches the shared Spring context. + * + *

Docs: docs/18-ehcache-l2-configuration.md + */ +class EntityL2CacheTest { + + private StandardServiceRegistry registry; + private SessionFactory sessionFactory; + + private void boot() { + registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:entityl2;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.generate_statistics", "true") + .applySetting("hibernate.cache.use_second_level_cache", "true") + .applySetting("hibernate.cache.region.factory_class", "jcache") + .applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider") + .applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml") + .build(); + Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(CacheProduct.class) + .buildMetadata(); + sessionFactory = metadata.buildSessionFactory(); + } + + @AfterEach + void tearDown() { + if (sessionFactory != null) { + sessionFactory.close(); + } + if (registry != null) { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void getById_fromABrandNewSession_isServedFromL2WithNoSql() { + boot(); + Statistics stats = sessionFactory.getStatistics(); + + Long id; + try (Session seed = sessionFactory.openSession()) { + Transaction tx = seed.beginTransaction(); + CacheProduct p = new CacheProduct("Laptop Pro", 1199.0); + seed.persist(p); + tx.commit(); + id = p.getId(); + } + + stats.clear(); + try (Session s1 = sessionFactory.openSession()) { + CacheProduct p = s1.get(CacheProduct.class, id); + assertThat(p.getName()).isEqualTo("Laptop Pro"); + } + long queriesAfterSession1 = stats.getPrepareStatementCount(); + + try (Session s2 = sessionFactory.openSession()) { + CacheProduct p = s2.get(CacheProduct.class, id); + assertThat(p.getName()).isEqualTo("Laptop Pro"); + } + long queriesAfterSession2 = stats.getPrepareStatementCount(); + long l2HitsAfterSession2 = stats.getSecondLevelCacheHitCount(); + + System.out.println("RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries=" + + queriesAfterSession1 + " | session2 (brand-new session, same id) cumulative queries=" + + queriesAfterSession2 + ", L2 entity cache hits=" + l2HitsAfterSession2); + + // Surprise, and the same one chapter 06 already documented for @NaturalIdCache: persist() + // + commit() populates the L2 entity cache region itself, before anyone ever calls get(). + // So "session1" here is ALREADY a cache hit, not a cold DB read -- there is no query left + // for session1's own get() to save. This generalizes chapter 06's natural-id finding to + // plain @Cacheable/@Cache entity caching too, not just @NaturalIdCache. + assertThat(queriesAfterSession1) + .as("persist()+commit() already populated the L2 region -- session1's get() fires no SQL") + .isZero(); + assertThat(queriesAfterSession2) + .as("session2, a brand-new session, must not fire any query either -- served from L2") + .isEqualTo(queriesAfterSession1); + assertThat(l2HitsAfterSession2) + .as("both get() calls (session1 and session2) register as L2 entity cache hits") + .isEqualTo(2L); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/cache/MissingUpdateTimestampsRegionTest.java b/src/test/java/com/ankurm/hibernatedemo/cache/MissingUpdateTimestampsRegionTest.java new file mode 100644 index 0000000..ae6b6cc --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/cache/MissingUpdateTimestampsRegionTest.java @@ -0,0 +1,119 @@ +package com.ankurm.hibernatedemo.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.junit.jupiter.api.Test; + +/** + * Checks the original article's claim -- "Missing default-update-timestamps-region: Required for + * the Query Cache -- omitting it causes startup errors" -- by actually omitting it, rather than + * repeating the warning unverified. The claim is FALSE for this stack: {@code SessionFactory} + * builds with no error at all. + * + *

{@code hibernate-jcache}'s default {@code MissingCacheStrategy} is {@code CREATE_WARN} + * (external representation {@code "create-warn"}, confirmed by disassembling {@code + * MissingCacheStrategy.class} in {@code hibernate-jcache-7.4.5.Final.jar}): a missing region is + * created on the fly with provider-specific default policies, and Hibernate only logs {@code + * HHH90001006}. Setting {@code hibernate.javax.cache.missing_cache_strategy} to {@code "fail"} + * turns this into the hard startup error the article describes; {@code "create"} keeps the + * auto-create behavior but silences the warning. + * + *

Docs: docs/18-ehcache-l2-configuration.md + */ +class MissingUpdateTimestampsRegionTest { + + @Test + void queryCacheEnabled_withNoUpdateTimestampsRegionInEhcacheXml_buildsFineWithOnlyAWarning() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestamps;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.cache.use_second_level_cache", "true") + .applySetting("hibernate.cache.use_query_cache", "true") + .applySetting("hibernate.cache.region.factory_class", "jcache") + .applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider") + .applySetting("hibernate.javax.cache.uri", "ehcache-chapter18-missing-timestamps.xml") + .build(); + try { + Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(CacheProduct.class) + .buildMetadata(); + + Throwable thrown = catchThrowableFromBuildSessionFactory(metadata); + + System.out.println("RESULT[cache-missing-timestamps-region]: " + + (thrown == null + ? "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" + : thrown.getClass().getName() + ": " + rootMessage(thrown))); + + assertThat(thrown) + .as("corrected finding: with the default missing_cache_strategy, a missing " + + "default-update-timestamps-region does NOT fail SessionFactory startup") + .isNull(); + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void settingMissingCacheStrategyToFail_reproducesTheHardStartupErrorTheArticleDescribed() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestampsfail;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.cache.use_second_level_cache", "true") + .applySetting("hibernate.cache.use_query_cache", "true") + .applySetting("hibernate.cache.region.factory_class", "jcache") + .applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider") + .applySetting("hibernate.javax.cache.uri", "ehcache-chapter18-missing-timestamps.xml") + .applySetting("hibernate.javax.cache.missing_cache_strategy", "fail") + .build(); + try { + Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(CacheProduct.class) + .buildMetadata(); + + Throwable thrown = catchThrowableFromBuildSessionFactory(metadata); + + System.out.println("RESULT[cache-missing-timestamps-region-strict]: " + + "hibernate.javax.cache.missing_cache_strategy=fail -> " + + (thrown == null ? "SessionFactory built with NO error" + : thrown.getClass().getName() + ": " + rootMessage(thrown)) + + " -- this is how to opt into the hard-failure behavior the original article assumed " + + "was the default."); + + assertThat(thrown) + .as("with missing_cache_strategy explicitly set to fail, a missing " + + "default-update-timestamps-region does cause a startup error -- this is an " + + "opt-in, not the default") + .isNotNull(); + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + private static Throwable catchThrowableFromBuildSessionFactory(Metadata metadata) { + try { + metadata.buildSessionFactory().close(); + return null; + } catch (Throwable t) { + return t; + } + } + + private static String rootMessage(Throwable t) { + Throwable cause = t; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return cause.getMessage(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/cache/QueryCacheWithoutEntityCacheTest.java b/src/test/java/com/ankurm/hibernatedemo/cache/QueryCacheWithoutEntityCacheTest.java new file mode 100644 index 0000000..ae496c4 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/cache/QueryCacheWithoutEntityCacheTest.java @@ -0,0 +1,138 @@ +package com.ankurm.hibernatedemo.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.query.Query; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Checks the original article's claim -- "Query Cache without Entity Cache causes N+1 selects" -- + * by actually measuring it rather than repeating it. The claim does NOT hold: with {@link + * UncachedProduct} carrying no {@code @Cacheable}/{@code @Cache} at all, a second, brand-new + * session repeating the same {@code setCacheable(true)} query fires ZERO SQL statements, not five. + * + *

The query cache region does not store only the row ids. It stores the full hydrated tuple + * state of each result row (id plus every mapped column) at the moment the query first ran. + * Hibernate reconstitutes {@code UncachedProduct} instances directly from that stored tuple data + * on a cache hit -- no re-query of the rows, and (confirmed below via {@code + * getSecondLevelCacheHitCount()}) no dependency on the entity's own L2 region at all, because + * {@link UncachedProduct} does not have one. This test does not rule out an N+1 appearing for a + * query that returns associations Hibernate must still initialize per row, or for a partial-hit + * scenario after individual cache entries are evicted -- only the specific, simple case the + * article described (a flat entity, no associations, a repeated identical query) is measured and + * corrected here. + * + *

Docs: docs/18-ehcache-l2-configuration.md + */ +class QueryCacheWithoutEntityCacheTest { + + private StandardServiceRegistry registry; + private SessionFactory sessionFactory; + + private void boot() { + registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:querycachenoentity;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.generate_statistics", "true") + .applySetting("hibernate.cache.use_second_level_cache", "true") + .applySetting("hibernate.cache.use_query_cache", "true") + .applySetting("hibernate.cache.region.factory_class", "jcache") + .applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider") + .applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml") + .build(); + Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(UncachedProduct.class) + .buildMetadata(); + sessionFactory = metadata.buildSessionFactory(); + } + + @AfterEach + void tearDown() { + if (sessionFactory != null) { + sessionFactory.close(); + } + if (registry != null) { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void cachedQuery_overUncachedEntities_repeatRunIsWorseThanNoCachingAtAll() { + boot(); + Statistics stats = sessionFactory.getStatistics(); + + try (Session seed = sessionFactory.openSession()) { + Transaction tx = seed.beginTransaction(); + for (int i = 1; i <= 5; i++) { + seed.persist(new UncachedProduct("Widget " + i)); + } + tx.commit(); + } + + String hql = "select p from UncachedProduct p order by p.id"; + + stats.clear(); + List first; + try (Session s1 = sessionFactory.openSession()) { + Query q = s1.createQuery(hql, UncachedProduct.class); + q.setCacheable(true); + first = q.list(); + } + long queriesForFirstRun = stats.getPrepareStatementCount(); + long queryCacheMissesAfterFirst = stats.getQueryCacheMissCount(); + + stats.clear(); + List second; + try (Session s2 = sessionFactory.openSession()) { + Query q = s2.createQuery(hql, UncachedProduct.class); + q.setCacheable(true); + second = q.list(); + } + long queriesForSecondRun = stats.getPrepareStatementCount(); + long queryCacheHitsAfterSecond = stats.getQueryCacheHitCount(); + long l2EntityHitsAfterSecond = stats.getSecondLevelCacheHitCount(); + + System.out.println("RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements=" + + queriesForFirstRun + ", query-cache misses=" + queryCacheMissesAfterFirst + + " | second run (new session, query-cache HIT) SQL statements=" + queriesForSecondRun + + ", query-cache hits=" + queryCacheHitsAfterSecond + + ", L2 entity-cache hits=" + l2EntityHitsAfterSecond + + " -- 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."); + + assertThat(first).hasSize(5); + assertThat(second).hasSize(5); + assertThat(queriesForFirstRun) + .as("the first, cold run executes the query itself as ONE statement") + .isEqualTo(1); + assertThat(queryCacheHitsAfterSecond) + .as("the second run IS a genuine query-cache hit") + .isEqualTo(1L); + assertThat(queriesForSecondRun) + .as("corrected finding: the second run costs ZERO SQL statements, not the 5 individual " + + "SELECTs the original article claimed -- the query cache reconstitutes the " + + "entities directly from its own stored tuple data") + .isZero(); + assertThat(l2EntityHitsAfterSecond) + .as("and none of that reconstruction is an L2 entity-cache hit -- UncachedProduct has " + + "no L2 region to hit, so the mechanism is the query cache's own stored data, " + + "not a secretly-enabled entity cache") + .isZero(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/datetime/BasicTemporalTypesTest.java b/src/test/java/com/ankurm/hibernatedemo/datetime/BasicTemporalTypesTest.java new file mode 100755 index 0000000..d07c97e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/datetime/BasicTemporalTypesTest.java @@ -0,0 +1,83 @@ +package com.ankurm.hibernatedemo.datetime; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "Basic temporal types round trip". + * The generated DDL for {@code temporal_types} (visible in the captured log because + * {@code show_sql=true}) is the primary artifact here. + */ +@SpringBootTest +class BasicTemporalTypesTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void roundTripEveryBasicTemporalType() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + + TemporalTypesEntity e = new TemporalTypesEntity(); + e.setLocalDate(LocalDate.of(2026, 3, 15)); + e.setLocalDateTime(LocalDateTime.of(2026, 3, 15, 10, 30, 45)); + e.setLocalTime(LocalTime.of(10, 30, 45)); + e.setInstant(Instant.parse("2026-03-15T10:30:45Z")); + e.setOffsetDateTime(OffsetDateTime.of(2026, 3, 15, 10, 30, 45, 0, ZoneOffset.ofHoursMinutes(5, 30))); + e.setZonedDateTime(ZonedDateTime.of(2026, 3, 15, 10, 30, 45, 0, java.time.ZoneId.of("Europe/Paris"))); + + Calendar cal = new GregorianCalendar(2026, Calendar.MARCH, 15, 10, 30, 45); + Date legacyDate = cal.getTime(); + e.setLegacyDateAsDate(legacyDate); + e.setLegacyDateAsTimestamp(legacyDate); + e.setLegacyDateNoTemporal(legacyDate); + e.setLegacyCalendar(cal); + + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + TemporalTypesEntity loaded = em2.find(TemporalTypesEntity.class, id); + + DEMO.info("ROUNDTRIP localDate = {}", loaded.getLocalDate()); + DEMO.info("ROUNDTRIP localDateTime = {}", loaded.getLocalDateTime()); + DEMO.info("ROUNDTRIP localTime = {}", loaded.getLocalTime()); + DEMO.info("ROUNDTRIP instant = {}", loaded.getInstant()); + DEMO.info("ROUNDTRIP offsetDateTime = {}", loaded.getOffsetDateTime()); + DEMO.info("ROUNDTRIP zonedDateTime = {}", loaded.getZonedDateTime()); + DEMO.info("ROUNDTRIP legacyDateAsDate = {}", loaded.getLegacyDateAsDate()); + DEMO.info("ROUNDTRIP legacyDateAsTimestamp = {}", loaded.getLegacyDateAsTimestamp()); + DEMO.info("ROUNDTRIP legacyDateNoTemporal = {} (class={})", loaded.getLegacyDateNoTemporal(), loaded.getLegacyDateNoTemporal().getClass()); + DEMO.info("ROUNDTRIP legacyCalendar = {}", loaded.getLegacyCalendar() == null ? null : loaded.getLegacyCalendar().getTime()); + DEMO.info("JVM default timezone during this run = {}", TimeZone.getDefault().getID()); + + assertThat(loaded.getLocalDate()).isEqualTo(LocalDate.of(2026, 3, 15)); + assertThat(loaded.getLocalTime()).isEqualTo(LocalTime.of(10, 30, 45)); + assertThat(loaded.getInstant()).isEqualTo(Instant.parse("2026-03-15T10:30:45Z")); + assertThat(loaded.getZonedDateTime().toInstant()).isEqualTo(e.getZonedDateTime().toInstant()); + em2.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/datetime/JdbcTimeZoneTest.java b/src/test/java/com/ankurm/hibernatedemo/datetime/JdbcTimeZoneTest.java new file mode 100755 index 0000000..7516cc6 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/datetime/JdbcTimeZoneTest.java @@ -0,0 +1,74 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "hibernate.jdbc.time_zone". + * Sets {@code hibernate.jdbc.time_zone=America/New_York} (JVM default left at whatever the + * build runs under) and observes: (1) what it does to a {@code LocalDateTime} (no offset of + * its own -- the JDBC time zone controls what wall-clock value is actually sent to the driver), + * and (2) whether it changes anything for a {@code TimeZoneStorage.NATIVE} OffsetDateTime, + * which already carries its own explicit offset. + */ +@SpringBootTest +@TestPropertySource(properties = { + "spring.jpa.properties.hibernate.jdbc.time_zone=America/New_York" +}) +class JdbcTimeZoneTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void jdbcTimeZone_affectsLocalDateTime_doesNotAffectExplicitOffset() { + LocalDateTime localDateTime = LocalDateTime.of(2026, 7, 4, 9, 0, 0); + OffsetDateTime explicitOffset = OffsetDateTime.of(2026, 7, 4, 9, 0, 0, 0, ZoneOffset.ofHoursMinutes(5, 30)); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + NanoPrecisionEntity np = new NanoPrecisionEntity(); + np.setPlainLocalDateTime(localDateTime); + em.persist(np); + + TimeZoneStorageEntity tz = new TimeZoneStorageEntity(); + tz.setNativeMode(explicitOffset); + em.persist(tz); + + em.getTransaction().commit(); + Long npId = np.getId(); + Long tzId = tz.getId(); + em.close(); + + // Read back the raw stored value via native SQL to see the literal DB representation. + EntityManager em2 = emf.createEntityManager(); + Object rawLocalDateTime = em2.createNativeQuery( + "SELECT CAST(plain_local_date_time AS VARCHAR) FROM nano_precision WHERE id = :id") + .setParameter("id", npId).getSingleResult(); + Object rawOffset = em2.createNativeQuery( + "SELECT CAST(native_col AS VARCHAR) FROM tz_storage WHERE id = :id") + .setParameter("id", tzId).getSingleResult(); + + NanoPrecisionEntity npLoaded = em2.find(NanoPrecisionEntity.class, npId); + TimeZoneStorageEntity tzLoaded = em2.find(TimeZoneStorageEntity.class, tzId); + + DEMO.info("hibernate.jdbc.time_zone=America/New_York -- original LocalDateTime = {}", localDateTime); + DEMO.info("hibernate.jdbc.time_zone=America/New_York -- raw DB value for LocalDateTime column = {}", rawLocalDateTime); + DEMO.info("hibernate.jdbc.time_zone=America/New_York -- round-tripped LocalDateTime = {}", npLoaded.getPlainLocalDateTime()); + DEMO.info("hibernate.jdbc.time_zone=America/New_York -- original OffsetDateTime (NATIVE) = {}", explicitOffset); + DEMO.info("hibernate.jdbc.time_zone=America/New_York -- raw DB value for NATIVE offset column = {}", rawOffset); + DEMO.info("hibernate.jdbc.time_zone=America/New_York -- round-tripped OffsetDateTime (NATIVE) = {}", tzLoaded.getNativeMode()); + em2.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationHsqldbTest.java b/src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationHsqldbTest.java new file mode 100755 index 0000000..1206a31 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationHsqldbTest.java @@ -0,0 +1,62 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.Instant; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Same experiment as {@link NanosecondTruncationTest} but against HSQLDB 2.7.3 in-memory, + * to see whether the microsecond rounding is H2-specific or shared. Docs: 13-date-and-time-mapping.md. + */ +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:hsqldb:mem:nanotest;shutdown=true", + "spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver", + "spring.datasource.username=SA", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.HSQLDialect" +}) +class NanosecondTruncationHsqldbTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void nanosecondsOnHsqldb() { + LocalDateTime withNanos = LocalDateTime.of(2026, 1, 1, 12, 0, 0, 123_456_789); + Instant instantWithNanos = Instant.ofEpochSecond(1_800_000_000L, 123_456_789); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + NanoPrecisionEntity e = new NanoPrecisionEntity(); + e.setPlainLocalDateTime(withNanos); + e.setHighPrecisionLocalDateTime(withNanos); + e.setPlainInstant(instantWithNanos); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + NanoPrecisionEntity loaded = em2.find(NanoPrecisionEntity.class, id); + + DEMO.info("HSQLDB 2.7.3: original LocalDateTime nanos = {}", withNanos.getNano()); + DEMO.info("HSQLDB 2.7.3: plain column (precision default) nanos = {} (value={})", + loaded.getPlainLocalDateTime().getNano(), loaded.getPlainLocalDateTime()); + DEMO.info("HSQLDB 2.7.3: @Column(precision=9) column nanos = {} (value={})", + loaded.getHighPrecisionLocalDateTime().getNano(), loaded.getHighPrecisionLocalDateTime()); + DEMO.info("HSQLDB 2.7.3: original Instant nanos = {}", instantWithNanos.getNano()); + DEMO.info("HSQLDB 2.7.3: plain Instant column nanos = {} (value={})", + loaded.getPlainInstant().getNano(), loaded.getPlainInstant()); + em2.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationTest.java b/src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationTest.java new file mode 100755 index 0000000..cc9f6b7 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationTest.java @@ -0,0 +1,54 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.Instant; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "Second-precision / truncation". + * Default (H2) profile here; see {@link NanosecondTruncationHsqldbTest} for the HSQLDB variant. + */ +@SpringBootTest +class NanosecondTruncationTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void nanosecondsOnH2() { + LocalDateTime withNanos = LocalDateTime.of(2026, 1, 1, 12, 0, 0, 123_456_789); + Instant instantWithNanos = Instant.ofEpochSecond(1_800_000_000L, 123_456_789); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + NanoPrecisionEntity e = new NanoPrecisionEntity(); + e.setPlainLocalDateTime(withNanos); + e.setHighPrecisionLocalDateTime(withNanos); + e.setPlainInstant(instantWithNanos); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + NanoPrecisionEntity loaded = em2.find(NanoPrecisionEntity.class, id); + + DEMO.info("H2 2.4.240: original LocalDateTime nanos = {}", withNanos.getNano()); + DEMO.info("H2 2.4.240: plain column (precision default) nanos = {} (value={})", + loaded.getPlainLocalDateTime().getNano(), loaded.getPlainLocalDateTime()); + DEMO.info("H2 2.4.240: @Column(precision=9) column nanos = {} (value={})", + loaded.getHighPrecisionLocalDateTime().getNano(), loaded.getHighPrecisionLocalDateTime()); + DEMO.info("H2 2.4.240: original Instant nanos = {}", instantWithNanos.getNano()); + DEMO.info("H2 2.4.240: plain Instant column nanos = {} (value={})", + loaded.getPlainInstant().getNano(), loaded.getPlainInstant()); + em2.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/datetime/TemporalAnnotationTest.java b/src/test/java/com/ankurm/hibernatedemo/datetime/TemporalAnnotationTest.java new file mode 100755 index 0000000..b36f0b9 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/datetime/TemporalAnnotationTest.java @@ -0,0 +1,45 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "@Temporal verified". + * The application context bootstrapping successfully with {@link TemporalOnJavaTimeEntity} on + * the classpath is itself the finding: Hibernate 7.4.5 does NOT reject the misuse at boot. + */ +@SpringBootTest +class TemporalAnnotationTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void temporalAnnotationOnInstantIsSilentlyIgnored_bootSucceedsAndValueRoundTrips() { + Instant now = Instant.parse("2026-05-20T09:15:30Z"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + TemporalOnJavaTimeEntity e = new TemporalOnJavaTimeEntity(); + e.setInstantWithTemporalAnnotation(now); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + TemporalOnJavaTimeEntity loaded = em2.find(TemporalOnJavaTimeEntity.class, id); + DEMO.info("@Temporal(TIMESTAMP) on Instant field: boot succeeded, round-tripped value = {} (expected {})", + loaded.getInstantWithTemporalAnnotation(), now); + em2.close(); + + org.assertj.core.api.Assertions.assertThat(loaded.getInstantWithTemporalAnnotation()).isEqualTo(now); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageTest.java b/src/test/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageTest.java new file mode 100755 index 0000000..26cf930 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageTest.java @@ -0,0 +1,61 @@ +package com.ankurm.hibernatedemo.datetime; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.TimeZone; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "The central experiment". + * Stores an {@code OffsetDateTime} with offset {@code +05:30} through every + * {@code @TimeZoneStorage} mode and logs exactly what offset comes back. Run this same test + * under different {@code -Duser.timezone} values (see docs/output capture commands) to observe + * the JVM-default-timezone hazard. + */ +@SpringBootTest +class TimeZoneStorageTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void storeNonUtcOffsetAndReadBackUnderEveryMode() { + OffsetDateTime original = OffsetDateTime.of(2026, 6, 15, 14, 0, 0, 0, ZoneOffset.ofHoursMinutes(5, 30)); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + TimeZoneStorageEntity e = new TimeZoneStorageEntity(); + e.setNoAnnotation(original); + e.setNativeMode(original); + e.setNormalizeMode(original); + e.setNormalizeUtcMode(original); + e.setColumnMode(original); + e.setAutoMode(original); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + TimeZoneStorageEntity loaded = em2.find(TimeZoneStorageEntity.class, id); + + DEMO.info("JVM user.timezone system property = {}", System.getProperty("user.timezone")); + DEMO.info("JVM TimeZone.getDefault() = {}", TimeZone.getDefault().getID()); + DEMO.info("ORIGINAL stored = {}", original); + DEMO.info("TZ_MODE no-annotation (default) = {}", loaded.getNoAnnotation()); + DEMO.info("TZ_MODE NATIVE = {}", loaded.getNativeMode()); + DEMO.info("TZ_MODE NORMALIZE = {}", loaded.getNormalizeMode()); + DEMO.info("TZ_MODE NORMALIZE_UTC = {}", loaded.getNormalizeUtcMode()); + DEMO.info("TZ_MODE COLUMN = {}", loaded.getColumnMode()); + DEMO.info("TZ_MODE AUTO = {}", loaded.getAutoMode()); + em2.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/hikari/HikariLeakDetectionTest.java b/src/test/java/com/ankurm/hibernatedemo/hikari/HikariLeakDetectionTest.java new file mode 100644 index 0000000..5541098 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/hikari/HikariLeakDetectionTest.java @@ -0,0 +1,119 @@ +package com.ankurm.hibernatedemo.hikari; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.Connection; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * The original article's "leak detection" advice was a single line -- "set + * leakDetectionThreshold to catch connections that are opened but never closed" -- with no + * mention of a floor value. Measuring it surfaces a trap the article missed entirely: HikariCP + * silently disables leak detection for any threshold under 2000ms, rather than honoring a short + * value for a fast test or a tight dev-loop check. + * + *

Both behaviors are confirmed by disassembling {@code HikariConfig.class} in {@code + * HikariCP-7.0.2.jar}: {@code validate()} logs {@code "{} - leakDetectionThreshold is less than + * 2000ms or more than maxLifetime, disabling it."} via {@code HikariConfig}'s own logger and + * resets the field to {@code 0} (disabled) whenever the configured value is under 2000ms -- this + * runs synchronously inside the {@code HikariDataSource} constructor, before any connection is + * ever checked out. + * + *

Docs: docs/19-hikaricp-connection-pooling.md + */ +class HikariLeakDetectionTest { + + private HikariDataSource dataSource; + + @AfterEach + void tearDown() { + if (dataSource != null) { + dataSource.close(); + } + } + + @Test + void thresholdUnder2000ms_isSilentlyDisabled_notShortenedToWhatWasAsked() { + Logger configLogger = (Logger) LoggerFactory.getLogger(HikariConfig.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + configLogger.addAppender(appender); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:h2:mem:hikarileakshort;DB_CLOSE_DELAY=-1"); + config.setUsername("sa"); + config.setLeakDetectionThreshold(500); // the trap: under the 2000ms floor + + dataSource = new HikariDataSource(config); // validate() runs here, synchronously + + System.out.println("RESULT[hikari-leak-threshold-floor]: requested leakDetectionThreshold=500ms | " + + "actual leakDetectionThreshold after construction=" + config.getLeakDetectionThreshold() + + "ms | logged warnings=" + appender.list.size() + + (appender.list.isEmpty() ? "" : " | message=" + appender.list.get(0).getFormattedMessage()) + + " -- HikariCP does not clamp 500ms up to 2000ms, it disables leak detection " + + "entirely and logs a WARN naming the reason."); + + assertThat(config.getLeakDetectionThreshold()) + .as("HikariConfig.validate() resets a sub-2000ms threshold to 0 (disabled), it does " + + "not round it up to the 2000ms floor") + .isZero(); + assertThat(appender.list) + .as("and it says so, once, at construction time") + .hasSize(1); + assertThat(appender.list.get(0).getFormattedMessage()) + .contains("leakDetectionThreshold is less than 2000ms"); + + configLogger.detachAppender(appender); + } + + @Test + void thresholdAtOrAbove2000ms_actuallyLogsAWarning_whenAConnectionIsHeldPastIt() throws Exception { + Logger leakTaskLogger = (Logger) LoggerFactory.getLogger("com.zaxxer.hikari.pool.ProxyLeakTask"); + ListAppender appender = new ListAppender<>(); + appender.start(); + leakTaskLogger.addAppender(appender); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:h2:mem:hikarileak;DB_CLOSE_DELAY=-1"); + config.setUsername("sa"); + config.setMaximumPoolSize(2); + config.setLeakDetectionThreshold(2000); // the floor itself -- the shortest value that "counts" + dataSource = new HikariDataSource(config); + + assertThat(config.getLeakDetectionThreshold()) + .as("2000ms is exactly at the floor, so unlike 500ms above, it is NOT disabled") + .isEqualTo(2000L); + + // Deliberately leaked: checked out and never closed within this test method. + Connection leaked = dataSource.getConnection(); + + long deadline = System.currentTimeMillis() + 8000; + while (appender.list.isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + assertThat(appender.list) + .as("HikariCP's own leak-detection background task should have logged by now") + .isNotEmpty(); + + String warning = appender.list.get(0).getFormattedMessage(); + System.out.println("RESULT[hikari-leak-detection]: leakDetectionThreshold=2000ms | logger=" + + leakTaskLogger.getName() + " | level=" + appender.list.get(0).getLevel() + + " | message=" + warning); + + assertThat(warning) + .as("HikariCP's own leak-detection task logs a WARN naming the pool and the stack " + + "trace of where the connection was checked out, once it's been held longer " + + "than leakDetectionThreshold") + .contains("Connection leak detection triggered"); + + leaked.close(); // clean up now that the test has what it needs + leakTaskLogger.detachAppender(appender); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/hikari/HikariPoolExhaustionTest.java b/src/test/java/com/ankurm/hibernatedemo/hikari/HikariPoolExhaustionTest.java new file mode 100644 index 0000000..ec5dbaa --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/hikari/HikariPoolExhaustionTest.java @@ -0,0 +1,84 @@ +package com.ankurm.hibernatedemo.hikari; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.Connection; +import java.sql.SQLTransientConnectionException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The failure mode a pool-sizing article should lead with: what actually happens when every + * connection in the pool is checked out and something else asks for one more. Not "it blocks + * forever" and not a generic timeout -- HikariCP throws a specific, named exception with the pool + * name and the timeout value baked into the message. + * + *

Docs: docs/19-hikaricp-connection-pooling.md + */ +class HikariPoolExhaustionTest { + + private HikariDataSource dataSource; + + @AfterEach + void tearDown() { + if (dataSource != null) { + dataSource.close(); + } + } + + @Test + void requestingOneMoreConnectionThanMaximumPoolSize_failsFastWithSqlTransientConnectionException() + throws Exception { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:h2:mem:hikariexhaustion;DB_CLOSE_DELAY=-1"); + config.setUsername("sa"); + config.setPoolName("exhaustion-pool"); + config.setMaximumPoolSize(1); + config.setConnectionTimeout(1000); // fail fast for the test, default is 30000ms + dataSource = new HikariDataSource(config); + + // Hold the pool's one and only connection open. + Connection held = dataSource.getConnection(); + + long start = System.currentTimeMillis(); + Throwable thrown = catchThrowable(() -> dataSource.getConnection()); + long waitedMs = System.currentTimeMillis() - start; + + System.out.println("RESULT[hikari-pool-exhaustion]: maximumPoolSize=1, connectionTimeout=1000ms | " + + "second getConnection() waited=" + waitedMs + "ms before throwing " + + thrown.getClass().getName() + ": " + thrown.getMessage()); + + assertThat(thrown).isInstanceOf(SQLTransientConnectionException.class); + assertThat(thrown.getMessage()) + .as("the exception message names the pool and states it's a connection-timeout, " + + "not a generic SQL error -- this is what to grep application logs for") + .contains("exhaustion-pool") + .contains("Connection is not available"); + assertThat(waitedMs) + .as("the caller waits roughly connectionTimeout, not forever and not instantly") + .isGreaterThanOrEqualTo(900); + + held.close(); + + // With the held connection released, a new request succeeds immediately. + try (Connection recovered = dataSource.getConnection()) { + assertThat(recovered.isValid(1)).isTrue(); + } + } + + private static Throwable catchThrowable(ThrowingCallable callable) { + try { + callable.call(); + return null; + } catch (Throwable t) { + return t; + } + } + + @FunctionalInterface + private interface ThrowingCallable { + void call() throws Exception; + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/hikari/HikariRawBootstrapTest.java b/src/test/java/com/ankurm/hibernatedemo/hikari/HikariRawBootstrapTest.java new file mode 100644 index 0000000..269158a --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/hikari/HikariRawBootstrapTest.java @@ -0,0 +1,102 @@ +package com.ankurm.hibernatedemo.hikari; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariDataSource; +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The non-Spring side of this chapter: a plain {@code StandardServiceRegistry} -- no Spring Boot + * autoconfiguration anywhere -- explicitly wired to HikariCP via {@code hibernate-hikaricp} + * (the {@code org.hibernate.orm:hibernate-hikaricp} integration jar, added to this project's + * {@code pom.xml} test scope specifically for this chapter). This is the style a Java SE + * application, a batch job, or the original article's own non-Spring example would use. + * + *

{@code hibernate.connection.provider_class} accepts the short name {@code hikari} or {@code + * hikaricp} (both registered, confirmed by disassembling {@code + * StrategyRegistrationProviderImpl.class} in {@code hibernate-hikaricp-7.4.5.Final.jar}) as well + * as the fully-qualified {@code org.hibernate.hikaricp.internal.HikariCPConnectionProvider}. + * Every {@code hibernate.hikari.*}-prefixed setting (confirmed via the same jar's {@code + * HikariConfigurationUtil.CONFIG_PREFIX} constant, {@code "hibernate.hikari."}) is copied + * directly onto the underlying {@code HikariConfig} by property name -- {@code + * hibernate.hikari.maximumPoolSize} sets {@code HikariConfig#setMaximumPoolSize}, {@code + * hibernate.hikari.poolName} sets {@code HikariConfig#setPoolName}, and so on. + * + *

Docs: docs/19-hikaricp-connection-pooling.md + */ +class HikariRawBootstrapTest { + + private StandardServiceRegistry registry; + private SessionFactory sessionFactory; + + @AfterEach + void tearDown() { + if (sessionFactory != null) { + sessionFactory.close(); + } + if (registry != null) { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void rawStandardServiceRegistry_wiredToHikariByShortName_actuallyUsesHikariUnderneath() { + registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:hikarirawbootstrap;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.connection.provider_class", "hikari") + .applySetting("hibernate.hikari.maximumPoolSize", "7") + .applySetting("hibernate.hikari.poolName", "hibernate-demo-ch19-pool") + .applySetting("hibernate.hikari.connectionTimeout", "5000") + .build(); + Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(PoolProbe.class) + .buildMetadata(); + sessionFactory = metadata.buildSessionFactory(); + + ConnectionProvider provider = sessionFactory.unwrap(SessionFactoryImplementor.class) + .getServiceRegistry() + .getService(ConnectionProvider.class); + + System.out.println("RESULT[hikari-raw-bootstrap]: ConnectionProvider class=" + + provider.getClass().getName() + + " | isUnwrappableAs(HikariDataSource)=" + provider.isUnwrappableAs(HikariDataSource.class)); + + assertThat(provider.getClass().getName()) + .as("hibernate.connection.provider_class=hikari resolves to the real HikariCP integration") + .isEqualTo("org.hibernate.hikaricp.internal.HikariCPConnectionProvider"); + assertThat(provider.isUnwrappableAs(HikariDataSource.class)).isTrue(); + + HikariDataSource hikari = provider.unwrap(HikariDataSource.class); + + System.out.println("RESULT[hikari-raw-bootstrap-config]: poolName=" + hikari.getPoolName() + + " | maximumPoolSize=" + hikari.getMaximumPoolSize() + + " | connectionTimeout=" + hikari.getConnectionTimeout() + "ms" + + " -- every value traces back to a hibernate.hikari.* setting passed into " + + "StandardServiceRegistryBuilder, with zero Spring involved."); + + assertThat(hikari.getPoolName()).isEqualTo("hibernate-demo-ch19-pool"); + assertThat(hikari.getMaximumPoolSize()).isEqualTo(7); + assertThat(hikari.getConnectionTimeout()).isEqualTo(5000L); + + // Prove the pool is actually live, not just configured: persist through it. + try (var session = sessionFactory.openSession()) { + var tx = session.beginTransaction(); + session.persist(new PoolProbe("raw-bootstrap-probe")); + tx.commit(); + } + assertThat(hikari.getHikariPoolMXBean().getTotalConnections()) + .as("the pool actually opened at least one real connection to service that persist()") + .isGreaterThanOrEqualTo(1); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/hikari/SpringAutoConfiguredHikariTest.java b/src/test/java/com/ankurm/hibernatedemo/hikari/SpringAutoConfiguredHikariTest.java new file mode 100644 index 0000000..378ed44 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/hikari/SpringAutoConfiguredHikariTest.java @@ -0,0 +1,55 @@ +package com.ankurm.hibernatedemo.hikari; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import com.zaxxer.hikari.HikariDataSource; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Checks the original article's opening claim -- "Spring Boot uses HikariCP by default since 2.0" + * -- against the actual bean in this project's own Spring context, rather than repeating the + * version number from memory. `application.yml` in this repo names no connection-pool type at + * all: no {@code spring.datasource.type}, no {@code spring.datasource.hikari.*} block, nothing. + * The {@code DataSource} bean Spring Boot 4.1.1 hands out anyway is a real {@code + * HikariDataSource} -- confirmed here, not assumed. + * + *

Docs: docs/19-hikaricp-connection-pooling.md + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class SpringAutoConfiguredHikariTest { + + @Autowired + private DataSource dataSource; + + @Test + void springBootDataSourceBean_isHikariByDefault_withNoExplicitPoolConfiguration() { + assertThat(dataSource) + .as("with no spring.datasource.type and no spring.datasource.hikari.* block " + + "anywhere in application.yml, Spring Boot's own default connection pool " + + "choice is still HikariCP") + .isInstanceOf(HikariDataSource.class); + + HikariDataSource hikari = (HikariDataSource) dataSource; + + System.out.println("RESULT[hikari-spring-default]: dataSource class=" + dataSource.getClass().getName() + + " | pool name=" + hikari.getPoolName() + + " | maximumPoolSize=" + hikari.getMaximumPoolSize() + + " | minimumIdle=" + hikari.getMinimumIdle() + + " | connectionTimeout=" + hikari.getConnectionTimeout() + "ms" + + " | idleTimeout=" + hikari.getIdleTimeout() + "ms" + + " -- these are HikariCP's own built-in defaults (maximumPoolSize=10, " + + "minimumIdle defaults to maximumPoolSize), not anything this project set."); + + assertThat(hikari.getMaximumPoolSize()) + .as("HikariCP's own documented default pool size, unless overridden") + .isEqualTo(10); + assertThat(hikari.getPoolName()) + .as("with no spring.datasource.hikari.pool-name set, Spring Boot lets HikariCP " + + "generate its own default name (HikariPool-N)") + .startsWith("HikariPool-"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/interceptor/InterceptorTest.java b/src/test/java/com/ankurm/hibernatedemo/interceptor/InterceptorTest.java new file mode 100644 index 0000000..2e838de --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/interceptor/InterceptorTest.java @@ -0,0 +1,203 @@ +package com.ankurm.hibernatedemo.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Proves chapter 24's interceptor claims: implementing {@code Interceptor} directly (no + * {@code EmptyInterceptor} base class needed), the {@code state}-array-mutation contract on + * {@code onSave}/{@code onFlushDirty}, session-scoped vs. globally-registered interceptors (the + * property Spring Boot's {@code HibernatePropertiesCustomizer} sets under the hood), and bulk + * HQL updates bypassing interceptor callbacks entirely. + * + *

Docs: docs/24-interceptors.md. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class InterceptorTest { + + @Autowired + private EntityManagerFactory emf; + + private StandardServiceRegistry nativeRegistry; + private SessionFactory nativeSessionFactory; + + @AfterEach + void tearDown() { + if (nativeSessionFactory != null) { + nativeSessionFactory.close(); + } + if (nativeRegistry != null) { + StandardServiceRegistryBuilder.destroy(nativeRegistry); + } + } + + @Test + void sessionScopedInterceptor_mutatesStateArray_onSaveAndOnFlushDirty() { + SessionFactory sessionFactory = emf.unwrap(SessionFactory.class); + UppercasingInterceptor interceptor = new UppercasingInterceptor(); + + Long id; + try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) { + session.getTransaction().begin(); + Task task = new Task("wash the car", 3); + session.persist(task); + session.getTransaction().commit(); + id = task.getId(); + } + + String persistedNameAfterInsert; + try (Session plain = sessionFactory.openSession()) { + persistedNameAfterInsert = plain.find(Task.class, id).getName(); + } + + try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) { + session.getTransaction().begin(); + Task task = session.find(Task.class, id); + task.setName("buy milk"); + session.getTransaction().commit(); + } + + String persistedNameAfterUpdate; + try (Session plain = sessionFactory.openSession()) { + persistedNameAfterUpdate = plain.find(Task.class, id).getName(); + } + + System.out.println("RESULT[interceptor-session-scoped-mutation]: onSave called " + + interceptor.getOnSaveCalls() + " time(s), onFlushDirty called " + + interceptor.getOnFlushDirtyCalls() + " time(s) -- name after insert, reloaded " + + "from the database: '" + persistedNameAfterInsert + "' | name after update, " + + "reloaded from the database: '" + persistedNameAfterUpdate + "' -- both " + + "mutations happened inside the interceptor's state array, not in application " + + "code, and both are visible in what was actually persisted."); + + assertThat(interceptor.getOnSaveCalls()).isEqualTo(1); + assertThat(interceptor.getOnFlushDirtyCalls()).isEqualTo(1); + assertThat(persistedNameAfterInsert).isEqualTo("WASH THE CAR"); + assertThat(persistedNameAfterUpdate).isEqualTo("BUY MILK"); + } + + @Test + void plainSessionWithoutInterceptor_leavesNameUnchanged() { + SessionFactory sessionFactory = emf.unwrap(SessionFactory.class); + + Long id; + try (Session session = sessionFactory.openSession()) { + session.getTransaction().begin(); + Task task = new Task("mow the lawn", 1); + session.persist(task); + session.getTransaction().commit(); + id = task.getId(); + } + + String name; + try (Session session = sessionFactory.openSession()) { + name = session.find(Task.class, id).getName(); + } + + System.out.println("RESULT[interceptor-scoping]: a plain sessionFactory.openSession() " + + "with no interceptor supplied left the name exactly as the application wrote " + + "it -- '" + name + "' -- 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."); + + assertThat(name).isEqualTo("mow the lawn"); + } + + @Test + void globalInterceptorViaSessionFactoryInterceptorProperty_appliesToEverySessionAutomatically() { + // What Spring Boot's HibernatePropertiesCustomizer does under the hood is set exactly + // this property -- hibernate.session_factory.interceptor -- on the properties map before + // the SessionFactory is built, so every Session obtained from it carries the interceptor + // without any per-session withOptions() call. Demonstrated here on a standalone, + // non-Spring registry so it doesn't retroactively change every OTHER test's shared + // Spring-managed SessionFactory. + UppercasingInterceptor globalInterceptor = new UppercasingInterceptor(); + nativeRegistry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", + "jdbc:h2:mem:globalinterceptortest;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.session_factory.interceptor", globalInterceptor) + .build(); + Metadata metadata = new MetadataSources(nativeRegistry) + .addAnnotatedClass(Task.class) + .buildMetadata(); + nativeSessionFactory = metadata.buildSessionFactory(); + + // Two entirely separate, unrelated openSession() calls -- neither one asks for the + // interceptor explicitly. + try (Session s1 = nativeSessionFactory.openSession()) { + s1.getTransaction().begin(); + s1.persist(new Task("first task", 1)); + s1.getTransaction().commit(); + } + try (Session s2 = nativeSessionFactory.openSession()) { + s2.getTransaction().begin(); + s2.persist(new Task("second task", 2)); + s2.getTransaction().commit(); + } + + System.out.println("RESULT[interceptor-global-via-property]: hibernate.session_factory." + + "interceptor set once at SessionFactory build time -- onSave fired " + + globalInterceptor.getOnSaveCalls() + " 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."); + + assertThat(globalInterceptor.getOnSaveCalls()).isEqualTo(2); + } + + @Test + void bulkHqlUpdate_bypassesInterceptorCallbacksEntirely() { + SessionFactory sessionFactory = emf.unwrap(SessionFactory.class); + UppercasingInterceptor interceptor = new UppercasingInterceptor(); + + Long id; + try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) { + session.getTransaction().begin(); + Task task = new Task("bulk-update-seed-task", 5); + session.persist(task); + session.getTransaction().commit(); + id = task.getId(); + } + int onSaveCallsAfterInsert = interceptor.getOnSaveCalls(); + + try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) { + session.getTransaction().begin(); + session.createMutationQuery("update Task set name = 'renamed by bulk update' where id = :id") + .setParameter("id", id) + .executeUpdate(); + session.getTransaction().commit(); + } + int onFlushDirtyCallsAfterBulkUpdate = interceptor.getOnFlushDirtyCalls(); + + String persistedName; + try (Session session = sessionFactory.openSession()) { + persistedName = session.find(Task.class, id).getName(); + } + + System.out.println("RESULT[interceptor-bulk-update-bypass]: onSaveCalls after the initial" + + " insert=" + onSaveCallsAfterInsert + " | onFlushDirtyCalls after a bulk " + + "'update Task set name = ...' executeUpdate()=" + onFlushDirtyCallsAfterBulkUpdate + + " (still 0) | actual persisted name: '" + persistedName + "' -- 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."); + + assertThat(onFlushDirtyCallsAfterBulkUpdate).isZero(); + assertThat(persistedName).isEqualTo("renamed by bulk update"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/jndi/CrossTestPollutionTest.java b/src/test/java/com/ankurm/hibernatedemo/jndi/CrossTestPollutionTest.java new file mode 100755 index 0000000..6c137b9 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/jndi/CrossTestPollutionTest.java @@ -0,0 +1,87 @@ +package com.ankurm.hibernatedemo.jndi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import javax.naming.Context; +import javax.naming.InitialContext; +import javax.naming.NameAlreadyBoundException; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/10-mocking-jndi-datasources.md, chapter "'Name already bound' across two tests in the same JVM". + * Simple-JNDI's {@code MemoryContextFactory}, with {@code org.osjava.sj.jndi.shared=true}, + * backs its bindings with a single JVM-wide static map (see + * {@code JndiDataSourceResolutionTest}'s javadoc). If test A binds a name and never unbinds it, + * test B's attempt to bind the SAME name in the SAME JVM fails with + * {@code NameAlreadyBoundException} -- even though the two tests share no code, no fixture, no + * Spring context. {@link MethodOrderer.OrderAnnotation} pins the order here deliberately, so the + * pollution is forced to happen rather than left to JUnit's default (effectively random) + * ordering. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CrossTestPollutionTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static final String SHARED_NAME = "jdbc/SharedAcrossTests"; + + static { + System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.MemoryContextFactory"); + System.setProperty("org.osjava.sj.jndi.shared", "true"); + } + + @Test + @Order(1) + void testA_bindsAName_andDeliberatelyDoesNotCleanUp() throws Exception { + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:pollution-a;DB_CLOSE_DELAY=-1"); + Context ctx = new InitialContext(); + ctx.createSubcontext("jdbc"); + ctx.bind(SHARED_NAME, ds); + DEMO.info("testA bound {} -- no @AfterEach unbind on purpose, to force the pollution", SHARED_NAME); + // If this assertion passes, the bind succeeded. + assertThat(new InitialContext().lookup(SHARED_NAME)).isNotNull(); + } + + @Test + @Order(2) + void testB_bindingTheSameNameAgain_throwsNameAlreadyBoundException() throws Exception { + JdbcDataSource ds2 = new JdbcDataSource(); + ds2.setURL("jdbc:h2:mem:pollution-b;DB_CLOSE_DELAY=-1"); + Context ctx = new InitialContext(); + + NameAlreadyBoundException ex = assertThrows(NameAlreadyBoundException.class, + () -> ctx.bind(SHARED_NAME, ds2)); + DEMO.info("testB's bind() of the SAME name testA left behind failed verbatim with: {}: {}", + ex.getClass().getName(), ex.getMessage()); + + // The fix: rebind() instead of bind() is idempotent for this exact failure mode -- + // it replaces whatever is there instead of throwing. + ctx.rebind(SHARED_NAME, ds2); + Object rebound = new InitialContext().lookup(SHARED_NAME); + assertThat(rebound).isSameAs(ds2); + DEMO.info("ctx.rebind() instead of ctx.bind() -- the standard fix -- succeeded: {}", rebound); + } + + @Test + @Order(3) + void testC_theRealFix_isAfterEachUnbindNotRebind() throws Exception { + // rebind() papers over the symptom but each test still leaks its binding into the next + // one's JNDI namespace unless something cleans up. The actual fix is symmetry: whatever + // a test binds, that same test unbinds, in @AfterEach, unconditionally. + try { + new InitialContext().unbind(SHARED_NAME); + DEMO.info("cleanup: unbound {} so it does not leak into any test that runs after this class", SHARED_NAME); + } catch (Exception e) { + DEMO.info("cleanup: nothing to unbind ({})", e.toString()); + } + assertThrows(javax.naming.NameNotFoundException.class, + () -> new InitialContext().lookup(SHARED_NAME)); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/jndi/HibernateJndiDataSourceTest.java b/src/test/java/com/ankurm/hibernatedemo/jndi/HibernateJndiDataSourceTest.java new file mode 100755 index 0000000..77348ba --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/jndi/HibernateJndiDataSourceTest.java @@ -0,0 +1,101 @@ +package com.ankurm.hibernatedemo.jndi; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.naming.Context; +import javax.naming.InitialContext; +import javax.naming.NameAlreadyBoundException; +import org.h2.jdbcx.JdbcDataSource; +import org.hibernate.SessionFactory; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cfg.Configuration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/10-mocking-jndi-datasources.md, chapter "Getting Hibernate itself to resolve a JNDI DataSource by + * name". Deliberately bypasses Spring Boot's autoconfiguration entirely and goes straight at + * Hibernate's own {@code hibernate.connection.datasource} setting (see + * {@code org.hibernate.cfg.JdbcSettings.DATASOURCE} -- confirmed present in 7.4.5.Final via + * javap, docs/output/jndi-hibernate-datasource-setting-javap.txt), which is the exact mechanism + * the article's original code was trying to exercise through Spring. + * + *

A separate attempt to get Spring Boot's own {@code spring.datasource.jndi-name} property + * to resolve through a full {@code @SpringBootTest} context (with {@code spring-boot-starter-web} + * on the test classpath and {@code simple-jndi}'s {@code MemoryContextFactory}) reproduced a + * {@code NameNotFoundException} at Spring bean-creation time that this test's plain-Hibernate + * bootstrap does not hit at all -- see the findings report for what was and was not diagnosed + * about that gap. This test is the one that stayed green and demonstrates the real mechanism: + * a JNDI-bound {@code DataSource}, resolved by name, driving a working {@code SessionFactory}. + */ +class HibernateJndiDataSourceTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static final String JNDI_NAME = "jdbc/HibernateTestDS"; + + @BeforeEach + void bindDataSource() throws Exception { + System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.MemoryContextFactory"); + System.setProperty("org.osjava.sj.jndi.shared", "true"); + + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:hibernate-jndi-test;DB_CLOSE_DELAY=-1"); + ds.setUser("sa"); + ds.setPassword(""); + + Context ctx = new InitialContext(); + createSubcontextIfAbsent(ctx, "jdbc"); + ctx.rebind(JNDI_NAME, ds); + } + + @AfterEach + void unbindAndClearFactory() throws Exception { + try { + new InitialContext().unbind(JNDI_NAME); + } catch (Exception ignored) { + } + System.clearProperty(Context.INITIAL_CONTEXT_FACTORY); + System.clearProperty("org.osjava.sj.jndi.shared"); + } + + // Real-world hygiene, discovered the hard way: simple-jndi's shared MemoryContext is a + // single JVM-wide namespace. If a DIFFERENT test class in the same run already created the + // "jdbc" subcontext and did not tear it down, a plain createSubcontext("jdbc") here throws + // NameAlreadyBoundException before this test even gets to the DataSource bind -- this is + // the exact cross-test pollution CrossTestPollutionTest demonstrates on purpose, caught + // here by accident the first time this suite ran as a whole rather than one class at a time. + private static void createSubcontextIfAbsent(Context ctx, String name) throws Exception { + try { + ctx.createSubcontext(name); + } catch (NameAlreadyBoundException alreadyThere) { + // fine -- another test in this JVM already created it + } + } + + @Test + void hibernateResolvesDataSourceByJndiName_andBuildsAWorkingSessionFactory() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.datasource", JNDI_NAME) + .applySetting("hibernate.hbm2ddl.auto", "create-drop") + .build(); + + try { + SessionFactory sessionFactory = new Configuration().buildSessionFactory(registry); + + assertThat(sessionFactory).isNotNull(); + try (var session = sessionFactory.openSession()) { + Integer result = session.createNativeQuery("SELECT 1", Integer.class).getSingleResult(); + DEMO.info("Hibernate SessionFactory built from JNDI name '{}' ran SELECT 1 -> {}", JNDI_NAME, result); + assertThat(result).isEqualTo(1); + } finally { + sessionFactory.close(); + } + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/jndi/JndiDataSourceResolutionTest.java b/src/test/java/com/ankurm/hibernatedemo/jndi/JndiDataSourceResolutionTest.java new file mode 100755 index 0000000..14861aa --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/jndi/JndiDataSourceResolutionTest.java @@ -0,0 +1,108 @@ +package com.ankurm.hibernatedemo.jndi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.SQLException; +import javax.naming.Context; +import javax.naming.InitialContext; +import javax.naming.NameAlreadyBoundException; +import javax.naming.NameNotFoundException; +import javax.naming.NamingException; +import javax.sql.DataSource; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/10-mocking-jndi-datasources.md (post 4871 rewrite), chapter "Getting simple-jndi 0.25.0 actually + * working". Uses {@code org.osjava.sj.MemoryContextFactory} directly -- no root properties + * file needed for a pure in-memory bind/lookup within one JVM. Every test here sets + * {@code java.naming.factory.initial} itself in {@code @BeforeEach} and clears the JVM-wide + * JNDI environment in {@code @AfterEach}, on purpose: JNDI's {@code InitialContext} is backed + * by static, JVM-global state (see {@code CrossTestPollutionTest} for what happens when this + * discipline is skipped). + */ +class JndiDataSourceResolutionTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static final String JNDI_NAME = "java:comp/env/jdbc/TestDS"; + + @BeforeEach + void setJndiFactory() { + System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.MemoryContextFactory"); + // Without this, org.osjava.sj.MemoryContextFactory.getInitialContext() hands back a + // BRAND NEW org.osjava.sj.jndi.MemoryContext on every single `new InitialContext()` + // call (confirmed via javap -c on MemoryContextFactory.class: it branches on this exact + // property name before deciding whether to consult its static, shared context cache). + // Real application code calls `new InitialContext()` in more than one place -- a DAO + // here, a test's @BeforeAll there -- so without this flag, a bind() in one InitialContext + // instance is invisible to a lookup() via a different instance, even in the same test. + System.setProperty("org.osjava.sj.jndi.shared", "true"); + } + + @AfterEach + void clearJndiFactory() throws NamingException { + try { + new InitialContext().unbind(JNDI_NAME); + } catch (NamingException ignored) { + // not bound -- fine + } + System.clearProperty(Context.INITIAL_CONTEXT_FACTORY); + System.clearProperty("org.osjava.sj.jndi.shared"); + } + + @Test + void bindDataSourceIntoMockJndi_andLookItUpByName() throws Exception { + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:jnditestdb;DB_CLOSE_DELAY=-1"); + ds.setUser("sa"); + ds.setPassword(""); + + Context ctx = new InitialContext(); + createSubcontextIfAbsent(ctx, "java:"); + createSubcontextIfAbsent(ctx, "java:comp"); + createSubcontextIfAbsent(ctx, "java:comp/env"); + createSubcontextIfAbsent(ctx, "java:comp/env/jdbc"); + ctx.rebind(JNDI_NAME, ds); + + DataSource looked = (DataSource) new InitialContext().lookup(JNDI_NAME); + assertThat(looked).isNotNull(); + try (var conn = looked.getConnection()) { + assertThat(conn.isValid(2)).isTrue(); + DEMO.info("looked-up DataSource produced a valid connection: {}", conn.getMetaData().getURL()); + } + } + + @Test + void noInitialContextFactoryProperty_throwsNoInitialContextException() { + System.clearProperty(Context.INITIAL_CONTEXT_FACTORY); + javax.naming.NoInitialContextException ex = assertThrows( + javax.naming.NoInitialContextException.class, + () -> new InitialContext().lookup("java:comp/env/jdbc/Anything")); + DEMO.info("verbatim NoInitialContextException message: {}", ex.getMessage()); + assertThat(ex.getMessage()).contains("Need to specify class name in environment or system property"); + assertThat(ex.getMessage()).contains(Context.INITIAL_CONTEXT_FACTORY); + } + + @Test + void lookupOfUnboundName_throwsNameNotFoundException() throws NamingException { + Context ctx = new InitialContext(); + NameNotFoundException ex = assertThrows(NameNotFoundException.class, + () -> ctx.lookup("java:comp/env/jdbc/DoesNotExist")); + DEMO.info("verbatim NameNotFoundException message: {}", ex.getMessage()); + } + + // See docs/10-mocking-jndi-datasources.md's cross-test-pollution chapter -- this suite tripped over its own + // advice the first time it ran as a whole rather than one class at a time. + private static void createSubcontextIfAbsent(Context ctx, String name) throws Exception { + try { + ctx.createSubcontext(name); + } catch (NameAlreadyBoundException alreadyThere) { + // fine -- another test in this JVM already created it + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/mappingstyle/AnnotationXmlOverrideTest.java b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/AnnotationXmlOverrideTest.java new file mode 100755 index 0000000..4a13037 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/AnnotationXmlOverrideTest.java @@ -0,0 +1,58 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.persister.entity.EntityPersister; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * The entity's field is annotated {@code @Column(name = "annotation_name")}. The matching + * orm.xml entry maps the same field to {@code xml_name}. This test reads back the ACTUAL + * column name Hibernate used, straight from the runtime metamodel and from the real DDL/SQL, + * to settle who wins. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +@TestPropertySource(properties = { + "spring.jpa.mapping-resources=orm-xml-override-mapping.xml" +}) +class AnnotationXmlOverrideTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void xmlColumnMapping_winsOverAnnotationColumnMapping() { + SessionFactoryImplementor sfi = emf.unwrap(SessionFactoryImplementor.class); + EntityPersister persister = sfi.getMappingMetamodel() + .getEntityDescriptor(OverrideEntity.class); + String[] columnNames = persister.getPropertyColumnNames("value"); + System.out.println("RESULT[override]: runtime column name for OverrideEntity.value = " + columnNames[0] + + " (annotation said 'annotation_name', orm.xml said 'xml_name')"); + assertThat(columnNames).containsExactly("xml_name"); + + // Prove it end-to-end: actually persist and read the row back through the winning column. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + OverrideEntity e = new OverrideEntity("hello"); + em.persist(e); + em.getTransaction().commit(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + Object row = em2.createNativeQuery("select xml_name from override_entity where id = " + e.getId()) + .getSingleResult(); + em2.close(); + assertThat(row).isEqualTo("hello"); + System.out.println("RESULT[override]: native query against column 'xml_name' returned: " + row); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlBootTest.java b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlBootTest.java new file mode 100755 index 0000000..ebc1f5e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlBootTest.java @@ -0,0 +1,79 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cfg.MappingSettings; +import org.junit.jupiter.api.Test; + +/** + * Empirical probe: what does Hibernate 7.4.5.Final actually do with a legacy + * {@code .hbm.xml} mapping file, bypassing Spring entirely so every setting is explicit. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1. + */ +class HbmXmlBootTest { + + private StandardServiceRegistry baseRegistry() { + return new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmtest;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + .build(); + } + + @Test + void defaultSettings_bootingWithHbmXml_printsWhatHappens() { + StandardServiceRegistry registry = baseRegistry(); + try { + Metadata metadata = new MetadataSources(registry) + .addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml") + .buildMetadata(); + SessionFactory sf = metadata.buildSessionFactory(); + System.out.println("RESULT[default]: BOOT SUCCEEDED, SessionFactory built without hibernate.transform_hbm_xml.enabled"); + sf.close(); + } catch (Throwable t) { + System.out.println("RESULT[default]: BOOT FAILED: " + t.getClass().getName() + ": " + t.getMessage()); + Throwable cause = t.getCause(); + while (cause != null) { + System.out.println("RESULT[default]: caused by: " + cause.getClass().getName() + ": " + cause.getMessage()); + cause = cause.getCause(); + } + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void transformHbmXmlEnabled_bootingWithHbmXml_printsWhatHappens() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmtest2;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting(MappingSettings.TRANSFORM_HBM_XML, "true") + .build(); + try { + Metadata metadata = new MetadataSources(registry) + .addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml") + .buildMetadata(); + SessionFactory sf = metadata.buildSessionFactory(); + System.out.println("RESULT[transform=true]: BOOT SUCCEEDED, SessionFactory built WITH " + MappingSettings.TRANSFORM_HBM_XML + "=true"); + sf.close(); + } catch (Throwable t) { + System.out.println("RESULT[transform=true]: BOOT FAILED: " + t.getClass().getName() + ": " + t.getMessage()); + Throwable cause = t.getCause(); + while (cause != null) { + System.out.println("RESULT[transform=true]: caused by: " + cause.getClass().getName() + ": " + cause.getMessage()); + cause = cause.getCause(); + } + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlRuntimeTest.java b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlRuntimeTest.java new file mode 100755 index 0000000..dbec46b --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlRuntimeTest.java @@ -0,0 +1,58 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cfg.MappingSettings; +import org.junit.jupiter.api.Test; + +/** + * Proves the hbm.xml-mapped entity actually round-trips through the database -- not just + * that metadata parses. Docs: docs/04-annotations-vs-xml.md, Topic 1. + */ +class HbmXmlRuntimeTest { + + @Test + void hbmXmlEntity_defaultSettings_actuallyPersistsAndQueries() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmruntime1;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + // NOTE: hibernate.transform_hbm_xml.enabled is deliberately NOT set here. + .build(); + try { + Metadata metadata = new MetadataSources(registry) + .addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml") + .buildMetadata(); + try (SessionFactory sf = metadata.buildSessionFactory()) { + Long id; + try (Session s = sf.openSession()) { + Transaction tx = s.beginTransaction(); + HbmEmployee e = new HbmEmployee("Ada", "ada@example.com"); + s.persist(e); + tx.commit(); + id = e.getId(); + } + try (Session s = sf.openSession()) { + HbmEmployee loaded = s.get(HbmEmployee.class, id); + assertThat(loaded).isNotNull(); + assertThat(loaded.getFirstName()).isEqualTo("Ada"); + assertThat(loaded.getEmail()).isEqualTo("ada@example.com"); + System.out.println("RESULT[hbm-default-runtime]: persisted+loaded id=" + id + + " firstName=" + loaded.getFirstName() + " email=" + loaded.getEmail() + + " -- NO hibernate.transform_hbm_xml.enabled setting was applied."); + } + } + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlTransformFalseTest.java b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlTransformFalseTest.java new file mode 100755 index 0000000..6e4bfc4 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/HbmXmlTransformFalseTest.java @@ -0,0 +1,37 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cfg.MappingSettings; +import org.junit.jupiter.api.Test; + +/** Explicitly setting transform_hbm_xml.enabled=false -- does it throw or refuse? */ +class HbmXmlTransformFalseTest { + + @Test + void transformExplicitlyFalse_bootingWithHbmXml_printsWhatHappens() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmtest3;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting(MappingSettings.TRANSFORM_HBM_XML, "false") + .build(); + try { + Metadata metadata = new MetadataSources(registry) + .addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml") + .buildMetadata(); + SessionFactory sf = metadata.buildSessionFactory(); + System.out.println("RESULT[transform=false]: BOOT SUCCEEDED even with transform_hbm_xml.enabled=false"); + sf.close(); + } catch (Throwable t) { + System.out.println("RESULT[transform=false]: BOOT FAILED: " + t.getClass().getName() + ": " + t.getMessage()); + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdTest.java b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdTest.java new file mode 100755 index 0000000..f07536f --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/MappingXmlNaturalIdTest.java @@ -0,0 +1,53 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * A @NaturalId concept, expressed in Hibernate's native "mapping.xml" XML dialect + * (namespace http://www.hibernate.org/xsd/orm/mapping) on a class with ZERO annotations. + * The JPA-standard orm.xml dialect (orm_3_2.xsd) has no <natural-id> element -- this is a + * Hibernate-only extension that only the extended XML dialect (or Java annotations) can express. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +@TestPropertySource(properties = { + "spring.jpa.mapping-resources=mapping-xml-natural-id.xml" +}) +class MappingXmlNaturalIdTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void naturalIdDefinedPurelyInXml_resolvesViaByNaturalId() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + MappingXmlNaturalIdEntity e = new MappingXmlNaturalIdEntity("SKU-XML-1", "XML Widget"); + em.persist(e); + em.getTransaction().commit(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + Session session = em2.unwrap(Session.class); + MappingXmlNaturalIdEntity loaded = session.byNaturalId(MappingXmlNaturalIdEntity.class) + .using("sku", "SKU-XML-1") + .load(); + em2.close(); + + assertThat(loaded).isNotNull(); + assertThat(loaded.getName()).isEqualTo("XML Widget"); + System.out.println("RESULT[mapping-xml-natural-id]: session.byNaturalId() resolved an entity whose " + + "@NaturalId-equivalent was declared ENTIRELY in Hibernate's native mapping.xml dialect, " + + "zero Java annotations. name=" + loaded.getName()); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlMappingResourcesTest.java b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlMappingResourcesTest.java new file mode 100755 index 0000000..8754d7f --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/OrmXmlMappingResourcesTest.java @@ -0,0 +1,56 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Proves that {@code spring.jpa.mapping-resources} is the Spring Boot 4.1.1 wiring that makes + * an orm.xml-only mapping (no {@code @Entity} at all) a real managed JPA entity, backed by a + * table ({@code xml_only_widgets}) that ONLY orm.xml knows about. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +@TestPropertySource(properties = { + "spring.jpa.mapping-resources=orm-xml-only-mapping.xml" +}) +class OrmXmlMappingResourcesTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void ormXmlOnlyEntity_isManaged_andRoundTripsThroughItsOwnTable() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + OrmXmlOnlyEntity e = new OrmXmlOnlyEntity("mapped-by-orm-xml-only"); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + assertThat(id).isNotNull(); + + EntityManager em2 = emf.createEntityManager(); + // This JPQL uses the entity name (defaults to simple class name) -- proves the entity is + // registered in metadata purely via orm.xml, with zero annotations on the Java class. + List found = em2.createQuery( + "select o from OrmXmlOnlyEntity o where o.label = :label", OrmXmlOnlyEntity.class) + .setParameter("label", "mapped-by-orm-xml-only") + .getResultList(); + em2.close(); + + assertThat(found).hasSize(1); + assertThat(found.get(0).getId()).isEqualTo(id); + System.out.println("RESULT[orm-xml-only]: persisted+queried id=" + id + + " via JPQL against entity mapped ENTIRELY by orm.xml (table xml_only_widgets), zero annotations on the Java class."); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/mappingstyle/XmlMappingMetadataCompleteTest.java b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/XmlMappingMetadataCompleteTest.java new file mode 100755 index 0000000..08aa001 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/mappingstyle/XmlMappingMetadataCompleteTest.java @@ -0,0 +1,46 @@ +package com.ankurm.hibernatedemo.mappingstyle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import org.hibernate.AnnotationException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * <xml-mapping-metadata-complete/> makes Hibernate IGNORE ALL annotations for this + * persistence unit -- not just the ones a matching orm.xml entry overrides. OverrideEntity + * carries @Id/@GeneratedValue on "id" in Java, but the metadata-complete orm.xml entry only + * declares <basic name="value"> and omits <id> entirely. Result: bootstrap fails because + * the (ignored) @Id annotation no longer counts -- proving metadata-complete is not just an + * override switch, it is a full annotation kill-switch for the persistence unit. + * + *

Docs: docs/04-annotations-vs-xml.md, Topic 1. + */ +class XmlMappingMetadataCompleteTest { + + @Test + void metadataComplete_ignoresAtIdAnnotation_bootFailsWithNoIdentifier() { + SpringApplicationBuilder builder = new SpringApplicationBuilder(HibernateDemoApplication.class) + .properties("spring.jpa.mapping-resources=orm-xml-metadata-complete.xml"); + + BeanCreationException ex = assertThrows(BeanCreationException.class, () -> { + try (ConfigurableApplicationContext ctx = builder.run()) { + // never reached + } + }); + + Throwable root = ex; + while (root.getCause() != null) { + root = root.getCause(); + } + System.out.println("RESULT[metadata-complete]: boot FAILED as predicted: " + root.getClass().getName() + + ": " + root.getMessage()); + assertThat(root).isInstanceOf(AnnotationException.class); + assertThat(root.getMessage()).contains("has no identifier"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryExecutionTest.java b/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryExecutionTest.java new file mode 100755 index 0000000..035a76c --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryExecutionTest.java @@ -0,0 +1,159 @@ +package com.ankurm.hibernatedemo.namedquery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.NoResultException; +import jakarta.persistence.Query; +import java.util.List; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Backs ankurm.com post 4877, docs/14-named-queries.md. Covers: jakarta @NamedQuery execution, + * org.hibernate.annotations.NamedQuery's cacheable extra actually taking effect, + * @NamedNativeQuery + @SqlResultSetMapping DTO projection, record constructor-result (JPA 3.2), + * and getSingleResultOrNull() vs getSingleResult(). + */ +@SpringBootTest(properties = { + "spring.jpa.properties.hibernate.cache.use_second_level_cache=true", + "spring.jpa.properties.hibernate.cache.use_query_cache=true", + "spring.jpa.properties.hibernate.cache.region.factory_class=jcache", + "spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider" +}) +class NamedQueryExecutionTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @BeforeEach + void cleanTables() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM NqEmployee").executeUpdate(); + em.createQuery("DELETE FROM HibernateExtraEmployee").executeUpdate(); + em.getTransaction().commit(); + em.close(); + } + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + @Test + void jakartaNamedQuery_executesAndReturnsExpectedRows() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new NqEmployee("Ankur", "ACTIVE")); + em.persist(new NqEmployee("Priya", "ACTIVE")); + em.getTransaction().commit(); + + List found = em.createNamedQuery("Employee.findByName", NqEmployee.class) + .setParameter("name", "Ankur") + .getResultList(); + DEMO.info("Employee.findByName(Ankur): {} rows", found.size()); + assertThat(found).hasSize(1); + em.close(); + } + + @Test + void hibernateNamedQuery_cacheableExtra_actuallyMarksTheQueryCacheable() { + // org.hibernate.annotations.NamedQuery offers `cacheable` -- jakarta.persistence.NamedQuery + // does not. Verify it actually takes effect using Statistics' query-cache put count. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new HibernateExtraEmployee("A")); + em.persist(new HibernateExtraEmployee("B")); + em.getTransaction().commit(); + + em.close(); + + stats().clear(); + EntityManager em1 = emf.createEntityManager(); + List first = em1.createNamedQuery("HibernateExtraEmployee.cacheableFindAll", HibernateExtraEmployee.class) + .getResultList(); + long putsAfterFirst = stats().getQueryCachePutCount(); + em1.close(); + + EntityManager em2 = emf.createEntityManager(); + List second = em2.createNamedQuery("HibernateExtraEmployee.cacheableFindAll", HibernateExtraEmployee.class) + .getResultList(); + long hitsAfterSecond = stats().getQueryCacheHitCount(); + em2.close(); + + DEMO.info("cacheable=true named query: puts after 1st run = {}, cache hits after 2nd run = {}", putsAfterFirst, hitsAfterSecond); + + assertThat(first).hasSize(2); + assertThat(second).hasSize(2); + assertThat(putsAfterFirst).as("cacheable=true on org.hibernate.annotations.NamedQuery must populate the query cache").isGreaterThan(0); + assertThat(hitsAfterSecond).as("second identical call must hit the query cache").isGreaterThan(0); + } + + @Test + void namedNativeQuery_withSqlResultSetMapping_projectsToDto() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new NqEmployee("Native1", "ACTIVE")); + em.persist(new NqEmployee("Native2", "INACTIVE")); + em.getTransaction().commit(); + + @SuppressWarnings("unchecked") + List dtos = (List) (List) em.createNamedQuery("Employee.byNativeDto") + .setParameter("status", "ACTIVE") + .getResultList(); + + DEMO.info("Employee.byNativeDto(ACTIVE): {}", dtos); + assertThat(dtos).hasSize(1); + assertThat(dtos.get(0).getFirstName()).isEqualTo("Native1"); + em.close(); + } + + @Test + void jpql31ConstructorExpression_worksWithARecordAsTheTarget() { + // Jakarta Persistence 3.2 alternative to @SqlResultSetMapping: a plain JPQL constructor + // expression targeting a java `record`. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new NqEmployee("RecordTest", "ACTIVE")); + em.getTransaction().commit(); + + List records = em.createQuery( + "SELECT NEW com.ankurm.hibernatedemo.namedquery.EmployeeRecordDto(e.id, e.firstName) FROM NqEmployee e WHERE e.firstName = :name", + EmployeeRecordDto.class) + .setParameter("name", "RecordTest") + .getResultList(); + + DEMO.info("JPQL constructor expression into a record: {}", records); + assertThat(records).hasSize(1); + assertThat(records.get(0).firstName()).isEqualTo("RecordTest"); + em.close(); + } + + @Test + void getSingleResultOrNull_vs_getSingleResult_onNoRows() { + EntityManager em = emf.createEntityManager(); + + Object viaOrNull = em.createQuery("SELECT e FROM NqEmployee e WHERE e.firstName = :n") + .setParameter("n", "NoSuchPersonAtAll") + .getSingleResultOrNull(); + DEMO.info("getSingleResultOrNull() on zero rows returned: {}", viaOrNull); + assertThat(viaOrNull).isNull(); + + Query q = em.createQuery("SELECT e FROM NqEmployee e WHERE e.firstName = :n") + .setParameter("n", "NoSuchPersonAtAll"); + NoResultException ex = assertThrows(NoResultException.class, q::getSingleResult); + DEMO.info("getSingleResult() on zero rows threw: {}: {}", ex.getClass().getName(), ex.getMessage()); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryPerformanceTest.java b/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryPerformanceTest.java new file mode 100755 index 0000000..22b56dc --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryPerformanceTest.java @@ -0,0 +1,86 @@ +package com.ankurm.hibernatedemo.namedquery; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4877, docs/14-named-queries.md chapter "Does pre-parsing actually help?" + * + *

Measures whether a {@code @NamedQuery} is measurably faster than the identical inline JPQL + * string, after JIT/plan-cache warmup. Sandbox is a shared container -- treat absolute numbers + * as indicative only, the RATIO between the two is what matters here. + */ +@SpringBootTest(properties = { + "spring.jpa.properties.hibernate.generate_statistics=false" // avoid stats overhead skewing a micro-benchmark +}) +class NamedQueryPerformanceTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static final int WARMUP = 500; + private static final int MEASURED = 5000; + + @Autowired + private EntityManagerFactory emf; + + @BeforeEach + void seed() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM NqEmployee").executeUpdate(); + for (int i = 0; i < 20; i++) { + em.persist(new NqEmployee("Perf" + i, "ACTIVE")); + } + em.getTransaction().commit(); + em.close(); + } + + @Test + void measureNamedQueryVsInlineJpql() { + // Warmup both paths (JIT + Hibernate's internal query-plan cache, which is keyed by the + // query STRING regardless of whether it came from a @NamedQuery or an inline literal). + for (int i = 0; i < WARMUP; i++) { + runNamed(); + runInline(); + } + + // Interleave the two so neither gets an unfair ordering/JIT/GC advantage. + long namedElapsedNs = 0; + long inlineElapsedNs = 0; + for (int i = 0; i < MEASURED; i++) { + long t0 = System.nanoTime(); + runNamed(); + long t1 = System.nanoTime(); + runInline(); + long t2 = System.nanoTime(); + namedElapsedNs += (t1 - t0); + inlineElapsedNs += (t2 - t1); + } + + double namedAvgUs = namedElapsedNs / 1000.0 / MEASURED; + double inlineAvgUs = inlineElapsedNs / 1000.0 / MEASURED; + + DEMO.info("PERF (shared sandbox container, indicative only): {} iterations after {} warmup each", + MEASURED, WARMUP); + DEMO.info("PERF named query : total={} ms, avg={} us/call", namedElapsedNs / 1_000_000.0, namedAvgUs); + DEMO.info("PERF inline JPQL : total={} ms, avg={} us/call", inlineElapsedNs / 1_000_000.0, inlineAvgUs); + DEMO.info("PERF ratio (named/inline) = {}", namedAvgUs / inlineAvgUs); + } + + private void runNamed() { + EntityManager em = emf.createEntityManager(); + em.createNamedQuery("Employee.findAllActive", NqEmployee.class).getResultList(); + em.close(); + } + + private void runInline() { + EntityManager em = emf.createEntityManager(); + em.createQuery("SELECT e FROM NqEmployee e WHERE e.status = 'ACTIVE' ORDER BY e.id DESC", NqEmployee.class).getResultList(); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryStartupValidationTest.java b/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryStartupValidationTest.java new file mode 100755 index 0000000..e506d55 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/namedquery/NamedQueryStartupValidationTest.java @@ -0,0 +1,92 @@ +package com.ankurm.hibernatedemo.namedquery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.ankurm.brokenprobe.BrokenNamedQueryEmployee; +import java.util.Properties; +import org.hibernate.HibernateException; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cfg.JdbcSettings; +import org.hibernate.cfg.MappingSettings; +import org.hibernate.cfg.QuerySettings; +import org.hibernate.cfg.SchemaToolingSettings; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs ankurm.com post 4877, docs/14-named-queries.md chapter "Startup validation". + * + *

Uses a fully standalone Hibernate bootstrap (no Spring) specifically so the deliberately + * broken {@link BrokenNamedQueryEmployee} entity never enters the shared Spring application + * context that every other {@code @SpringBootTest} in this repository relies on. + */ +class NamedQueryStartupValidationTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + private StandardServiceRegistry buildRegistry(boolean startupCheckEnabled) { + Properties props = new Properties(); + props.put(JdbcSettings.JAKARTA_JDBC_URL, "jdbc:h2:mem:namedquery-startup-" + System.nanoTime() + ";DB_CLOSE_DELAY=-1"); + props.put(JdbcSettings.JAKARTA_JDBC_DRIVER, "org.h2.Driver"); + props.put(JdbcSettings.JAKARTA_JDBC_USER, "sa"); + props.put(JdbcSettings.JAKARTA_JDBC_PASSWORD, ""); + props.put(SchemaToolingSettings.HBM2DDL_AUTO, "update"); + props.put(QuerySettings.QUERY_STARTUP_CHECKING, String.valueOf(startupCheckEnabled)); + return new StandardServiceRegistryBuilder().applySettings(props).build(); + } + + @Test + void brokenNamedQuery_failsFastAtSessionFactoryBuildTime_withStartupCheckEnabled() { + StandardServiceRegistry registry = buildRegistry(true); + try { + MetadataSources sources = new MetadataSources(registry); + sources.addAnnotatedClass(BrokenNamedQueryEmployee.class); + Metadata metadata = sources.buildMetadata(); + + HibernateException ex = assertThrows(HibernateException.class, + () -> metadata.getSessionFactoryBuilder().build()); + + DEMO.info("startup_check=true (default): bootstrap failure -- wrapper class: {}", ex.getClass().getName()); + DEMO.info("startup_check=true (default): verbatim message: {}", ex.getMessage()); + + assertThat(ex.getMessage()).contains("firsNam"); + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void brokenNamedQuery_bootsFineWithStartupCheckDisabled_failsOnlyAtCallTime() { + StandardServiceRegistry registry = buildRegistry(false); + SessionFactory sf = null; + try { + MetadataSources sources = new MetadataSources(registry); + sources.addAnnotatedClass(BrokenNamedQueryEmployee.class); + Metadata metadata = sources.buildMetadata(); + + // With hibernate.query.startup_check=false, this must NOT throw. + sf = metadata.getSessionFactoryBuilder().build(); + DEMO.info("startup_check=false: SessionFactory built successfully with the broken named query still inside it: {}", sf != null); + + SessionFactory finalSf = sf; + try (Session session = finalSf.openSession()) { + RuntimeException callTimeEx = assertThrows(RuntimeException.class, + () -> session.createNamedQuery("BrokenNamedQueryEmployee.badProperty", BrokenNamedQueryEmployee.class).getResultList()); + DEMO.info("startup_check=false: query only fails when actually CALLED -- class: {}, message: {}", + callTimeEx.getClass().getName(), callTimeEx.getMessage()); + } + } finally { + if (sf != null) { + sf.close(); + } + StandardServiceRegistryBuilder.destroy(registry); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/namedquery/OrmXmlNamedQueryTest.java b/src/test/java/com/ankurm/hibernatedemo/namedquery/OrmXmlNamedQueryTest.java new file mode 100755 index 0000000..5b12eb4 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/namedquery/OrmXmlNamedQueryTest.java @@ -0,0 +1,77 @@ +package com.ankurm.hibernatedemo.namedquery; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4877, docs/14-named-queries.md chapter "Named queries in orm.xml". + */ +@SpringBootTest +class OrmXmlNamedQueryTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @BeforeEach + void cleanTable() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM XmlQueryEmployee").executeUpdate(); + em.getTransaction().commit(); + em.close(); + } + + @Test + void namedQueryDefinedInOrmXml_worksAlongsideTheAnnotatedOne() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new XmlQueryEmployee("Low", 30_000)); + em.persist(new XmlQueryEmployee("High", 90_000)); + em.getTransaction().commit(); + + List viaAnnotation = em.createNamedQuery("XmlQueryEmployee.findBySalaryAbove", XmlQueryEmployee.class) + .setParameter("min", 50_000.0) + .getResultList(); + List viaXml = em.createNamedQuery("XmlQueryEmployee.findBySalaryAboveXml", XmlQueryEmployee.class) + .setParameter("min", 50_000.0) + .getResultList(); + + DEMO.info("annotation-defined named query result: {} rows", viaAnnotation.size()); + DEMO.info("orm.xml-defined named query result: {} rows", viaXml.size()); + + assertThat(viaAnnotation).hasSize(1); + assertThat(viaXml).hasSize(1); + em.close(); + } + + @Test + void ormXmlNamedQuery_overridesTheSameNamedAnnotatedOne() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new XmlQueryEmployee("Whoever", 60_000)); + em.getTransaction().commit(); + + // The annotation on XmlQueryEmployee for "overridden" is deliberately wrong (salary < 0). + // If orm.xml wins, this returns 1 row; if the annotation wins, it returns 0. + List result = em.createNamedQuery("XmlQueryEmployee.overridden", XmlQueryEmployee.class) + .setParameter("min", 50_000.0) + .getResultList(); + + DEMO.info("XmlQueryEmployee.overridden (annotation says salary<0, orm.xml says salary>:min): {} rows", result.size()); + assertThat(result) + .as("orm.xml's definition must win over the annotation of the same name") + .hasSize(1); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/naturalid/CompositeNaturalIdTest.java b/src/test/java/com/ankurm/hibernatedemo/naturalid/CompositeNaturalIdTest.java new file mode 100755 index 0000000..76c49e1 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/naturalid/CompositeNaturalIdTest.java @@ -0,0 +1,88 @@ +package com.ankurm.hibernatedemo.naturalid; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Proves the article's composite-natural-id code sample actually resolves, and captures the + * real generated SQL: a two-column WHERE clause that includes the FK to Company, not a join + * on the natural id of Company itself. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class CompositeNaturalIdTest { + + @Autowired + private EntityManagerFactory emf; + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + @Test + void byNaturalId_compositeKey_usingTwoFields_resolvesCorrectRow() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Company acme = new Company("Acme"); + Company other = new Company("Other Co"); + em.persist(acme); + em.persist(other); + // Same deptCode ("ENG-01") at TWO different companies -- proves it's the (company, code) + // PAIR that is unique, not deptCode alone. + em.persist(new Department(acme, "ENG-01", "Acme Engineering")); + em.persist(new Department(other, "ENG-01", "Other Co Engineering")); + em.getTransaction().commit(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + Session session = em2.unwrap(Session.class); + stats().clear(); + + Department dept = session.byNaturalId(Department.class) + .using("company", acme) + .using("deptCode", "ENG-01") + .load(); + + long queries = stats().getPrepareStatementCount(); + em2.close(); + + System.out.println("RESULT[composite-naturalid]: byNaturalId(company=Acme, deptCode=ENG-01) resolved to '" + + dept.getName() + "' in " + queries + " query/queries"); + + assertThat(dept).isNotNull(); + assertThat(dept.getName()).isEqualTo("Acme Engineering"); + assertThat(queries).isEqualTo(1); + } + + @Test + void byNaturalId_compositeKey_capturesTheGeneratedSql() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Company acme = new Company("SQL-Capture Co"); + em.persist(acme); + em.persist(new Department(acme, "OPS-1", "Operations")); + em.getTransaction().commit(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + Session session = em2.unwrap(Session.class); + Department dept = session.byNaturalId(Department.class) + .using("company", acme) + .using("deptCode", "OPS-1") + .load(); + em2.close(); + assertThat(dept).isNotNull(); + // The actual SQL is captured in docs/output/naturalid-composite-sql.txt via show_sql + // logging on this test run -- see that file for the verbatim SELECT. + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsHashSetTest.java b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsHashSetTest.java new file mode 100755 index 0000000..745417e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsHashSetTest.java @@ -0,0 +1,84 @@ +package com.ankurm.hibernatedemo.naturalid; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Does the "base equals()/hashCode() on the natural id, not the surrogate id" advice from + * posts 4864/4865 actually hold up? Reproduces the SAME HashSet-before-flush scenario as + * {@code IdBasedEqualsHashSetTrapTest} (persistenceannotations package) but with natural-id-based + * equals/hashCode instead of surrogate-id-based -- this time contains() should stay TRUE. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class NaturalIdEqualsHashSetTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void naturalIdBasedEquals_addedToSetBeforeFlush_containsStaysTrueAfterPersist() { + Set set = new HashSet<>(); + NaturalIdEqualsEntity e = new NaturalIdEqualsEntity("SKU-EQ-1", "widget"); + + set.add(e); // sku is already set -- hashCode() is stable from construction + assertThat(set.contains(e)).isTrue(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(e); + em.getTransaction().commit(); // id gets assigned, but sku (and hence hashCode) never changes + em.close(); + + boolean stillFound = set.contains(e); + System.out.println("RESULT[naturalid-equals-hashset]: after persist(), e.getId()=" + e.getId() + + ", e.getSku()=" + e.getSku() + ", set.contains(e) = " + stillFound + + " (equals/hashCode based on the immutable natural id, NOT the surrogate id)"); + + assertThat(stillFound) + .as("natural-id-based equals/hashCode must survive persist() -- the advice holds up, unlike the surrogate-id version") + .isTrue(); + } + + @Test + void naturalIdBasedEquals_twoTransientInstancesWithDifferentSkus_areNotEqual() { + NaturalIdEqualsEntity a = new NaturalIdEqualsEntity("SKU-EQ-A", "Widget A"); + NaturalIdEqualsEntity b = new NaturalIdEqualsEntity("SKU-EQ-B", "Widget B"); + + // Unlike surrogate-id equals (where two transient instances are BOTH "equal" because + // both ids are null), natural-id equals correctly distinguishes them even before persist. + System.out.println("RESULT[naturalid-equals-transient]: a.equals(b) for two DIFFERENT transient instances = " + + a.equals(b) + " (both have null surrogate ids, but different natural ids)"); + assertThat(a).isNotEqualTo(b); + } + + @Test + void naturalIdBasedEquals_twoTransientInstancesWithSameSku_areEqual_evenBeforePersist() { + // This is the flip side worth knowing: TWO DIFFERENT transient objects representing + // the "same" business entity (same sku, not yet persisted) are already equal() to + // each other, well before either has a database identity. That is usually desirable, + // but it means a HashSet can silently dedupe two not-yet-persisted objects. + NaturalIdEqualsEntity a = new NaturalIdEqualsEntity("SKU-EQ-DUP", "First instance"); + NaturalIdEqualsEntity b = new NaturalIdEqualsEntity("SKU-EQ-DUP", "Second instance, different name"); + + Set set = new HashSet<>(); + set.add(a); + boolean bWasRejectedAsADuplicate = !set.add(b); + + System.out.println("RESULT[naturalid-equals-transient-dup]: a.equals(b)=" + a.equals(b) + + " for two transient instances sharing sku='SKU-EQ-DUP' but different names; " + + "HashSet.add(b) rejected it as a duplicate = " + bWasRejectedAsADuplicate); + + assertThat(a).isEqualTo(b); + assertThat(bWasRejectedAsADuplicate).isTrue(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL1CacheTest.java b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL1CacheTest.java new file mode 100755 index 0000000..d6d9032 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL1CacheTest.java @@ -0,0 +1,92 @@ +package com.ankurm.hibernatedemo.naturalid; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * THE central empirical question for post 4865: with NO second-level cache, does a + * bySimpleNaturalId lookup save a query on a second call in the SAME session? Counted with + * real Hibernate Statistics, not asserted from documentation. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class NaturalIdL1CacheTest { + + @Autowired + private EntityManagerFactory emf; + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + @Test + void bySimpleNaturalId_secondCallSameSession_noL2Cache_savesTheQuery() { + EntityManager seedEm = emf.createEntityManager(); + seedEm.getTransaction().begin(); + seedEm.persist(new NaturalIdProduct("SKU-L1-1", "Widget")); + seedEm.getTransaction().commit(); + seedEm.close(); + + EntityManager em = emf.createEntityManager(); + Session session = em.unwrap(Session.class); + stats().clear(); + + NaturalIdProduct first = session.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-1"); + long queriesAfterFirst = stats().getPrepareStatementCount(); + + NaturalIdProduct second = session.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-1"); + long queriesAfterSecond = stats().getPrepareStatementCount(); + + em.close(); + + System.out.println("RESULT[naturalid-l1-no-l2]: queries after 1st bySimpleNaturalId=" + queriesAfterFirst + + ", after 2nd (same session)=" + queriesAfterSecond + + " (no @NaturalIdCache, no L2 cache provider configured)"); + + assertThat(queriesAfterFirst).isEqualTo(1); + assertThat(queriesAfterSecond) + .as("second bySimpleNaturalId call in the SAME session must NOT re-fire a query -- L1 natural-id resolution cache") + .isEqualTo(1); + assertThat(second).isSameAs(first); + } + + @Test + void bySimpleNaturalId_newSession_noL2Cache_reFiresTheQuery() { + EntityManager seedEm = emf.createEntityManager(); + seedEm.getTransaction().begin(); + seedEm.persist(new NaturalIdProduct("SKU-L1-2", "Gadget")); + seedEm.getTransaction().commit(); + seedEm.close(); + + EntityManager em1 = emf.createEntityManager(); + Session session1 = em1.unwrap(Session.class); + stats().clear(); + session1.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-2"); + long queriesSession1 = stats().getPrepareStatementCount(); + em1.close(); + + EntityManager em2 = emf.createEntityManager(); + Session session2 = em2.unwrap(Session.class); + session2.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-2"); + long queriesAfterSession2 = stats().getPrepareStatementCount(); + em2.close(); + + System.out.println("RESULT[naturalid-cross-session-no-l2]: queries after session 1 lookup=" + queriesSession1 + + ", cumulative after a NEW session repeats the same lookup=" + queriesAfterSession2 + + " (no L2 cache -- the L1 natural-id map dies with the session)"); + + assertThat(queriesAfterSession2) + .as("without L2 cache, a brand-new session must re-fire the query -- the L1 mapping does not survive session close") + .isEqualTo(queriesSession1 + 1); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL2CacheTest.java b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL2CacheTest.java new file mode 100755 index 0000000..9779446 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL2CacheTest.java @@ -0,0 +1,160 @@ +package com.ankurm.hibernatedemo.naturalid; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The other half of the central empirical question: turn ON the natural-id resolution L2 + * cache (@NaturalIdCache + @Cache, backed by hibernate-jcache + ehcache) and count queries + * for a bySimpleNaturalId lookup from a SECOND, brand-new session -- something + * {@link NaturalIdL1CacheTest} proved fails (re-fires a query) without L2. + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +class NaturalIdL2CacheTest { + + private StandardServiceRegistry registry; + private SessionFactory sessionFactory; + + private void bootWithL2Cache() { + registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:naturalidl2;DB_CLOSE_DELAY=-1") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", "create") + .applySetting("hibernate.generate_statistics", "true") + .applySetting("hibernate.cache.use_second_level_cache", "true") + .applySetting("hibernate.cache.region.factory_class", "jcache") + .applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider") + .build(); + Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(CachedNaturalIdProduct.class) + .buildMetadata(); + sessionFactory = metadata.buildSessionFactory(); + } + + @AfterEach + void tearDown() { + if (sessionFactory != null) { + sessionFactory.close(); + } + if (registry != null) { + StandardServiceRegistryBuilder.destroy(registry); + } + } + + @Test + void bySimpleNaturalId_withNaturalIdCache_crossSession_savesTheQuery() { + bootWithL2Cache(); + Statistics stats = sessionFactory.getStatistics(); + + stats.clear(); + Long id; + try (Session seed = sessionFactory.openSession()) { + Transaction tx = seed.beginTransaction(); + CachedNaturalIdProduct p = new CachedNaturalIdProduct("SKU-L2-1", "Cached Widget"); + seed.persist(p); + tx.commit(); + id = p.getId(); + } + System.out.println("RESULT[naturalid-l2-cache]: immediately after persist()+commit(): queries=" + + stats.getPrepareStatementCount() + ", naturalId cache put=" + stats.getNaturalIdCachePutCount() + + ", naturalId cache hit=" + stats.getNaturalIdCacheHitCount() + + " -- @NaturalIdCache populates the L2 region on INSERT, before anyone ever looked it up."); + + stats.clear(); + + // Session 1: lookup right after insert. Given the surprise above, this is ALREADY a + // cache hit, not a cold DB miss -- there is no query left to save at this point. + try (Session s1 = sessionFactory.openSession()) { + CachedNaturalIdProduct p = s1.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-1"); + assertThat(p.getId()).isEqualTo(id); + } + long queriesAfterSession1 = stats.getPrepareStatementCount(); + + // Session 2: BRAND NEW session, same natural id. With NO L2 cache (see NaturalIdL1CacheTest) + // this re-fires a query. With @NaturalIdCache + L2 enabled, it should NOT. + try (Session s2 = sessionFactory.openSession()) { + CachedNaturalIdProduct p = s2.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-1"); + assertThat(p.getId()).isEqualTo(id); + } + long queriesAfterSession2 = stats.getPrepareStatementCount(); + long naturalIdCacheHitsAfterSession2 = stats.getNaturalIdCacheHitCount(); + + System.out.println("RESULT[naturalid-l2-cache]: session1 (post-insert) cumulative queries=" + queriesAfterSession1 + + " | session2 (new session, same natural id) cumulative queries=" + queriesAfterSession2 + + ", naturalId cache hits=" + naturalIdCacheHitsAfterSession2); + + // Surprise (see the persist()/commit() log line above): @NaturalIdCache populates the L2 + // region on INSERT itself, so even "session1" here is already a cache hit, not a cold + // DB miss -- there is no query left to save by the time we get here. + assertThat(queriesAfterSession1) + .as("both post-insert lookups resolve entirely from the L2 natural-id cache -- zero queries") + .isEqualTo(0); + assertThat(queriesAfterSession2) + .as("session2, a BRAND NEW session, must not fire any query either -- the L2 natural-id cache resolves it") + .isEqualTo(queriesAfterSession1); + assertThat(naturalIdCacheHitsAfterSession2) + .as("both lookups (session1 and session2) register as L2 natural-id cache hits") + .isEqualTo(2L); + } + + @Test + void bySimpleNaturalId_rowInsertedOutsideHibernate_firstLookupIsARealMiss_secondIsARealHit() { + bootWithL2Cache(); + Statistics stats = sessionFactory.getStatistics(); + + // Insert via raw JDBC, bypassing Hibernate entirely -- @NaturalIdCache never gets a + // chance to populate the L2 region at insert time, unlike the persist()-based test above. + try (Session s = sessionFactory.openSession()) { + s.doWork(connection -> { + try (var stmt = connection.createStatement()) { + stmt.execute("insert into CachedNaturalIdProduct (id, sku, name) " + + "values (999, 'SKU-L2-RAW', 'Raw Insert Widget')"); + } + connection.commit(); + }); + } + + stats.clear(); + + try (Session s1 = sessionFactory.openSession()) { + CachedNaturalIdProduct p = s1.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-RAW"); + assertThat(p.getName()).isEqualTo("Raw Insert Widget"); + } + long queriesAfterMiss = stats.getPrepareStatementCount(); + long missesAfterFirst = stats.getNaturalIdCacheMissCount(); + long putsAfterFirst = stats.getNaturalIdCachePutCount(); + + try (Session s2 = sessionFactory.openSession()) { + CachedNaturalIdProduct p = s2.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-RAW"); + assertThat(p.getName()).isEqualTo("Raw Insert Widget"); + } + long queriesAfterHit = stats.getPrepareStatementCount(); + long hitsAfterSecond = stats.getNaturalIdCacheHitCount(); + + System.out.println("RESULT[naturalid-l2-cache-real-miss-then-hit]: first lookup (genuine cold row) queries=" + + queriesAfterMiss + ", naturalId miss=" + missesAfterFirst + ", naturalId put=" + putsAfterFirst + + " | second lookup (new session) cumulative queries=" + queriesAfterHit + + ", naturalId hits=" + hitsAfterSecond); + + assertThat(queriesAfterMiss).as("the first lookup of a row Hibernate never cached must hit the DB").isEqualTo(1); + assertThat(missesAfterFirst).isEqualTo(1L); + assertThat(putsAfterFirst).isEqualTo(1L); + assertThat(queriesAfterHit) + .as("the second lookup, from a brand-new session, must be satisfied entirely from the L2 cache") + .isEqualTo(queriesAfterMiss); + assertThat(hitsAfterSecond).isEqualTo(1L); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdMutabilityTest.java b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdMutabilityTest.java new file mode 100755 index 0000000..3fe3357 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdMutabilityTest.java @@ -0,0 +1,111 @@ +package com.ankurm.hibernatedemo.naturalid; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.HibernateException; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * What actually happens if you mutate a {@code @NaturalId} field Hibernate believes is + * immutable (the default), versus one explicitly marked {@code mutable = true}? + * + *

Docs: docs/06-natural-ids.md, Topic 3. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class NaturalIdMutabilityTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void mutatingAnImmutableNaturalId_throwsOnFlush() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ImmutableNaturalIdEntity e = new ImmutableNaturalIdEntity("CODE-A"); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + Session session = em2.unwrap(Session.class); + session.getTransaction().begin(); + ImmutableNaturalIdEntity managed = session.find(ImmutableNaturalIdEntity.class, id); + managed.setCode("CODE-B"); // mutate the field Hibernate believes is immutable + + HibernateException ex = org.junit.jupiter.api.Assertions.assertThrows( + HibernateException.class, + () -> { + session.flush(); + }); + System.out.println("RESULT[naturalid-immutable-mutation]: flushing a changed IMMUTABLE natural id threw: " + + ex.getClass().getName() + ": " + ex.getMessage()); + session.getTransaction().rollback(); + em2.close(); + + assertThat(ex.getMessage()).containsIgnoringCase("immutable"); + } + + @Test + void mutatingAMutableNaturalId_flushesFine_andUpdatesTheColumn() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + MutableNaturalIdEntity e = new MutableNaturalIdEntity("CODE-X"); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + MutableNaturalIdEntity managed = em2.find(MutableNaturalIdEntity.class, id); + managed.setCode("CODE-Y"); + em2.getTransaction().commit(); // should NOT throw + em2.close(); + + EntityManager em3 = emf.createEntityManager(); + Object raw = em3.createNativeQuery( + "select code from mutable_natural_id_entity where id = " + id).getSingleResult(); + em3.close(); + + System.out.println("RESULT[naturalid-mutable-mutation]: flush succeeded, DB column now = " + raw); + assertThat(raw).isEqualTo("CODE-Y"); + } + + @Test + void mutatingAMutableNaturalId_staleLookupByOldValue_noLongerResolves() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + MutableNaturalIdEntity e = new MutableNaturalIdEntity("CODE-OLD"); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + MutableNaturalIdEntity managed = em2.find(MutableNaturalIdEntity.class, id); + managed.setCode("CODE-NEW"); + em2.getTransaction().commit(); + em2.close(); + + EntityManager em3 = emf.createEntityManager(); + Session session = em3.unwrap(Session.class); + MutableNaturalIdEntity byOld = session.bySimpleNaturalId(MutableNaturalIdEntity.class).load("CODE-OLD"); + MutableNaturalIdEntity byNew = session.bySimpleNaturalId(MutableNaturalIdEntity.class).load("CODE-NEW"); + em3.close(); + + System.out.println("RESULT[naturalid-mutable-stale-lookup]: byNaturalId(\"CODE-OLD\") = " + byOld + + ", byNaturalId(\"CODE-NEW\") = " + (byNew == null ? "null" : byNew.getId())); + + assertThat(byOld).isNull(); + assertThat(byNew).isNotNull(); + assertThat(byNew.getId()).isEqualTo(id); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java b/src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java new file mode 100644 index 0000000..0363f3e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java @@ -0,0 +1,251 @@ +package com.ankurm.hibernatedemo.pagination; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.TypedQuery; +import java.util.ArrayList; +import java.util.List; +import org.hibernate.ScrollMode; +import org.hibernate.ScrollableResults; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Proves chapter 23's pagination claims: {@code setFirstResult}/{@code setMaxResults} + * translating to the dialect's real LIMIT/OFFSET syntax, {@code ScrollableResults} with + * {@code ScrollMode.FORWARD_ONLY}, the in-memory-pagination fallback that a JOIN FETCH triggers + * (and its real warning code), keyset/seek pagination as the fix for deep offsets, and the + * total-count-query pattern for a "Page N of M" UI. + * + *

Docs: docs/23-pagination.md. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class PaginationTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void limitOffset_translatesToDialectSyntax() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= 5; i++) { + em.persist(new Article("LimitOffset-" + i, i)); + } + em.getTransaction().commit(); + em.clear(); + + TypedQuery

query = em.createQuery( + "select a from Article a where a.title like 'LimitOffset-%' order by a.sequence", + Article.class); + query.setFirstResult(2); + query.setMaxResults(2); + List
page = query.getResultList(); + em.close(); + + List titles = page.stream().map(Article::getTitle).toList(); + System.out.println("RESULT[pagination-limit-offset]: setFirstResult(2).setMaxResults(2) " + + "over 5 rows ordered by sequence -- page contents: " + titles + + " -- items 3 and 4 of 5, confirming the OFFSET skipped exactly 2 rows and the" + + " LIMIT capped the page at exactly 2."); + + assertThat(titles).containsExactly("LimitOffset-3", "LimitOffset-4"); + } + + @Test + void scrollableResults_forwardOnly_readsWithoutLoadingWholeListUpfront() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= 10; i++) { + em.persist(new Article("Scroll-" + i, i)); + } + em.getTransaction().commit(); + + Session session = em.unwrap(Session.class); + int seen = 0; + List firstThree = new ArrayList<>(); + try (ScrollableResults
results = session.createQuery( + "select a from Article a where a.title like 'Scroll-%' order by a.sequence", + Article.class) + .setReadOnly(true) + .scroll(ScrollMode.FORWARD_ONLY)) { + while (results.next()) { + Article a = results.get(); + seen++; + if (firstThree.size() < 3) { + firstThree.add(a.getTitle()); + } + } + } + em.close(); + + System.out.println("RESULT[pagination-scrollable-forward-only]: ScrollMode.FORWARD_ONLY " + + "walked all " + seen + " rows one at a time via results.next()/results.get() -- " + + "first three encountered: " + firstThree + + " -- no List
holding all 10 rows was ever built by this test's own" + + " code, unlike getResultList()."); + + assertThat(seen).isEqualTo(10); + assertThat(firstThree).containsExactly("Scroll-1", "Scroll-2", "Scroll-3"); + } + + @Test + void joinFetchOrderedByRoot_paginatesViaDerivedTable_noInMemoryFallback() { + // Ordering by a column on the ROOT entity: Hibernate 7.4.5.Final's query translator + // paginates the root ids first (a derived-table subquery with its own OFFSET/FETCH) and + // joins the fetched collection onto that already-paginated set of ids -- confirmed by + // reading the generated SQL below, not by assuming the older in-memory-fallback story + // still applies here. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= 3; i++) { + Article a = new Article("JoinFetch-" + i, i); + a.addComment(new Comment("first comment on " + i)); + a.addComment(new Comment("second comment on " + i)); + em.persist(a); + } + em.getTransaction().commit(); + em.clear(); + + TypedQuery
query = em.createQuery( + "select distinct a from Article a join fetch a.comments " + + "where a.title like 'JoinFetch-%' order by a.sequence", + Article.class); + query.setFirstResult(0); + query.setMaxResults(2); + List
page = query.getResultList(); + em.close(); + + System.out.println("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: " + page.size() + + " distinct articles."); + + assertThat(page).hasSize(2); + } + + @Test + void joinFetchOrderedByCollectionColumn_fallsBackToInMemoryPagination() { + // Ordering by a column that lives on the FETCHED COLLECTION itself: the "paginate the + // root ids first" trick above can't work when the sort key isn't a root-entity column, + // so this is the query shape that actually reproduces the classic in-memory-pagination + // fallback and its real warning code. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= 3; i++) { + Article a = new Article("JoinFetchOrder-" + i, i); + a.addComment(new Comment("first comment on " + i)); + a.addComment(new Comment("second comment on " + i)); + em.persist(a); + } + em.getTransaction().commit(); + em.clear(); + + TypedQuery
query = em.createQuery( + "select distinct a from Article a join fetch a.comments c " + + "where a.title like 'JoinFetchOrder-%' order by c.body", + Article.class); + query.setFirstResult(0); + query.setMaxResults(2); + List
page = query.getResultList(); + em.close(); + + System.out.println("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: " + page.size() + + " distinct articles, computed by loading the full joined result set into " + + "memory and paginating it there in application code."); + + assertThat(page).hasSize(2); + } + + @Test + void keysetPagination_avoidsOffsetEntirely() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= 20; i++) { + em.persist(new Article("Keyset-" + i, i)); + } + em.getTransaction().commit(); + em.clear(); + + // Page 1: no keyset yet, so a sentinel "everything greater than the smallest possible id". + List
firstPage = em.createQuery( + "select a from Article a where a.title like 'Keyset-%' and a.id > :lastId " + + "order by a.id asc", Article.class) + .setParameter("lastId", 0L) + .setMaxResults(5) + .getResultList(); + + Long lastIdOfFirstPage = firstPage.get(firstPage.size() - 1).getId(); + + // Page 2: WHERE id > -- no OFFSET at all, so this stays O(page size) no + // matter how deep into the result set the caller has paged. + List
secondPage = em.createQuery( + "select a from Article a where a.title like 'Keyset-%' and a.id > :lastId " + + "order by a.id asc", Article.class) + .setParameter("lastId", lastIdOfFirstPage) + .setMaxResults(5) + .getResultList(); + em.close(); + + List firstTitles = firstPage.stream().map(Article::getTitle).toList(); + List secondTitles = secondPage.stream().map(Article::getTitle).toList(); + System.out.println("RESULT[pagination-keyset-seek]: keyset page 1 (id > 0) -- " + + firstTitles + " | keyset page 2 (id > last id of page 1) -- " + secondTitles + + " -- 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."); + + assertThat(firstTitles).containsExactly( + "Keyset-1", "Keyset-2", "Keyset-3", "Keyset-4", "Keyset-5"); + assertThat(secondTitles).containsExactly( + "Keyset-6", "Keyset-7", "Keyset-8", "Keyset-9", "Keyset-10"); + } + + @Test + void totalCountQuery_forPageOfMPattern() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (int i = 1; i <= 7; i++) { + em.persist(new Article("CountPattern-" + i, i)); + } + em.getTransaction().commit(); + em.clear(); + + long totalMatching = em.createQuery( + "select count(a) from Article a where a.title like 'CountPattern-%'", Long.class) + .getSingleResult(); + int pageSize = 3; + long totalPages = (totalMatching + pageSize - 1) / pageSize; + + List
pageTwo = em.createQuery( + "select a from Article a where a.title like 'CountPattern-%' order by a.sequence", + Article.class) + .setFirstResult(pageSize) + .setMaxResults(pageSize) + .getResultList(); + em.close(); + + List pageTwoTitles = pageTwo.stream().map(Article::getTitle).toList(); + System.out.println("RESULT[pagination-total-count-pattern]: " + totalMatching + + " matching rows, page size " + pageSize + " -> " + totalPages + + " total pages ('Page 2 of " + totalPages + "') | page 2 contents: " + + pageTwoTitles + + " -- two separate queries (a COUNT and a LIMIT/OFFSET SELECT), not one query" + + " doing both."); + + assertThat(totalMatching).isEqualTo(7); + assertThat(totalPages).isEqualTo(3); + assertThat(pageTwoTitles).containsExactly("CountPattern-4", "CountPattern-5", "CountPattern-6"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/EnumOrdinalDefaultTest.java b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/EnumOrdinalDefaultTest.java new file mode 100755 index 0000000..561166f --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/EnumOrdinalDefaultTest.java @@ -0,0 +1,85 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.junit.jupiter.api.Test; + +/** + * Reproduces the real failure mode of the JPA default {@code @Enumerated} strategy (ORDINAL): + * persist a row with the V1 enum ordering (via one standalone SessionFactory that owns only + * {@link EnumDefaultOrdinalEntity}), then read the SAME row back through a second SessionFactory + * that only knows {@link EnumReorderedV2Entity} -- a status enum with a constant inserted before + * SHIPPED. Two separate SessionFactories against the same physical H2 database avoid Hibernate's + * "same table mapped twice in one persistence unit" schema-export guard, which a single Spring + * context would trip. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +class EnumOrdinalDefaultTest { + + private static final String DB_URL = "jdbc:h2:mem:enumordinal;DB_CLOSE_DELAY=-1"; + + private StandardServiceRegistry registry(String ddlAuto) { + return new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", DB_URL) + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.connection.password", "") + .applySetting("hibernate.hbm2ddl.auto", ddlAuto) + .build(); + } + + @Test + void ordinalDefault_reorderedEnum_silentlyReturnsWrongConstant_noException() { + StandardServiceRegistry writeRegistry = registry("create"); + Long id; + try (SessionFactory writeSf = new MetadataSources(writeRegistry) + .addAnnotatedClass(EnumDefaultOrdinalEntity.class) + .buildMetadata() + .buildSessionFactory()) { + try (Session s = writeSf.openSession()) { + Transaction tx = s.beginTransaction(); + EnumDefaultOrdinalEntity original = new EnumDefaultOrdinalEntity(EnumDefaultOrdinalEntity.OrderStatus.SHIPPED); + s.persist(original); + tx.commit(); + id = original.getId(); + } + // Prove the raw stored ordinal. + try (Session s = writeSf.openSession()) { + Number rawOrdinal = (Number) s.createNativeQuery( + "select status from enum_default_ordinal_entity where id = " + id) + .getSingleResult(); + assertThat(rawOrdinal.intValue()).isEqualTo(1); + System.out.println("RESULT[enum-ordinal-default]: stored ordinal for SHIPPED (V1 ordering) = " + rawOrdinal); + } + } finally { + StandardServiceRegistryBuilder.destroy(writeRegistry); + } + + // Second SessionFactory, same physical DB, "V2" reordered enum, table already exists (ddl-auto=none). + StandardServiceRegistry readRegistry = registry("none"); + try (SessionFactory readSf = new MetadataSources(readRegistry) + .addAnnotatedClass(EnumReorderedV2Entity.class) + .buildMetadata() + .buildSessionFactory()) { + try (Session s = readSf.openSession()) { + EnumReorderedV2Entity reread = s.get(EnumReorderedV2Entity.class, id); + System.out.println("RESULT[enum-ordinal-default]: same row re-read through V2 (PENDING_REVIEW inserted " + + "before SHIPPED) enum ordering = " + reread.getStatus() + + " -- no exception thrown, silently resolves to the WRONG constant."); + assertThat(reread.getStatus()) + .as("ordinal 1 now means PENDING_REVIEW in the V2 enum, not SHIPPED -- silent data corruption") + .isEqualTo(EnumReorderedV2Entity.ReorderedStatus.PENDING_REVIEW); + } + } finally { + StandardServiceRegistryBuilder.destroy(readRegistry); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsHashSetTrapTest.java b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsHashSetTrapTest.java new file mode 100755 index 0000000..05aca38 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsHashSetTrapTest.java @@ -0,0 +1,59 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * The concrete failure: an entity with id-based equals()/hashCode() is added to a HashSet + * BEFORE flush (id == null), then persisted (id gets assigned). The same reference, looked up + * in the SAME set, is no longer found -- because its hash code changed after insertion, so + * HashSet is now probing the wrong bucket. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class IdBasedEqualsHashSetTrapTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void idBasedEquals_addedToSetBeforeFlush_containsReturnsFalseAfterIdAssigned() { + Set set = new HashSet<>(); + IdBasedEqualsEntity e = new IdBasedEqualsEntity("widget"); + + set.add(e); // id is null at this point -- hashCode() = Objects.hash((Long) null) + assertThat(set.contains(e)).isTrue(); // trivially true right now + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(e); + em.getTransaction().commit(); // id gets assigned HERE -- hashCode() changes + em.close(); + + assertThat(e.getId()).isNotNull(); + + boolean stillFound = set.contains(e); + System.out.println("RESULT[id-based-equals-hashset-trap]: after persist(), e.getId()=" + e.getId() + + ", set.contains(e) = " + stillFound + " (same reference, same set, only the hash code changed)"); + + assertThat(stillFound) + .as("HashSet.contains() on the SAME reference must return false once the id-based hashCode changed after insertion") + .isFalse(); + + // Bonus: iterating the set and calling equals() manually still finds it (equals() itself is fine) -- + // it's specifically the bucket lookup (which trusts the now-stale cached hash) that is broken. + boolean foundByIteration = set.stream().anyMatch(x -> x.equals(e)); + System.out.println("RESULT[id-based-equals-hashset-trap]: manual iteration foundByIteration = " + foundByIteration + + " -- confirms equals() itself still works; it's HashSet's bucket indexing that is now wrong."); + assertThat(foundByIteration).isTrue(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/Jpa32NewFeaturesTest.java b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/Jpa32NewFeaturesTest.java new file mode 100755 index 0000000..0cc3258 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/Jpa32NewFeaturesTest.java @@ -0,0 +1,81 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.TypedQuery; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Three things new in Jakarta Persistence 3.2 that post 4864 predates: {@code @EnumeratedValue} + * (custom enum persisted representation), {@code getSingleResultOrNull()} on TypedQuery, and + * record types as JPQL constructor-expression targets. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class Jpa32NewFeaturesTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void enumeratedValue_persistsTheCustomCode_notOrdinalOrName() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + EnumeratedValueEntity e = new EnumeratedValueEntity(EnumeratedValueEntity.Priority.HIGH); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + Object raw = em2.createNativeQuery( + "select priority from enumerated_value_entity where id = " + id).getSingleResult(); + EnumeratedValueEntity reread = em2.find(EnumeratedValueEntity.class, id); + em2.close(); + + System.out.println("RESULT[jpa32-enumeratedvalue]: raw DB value for HIGH = '" + raw + + "' (neither ordinal '2' nor name 'HIGH' -- the @EnumeratedValue-annotated code 'H')"); + assertThat(raw).isEqualTo("H"); + assertThat(reread.getPriority()).isEqualTo(EnumeratedValueEntity.Priority.HIGH); + } + + @Test + void getSingleResultOrNull_newIn32_returnsNullInsteadOfThrowing() { + EntityManager em = emf.createEntityManager(); + TypedQuery q = em.createQuery( + "select e from EnumeratedValueEntity e where e.id = :id", EnumeratedValueEntity.class); + q.setParameter("id", -999L); + EnumeratedValueEntity result = q.getSingleResultOrNull(); + em.close(); + System.out.println("RESULT[jpa32-getsingleresultornull]: query matching zero rows via getSingleResultOrNull() = " + result + + " (getSingleResult() would have thrown NoResultException here)"); + assertThat(result).isNull(); + } + + @Test + void jpqlConstructorExpression_targetingARecord_worksIn725() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new EnumeratedValueEntity(EnumeratedValueEntity.Priority.HIGH)); + em.persist(new EnumeratedValueEntity(EnumeratedValueEntity.Priority.HIGH)); + em.persist(new EnumeratedValueEntity(EnumeratedValueEntity.Priority.LOW)); + em.getTransaction().commit(); + + List counts = em.createQuery( + "select new com.ankurm.hibernatedemo.persistenceannotations.PriorityCountView(e.priority, count(e)) " + + "from EnumeratedValueEntity e group by e.priority order by e.priority", + PriorityCountView.class).getResultList(); + em.close(); + + System.out.println("RESULT[jpa32-record-constructor-expression]: " + counts); + assertThat(counts).extracting(PriorityCountView::priority) + .contains(EnumeratedValueEntity.Priority.HIGH, EnumeratedValueEntity.Priority.LOW); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnOnH2Test.java b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnOnH2Test.java new file mode 100755 index 0000000..cdce311 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnOnH2Test.java @@ -0,0 +1,51 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Does {@code @JdbcTypeCode(SqlTypes.JSON)} actually work against H2 2.4.240? Honest empirical + * check -- if H2 rejects it, this test says so instead of asserting success. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class JsonColumnOnH2Test { + + @Autowired + private EntityManagerFactory emf; + + @Test + void jdbcTypeCodeJson_onH2_roundTripsAMap() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + JsonColumnEntity e = new JsonColumnEntity(Map.of("color", "red", "qty", 5)); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + JsonColumnEntity loaded = em2.find(JsonColumnEntity.class, id); + em2.close(); + + System.out.println("RESULT[jdbctypecode-json-h2]: persisted+loaded details=" + loaded.getDetails()); + assertThat(loaded.getDetails()).containsEntry("color", "red"); + + // Inspect the actual column type H2 chose for it. + EntityManager em3 = emf.createEntityManager(); + Object colType = em3.createNativeQuery( + "select data_type from information_schema.columns " + + "where table_name = 'JSON_COLUMN_ENTITY' and column_name = 'DETAILS'") + .getSingleResult(); + em3.close(); + System.out.println("RESULT[jdbctypecode-json-h2]: H2 column type for the JSON field = " + colType); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessTest.java b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessTest.java new file mode 100755 index 0000000..22914d8 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessTest.java @@ -0,0 +1,51 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * MixedAccessEntity has FIELD access by default (because @Id sits on a field), but one + * property ("computedLabel") is explicitly switched to PROPERTY access. This proves: + * (1) Hibernate really does call the getter -- not read a backing field -- to determine the + * persisted value for that one property, and (2) it calls it MORE than once per flush + * (dirty-check read + write), which matters if the getter has side effects or is expensive. + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class MixedAccessTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void propertyAccessAttribute_getterIsCalledByHibernate_multipleTimesPerFlush() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + MixedAccessEntity e = new MixedAccessEntity("widget"); + int callsBeforePersist = e.getGetterCallCount(); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + int callsAfterFlush = e.getGetterCallCount(); + em.close(); + + System.out.println("RESULT[mixed-access]: getComputedLabel() call count before persist=" + callsBeforePersist + + ", after commit/flush=" + callsAfterFlush + + " (PROPERTY-access attributes are read via the getter at flush time, not via a backing field)"); + assertThat(callsAfterFlush).isGreaterThan(callsBeforePersist); + + EntityManager em2 = emf.createEntityManager(); + Object storedLabel = em2.createNativeQuery( + "select computed_label from mixed_access_entity where id = " + id).getSingleResult(); + em2.close(); + System.out.println("RESULT[mixed-access]: DB column computed_label = " + storedLabel); + assertThat(storedLabel).isEqualTo("WIDGET"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnJavaTimeTest.java b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnJavaTimeTest.java new file mode 100755 index 0000000..8ef33ba --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnJavaTimeTest.java @@ -0,0 +1,45 @@ +package com.ankurm.hibernatedemo.persistenceannotations; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Empirical: does {@code @Temporal} on a {@code java.time.LocalDate} field boot cleanly in + * Hibernate 7.4.5.Final, despite the annotation itself being {@code @Deprecated(since="3.2")}? + * + *

Docs: docs/05-jpa-persistence-annotations.md, Topic 2. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class TemporalOnJavaTimeTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void temporalOnLocalDate_bootsAndPersistsFine_annotationIsSimplyIgnored() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + TemporalOnLocalDateEntity e = new TemporalOnLocalDateEntity(LocalDate.of(2026, 1, 15)); + em.persist(e); + em.getTransaction().commit(); + Long id = e.getId(); + em.close(); + + EntityManager em2 = emf.createEntityManager(); + TemporalOnLocalDateEntity loaded = em2.find(TemporalOnLocalDateEntity.class, id); + em2.close(); + + assertThat(loaded.getEventDate()).isEqualTo(LocalDate.of(2026, 1, 15)); + System.out.println("RESULT[temporal-on-localdate]: boot succeeded (not silent -- Hibernate logs " + + "HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] " + + "at boot time, WARN level, one line per annotated field); round-tripped eventDate=" + loaded.getEventDate() + + ". The mapping itself is unaffected -- LocalDate maps the same with or without @Temporal."); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureFailureModesTest.java b/src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureFailureModesTest.java new file mode 100755 index 0000000..0b97d98 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureFailureModesTest.java @@ -0,0 +1,297 @@ +package com.ankurm.hibernatedemo.procedure; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.ParameterMode; +import jakarta.persistence.StoredProcedureQuery; +import java.math.BigDecimal; +import javax.sql.DataSource; +import org.hibernate.Session; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs docs/08-stored-procedures.md, "Potential Pitfalls" section (merged posts 4867 + 4881) -- + * this is the highest-value part of the chapter: real, verbatim exceptions from real + * misconfigurations, not described-but-untested prose. + * + *

Own DataSource: {@code jdbc:hsqldb:mem:procdemo-failures}. + */ +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:hsqldb:mem:procdemo-failures;shutdown=true", + "spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver", + "spring.datasource.username=SA", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ProcedureFailureModesTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static volatile boolean schemaReady = false; + + @Autowired + private EntityManagerFactory emf; + + @Autowired + private DataSource dataSource; + + private void ensureSchema() throws Exception { + if (!schemaReady) { + synchronized (ProcedureFailureModesTest.class) { + if (!schemaReady) { + ProcedureSchemaSupport.createAll(dataSource); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new ProcEmployee(1, "Alice", new BigDecimal("50000.00"))); + em.persist(new ProcEmployee(2, "Bob", new BigDecimal("60000.00"))); + em.getTransaction().commit(); + em.close(); + schemaReady = true; + } + } + } + } + + /** + * Registering a parameter under a name the procedure does not actually have. + * + *

SURPRISE (verified, corrects an assumption): this does NOT fail, and does NOT bind a + * NULL. HSQLDB's JDBC driver calls procedures using positional {@code {call GET_TAX(?, ?)}} + * syntax -- the parameter NAME never crosses the wire to the database at all. Hibernate maps + * a {@code registerStoredProcedureParameter(String name, ...)} call to the Nth JDBC "?" + * purely by REGISTRATION ORDER; the {@code name} string is only a client-side label used + * later by {@code setParameter(name, ...)} / {@code getOutputParameterValue(name)}. So + * registering "employee_id" first (matching the procedure's real first parameter by + * POSITION) still binds correctly and returns the right tax value, even though "employee_id" + * is not a real parameter of GET_TAX. The article's advice to "match parameter order" is + * therefore the operative safety rule -- matching NAMES is cosmetic for drivers like this one. + */ + @Test + @Order(1) + void wrongParameterName_bindsPositionally_nameIsCosmeticOnHsqldb() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX"); + query.registerStoredProcedureParameter("employee_id", Integer.class, ParameterMode.IN); // WRONG: should be "emp_id" + query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT); + + Exception caught = null; + Object outValue = null; + try { + query.setParameter("employee_id", 1); + query.execute(); + outValue = query.getOutputParameterValue("tax_amount"); + } catch (Exception e) { + caught = e; + } + em.getTransaction().rollback(); + em.close(); + + DEMO.info("wrong parameter name 'employee_id' (procedure expects 'emp_id') threw: {}: {}", + caught == null ? "NOTHING -- bound positionally regardless of the name" : caught.getClass().getName(), + caught == null ? "tax_amount output = " + outValue : caught.getMessage()); + // The mislabeled parameter still binds to JDBC position 1 (registration order), which is + // GET_TAX's real first ("emp_id") slot -- so the call succeeds with the CORRECT result + // despite the wrong name. Proof that "name" is not validated against the database here. + assertThat(caught).isNull(); + assertThat((BigDecimal) outValue).isEqualByComparingTo("7500.00"); + } + + /** Registering parameters in the wrong positional order (swap IN and OUT slots). */ + @Test + @Order(2) + void wrongPositionalOrder_throwsOrMisbinds() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + // GET_TAX is really (1=emp_id IN, 2=tax_amount OUT); register them swapped. + StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX"); + query.registerStoredProcedureParameter(1, BigDecimal.class, ParameterMode.OUT); + query.registerStoredProcedureParameter(2, Integer.class, ParameterMode.IN); + + Exception caught = null; + try { + query.setParameter(2, 1); + query.execute(); + Object out = query.getOutputParameterValue(1); + DEMO.info("swapped positional registration did NOT throw; out param 1 = {}", out); + } catch (Exception e) { + caught = e; + } + em.getTransaction().rollback(); + em.close(); + + DEMO.info("swapped IN/OUT positional registration on GET_TAX threw: {}: {}", + caught == null ? "NOTHING (see logged value above)" : caught.getClass().getName(), + caught == null ? "" : caught.getMessage()); + } + + /** ParameterMode mismatch: registering the real IN parameter as OUT. */ + @Test + @Order(3) + void parameterModeMismatch_inRegisteredAsOut_throws() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX"); + query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.OUT); // WRONG mode + query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT); + + Exception caught = null; + try { + query.execute(); + } catch (Exception e) { + caught = e; + } + em.getTransaction().rollback(); + em.close(); + + DEMO.info("emp_id (really IN) registered as ParameterMode.OUT threw: {}: {}", + caught == null ? "NOTHING" : caught.getClass().getName(), + caught == null ? "" : caught.getMessage()); + assertThat(caught).isNotNull(); + } + + /** getResultList() called on a procedure that has NO result set and NO output parameters. */ + @Test + @Order(4) + void getResultListOnProcedureWithNoResultSet_throws() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createStoredProcedureQuery("NO_RESULT_SET_PROC"); + query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN); + query.setParameter("emp_id", 1); + query.execute(); + + IllegalStateException caught = Assertions.assertThrows(IllegalStateException.class, query::getResultList); + DEMO.info("getResultList() on a no-result-set procedure threw: {}: {}", + caught.getClass().getName(), caught.getMessage()); + em.getTransaction().commit(); + em.close(); + } + + /** Forgetting to call execute() before reading an output parameter. */ + @Test + @Order(5) + void forgettingExecute_beforeReadingOutputParameter_throws() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX"); + query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN); + query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT); + query.setParameter("emp_id", 1); + // Deliberately NOT calling query.execute() here. + + Exception caught = null; + Object value = null; + try { + value = query.getOutputParameterValue("tax_amount"); + } catch (Exception e) { + caught = e; + } + em.getTransaction().rollback(); + em.close(); + + DEMO.info("getOutputParameterValue() WITHOUT calling execute() first threw: {} (value={})", + caught == null ? "NOTHING -- getOutputParameterValue() triggered execution implicitly" : caught.getClass().getName(), + value); + // SURPRISE (verified): calling getOutputParameterValue() without an explicit execute() + // call first does NOT throw "forgot to call execute()". Hibernate's ProcedureCallImpl + // lazily triggers the JDBC execute() itself the first time output is requested. The + // "forgetting execute()" pitfall described in blog folklore is not reproducible against + // Hibernate 7.4.5's StoredProcedureQuery for OUT-parameter access. + assertThat(caught).isNull(); + assertThat((BigDecimal) value).isEqualByComparingTo("7500.00"); + } + + /** + * THE flush question: persist a row, do NOT flush, then call a stored procedure that counts + * rows in the same table over the SAME connection/transaction. Does the procedure see the + * unflushed row? + */ + @Test + @Order(6) + void unflushedPersist_isNotVisibleToStoredProcedure_untilFlushed() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + + // Baseline count before adding anything in this transaction. + StoredProcedureQuery baseline = em.createStoredProcedureQuery("COUNT_EMPLOYEES"); + baseline.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT); + baseline.execute(); + int before = (Integer) baseline.getOutputParameterValue("total"); + DEMO.info("COUNT_EMPLOYEES before persisting a new row = {}", before); + + // Persist a THIRD employee but deliberately do not flush. + em.persist(new ProcEmployee(3, "Carol", new BigDecimal("70000.00"))); + + StoredProcedureQuery afterPersist = em.createStoredProcedureQuery("COUNT_EMPLOYEES"); + afterPersist.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT); + afterPersist.execute(); + int afterUnflushed = (Integer) afterPersist.getOutputParameterValue("total"); + DEMO.info("COUNT_EMPLOYEES after persist() but WITHOUT an explicit flush() = {}", afterUnflushed); + + em.flush(); + StoredProcedureQuery afterFlush = em.createStoredProcedureQuery("COUNT_EMPLOYEES"); + afterFlush.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT); + afterFlush.execute(); + int afterExplicitFlush = (Integer) afterFlush.getOutputParameterValue("total"); + DEMO.info("COUNT_EMPLOYEES after an explicit flush() = {}", afterExplicitFlush); + + em.getTransaction().rollback(); + em.close(); + + assertThat(afterUnflushed).isEqualTo(before); + assertThat(afterExplicitFlush).isEqualTo(before + 1); + } + + /** + * CORRECTION to a common assumption: for HQL/native queries, registering + * addSynchronizedEntityClass(...)/addSynchronizedQuerySpace(...) is documented to trigger an + * auto-flush of pending changes to the synchronized table before the query runs. Stored + * procedures are DIFFERENT: {@link org.hibernate.procedure.ProcedureCall} exposes the same + * addSynchronizedEntityClass(...) method (inherited from SynchronizeableQuery), but calling + * it here does NOT cause an auto-flush before the CallableStatement executes. Verified below: + * an unflushed persist() is still invisible to the procedure even after declaring the + * synchronization. + */ + @Test + @Order(7) + void procedureCallSynchronizedEntityClass_doesNotAutoFlush() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + Session session = em.unwrap(Session.class); + em.getTransaction().begin(); + + em.persist(new ProcEmployee(4, "Dave", new BigDecimal("80000.00"))); // NOT flushed explicitly + + org.hibernate.procedure.ProcedureCall call = session.createStoredProcedureCall("COUNT_EMPLOYEES"); + call.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT); + call.addSynchronizedEntityClass(ProcEmployee.class); // documented to auto-flush for HQL/native -- does NOT for procedures + call.execute(); + int total = (Integer) call.getOutputParameterValue("total"); + DEMO.info("ProcedureCall with addSynchronizedEntityClass(ProcEmployee.class), unflushed Dave NOT counted: " + + "COUNT_EMPLOYEES = {} (still just Alice+Bob -- addSynchronizedEntityClass had NO auto-flush effect here)", total); + + em.getTransaction().rollback(); + em.close(); + + // Alice + Bob (seeded) only -- Dave was never flushed, and addSynchronizedEntityClass did + // not force it. Carol from the earlier test was rolled back, so she is not present either. + assertThat(total).isEqualTo(2); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureSchemaSupport.java b/src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureSchemaSupport.java new file mode 100755 index 0000000..7153500 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/procedure/ProcedureSchemaSupport.java @@ -0,0 +1,96 @@ +package com.ankurm.hibernatedemo.procedure; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import javax.sql.DataSource; + +/** + * Creates the real HSQLDB 2.7.3 SQL/PSM stored procedures used by every test in this package. + * Hibernate's {@code ddl-auto} only creates the entity table; it has no concept of stored + * procedures, so they are created directly over JDBC once per test-class DataSource. + * + *

Idempotent: {@code DROP PROCEDURE} first (ignoring failure), so re-running against a + * context that Spring's test cache kept warm from a previous run doesn't fail with + * "already exists". + */ +final class ProcedureSchemaSupport { + + private ProcedureSchemaSupport() {} + + static void createAll(DataSource ds) throws SQLException { + try (Connection c = ds.getConnection(); Statement st = c.createStatement()) { + drop(st, "GET_TAX"); + drop(st, "ADJUST_SALARY"); + drop(st, "LIST_EMPLOYEES"); + drop(st, "LIST_EMPLOYEE_NAMES"); + drop(st, "COUNT_EMPLOYEES"); + drop(st, "NO_RESULT_SET_PROC"); + + st.execute(""" + CREATE PROCEDURE GET_TAX(IN emp_id INT, OUT tax_amount DECIMAL(10,2)) + READS SQL DATA + BEGIN ATOMIC + SELECT salary * 0.15 INTO tax_amount FROM PROC_EMPLOYEES WHERE id = emp_id; + END + """); + + st.execute(""" + CREATE PROCEDURE ADJUST_SALARY(INOUT sal DECIMAL(10,2), IN bonus_pct DECIMAL(5,2)) + MODIFIES SQL DATA + BEGIN ATOMIC + SET sal = sal * (1 + bonus_pct / 100); + END + """); + + st.execute(""" + CREATE PROCEDURE LIST_EMPLOYEES() + READS SQL DATA DYNAMIC RESULT SETS 1 + BEGIN ATOMIC + DECLARE result CURSOR WITH RETURN FOR + SELECT id, name, salary FROM PROC_EMPLOYEES ORDER BY id; + OPEN result; + END + """); + + st.execute(""" + CREATE PROCEDURE LIST_EMPLOYEE_NAMES() + READS SQL DATA DYNAMIC RESULT SETS 1 + BEGIN ATOMIC + DECLARE result CURSOR WITH RETURN FOR + SELECT id, name FROM PROC_EMPLOYEES ORDER BY id; + OPEN result; + END + """); + + // Deliberately has NO result set and NO out parameter -- used for the + // "getResultList() on a procedure with no result set" and "forgetting execute()" + // failure-mode tests. + st.execute(""" + CREATE PROCEDURE NO_RESULT_SET_PROC(IN emp_id INT) + MODIFIES SQL DATA + BEGIN ATOMIC + UPDATE PROC_EMPLOYEES SET name = name WHERE id = emp_id; + END + """); + + // Used for the flush-visibility trap: does a procedure called mid-transaction see a + // persisted-but-not-yet-flushed row? + st.execute(""" + CREATE PROCEDURE COUNT_EMPLOYEES(OUT total INT) + READS SQL DATA + BEGIN ATOMIC + SELECT COUNT(*) INTO total FROM PROC_EMPLOYEES; + END + """); + } + } + + private static void drop(Statement st, String name) throws SQLException { + try { + st.execute("DROP PROCEDURE " + name); + } catch (SQLException ignored) { + // did not exist yet + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/procedure/StoredProcedureHappyPathTest.java b/src/test/java/com/ankurm/hibernatedemo/procedure/StoredProcedureHappyPathTest.java new file mode 100755 index 0000000..3ad874c --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/procedure/StoredProcedureHappyPathTest.java @@ -0,0 +1,204 @@ +package com.ankurm.hibernatedemo.procedure; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.ParameterMode; +import jakarta.persistence.StoredProcedureQuery; +import java.math.BigDecimal; +import java.util.List; +import javax.sql.DataSource; +import org.hibernate.Session; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +/** + * Backs docs/08-stored-procedures.md (merged posts 4867 + 4881). Runs REAL SQL/PSM stored procedures + * on HSQLDB 2.7.3 -- see {@link ProcedureSchemaSupport} for the DDL -- and calls them through + * every API surface the two source articles described but never executed: + * {@code @NamedStoredProcedureQuery}, {@code EntityManager.createStoredProcedureQuery(...)}, + * and {@code Session.createStoredProcedureQuery(...)}. + * + *

Own DataSource: {@code jdbc:hsqldb:mem:procdemo-happy} (HSQLDB, not H2 -- H2 does not + * support IN/OUT/INOUT stored procedures with SQL/PSM bodies the way HSQLDB does). + */ +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:hsqldb:mem:procdemo-happy;shutdown=true", + "spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver", + "spring.datasource.username=SA", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class StoredProcedureHappyPathTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static volatile boolean schemaReady = false; + + @Autowired + private EntityManagerFactory emf; + + @Autowired + private DataSource dataSource; + + @BeforeAll + static void resetFlag() { + schemaReady = false; + } + + private void ensureSchema() throws Exception { + if (!schemaReady) { + synchronized (StoredProcedureHappyPathTest.class) { + if (!schemaReady) { + ProcedureSchemaSupport.createAll(dataSource); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new ProcEmployee(1, "Alice", new BigDecimal("50000.00"))); + em.persist(new ProcEmployee(2, "Bob", new BigDecimal("60000.00"))); + em.getTransaction().commit(); + em.close(); + schemaReady = true; + } + } + } + } + + @Test + @Order(1) + void namedStoredProcedureQuery_viaEntityManager_returnsRealOutParameter() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createNamedStoredProcedureQuery("ProcEmployee.getTax"); + query.setParameter("emp_id", 1); + boolean hadResultSet = query.execute(); + BigDecimal tax = (BigDecimal) query.getOutputParameterValue("tax_amount"); + em.getTransaction().commit(); + em.close(); + + DEMO.info("@NamedStoredProcedureQuery ProcEmployee.getTax(emp_id=1) execute()={} tax_amount={}", hadResultSet, tax); + assertThat(tax).isEqualByComparingTo("7500.00"); + } + + @Test + @Order(2) + void unnamedStoredProcedureQuery_viaEntityManagerCreateStoredProcedureQuery() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX"); + query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN); + query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT); + query.setParameter("emp_id", 2); + query.execute(); + BigDecimal tax = (BigDecimal) query.getOutputParameterValue("tax_amount"); + em.getTransaction().commit(); + em.close(); + + DEMO.info("EntityManager.createStoredProcedureQuery(\"GET_TAX\") for emp 2, tax_amount={}", tax); + assertThat(tax).isEqualByComparingTo("9000.00"); + } + + @Test + @Order(3) + void unnamedStoredProcedureQuery_viaHibernateSession() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + Session session = em.unwrap(Session.class); + em.getTransaction().begin(); + StoredProcedureQuery query = session.createStoredProcedureQuery("GET_TAX"); + query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN); + query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT); + query.setParameter("emp_id", 1); + query.execute(); + BigDecimal tax = (BigDecimal) query.getOutputParameterValue("tax_amount"); + em.getTransaction().commit(); + em.close(); + + DEMO.info("Session.createStoredProcedureQuery(\"GET_TAX\") for emp 1, tax_amount={}", tax); + assertThat(tax).isEqualByComparingTo("7500.00"); + } + + @Test + @Order(4) + void inoutParameter_roundTripsThroughSameParameterSlot() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createStoredProcedureQuery("ADJUST_SALARY"); + query.registerStoredProcedureParameter("sal", BigDecimal.class, ParameterMode.INOUT); + query.registerStoredProcedureParameter("bonus_pct", BigDecimal.class, ParameterMode.IN); + query.setParameter("sal", new BigDecimal("1000.00")); + query.setParameter("bonus_pct", new BigDecimal("10.00")); + query.execute(); + Object result = query.getOutputParameterValue("sal"); + em.getTransaction().commit(); + em.close(); + + DEMO.info("INOUT parameter 'sal' after ADJUST_SALARY(1000.00, 10.00) = {}", result); + assertThat((BigDecimal) result).isEqualByComparingTo("1100.00"); + } + + /** + * DOCUMENTED FAILURE, not a bug in this test: Hibernate's {@code ProcedureCallImpl} decides + * whether a stored procedure produced a result set by trusting the boolean returned from + * JDBC {@code CallableStatement.execute()}. A raw-JDBC probe (outside Hibernate, see + * docs/output/procedure-hsqldb-jdbc-driver-quirk.txt) shows HSQLDB 2.7.3's driver returns + * {@code false} from {@code execute()} for a {@code DYNAMIC RESULT SETS} procedure even + * though {@code getResultSet()} (and {@code executeQuery()}) DOES return a populated + * {@code ResultSet}. Hibernate believes the (wrong) {@code false} and never attaches a + * {@code ResultSetOutput}, so {@code getResultList()} throws. This blocks "result set mapped + * to an entity" and "result set mapped to a DTO via @SqlResultSetMapping" through Hibernate's + * stored-procedure API specifically on HSQLDB + Hibernate 7.4.5 -- it is not something + * fixable from application code. A database whose JDBC driver reports {@code execute()} + * correctly (PostgreSQL's REF_CURSOR, or MySQL's direct result sets) would not hit this. + */ + @Test + @Order(5) + void resultSetProcedure_mappedToEntity_hitsHsqldbDriverIncompatibility() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createNamedStoredProcedureQuery("ProcEmployee.listAll"); + boolean hadResultSet = query.execute(); + DEMO.info("ProcEmployee.listAll execute() returned {} (HSQLDB misreports this as false)", hadResultSet); + + IllegalStateException caught = org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, query::getResultList); + DEMO.info("getResultList() on the (mis-reported) result-set procedure threw: {}: {}", + caught.getClass().getName(), caught.getMessage()); + em.getTransaction().rollback(); + em.close(); + + assertThat(caught.getMessage()).contains("was not a ResultSet"); + } + + /** Same HSQLDB-driver incompatibility as above, this time through @SqlResultSetMapping to a DTO. */ + @Test + @Order(6) + void resultSetProcedure_mappedToDto_alsoHitsHsqldbDriverIncompatibility() throws Exception { + ensureSchema(); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + StoredProcedureQuery query = em.createStoredProcedureQuery("LIST_EMPLOYEE_NAMES", "EmployeeSummaryMapping"); + query.execute(); + + IllegalStateException caught = org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, query::getResultList); + DEMO.info("DTO-mapped getResultList() threw: {}: {}", caught.getClass().getName(), caught.getMessage()); + em.getTransaction().rollback(); + em.close(); + + assertThat(caught.getMessage()).contains("was not a ResultSet"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/EntityGraphFetchTest.java b/src/test/java/com/ankurm/hibernatedemo/proxy/EntityGraphFetchTest.java new file mode 100755 index 0000000..cff3197 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/EntityGraphFetchTest.java @@ -0,0 +1,131 @@ +package com.ankurm.hibernatedemo.proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.Map; +import org.hibernate.Hibernate; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs docs/11-proxies-and-lazy-initialization.md, chapter "fetchgraph vs loadgraph, actually demonstrated". + * + *

{@link ProxyBook} has two associations: {@code publisher} (default fetch type, EAGER) and + * {@code reviews} (explicit LAZY). The named graph {@code Book.reviews-only} names only + * {@code reviews}. The two JPA hint keys treat the association NOT named in the graph + * differently: + * + *

    + *
  • {@code jakarta.persistence.loadgraph} -- attributes not in the graph keep their + * mapped fetch type. {@code publisher} stays EAGER and gets joined anyway.
  • + *
  • {@code jakarta.persistence.fetchgraph} -- the graph IS the complete fetch plan. + * Any attribute not in it is forced to LAZY, so {@code publisher} does NOT get joined, + * regardless of its mapped EAGER fetch type.
  • + *
+ */ +@SpringBootTest +class EntityGraphFetchTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + private Long seedBookWithReviewAndPublisher() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ProxyPublisher publisher = new ProxyPublisher("Graph Press"); + em.persist(publisher); + ProxyBook book = new ProxyBook("Graph Book", publisher); + em.persist(book); + ProxyReview review = new ProxyReview("Nice graph", book); + em.persist(review); + em.getTransaction().commit(); + Long id = book.getId(); + em.close(); + return id; + } + + @Test + void loadgraph_joinsNamedAttributeAndKeepsMappedEagerAttributeJoinedToo() { + Long id = seedBookWithReviewAndPublisher(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + EntityGraph graph = em.getEntityGraph("Book.reviews-only"); + + stats().clear(); + ProxyBook book = em.find(ProxyBook.class, id, Map.of("jakarta.persistence.loadgraph", graph)); + + // One SELECT total: publisher (mapped EAGER, loadgraph does not touch it) and + // reviews (named in the graph) are both fetched without a second round-trip. + assertThat(stats().getPrepareStatementCount()) + .as("loadgraph must fetch the named attribute AND keep the mapped-EAGER one, in one query") + .isEqualTo(1); + assertThat(Hibernate.isInitialized(book.getPublisher())) + .as("loadgraph does not demote attributes outside the graph -- publisher is still EAGER") + .isTrue(); + assertThat(Hibernate.isInitialized(book.getReviews())).isTrue(); + + em.getTransaction().commit(); + em.close(); + } + + @Test + void fetchgraph_joinsOnlyNamedAttributeAndForcesMappedEagerAttributeToLazy() { + Long id = seedBookWithReviewAndPublisher(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + EntityGraph graph = em.getEntityGraph("Book.reviews-only"); + + stats().clear(); + ProxyBook book = em.find(ProxyBook.class, id, Map.of("jakarta.persistence.fetchgraph", graph)); + + assertThat(stats().getPrepareStatementCount()) + .as("fetchgraph's single SELECT still includes the join for the named attribute") + .isEqualTo(1); + assertThat(Hibernate.isInitialized(book.getReviews())).isTrue(); + assertThat(Hibernate.isInitialized(book.getPublisher())) + .as("fetchgraph treats the graph as the WHOLE fetch plan -- publisher is forced back to LAZY " + + "even though its mapping says EAGER") + .isFalse(); + DEMO.info("fetchgraph: book.getPublisher() runtime class = {}", book.getPublisher().getClass().getName()); + + em.getTransaction().commit(); + em.close(); + } + + @Test + void noGraphAtAll_defaultFindStillJoinsTheMappedEagerAssociationButLeavesLazyCollectionAlone() { + Long id = seedBookWithReviewAndPublisher(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + stats().clear(); + ProxyBook book = em.find(ProxyBook.class, id); + + assertThat(stats().getPrepareStatementCount()).isEqualTo(1); + assertThat(Hibernate.isInitialized(book.getPublisher())) + .as("plain find() honours the mapping: publisher is EAGER by default") + .isTrue(); + assertThat(Hibernate.isInitialized(book.getReviews())) + .as("plain find() honours the mapping: reviews is LAZY by default") + .isFalse(); + + em.getTransaction().commit(); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/LazyInitializationTest.java b/src/test/java/com/ankurm/hibernatedemo/proxy/LazyInitializationTest.java new file mode 100755 index 0000000..4c24306 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/LazyInitializationTest.java @@ -0,0 +1,147 @@ +package com.ankurm.hibernatedemo.proxy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.Hibernate; +import org.hibernate.LazyInitializationException; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs docs/11-proxies-and-lazy-initialization.md (post 4870 rewrite), chapter "The exception, verbatim". + * + *

Reproduces {@code org.hibernate.LazyInitializationException} for real against Hibernate + * 7.4.5.Final and captures the exact message and package, instead of restating the docs. + */ +@SpringBootTest +class LazyInitializationTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + private Long seedBookWithReview(String title) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ProxyPublisher publisher = new ProxyPublisher("O'Reilly"); + em.persist(publisher); + ProxyBook book = new ProxyBook(title, publisher); + em.persist(book); + ProxyReview review = new ProxyReview("Great book", book); + em.persist(review); + book.getReviews().add(review); + em.getTransaction().commit(); + Long id = book.getId(); + em.close(); + return id; + } + + @Test + void accessingLazyCollectionAfterSessionClose_throwsLazyInitializationException() { + Long id = seedBookWithReview("Effective Hibernate"); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ProxyBook book = em.find(ProxyBook.class, id); + assertThat(Hibernate.isInitialized(book.getReviews())) + .as("a freshly-found entity's LAZY collection must not be initialized yet") + .isFalse(); + em.getTransaction().commit(); + em.close(); + + // The persistence context is gone. Touching the collection now must fail, not + // silently open a new connection. + LazyInitializationException ex = assertThrows( + LazyInitializationException.class, + () -> book.getReviews().size()); + + DEMO.info("verbatim exception class: {}", ex.getClass().getName()); + DEMO.info("verbatim exception message: {}", ex.getMessage()); + + assertThat(ex.getClass().getName()).isEqualTo("org.hibernate.LazyInitializationException"); + // NOTE: this is the COLLECTION-flavoured message. It is NOT "could not initialize + // proxy - no Session" -- that wording belongs to a to-one association proxy, exercised + // separately below. Blog posts (including the one this test corrects) routinely quote + // the proxy message for a collection access; they are two different message templates. + assertThat(ex.getMessage()).contains("Cannot lazily initialize collection of role"); + assertThat(ex.getMessage()).contains("(no session)"); + } + + @Test + void accessingToOneProxyAfterSessionClose_throwsTheOtherLazyInitializationMessage() { + Long id = seedBookWithReview("To-One Proxy Message"); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + ProxyBook proxy = session.getReference(ProxyBook.class, id); + em.getTransaction().commit(); + em.close(); + + LazyInitializationException ex = assertThrows(LazyInitializationException.class, proxy::getTitle); + DEMO.info("to-one proxy verbatim message: {}", ex.getMessage()); + // Verbatim in 7.4.5.Final: "Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#4] - no session" + // Capital "Could", lower-case "no session" -- NOT the commonly-quoted + // "could not initialize proxy - no Session" (capital Session). Both this article's + // sources and long-standing folklore get the capitalization backwards. + assertThat(ex.getMessage()).contains("Could not initialize proxy"); + assertThat(ex.getMessage()).contains("no session"); + } + + @Test + void hibernateInitialize_forcesLoadInsideTransaction_thenIsInitializedFlips() { + Long id = seedBookWithReview("Initialize Me"); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ProxyBook book = em.find(ProxyBook.class, id); + + assertThat(Hibernate.isInitialized(book.getReviews())).isFalse(); + Hibernate.initialize(book.getReviews()); + assertThat(Hibernate.isInitialized(book.getReviews())).isTrue(); + + em.getTransaction().commit(); + em.close(); + + // Now that it was initialized while the session was open, reading it after close + // works -- no exception, because there is nothing left to lazily fetch. + assertThat(book.getReviews()).hasSize(1); + assertThat(book.getReviews().get(0).getComment()).isEqualTo("Great book"); + } + + @Test + void hibernateUnproxy_onManyToOneReference_returnsRealInstance_notTheProxySubclass() { + Long id = seedBookWithReview("Unproxy Me"); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + + ProxyBook proxy = session.getReference(ProxyBook.class, id); + DEMO.info("getReference() proxy class: {}", proxy.getClass().getName()); + assertThat(proxy.getClass()).isNotEqualTo(ProxyBook.class); + assertThat(Hibernate.isInitialized(proxy)).isFalse(); + + Object unproxied = Hibernate.unproxy(proxy); + DEMO.info("Hibernate.unproxy(proxy) class: {}", unproxied.getClass().getName()); + assertThat(unproxied.getClass()).isEqualTo(ProxyBook.class); + assertThat(Hibernate.isInitialized(proxy)) + .as("Hibernate.unproxy() initializes the proxy as a side effect") + .isTrue(); + + // Two-arg overload: Hibernate.unproxy(T, Class) + ProxyBook typed = Hibernate.unproxy(proxy, ProxyBook.class); + assertThat(typed.getClass()).isEqualTo(ProxyBook.class); + + em.getTransaction().commit(); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/OpenInViewAndLazyLoadNoTransTest.java b/src/test/java/com/ankurm/hibernatedemo/proxy/OpenInViewAndLazyLoadNoTransTest.java new file mode 100755 index 0000000..eee0651 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/OpenInViewAndLazyLoadNoTransTest.java @@ -0,0 +1,65 @@ +package com.ankurm.hibernatedemo.proxy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.LazyInitializationException; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Backs docs/11-proxies-and-lazy-initialization.md, chapter "hibernate.enable_lazy_load_no_trans: does it still exist?". + * + *

{@code org.hibernate.cfg.TransactionSettings.ENABLE_LAZY_LOAD_NO_TRANS} still resolves to + * the constant string {@code hibernate.enable_lazy_load_no_trans} in Hibernate 7.4.5.Final -- + * confirmed by {@code javap} on the jar (see docs/output/proxy-settings-javap.txt). It is NOT + * removed. It IS annotated {@code @org.hibernate.cfg.Unsafe}, a marker interface with no members + * that Hibernate's own codebase uses to flag settings the team does not want you reaching for. + * This test proves the setting still functionally does something in 7.4.5: with it enabled, a + * lazy proxy can be touched after its own originating transaction/session has ended, because + * Hibernate opens a temporary session behind the scenes to service exactly that one lazy load. + */ +@SpringBootTest +@TestPropertySource(properties = { + "spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true" +}) +class OpenInViewAndLazyLoadNoTransTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @Test + void withEnableLazyLoadNoTrans_toOneProxyInitializesAfterSessionCloses_viaTemporarySession() { + EntityManager seedEm = emf.createEntityManager(); + seedEm.getTransaction().begin(); + ProxyPublisher publisher = new ProxyPublisher("No-Trans Press"); + seedEm.persist(publisher); + ProxyBook book = new ProxyBook("No-Trans Book", publisher); + seedEm.persist(book); + seedEm.getTransaction().commit(); + Long id = book.getId(); + seedEm.close(); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + ProxyBook proxy = session.getReference(ProxyBook.class, id); + em.getTransaction().commit(); + em.close(); + + // No exception here: with enable_lazy_load_no_trans=true, Hibernate 7.4.5 opens a + // temporary session to service this access instead of throwing. + String title = proxy.getTitle(); + DEMO.info("enable_lazy_load_no_trans=true: proxy.getTitle() after close returned '{}' with no exception", title); + assertThat(title).isEqualTo("No-Trans Book"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookController.java b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookController.java new file mode 100755 index 0000000..045a708 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookController.java @@ -0,0 +1,27 @@ +package com.ankurm.hibernatedemo.proxy; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * Test-only support for docs/11-proxies-and-lazy-initialization.md's OSIV chapter (OsivMaskingTest). Returns the + * managed entity directly (not a DTO) ON PURPOSE -- Jackson's serialization of + * {@code getReviews()} on the response-writing thread is the exact moment open-in-view either + * saves you (session still bound, proxy initializes) or doesn't (session long closed, + * LazyInitializationException). + */ +@RestController +public class OsivBookController { + + private final OsivBookService service; + + public OsivBookController(OsivBookService service) { + this.service = service; + } + + @GetMapping("/osiv/books/{id}") + public ProxyBook getBook(@PathVariable Long id) { + return service.findBook(id); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookService.java b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookService.java new file mode 100755 index 0000000..7399e19 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookService.java @@ -0,0 +1,27 @@ +package com.ankurm.hibernatedemo.proxy; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Test-only support for docs/11-proxies-and-lazy-initialization.md's OSIV chapter (OsivMaskingTest). Deliberately + * uses the container-managed, request/transaction-synchronized {@link EntityManager} (via + * {@code @PersistenceContext}), not a manually created one -- open-in-view only has anything + * to bind to the request thread when the shared, Spring-managed EntityManager is the one in + * play. + */ +@Service +public class OsivBookService { + + @PersistenceContext + private EntityManager entityManager; + + @Transactional(readOnly = true) + public ProxyBook findBook(Long id) { + // Deliberately does NOT touch getReviews() here -- whether that succeeds later, during + // JSON serialization in the web layer, is exactly the thing under test. + return entityManager.find(ProxyBook.class, id); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/OsivDefaultWarningTest.java b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivDefaultWarningTest.java new file mode 100755 index 0000000..888a46a --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivDefaultWarningTest.java @@ -0,0 +1,89 @@ +package com.ankurm.hibernatedemo.proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.test.web.server.LocalServerPort; + +/** + * Backs docs/11-proxies-and-lazy-initialization.md, chapter "OSIV: the default, its warning, and what it actually + * masks". Deliberately does NOT set {@code spring.jpa.open-in-view} -- Boot's own default + * (true) is what is under test here, including the startup warning Boot prints because you + * didn't set it explicitly. + * + *

Needs a real servlet request (see {@link OsivBookController}, {@link OsivBookService}): + * open-in-view is implemented by {@code OpenEntityManagerInViewInterceptor}, which only exists + * for actual web applications -- the base app here runs with + * {@code spring.main.web-application-type=none}, so this test overrides that to {@code servlet} + * and spins up a random-port embedded Tomcat just for itself. + */ +@ExtendWith(OutputCaptureExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + // A dedicated config file that has NO spring.jpa.open-in-view key at all -- the shared + // src/main/resources/application.yml sets it to false explicitly, which would hide + // both the warning and the masking behaviour under test here. + "spring.config.name=osiv-default-test" +}) +class OsivDefaultWarningTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @LocalServerPort + private int port; + + private Long seedBookWithReview() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ProxyPublisher publisher = new ProxyPublisher("OSIV Default Press"); + em.persist(publisher); + ProxyBook book = new ProxyBook("OSIV Default Book", publisher); + em.persist(book); + ProxyReview review = new ProxyReview("Rendered fine", book); + em.persist(review); + em.getTransaction().commit(); + Long id = book.getId(); + em.close(); + return id; + } + + @Test + void defaultOpenInView_printsStartupWarning_andMasksLazyLoadingDuringSerialization(CapturedOutput output) throws Exception { + DEMO.info("checked at context-startup time: {}", output.toString().contains("open-in-view") + ? "the OSIV warning line is present in the captured log" + : "NOT FOUND"); + + assertThat(output.toString()) + .as("Boot must print its open-in-view default warning verbatim when the property is left unset") + .contains("spring.jpa.open-in-view is enabled by default"); + + Long id = seedBookWithReview(); + HttpClient client = HttpClient.newHttpClient(); + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/osiv/books/" + id)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + DEMO.info("default OSIV (true): GET /osiv/books/{} -> status {}, body {}", id, response.statusCode(), response.body()); + + // With OSIV on, the session/EntityManager is still bound to the request thread while + // Jackson serializes the response, so getReviews() initializes successfully instead of + // throwing -- the exception is masked, not fixed. + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("Rendered fine"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/OsivDisabledExceptionTest.java b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivDisabledExceptionTest.java new file mode 100755 index 0000000..e7652e0 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/OsivDisabledExceptionTest.java @@ -0,0 +1,95 @@ +package com.ankurm.hibernatedemo.proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.test.web.server.LocalServerPort; + +/** + * Backs docs/11-proxies-and-lazy-initialization.md, chapter "OSIV: the default, its warning, and what it actually + * masks" -- the "off" half of {@link OsivDefaultWarningTest}. Same controller, same service, + * same entity graph; only {@code spring.jpa.open-in-view} changes: the base + * src/main/resources/application.yml already sets it to {@code false} explicitly, which this + * test relies on and additionally restates for clarity. + * + *

The exception surfaces server-side (visible in the application log, asserted here via + * {@link CapturedOutput}) even though the HTTP error body Boot's default + * {@code BasicErrorController} returns is a bare + * {@code {"timestamp":...,"status":500,"error":"Internal Server Error","path":...}} -- + * {@code server.error.include-message=always} does not surface it in the JSON body for this + * failure mode, because the failure happens inside the {@code 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. That gap is itself worth a line in the + * article: don't rely on the HTTP response body to tell you WHY a 500 happened here -- check + * the log. + */ +@ExtendWith(OutputCaptureExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.main.web-application-type=servlet", + "spring.jpa.open-in-view=false" +}) +class OsivDisabledExceptionTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + @LocalServerPort + private int port; + + private Long seedBookWithReview() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ProxyPublisher publisher = new ProxyPublisher("OSIV Disabled Press"); + em.persist(publisher); + ProxyBook book = new ProxyBook("OSIV Disabled Book", publisher); + em.persist(book); + ProxyReview review = new ProxyReview("Never rendered", book); + em.persist(review); + em.getTransaction().commit(); + Long id = book.getId(); + em.close(); + return id; + } + + @Test + void openInViewFalse_theSameControllerNowThrowsDuringSerialization(CapturedOutput output) throws Exception { + Long id = seedBookWithReview(); + HttpClient client = HttpClient.newHttpClient(); + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/osiv/books/" + id)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + DEMO.info("open-in-view=false: GET /osiv/books/{} -> status {}, body {}", id, response.statusCode(), response.body()); + + assertThat(response.statusCode()) + .as("with OSIV off, the same request that worked in OsivDefaultWarningTest now fails") + .isEqualTo(500); + // Spring logs this as "Resolved [HttpMessageNotWritableException: Could not write + // JSON: Cannot lazily initialize collection of role '...' (no session)]" -- the + // OUTER exception is HttpMessageNotWritableException (thrown by the Jackson message + // converter while writing the response body); LazyInitializationException's own + // MESSAGE text is nested inside it, but the log line does not print + // LazyInitializationException's class name. Assert on the message text that is + // actually, verifiably there. + assertThat(output.toString()) + .as("the real cause is visible server-side even though the HTTP error body is generic") + .contains("HttpMessageNotWritableException") + .contains("Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews'") + .contains("(no session)"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/proxy/ProxyIdentityTest.java b/src/test/java/com/ankurm/hibernatedemo/proxy/ProxyIdentityTest.java new file mode 100755 index 0000000..f7e401e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/proxy/ProxyIdentityTest.java @@ -0,0 +1,101 @@ +package com.ankurm.hibernatedemo.proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.HashSet; +import java.util.Set; +import org.hibernate.Hibernate; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs docs/11-proxies-and-lazy-initialization.md, chapter "What a proxy actually is". + * + *

{@code getReference()} does not return a {@code ProxyBook} instance -- it returns a + * ByteBuddy-generated subclass. {@code instanceof} sees through it; a naive + * {@code getClass() == SomeEntity.class} check, or the default {@code Object.equals()}/ + * {@code hashCode()} pair, does not. + */ +@SpringBootTest +class ProxyIdentityTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + private Long seedPublisher(String name) { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + ProxyPublisher p = new ProxyPublisher(name); + em.persist(p); + em.getTransaction().commit(); + Long id = p.getId(); + em.close(); + return id; + } + + @Test + void getReference_returnsAByteBuddyGeneratedSubclass_notThePlainEntityClass() { + Long id = seedPublisher("Identity Press"); + + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Session session = em.unwrap(Session.class); + ProxyPublisher proxy = session.getReference(ProxyPublisher.class, id); + + String className = proxy.getClass().getName(); + DEMO.info("getReference() runtime class: {}", className); + + // Hibernate 7 generates the proxy as a NESTED class of the entity itself, named + // "$HibernateProxy" -- not a package-level class, and not the Javassist-era naming + // scheme (EntityName_$$_javassist_N) that a lot of still-circulating blog posts show. + assertThat(className).isEqualTo(ProxyPublisher.class.getName() + "$HibernateProxy"); + assertThat(className).isNotEqualTo(ProxyPublisher.class.getName()); + + assertThat(proxy).isInstanceOf(ProxyPublisher.class); + assertThat(proxy.getClass()).isNotEqualTo(ProxyPublisher.class); + assertThat(Hibernate.getClass(proxy)) + .as("Hibernate.getClass() sees through the proxy to report the real entity class") + .isEqualTo(ProxyPublisher.class); + + em.getTransaction().commit(); + em.close(); + } + + @Test + void naiveEqualsAndHashSet_cannotRecognizeProxyAndRealInstanceAsTheSameRow() { + Long id = seedPublisher("Naive Equals Press"); + + EntityManager em1 = emf.createEntityManager(); + em1.getTransaction().begin(); + ProxyPublisher real = em1.unwrap(Session.class).get(ProxyPublisher.class, id); + + EntityManager em2 = emf.createEntityManager(); + em2.getTransaction().begin(); + ProxyPublisher proxy = em2.unwrap(Session.class).getReference(ProxyPublisher.class, id); + + // ProxyPublisher does not override equals()/hashCode() -- default Object identity. + assertThat(real.getClass()).isNotEqualTo(proxy.getClass()); + assertThat(real.equals(proxy)) + .as("default equals() compares object identity; a proxy is never == the real instance") + .isFalse(); + + Set set = new HashSet<>(); + set.add(real); + assertThat(set.contains(proxy)) + .as("same underlying row, but the HashSet cannot tell -- this is the naive-equals() trap") + .isFalse(); + + em1.getTransaction().commit(); + em1.close(); + em2.getTransaction().commit(); + em2.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java b/src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java new file mode 100644 index 0000000..47ba3ad --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/query/CriteriaQueryTest.java @@ -0,0 +1,275 @@ +package com.ankurm.hibernatedemo.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaDelete; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.CriteriaUpdate; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; +import java.time.LocalDate; +import java.util.List; +import org.hibernate.stat.Statistics; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4880 (Criteria API). Docs: docs/16-criteria-queries.md. + * + *

Shares {@link Employee}/{@link Department} with {@link HqlQueryTest}. The static metamodel + * classes ({@link Employee_}, {@link Department_}) are real, generated by {@code + * hibernate-jpamodelgen} at build time (see the {@code annotationProcessorPaths} entry in + * pom.xml) -- not hand-written stand-ins. + * + *

Run with {@code ./mvnw -Dtest=CriteriaQueryTest test}. + */ +@SpringBootTest +class CriteriaQueryTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + private Long engineeringId; + + @BeforeEach + void seed() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.createQuery("DELETE FROM Employee").executeUpdate(); + em.createQuery("DELETE FROM QueryDept").executeUpdate(); + Department engineering = new Department("Engineering"); + Department marketing = new Department("Marketing"); + em.persist(engineering); + em.persist(marketing); + em.persist(new Employee("Ada", "Byron", 95_000.0, "ACTIVE", LocalDate.of(2019, 3, 1), engineering)); + em.persist(new Employee("Grace", "Hopper", 98_000.0, "ACTIVE", LocalDate.of(2018, 6, 15), engineering)); + em.persist(new Employee("Linus", "Torvalds", 92_000.0, "INACTIVE", LocalDate.of(2015, 1, 10), engineering)); + em.persist(new Employee("Margaret", "Hamilton", 72_000.0, "ACTIVE", LocalDate.of(2020, 9, 1), marketing)); + em.persist(new Employee("Katherine", "Johnson", 71_000.0, "ACTIVE", LocalDate.of(2021, 2, 20), marketing)); + em.getTransaction().commit(); + engineeringId = engineering.getId(); + em.close(); + } + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + @Test + void basicSelection_returnsEveryRow() { + EntityManager em = emf.createEntityManager(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Employee.class); + Root root = cr.from(Employee.class); + cr.select(root); + + List results = em.createQuery(cr).getResultList(); + assertThat(results).hasSize(5); + em.close(); + } + + @Test + void stringPathPredicates_andCombined_filterCorrectly() { + EntityManager em = emf.createEntityManager(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Employee.class); + Root 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"))); + + List filtered = em.createQuery(cr).getResultList(); + // salary > 90000 keeps Byron/Hopper/Torvalds; lastName LIKE '%o%' does NOT narrow that + // set further -- "Byron", "Hopper" and "Torvalds" all contain an 'o'. + assertThat(filtered).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds"); + DEMO.info("stringPathPredicates: {}", filtered.stream().map(Employee::getLastName).toList()); + em.close(); + } + + @Test + void staticMetamodel_isTypeSafeAndProducesTheSameResultAsStringPath() { + // The same query as above, but built through the generated Employee_ metamodel + // instead of root.get("salary") string paths. Proves the metamodel classes are real + // and functional, not just present on the classpath. + EntityManager em = emf.createEntityManager(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Employee.class); + Root root = cr.from(Employee.class); + + cr.select(root) + .where(cb.gt(root.get(Employee_.salary), 90_000.0)) + .orderBy(cb.asc(root.get(Employee_.lastName))); + + List results = em.createQuery(cr).getResultList(); + assertThat(results).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds"); + DEMO.info("staticMetamodel: {}", results.stream().map(Employee::getLastName).toList()); + em.close(); + } + + @Test + void joinViaMetamodel_filtersByRelatedEntity() { + EntityManager em = emf.createEntityManager(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Employee.class); + Root root = cr.from(Employee.class); + + Join department = root.join(Employee_.department, JoinType.INNER); + cr.select(root).where(cb.equal(department.get(Department_.name), "Engineering")); + + List engineeringStaff = em.createQuery(cr).getResultList(); + assertThat(engineeringStaff).hasSize(3); + DEMO.info("joinViaMetamodel: {} engineering employees", engineeringStaff.size()); + em.close(); + } + + @Test + void rootJoin_doesNotForceEagerLoad_rootFetchDoes() { + // root.join() builds a SQL join for filtering but does NOT initialize the association + // for the returned entities -- touching it afterward is a separate SELECT per entity, + // the same N+1 risk as HQL's plain JOIN. root.fetch() actually populates it. + EntityManager em1 = emf.createEntityManager(); + CriteriaBuilder cb1 = em1.getCriteriaBuilder(); + CriteriaQuery cr1 = cb1.createQuery(Employee.class); + Root root1 = cr1.from(Employee.class); + root1.join(Employee_.department, JoinType.INNER); + cr1.select(root1); + stats().clear(); + List viaJoin = em1.createQuery(cr1).getResultList(); + long afterSelect = stats().getPrepareStatementCount(); + viaJoin.forEach(e -> e.getDepartment().getName()); + long afterTouch = stats().getPrepareStatementCount(); + assertThat(afterTouch).as("root.join() alone still requires extra SELECTs to read the association") + .isGreaterThan(afterSelect); + em1.close(); + + EntityManager em2 = emf.createEntityManager(); + CriteriaBuilder cb2 = em2.getCriteriaBuilder(); + CriteriaQuery cr2 = cb2.createQuery(Employee.class); + Root root2 = cr2.from(Employee.class); + root2.fetch(Employee_.department, JoinType.INNER); + cr2.select(root2).distinct(true); + stats().clear(); + List viaFetch = em2.createQuery(cr2).getResultList(); + viaFetch.forEach(e -> e.getDepartment().getName()); + long totalWithFetch = stats().getPrepareStatementCount(); + assertThat(totalWithFetch).as("root.fetch() loads the association in the same SELECT") + .isEqualTo(1L); + DEMO.info("rootJoinVsFetch: join+touch={} statements, fetch+touch={} statement", afterTouch, totalWithFetch); + em2.close(); + } + + @Test + void aggregation_avgSalary_matchesHandComputedValue() { + EntityManager em = emf.createEntityManager(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery avgQuery = cb.createQuery(Double.class); + Root avgRoot = avgQuery.from(Employee.class); + avgQuery.select(cb.avg(avgRoot.get(Employee_.salary))); + + Double averageSalary = em.createQuery(avgQuery).getSingleResult(); + double expected = (95_000.0 + 98_000.0 + 92_000.0 + 72_000.0 + 71_000.0) / 5; + assertThat(averageSalary).isCloseTo(expected, org.assertj.core.data.Offset.offset(0.01)); + DEMO.info("aggregation: average salary = {}", averageSalary); + em.close(); + } + + @Test + void subquery_findsAboveAverageEarners() { + EntityManager em = emf.createEntityManager(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery mainQuery = cb.createQuery(Employee.class); + Root empRoot = mainQuery.from(Employee.class); + + Subquery sub = mainQuery.subquery(Double.class); + Root subRoot = sub.from(Employee.class); + sub.select(cb.avg(subRoot.get(Employee_.salary))); + + mainQuery.select(empRoot).where(cb.gt(empRoot.get(Employee_.salary), sub)).orderBy(cb.asc(empRoot.get(Employee_.lastName))); + + List highEarners = em.createQuery(mainQuery).getResultList(); + // Company-wide average is 85600 (428000/5) -- the two Marketing salaries (72000, + // 71000) drag it well below every Engineering salary, so all three Engineering + // employees clear it, not just the single highest earner. + assertThat(highEarners).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds"); + DEMO.info("subquery: above-average earners (avg=85600) = {}", highEarners.stream().map(Employee::getLastName).toList()); + em.close(); + } + + @Test + void criteriaUpdate_bulkRaisesSalaryForOneDepartment() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + + CriteriaUpdate update = cb.createCriteriaUpdate(Employee.class); + Root 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(); + assertThat(updated).isEqualTo(3); + + em.clear(); + Double newAdaSalary = em.createQuery("SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class) + .getSingleResult(); + assertThat(newAdaSalary).isCloseTo(95_000.0 * 1.1, org.assertj.core.data.Offset.offset(0.01)); + em.getTransaction().commit(); + DEMO.info("criteriaUpdate: {} rows updated, Ada's new salary = {}", updated, newAdaSalary); + em.close(); + } + + @Test + void criteriaDelete_removesEmployeesWithNoDepartment() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + // Insert one departmentless employee to have something real for CriteriaDelete to remove. + em.persist(new Employee("Orphan", "NoDept", 50_000.0, "ACTIVE", LocalDate.now(), null)); + em.flush(); + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaDelete delete = cb.createCriteriaDelete(Employee.class); + Root delRoot = delete.from(Employee.class); + delete.where(cb.isNull(delRoot.get(Employee_.department))); + + int deleted = em.createQuery(delete).executeUpdate(); + assertThat(deleted).isEqualTo(1); + + long remaining = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult(); + assertThat(remaining).isEqualTo(5L); + em.getTransaction().commit(); + DEMO.info("criteriaDelete: deleted={}, remaining={}", deleted, remaining); + em.close(); + } + + @Test + void orPredicate_combinesConditionsWithVarargsOverload() { + EntityManager em = emf.createEntityManager(); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Employee.class); + Root root = cr.from(Employee.class); + + cr.select(root).where(cb.or( + cb.equal(root.get(Employee_.status), "INACTIVE"), + cb.equal(root.get(Employee_.lastName), "Hamilton"))); + + List results = em.createQuery(cr).getResultList(); + assertThat(results).extracting(Employee::getLastName).containsExactlyInAnyOrder("Torvalds", "Hamilton"); + DEMO.info("orPredicate: {}", results.stream().map(Employee::getLastName).toList()); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java b/src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java new file mode 100644 index 0000000..c5e81d1 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/query/HqlQueryTest.java @@ -0,0 +1,294 @@ +package com.ankurm.hibernatedemo.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.LocalDate; +import java.util.List; +import org.hibernate.SessionFactory; +import org.hibernate.query.SemanticException; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Backs ankurm.com post 4879 (HQL). Docs: docs/15-hql-queries.md. + * + *

Run with {@code ./mvnw -Dtest=HqlQueryTest test}. Every assertion here was first observed by + * running the same query and reading the log, then pinned down as an assertion. + */ +@SpringBootTest +class HqlQueryTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Autowired + private EntityManagerFactory emf; + + private Long engineeringId; + private Long marketingId; + + @BeforeEach + void seed() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + // This method-scoped H2 database (jdbc:h2:mem:hibernate-demo) is shared across every + // test in the class run, not reset between tests, so each test must clear its own + // tables first -- otherwise counts creep upward test by test. + em.createQuery("DELETE FROM Employee").executeUpdate(); + em.createQuery("DELETE FROM QueryDept").executeUpdate(); + Department engineering = new Department("Engineering"); + Department marketing = new Department("Marketing"); + em.persist(engineering); + em.persist(marketing); + em.persist(new Employee("Ada", "Byron", 95_000.0, "ACTIVE", LocalDate.of(2019, 3, 1), engineering)); + em.persist(new Employee("Grace", "Hopper", 98_000.0, "ACTIVE", LocalDate.of(2018, 6, 15), engineering)); + em.persist(new Employee("Linus", "Torvalds", 92_000.0, "INACTIVE", LocalDate.of(2015, 1, 10), engineering)); + em.persist(new Employee("Margaret", "Hamilton", 72_000.0, "ACTIVE", LocalDate.of(2020, 9, 1), marketing)); + em.persist(new Employee("Katherine", "Johnson", 71_000.0, "ACTIVE", LocalDate.of(2021, 2, 20), marketing)); + em.getTransaction().commit(); + engineeringId = engineering.getId(); + marketingId = marketing.getId(); + em.close(); + } + + private Statistics stats() { + return emf.unwrap(SessionFactory.class).getStatistics(); + } + + @Test + void selectAll_returnsEveryRow() { + EntityManager em = emf.createEntityManager(); + List all = em.createQuery("FROM Employee", Employee.class).getResultList(); + assertThat(all).hasSize(5); + em.close(); + } + + @Test + void whereWithNamedParameter_filtersCorrectly() { + EntityManager em = emf.createEntityManager(); + List active = em.createQuery( + "SELECT e FROM Employee e WHERE e.status = :status", Employee.class) + .setParameter("status", "ACTIVE") + .getResultList(); + assertThat(active).hasSize(4).allMatch(e -> "ACTIVE".equals(e.getStatus())); + DEMO.info("whereWithNamedParameter: {} active employees", active.size()); + em.close(); + } + + @Test + void columnNameInsteadOfFieldName_failsAtQueryTimeNotSilently() { + // The classic HQL pitfall: writing the database column name (first_name) instead of + // the entity field name (firstName). This is NOT a silent wrong-result bug -- HQL + // resolves against the entity model, so an unknown path fails loudly before any SQL + // is sent. + EntityManager em = emf.createEntityManager(); + assertThatThrownBy(() -> em.createQuery("SELECT e FROM Employee e WHERE e.first_name = 'Ada'", Employee.class)) + .isInstanceOfAny(IllegalArgumentException.class, SemanticException.class) + .satisfies(ex -> DEMO.info("columnNameInsteadOfFieldName: {}: {}", ex.getClass().getSimpleName(), ex.getMessage())); + em.close(); + } + + @Test + void joinWithoutFetch_causesOneQueryPerDepartmentAccess() { + EntityManager em = emf.createEntityManager(); + stats().clear(); + List withDept = em.createQuery( + "SELECT e FROM Employee e JOIN e.department d WHERE d.name = :deptName", Employee.class) + .setParameter("deptName", "Engineering") + .getResultList(); + long afterQuery = stats().getPrepareStatementCount(); + assertThat(afterQuery).as("plain JOIN issues one SELECT for the employees").isEqualTo(1); + // Touching the association now triggers a SEPARATE select per distinct department -- + // this is the N+1 that JOIN alone (without FETCH) does not prevent. + withDept.forEach(e -> e.getDepartment().getName()); + long afterTouch = stats().getPrepareStatementCount(); + assertThat(afterTouch).as("touching the lazy association after a plain JOIN fires extra SELECTs") + .isGreaterThan(afterQuery); + DEMO.info("joinWithoutFetch: {} statements for the query, {} after touching department", afterQuery, afterTouch); + em.close(); + } + + @Test + void joinFetch_loadsAssociationInOneQuery() { + EntityManager em = emf.createEntityManager(); + stats().clear(); + List withFetch = em.createQuery( + "SELECT e FROM Employee e LEFT JOIN FETCH e.department", Employee.class) + .getResultList(); + withFetch.forEach(e -> e.getDepartment().getName()); + long total = stats().getPrepareStatementCount(); + assertThat(total).as("JOIN FETCH loads employees AND departments in exactly one SELECT, even after touching department") + .isEqualTo(1); + DEMO.info("joinFetch: {} statement total, {} rows", total, withFetch.size()); + em.close(); + } + + @Test + void aggregateCount_matchesSeedSize() { + EntityManager em = emf.createEntityManager(); + Long count = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult(); + assertThat(count).isEqualTo(5L); + DEMO.info("aggregateCount: {}", count); + em.close(); + } + + @Test + void avgSalaryGroupByDepartment_matchesHandComputedAverages() { + EntityManager em = emf.createEntityManager(); + List rows = 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(); + assertThat(rows).hasSize(2); + assertThat((String) rows.get(0)[0]).isEqualTo("Engineering"); + assertThat((Double) rows.get(0)[1]).isCloseTo((95_000.0 + 98_000.0 + 92_000.0) / 3, org.assertj.core.data.Offset.offset(0.01)); + assertThat((String) rows.get(1)[0]).isEqualTo("Marketing"); + assertThat((Double) rows.get(1)[1]).isCloseTo((72_000.0 + 71_000.0) / 2, org.assertj.core.data.Offset.offset(0.01)); + rows.forEach(r -> DEMO.info("avgSalaryGroupByDepartment: {} -> {}", r[0], r[1])); + em.close(); + } + + @Test + void pagination_returnsCorrectSlice() { + EntityManager em = emf.createEntityManager(); + List page1 = em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class) + .setFirstResult(0) + .setMaxResults(2) + .getResultList(); + List page2 = em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class) + .setFirstResult(2) + .setMaxResults(2) + .getResultList(); + assertThat(page1).hasSize(2); + assertThat(page2).hasSize(2); + assertThat(page1).extracting(Employee::getLastName).doesNotContainAnyElementsOf( + page2.stream().map(Employee::getLastName).toList()); + DEMO.info("pagination: page1={}, page2={}", page1.stream().map(Employee::getLastName).toList(), + page2.stream().map(Employee::getLastName).toList()); + em.close(); + } + + @Test + void bulkUpdate_changesRowsButNotAlreadyLoadedEntityInSameSession() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + // Load one INACTIVE-bound employee into the persistence context first. + Employee loaded = em.createQuery( + "SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class) + .getSingleResult(); + assertThat(loaded.getStatus()).isEqualTo("INACTIVE"); + + int updated = em.createQuery( + "UPDATE Employee e SET e.status = 'ARCHIVED' WHERE e.status = 'INACTIVE'") + .executeUpdate(); + assertThat(updated).isEqualTo(1); + + // The row changed in the database, but the already-loaded managed entity was never + // touched by the bulk UPDATE -- it still shows the old in-memory value until refreshed. + assertThat(loaded.getStatus()).as("bulk UPDATE bypasses the persistence context for already-loaded entities") + .isEqualTo("INACTIVE"); + + em.clear(); + Employee reloaded = em.find(Employee.class, loaded.getId()); + assertThat(reloaded.getStatus()).as("a fresh load after clear() sees the bulk UPDATE").isEqualTo("ARCHIVED"); + em.getTransaction().commit(); + DEMO.info("bulkUpdate: updated={} rows, stale in-memory status={}, reloaded status={}", + updated, loaded.getStatus(), reloaded.getStatus()); + em.close(); + } + + @Test + void bulkDelete_removesMatchingRowsOnly() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + int deleted = em.createQuery("DELETE FROM Employee e WHERE e.department.id = :deptId") + .setParameter("deptId", marketingId) + .executeUpdate(); + assertThat(deleted).isEqualTo(2); + long remaining = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult(); + assertThat(remaining).isEqualTo(3L); + em.getTransaction().commit(); + DEMO.info("bulkDelete: deleted={} rows, remaining={}", deleted, remaining); + em.close(); + } + + // ---- Flush-mode tests use an UPDATE to an already-managed entity, not a new insert. + // An earlier version of these tests persisted a brand-new Employee and expected FlushMode + // to control when it became visible -- that was confounded by GenerationType.IDENTITY + // (chapter 03's own finding: IDENTITY forces an immediate INSERT to obtain the generated + // key, on every persist(), regardless of flush mode). A dirty UPDATE on a loaded managed + // entity is unaffected by id-generation strategy and isolates what flush mode actually + // controls. + + @Test + void defaultFlushMode_autoFlushesDirtyUpdateBeforeQuery() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + 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 salaryFromFreshQuery = em.createQuery( + "SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class) + .getSingleResult(); + assertThat(salaryFromFreshQuery).as("default AUTO flush mode flushes the dirty UPDATE before the query runs") + .isEqualTo(999_999.0); + em.getTransaction().rollback(); + DEMO.info("defaultFlushMode: salary seen by a fresh query after an unflushed dirty change = {}", salaryFromFreshQuery); + em.close(); + } + + @Test + void jakartaCommitFlushMode_suppressesAutoFlushForThisQuery() { + // jakarta.persistence.FlushModeType.COMMIT's javadoc only says a provider is + // "permitted, but not required" to flush before a query -- it's implementation + // defined. Hibernate 7.4.5's actual choice, verified here: it does NOT flush, so the + // query sees the pre-update value. + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Employee grace = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Hopper'", Employee.class).getSingleResult(); + grace.setSalary(111_111.0); + Double salaryFromFreshQuery = em.createQuery( + "SELECT e.salary FROM Employee e WHERE e.lastName = 'Hopper'", Double.class) + .setFlushMode(jakarta.persistence.FlushModeType.COMMIT) + .getSingleResult(); + assertThat(salaryFromFreshQuery).as("Hibernate 7.4.5 chooses not to flush under FlushModeType.COMMIT -- the query still sees the old value") + .isEqualTo(98_000.0); + em.getTransaction().rollback(); + DEMO.info("jakartaCommitFlushMode: salary seen by query under FlushModeType.COMMIT = {} (pre-update value was 98000.0)", salaryFromFreshQuery); + em.close(); + } + + @Test + void nativeManualFlushMode_actuallySuppressesAutoFlush() { + // Hibernate's own native org.hibernate.FlushMode has a fourth value the jakarta enum + // doesn't: MANUAL, which means never auto-flush, full stop -- the one that genuinely + // suppresses it, matching the "set FlushMode.MANUAL for performance-sensitive loops" + // advice, as long as what you're deferring is an UPDATE, not an IDENTITY-generated + // INSERT (see the note above this block). + EntityManager em = emf.createEntityManager(); + org.hibernate.Session session = em.unwrap(org.hibernate.Session.class); + em.getTransaction().begin(); + Employee linus = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class).getSingleResult(); + session.setHibernateFlushMode(org.hibernate.FlushMode.MANUAL); + linus.setSalary(123_123.0); + Double beforeFlush = em.createQuery( + "SELECT e.salary FROM Employee e WHERE e.lastName = 'Torvalds'", Double.class) + .getSingleResult(); + assertThat(beforeFlush).as("FlushMode.MANUAL genuinely suppresses auto-flush before this query -- the old salary is still what the DB has") + .isEqualTo(92_000.0); + session.flush(); + Double afterFlush = em.createQuery( + "SELECT e.salary FROM Employee e WHERE e.lastName = 'Torvalds'", Double.class) + .getSingleResult(); + assertThat(afterFlush).as("an explicit flush() makes the change visible").isEqualTo(123_123.0); + em.getTransaction().rollback(); + DEMO.info("nativeManualFlushMode: before explicit flush={}, after={}", beforeFlush, afterFlush); + em.close(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/search/HibernateSearchTest.java b/src/test/java/com/ankurm/hibernatedemo/search/HibernateSearchTest.java new file mode 100644 index 0000000..81d78ae --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/search/HibernateSearchTest.java @@ -0,0 +1,198 @@ +package com.ankurm.hibernatedemo.search; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.List; +import org.hibernate.search.mapper.orm.Search; +import org.hibernate.search.mapper.orm.session.SearchSession; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Proves chapter 25's Hibernate Search claims: automatic indexing on persist, full-text search + * with fuzzy matching, exact keyword filtering, sortable range fields, searching through an + * {@code @IndexedEmbedded} association, and rebuilding the index with a {@code MassIndexer}. + * + *

All Movie titles in this class carry a unique per-test prefix, and every search query + * filters by that prefix -- the Lucene index, like the H2 database, is shared across every test + * method (and every OTHER test class in this repo that boots the same Spring context), so an + * unscoped query would also match movies indexed by earlier test runs still on disk under + * {@code target/lucene-indexes}. + * + *

Docs: docs/25-hibernate-search.md. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class HibernateSearchTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void fullTextSearch_withFuzzyMatching_findsATypo() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Director coppola = new Director("Fts Francis Ford Coppola"); + em.persist(coppola); + em.persist(new Movie("Fts The Godfather", "Drama", 1972, coppola)); + em.persist(new Movie("Fts The Godfather Part II", "Drama", 1974, coppola)); + em.getTransaction().commit(); + + SearchSession searchSession = Search.session(em); + // Deliberate typo: "Godfaher" instead of "Godfather" -- fuzzy(1) tolerates a + // one-character edit distance. + List hits = searchSession.search(Movie.class) + .where(f -> f.match().field("title").matching("Godfaher").fuzzy(1)) + .fetchHits(20); + em.close(); + + List titles = hits.stream().map(Movie::getTitle).filter(t -> t.startsWith("Fts")).toList(); + System.out.println("RESULT[search-fulltext-fuzzy]: searching title for 'Godfaher' " + + "(a one-character typo of 'Godfather') with .fuzzy(1) matched: " + titles + + " -- a plain SQL LIKE '%Godfaher%' would have matched nothing."); + + assertThat(titles).containsExactlyInAnyOrder("Fts The Godfather", "Fts The Godfather Part II"); + } + + @Test + void keywordField_exactMatchOnly_noPartialOrCaseInsensitiveMatch() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Director lucas = new Director("Kwf George Lucas"); + em.persist(lucas); + em.persist(new Movie("Kwf A New Hope", "Science Fiction", 1977, lucas)); + em.persist(new Movie("Kwf Annie Hall", "Comedy", 1977, lucas)); + em.getTransaction().commit(); + + SearchSession searchSession = Search.session(em); + List exactMatch = searchSession.search(Movie.class) + .where(f -> f.bool() + .must(f.match().field("genre").matching("Science Fiction")) + .must(f.match().field("title").matching("Kwf"))) + .fetchHits(20); + // A KeywordField compares the WHOLE stored value -- "science" alone (lowercase, partial) + // does not match "Science Fiction" the way a FullTextField's tokenized/lower-cased terms + // would. + List partialLowercaseAttempt = searchSession.search(Movie.class) + .where(f -> f.bool() + .must(f.match().field("genre").matching("science")) + .must(f.match().field("title").matching("Kwf"))) + .fetchHits(20); + em.close(); + + System.out.println("RESULT[search-keyword-exact-match]: @KeywordField genre matched by " + + "the exact stored value 'Science Fiction' -> " + exactMatch.size() + " hit(s) | " + + "the same field searched with the partial, lowercase 'science' -> " + + partialLowercaseAttempt.size() + " hit(s) -- a KeywordField is compared whole," + + " unlike a FullTextField's tokenized and lower-cased terms."); + + assertThat(exactMatch).hasSize(1); + assertThat(partialLowercaseAttempt).isEmpty(); + } + + @Test + void sortableGenericField_ordersByReleaseYear() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Director spielberg = new Director("Sgf Steven Spielberg"); + em.persist(spielberg); + em.persist(new Movie("Sgf Jaws", "Thriller", 1975, spielberg)); + em.persist(new Movie("Sgf E.T.", "Family", 1982, spielberg)); + em.persist(new Movie("Sgf Jurassic Park", "Adventure", 1993, spielberg)); + em.getTransaction().commit(); + + SearchSession searchSession = Search.session(em); + List sortedNewestFirst = searchSession.search(Movie.class) + .where(f -> f.match().field("title").matching("Sgf")) + .sort(f -> f.field("releaseYear").desc()) + .fetchHits(20); + em.close(); + + List years = sortedNewestFirst.stream().map(Movie::getReleaseYear).toList(); + System.out.println("RESULT[search-sortable-generic-field]: sort(f -> f.field(" + + "\"releaseYear\").desc()) over the 3 Sgf-prefixed movies -- years in the order " + + "returned: " + years + " -- @GenericField(sortable = Sortable.YES) is what " + + "makes this sort possible; the default is NOT sortable."); + + assertThat(years).containsExactly(1993, 1982, 1975); + } + + @Test + void indexedEmbedded_searchesThroughTheAssociation() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Director nolan = new Director("Iea Christopher Nolan"); + Director tarantino = new Director("Iea Quentin Tarantino"); + em.persist(nolan); + em.persist(tarantino); + em.persist(new Movie("Iea Inception", "Science Fiction", 2010, nolan)); + em.persist(new Movie("Iea Pulp Fiction", "Crime", 1994, tarantino)); + em.getTransaction().commit(); + + // director.name is a @KeywordField -- compared as one whole value, same as the genre + // field in the previous test -- so the query has to match the FULL stored director name, + // not a substring of it. That's why this searches "Iea Christopher Nolan" whole, not + // just "Nolan". + SearchSession searchSession = Search.session(em); + List hits = 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); + em.close(); + + List titles = hits.stream().map(Movie::getTitle).toList(); + System.out.println("RESULT[search-indexed-embedded]: field 'director.name' matched the " + + "full keyword value 'Iea Christopher Nolan' -- " + titles + " -- Director " + + "itself carries no @Indexed annotation at all; its @KeywordField only exists " + + "inside Movie's index because of @IndexedEmbedded on the director association."); + + assertThat(titles).containsExactly("Iea Inception"); + } + + @Test + void massIndexer_rebuildsTheIndexFromTheDatabase() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Director scorsese = new Director("Mix Martin Scorsese"); + em.persist(scorsese); + em.persist(new Movie("Mix Goodfellas", "Crime", 1990, scorsese)); + em.getTransaction().commit(); + + SearchSession searchSession = Search.session(em); + long beforePurge = searchSession.search(Movie.class) + .where(f -> f.match().field("title").matching("Mix")) + .fetchTotalHitCount(); + + // Purge the index (NOT the database) to simulate an index that has gone stale or was + // never built -- the row is still in H2, only the Lucene index forgot about it. + searchSession.workspace().purge(); + long afterPurgeBeforeReindex = searchSession.search(Movie.class) + .where(f -> f.match().field("title").matching("Mix")) + .fetchTotalHitCount(); + + try { + searchSession.massIndexer(Movie.class).startAndWait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + long afterMassIndexer = searchSession.search(Movie.class) + .where(f -> f.match().field("title").matching("Mix")) + .fetchTotalHitCount(); + em.close(); + + System.out.println("RESULT[search-mass-indexer]: hits before purge=" + beforePurge + + " | hits after workspace().purge() (row still in H2, index emptied)=" + + afterPurgeBeforeReindex + " | hits after massIndexer(Movie.class)." + + "startAndWait() (index rebuilt straight from the database, no re-persisting)=" + + afterMassIndexer + "."); + + assertThat(beforePurge).isEqualTo(1); + assertThat(afterPurgeBeforeReindex).isZero(); + assertThat(afterMassIndexer).isEqualTo(1); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java b/src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java new file mode 100644 index 0000000..46a65c9 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/sorting/SortingTest.java @@ -0,0 +1,220 @@ +package com.ankurm.hibernatedemo.sorting; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.hibernatedemo.HibernateDemoApplication; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Nulls; +import jakarta.persistence.criteria.Root; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Proves chapter 22's sorting claims: {@code @OrderBy} uses property names not column names, + * {@code @SortNatural}/{@code @SortComparator} on element collections, the injection risk in + * naive dynamic HQL sorting and the whitelist fix, Criteria API {@code Order} across a join, + * null precedence via {@code jakarta.persistence.criteria.Nulls}, and case-insensitive sorting. + * + *

Docs: docs/22-sorting.md. + */ +@SpringBootTest(classes = HibernateDemoApplication.class) +class SortingTest { + + @Autowired + private EntityManagerFactory emf; + + @Test + void orderByUsesPropertyName_notColumnName() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Playlist playlist = new Playlist("orderby-test-mix"); + playlist.addSong(new Song("Zulu", "Artist A", 4)); + playlist.addSong(new Song("Alpha", "Artist B", 5)); + playlist.addSong(new Song("Mike", "Artist C", 3)); + em.persist(playlist); + em.getTransaction().commit(); + em.clear(); + + Playlist reloaded = em.find(Playlist.class, playlist.getId()); + List titlesInOrder = reloaded.getSongs().stream().map(Song::getTitle).toList(); + em.close(); + + System.out.println("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: " + titlesInOrder + + " -- Hibernate resolved the PROPERTY name to the right column itself."); + + assertThat(titlesInOrder).containsExactly("Alpha", "Mike", "Zulu"); + } + + @Test + void sortNaturalAndSortComparator_onElementCollections() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Playlist playlist = new Playlist("sort-annotations-test"); + playlist.getTags().add("rock"); + playlist.getTags().add("acoustic"); + playlist.getTags().add("live"); + playlist.getGenres().add("pop"); + playlist.getGenres().add("jazz-fusion"); + playlist.getGenres().add("folk"); + em.persist(playlist); + em.getTransaction().commit(); + em.clear(); + + Playlist reloaded = em.find(Playlist.class, playlist.getId()); + List tagsInOrder = List.copyOf(reloaded.getTags()); + List genresInOrder = List.copyOf(reloaded.getGenres()); + em.close(); + + System.out.println("RESULT[sorting-natural-and-comparator]: @SortNatural tags=" + + tagsInOrder + " (plain alphabetical) | @SortComparator genres=" + genresInOrder + + " (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."); + + assertThat(tagsInOrder).containsExactly("acoustic", "live", "rock"); + assertThat(genresInOrder).containsExactly("pop", "folk", "jazz-fusion"); + } + + @Test + void dynamicSorting_unwhitelistedFieldRejected_whitelistedFieldWorks() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Playlist playlist = new Playlist("dynamic-sort-test"); + playlist.addSong(new Song("Bravo", "Zeta Band", 2)); + playlist.addSong(new Song("Charlie", "Alpha Band", 5)); + em.persist(playlist); + em.getTransaction().commit(); + + // The attack: a caller-supplied string that is not a real Song property at all, aimed at + // proving the whitelist rejects it BEFORE it ever reaches the query -- not that Hibernate + // somehow "sanitizes" it for you. + String malicious = "id) --"; + + Throwable rejected = org.assertj.core.api.Assertions.catchThrowable( + () -> SongSortField.toHqlPropertyOrThrow(malicious)); + + String safeField = SongSortField.toHqlPropertyOrThrow("artist"); + List artistsSortedSafely = em.createQuery( + "select s.artist from Song s where s.playlist = :p order by s." + safeField, + String.class) + .setParameter("p", playlist) + .getResultList(); + em.close(); + + System.out.println("RESULT[sorting-dynamic-injection-guard]: whitelist rejected '" + + malicious + "' with " + rejected.getClass().getSimpleName() + " (\"" + + rejected.getMessage() + "\") before it ever reached the query engine | " + + "whitelisted field 'artist' produced order by s.artist -- result: " + + artistsSortedSafely + " -- the string never touches the HQL unless it's one of" + + " the three known-safe property names."); + + assertThat(rejected).isInstanceOf(IllegalArgumentException.class); + assertThat(artistsSortedSafely).containsExactly("Alpha Band", "Zeta Band"); + } + + @Test + void criteriaOrder_acrossAJoin() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Playlist a = new Playlist("B-Playlist"); + a.addSong(new Song("Song One", "X", 1)); + Playlist b = new Playlist("A-Playlist"); + b.addSong(new Song("Song Two", "Y", 1)); + em.persist(a); + em.persist(b); + em.getTransaction().commit(); + em.clear(); + + // Scoped to this test's own two songs -- the database is shared across every test method + // in this class, so an unrestricted query here would also pick up playlists created by + // the other test methods. + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root root = cq.from(Song.class); + Join playlistJoin = root.join("playlist"); + cq.select(playlistJoin.get("name")) + .where(root.get("title").in("Song One", "Song Two")) + .orderBy(cb.asc(playlistJoin.get("name"))); + + List playlistNamesInOrder = em.createQuery(cq).getResultList(); + em.close(); + + System.out.println("RESULT[sorting-criteria-order-join]: Criteria root.join(\"playlist\")" + + " ordered by the JOINED entity's name -- " + playlistNamesInOrder + + " -- proves Order in the Criteria API is not limited to the root entity's own" + + " columns."); + + assertThat(playlistNamesInOrder).containsExactly("A-Playlist", "B-Playlist"); + } + + @Test + void nullPrecedence_viaJakartaPersistenceCriteriaNulls() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Playlist playlist = new Playlist("null-precedence-test"); + playlist.addSong(new Song("Rated High", "Artist", 5)); + playlist.addSong(new Song("Unrated One", "Artist", null)); + playlist.addSong(new Song("Rated Low", "Artist", 1)); + playlist.addSong(new Song("Unrated Two", "Artist", null)); + em.persist(playlist); + em.getTransaction().commit(); + em.clear(); + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root root = cq.from(Song.class); + cq.select(root.get("title")) + .where(cb.equal(root.get("playlist").get("id"), playlist.getId())) + .orderBy(cb.asc(root.get("rating"), Nulls.LAST), cb.asc(root.get("title"))); + + List titlesRatingAscNullsLast = em.createQuery(cq).getResultList(); + em.close(); + + System.out.println("RESULT[sorting-null-precedence]: cb.asc(root.get(\"rating\"), " + + "Nulls.LAST) -- " + titlesRatingAscNullsLast + + " -- 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."); + + assertThat(titlesRatingAscNullsLast).containsExactly( + "Rated Low", "Rated High", "Unrated One", "Unrated Two"); + } + + @Test + void caseInsensitiveSorting_viaCbLower() { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + Playlist playlist = new Playlist("case-insensitive-test"); + playlist.addSong(new Song("banana", "Artist", 1)); + playlist.addSong(new Song("Apple", "Artist", 1)); + playlist.addSong(new Song("cherry", "Artist", 1)); + em.persist(playlist); + em.getTransaction().commit(); + em.clear(); + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root root = cq.from(Song.class); + cq.select(root.get("title")) + .where(cb.equal(root.get("playlist").get("id"), playlist.getId())) + .orderBy(cb.asc(cb.lower(root.get("title")))); + + List caseInsensitiveOrder = em.createQuery(cq).getResultList(); + em.close(); + + System.out.println("RESULT[sorting-case-insensitive]: cb.lower(root.get(\"title\")) " + + "ascending -- " + caseInsensitiveOrder + + " -- 'Apple' sorts before 'banana' despite the capital A, because the " + + "comparison happens on the lower-cased value, not the raw column."); + + assertThat(caseInsensitiveOrder).containsExactly("Apple", "banana", "cherry"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/testdb/CrossDatabaseBehaviorTest.java b/src/test/java/com/ankurm/hibernatedemo/testdb/CrossDatabaseBehaviorTest.java new file mode 100755 index 0000000..7ab02e2 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/testdb/CrossDatabaseBehaviorTest.java @@ -0,0 +1,115 @@ +package com.ankurm.hibernatedemo.testdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/09-testing-in-memory-databases.md, chapter "A test that passes on one database and fails on + * another". Deliberately raw JDBC, no Hibernate mapping in the way, to isolate exactly what + * varies between engines rather than what an ORM layer smooths over. Two concrete cases: + * + *

    + *
  1. An UNQUOTED column named after a SQL reserved word ({@code value}) -- fails to even + * CREATE on some engines, succeeds on others, depending on how strictly each parser + * enforces the reserved-word list. (The word {@code order} was tried first and turned + * out to be the wrong probe -- all three engines reject it identically; see + * docs/output/testdb-reserved-word-survey.txt for the survey that found {@code value}.)
  2. + *
  3. CHAR(10) padding on read -- checks whether {@code java.sql.ResultSet.getString()} + * returns the space-padded or trimmed value on each engine. The folklore says this is + * "the classic Derby gotcha"; the actual, verified result here is that H2, HSQLDB AND + * Derby all pad identically -- standard CHAR semantics, not a Derby-only quirk. Still + * worth keeping in the article, corrected: the trap is real, just not exclusive to one + * engine.
  4. + *
+ */ +class CrossDatabaseBehaviorTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + private Connection connect(TestDbSupport.Db db, String suffix) throws Exception { + Class.forName(db.driver); + return DriverManager.getConnection(db.url(suffix), db.user, db.password); + } + + @Test + void unquotedReservedWordColumn_createTableSucceedsOrFailsDependingOnEngine() throws Exception { + record Outcome(TestDbSupport.Db db, boolean succeeded, String detail) {} + java.util.List outcomes = new java.util.ArrayList<>(); + + // "order" turned out to be the WRONG word to probe with -- a quick survey of 22 + // candidate words (docs/output/testdb-reserved-word-survey.txt) found H2, HSQLDB and + // Derby all reject an unquoted "order" column identically, which proves nothing about + // divergence. "value" is genuinely divergent: H2 reserves it, HSQLDB and Derby do not. + for (TestDbSupport.Db db : new TestDbSupport.Db[] {TestDbSupport.Db.H2, TestDbSupport.Db.HSQLDB, TestDbSupport.Db.DERBY}) { + try (Connection c = connect(db, "reservedword"); + Statement st = c.createStatement()) { + st.execute("create table reserved_word_test (id integer, value integer)"); + outcomes.add(new Outcome(db, true, "CREATE TABLE succeeded with an unquoted 'value' column")); + } catch (SQLException e) { + outcomes.add(new Outcome(db, false, e.getClass().getSimpleName() + ": " + e.getMessage())); + } + } + + for (Outcome o : outcomes) { + DEMO.info("unquoted 'value' column, db={} -> succeeded={}, detail={}", o.db(), o.succeeded(), o.detail()); + } + + // The actual, verified split: H2 REJECTS an unquoted "value" column, HSQLDB and Derby + // ACCEPT it. This is the concrete "passes on X, fails on Y" case -- an entity mapped + // with a field named exactly `value` and no @Column(name = "\"value\"") escaping will + // build a working schema on two of these three engines and throw a SQL syntax error, + // specifically on H2. + assertThat(outcomes).anySatisfy(o -> { + assertThat(o.db()).isEqualTo(TestDbSupport.Db.H2); + assertThat(o.succeeded()).isFalse(); + }); + assertThat(outcomes).filteredOn(o -> o.db() != TestDbSupport.Db.H2) + .allSatisfy(o -> assertThat(o.succeeded()).isTrue()); + } + + @Test + void charPadding_getStringReturnsDifferentlyPaddedValuesAcrossEngines() throws Exception { + record Outcome(TestDbSupport.Db db, String rawValue, int length) {} + java.util.List outcomes = new java.util.ArrayList<>(); + + for (TestDbSupport.Db db : new TestDbSupport.Db[] {TestDbSupport.Db.H2, TestDbSupport.Db.HSQLDB, TestDbSupport.Db.DERBY}) { + try (Connection c = connect(db, "charpad")) { + try (Statement st = c.createStatement()) { + st.execute("create table char_pad_test (code char(10))"); + st.execute("insert into char_pad_test (code) values ('AB')"); + } + try (Statement st = c.createStatement(); + ResultSet rs = st.executeQuery("select code from char_pad_test")) { + rs.next(); + String value = rs.getString(1); + outcomes.add(new Outcome(db, value, value.length())); + } + } + } + + for (Outcome o : outcomes) { + DEMO.info("CHAR(10) holding 'AB', db={} -> getString() = [{}], length={}", o.db(), o.rawValue(), o.length()); + } + + // CORRECTION to the folklore: this is NOT Derby-specific. All three engines pad + // CHAR(10) to the full length on read -- getString() returns "AB " (length 10) + // on H2 and HSQLDB too, not just Derby. A naive `"AB".equals(value)` fails against ALL + // THREE of these engines for a CHAR column; only VARCHAR does not have this problem. + assertThat(outcomes).allSatisfy(o -> { + assertThat(o.length()).as("db=" + o.db()).isEqualTo(10); + assertThat(o.rawValue()).as("db=" + o.db()).isNotEqualTo("AB"); + }); + Outcome derby = outcomes.stream().filter(o -> o.db() == TestDbSupport.Db.DERBY).findFirst().orElseThrow(); + assertThat("AB".equals(derby.rawValue())) + .as("this is the exact naive comparison that silently fails against a padded CHAR column -- on any of the three") + .isFalse(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/testdb/DbCloseDelayTest.java b/src/test/java/com/ankurm/hibernatedemo/testdb/DbCloseDelayTest.java new file mode 100755 index 0000000..1739728 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/testdb/DbCloseDelayTest.java @@ -0,0 +1,61 @@ +package com.ankurm.hibernatedemo.testdb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/09-testing-in-memory-databases.md, chapter "What DB_CLOSE_DELAY=-1 actually does". Raw JDBC, no + * Hibernate, to isolate exactly the H2-specific behaviour rather than anything the ORM layer + * might paper over. + */ +class DbCloseDelayTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + @Test + void withoutCloseDelay_dataDisappearsWhenTheLastConnectionCloses() throws Exception { + String url = "jdbc:h2:mem:no-close-delay-demo"; // no DB_CLOSE_DELAY at all + try (Connection c1 = DriverManager.getConnection(url, "sa", "")) { + try (Statement st = c1.createStatement()) { + st.execute("create table t (id integer)"); + st.execute("insert into t values (1)"); + } + } // c1 closes here -- this is H2's LAST open connection to this in-memory DB + + // A brand new connection to the SAME url now gets a BRAND NEW, EMPTY database -- + // querying the table we just created throws, because it no longer exists. + try (Connection c2 = DriverManager.getConnection(url, "sa", ""); + Statement st = c2.createStatement()) { + SQLException ex = assertThrows(SQLException.class, () -> st.execute("select * from t")); + DEMO.info("without DB_CLOSE_DELAY=-1, reconnecting after the last close throws: {}: {}", + ex.getClass().getSimpleName(), ex.getMessage()); + } + } + + @Test + void withCloseDelayMinusOne_dataSurvivesAcrossConnectionCloses() throws Exception { + String url = "jdbc:h2:mem:with-close-delay-demo;DB_CLOSE_DELAY=-1"; + try (Connection c1 = DriverManager.getConnection(url, "sa", "")) { + try (Statement st = c1.createStatement()) { + st.execute("create table t (id integer)"); + st.execute("insert into t values (1)"); + } + } // c1 closes -- but DB_CLOSE_DELAY=-1 tells H2 to keep the in-memory DB alive anyway + + try (Connection c2 = DriverManager.getConnection(url, "sa", ""); + Statement st = c2.createStatement()) { + var rs = st.executeQuery("select id from t"); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(1); + DEMO.info("with DB_CLOSE_DELAY=-1, reconnecting after the last close still sees the row inserted earlier"); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/testdb/DialectAndDdlTest.java b/src/test/java/com/ankurm/hibernatedemo/testdb/DialectAndDdlTest.java new file mode 100755 index 0000000..4a0acd3 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/testdb/DialectAndDdlTest.java @@ -0,0 +1,68 @@ +package com.ankurm.hibernatedemo.testdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/09-testing-in-memory-databases.md, chapter "Which dialect gets auto-selected, and does H2's MODE= + * actually change it". Same {@link TestDbWidget} mapping, five registries (plain H2, H2 in + * PostgreSQL compat mode, H2 in Oracle compat mode, HSQLDB, Derby), one dialect log line each. + */ +class DialectAndDdlTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + private String resolvedDialect(TestDbSupport.Db db, String suffix) { + SessionFactory sf = TestDbSupport.buildSessionFactory(db, suffix, "create-drop", TestDbWidget.class); + try { + String dialectClass = sf.unwrap(SessionFactoryImplementor.class) + .getJdbcServices().getDialect().getClass().getName(); + DEMO.info("db={} -> resolved dialect = {}", db, dialectClass); + return dialectClass; + } finally { + sf.close(); + } + } + + @Test + void plainH2_resolvesH2Dialect() { + assertThat(resolvedDialect(TestDbSupport.Db.H2, "plain")).isEqualTo("org.hibernate.dialect.H2Dialect"); + } + + @Test + void h2WithPostgresModeInTheUrl_stillResolvesH2Dialect_notPostgresDialect() { + // This is the widely-misunderstood point: MODE=PostgreSQL changes what SQL *H2 itself* + // accepts, not which Hibernate Dialect class gets selected. Hibernate has no idea the + // URL contains MODE=PostgreSQL -- dialect resolution reads the JDBC driver's own + // DatabaseMetaData (product name "H2"), and H2 reports itself as H2 regardless of MODE. + assertThat(resolvedDialect(TestDbSupport.Db.H2_POSTGRES_MODE, "pgmode")) + .isEqualTo("org.hibernate.dialect.H2Dialect"); + } + + @Test + void h2WithOracleModeInTheUrl_stillResolvesH2Dialect_notOracleDialect() { + assertThat(resolvedDialect(TestDbSupport.Db.H2_ORACLE_MODE, "oraclemode")) + .isEqualTo("org.hibernate.dialect.H2Dialect"); + } + + @Test + void hsqldb_resolvesHSQLDialect() { + assertThat(resolvedDialect(TestDbSupport.Db.HSQLDB, "plain")).isEqualTo("org.hibernate.dialect.HSQLDialect"); + } + + @Test + void derby_resolvesDerbyDialect_fromTheCommunityDialectsModule_notHibernateCore() { + // NOT org.hibernate.dialect.DerbyDialect -- that class was removed from hibernate-core + // in Hibernate 7. See docs/output/testdb-derby-dialect-not-found.txt for the two + // failures (auto-detection, then the old FQCN) this test's setup had to work around. + assertThat(resolvedDialect(TestDbSupport.Db.DERBY, "plain")) + .isEqualTo("org.hibernate.community.dialect.DerbyDialect"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/testdb/JCacheOnClasspathAutoEnablesL2Test.java b/src/test/java/com/ankurm/hibernatedemo/testdb/JCacheOnClasspathAutoEnablesL2Test.java new file mode 100755 index 0000000..51600a5 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/testdb/JCacheOnClasspathAutoEnablesL2Test.java @@ -0,0 +1,56 @@ +package com.ankurm.hibernatedemo.testdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.junit.jupiter.api.Test; + +/** + * Merely having hibernate-jcache on the classpath is enough to turn the second-level cache ON, + * with nothing configured. Hibernate 7.4.5 resolves a RegionFactory through the service loader + * and enables L2 caching on the strength of finding one. + * + *

That is why {@code application.yml} in this repository pins + * {@code hibernate.cache.use_second_level_cache: false} explicitly. Before it did, a standalone + * test closing the shared Ehcache CacheManager broke an unrelated Spring test's commit with + * {@code Cache[...] is closed} -- see docs/output/testdb-jcache-classpath-pollution.txt. + * + *

This test boots a bare SessionFactory with no cache settings at all, so it still observes + * the raw Hibernate default rather than this project's pinned override. + * + *

Docs: docs/09-testing-in-memory-databases.md + */ +class JCacheOnClasspathAutoEnablesL2Test { + + @Test + void jcacheOnTheClasspath_enablesSecondLevelCaching_withNothingConfigured() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", "org.h2.Driver") + .applySetting("hibernate.connection.url", "jdbc:h2:mem:jcacheprobe") + .applySetting("hibernate.connection.username", "sa") + .applySetting("hibernate.hbm2ddl.auto", "create-drop") + .build(); + try (SessionFactory sf = new MetadataSources(registry) + .addAnnotatedClass(TestDbWidget.class) + .buildMetadata() + .buildSessionFactory()) { + + SessionFactoryImplementor impl = sf.unwrap(SessionFactoryImplementor.class); + boolean l2Enabled = impl.getSessionFactoryOptions().isSecondLevelCacheEnabled(); + String regionFactory = impl.getCache().getRegionFactory().getClass().getName(); + + System.out.println("nothing configured about caching anywhere in this bootstrap"); + System.out.println("second-level cache enabled = " + l2Enabled); + System.out.println("region factory = " + regionFactory); + + assertThat(l2Enabled).isTrue(); + assertThat(regionFactory).contains("JCacheRegionFactory"); + } finally { + StandardServiceRegistryBuilder.destroy(registry); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/testdb/SchemaIsolationTest.java b/src/test/java/com/ankurm/hibernatedemo/testdb/SchemaIsolationTest.java new file mode 100755 index 0000000..884b111 --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/testdb/SchemaIsolationTest.java @@ -0,0 +1,94 @@ +package com.ankurm.hibernatedemo.testdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/09-testing-in-memory-databases.md, chapter "Schema isolation: create-drop vs rollback vs + * DB_CLOSE_DELAY". Uses one shared {@link EntityManagerFactory} across two ordered tests to + * PROVE the pollution, then a second, correctly-isolated pair to prove the fix -- both against + * plain H2 (the pollution mechanism is not H2-specific, but H2 is what the rest of this repo + * already uses for its default datasource). + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SchemaIsolationTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + private static SessionFactory pollutedSf; + + @BeforeAll + static void openSharedFactory() { + // DB_CLOSE_DELAY=-1 is what keeps this in-memory H2 database alive across the many + // short-lived connections each Session opens and closes -- without it, H2 tears the + // whole in-memory database down the moment the LAST open connection closes, and the + // next openSession() call would silently reconnect to a brand-new, empty database + // instead of failing loudly. + pollutedSf = TestDbSupport.buildSessionFactory( + TestDbSupport.Db.H2, "isolation-demo", "create-drop", TestDbWidget.class); + } + + @AfterAll + static void closeSharedFactory() { + pollutedSf.close(); + } + + @Test + @Order(1) + void testA_insertsARowWithNoCleanup() { + Session session = pollutedSf.openSession(); + session.beginTransaction(); + session.persist(new TestDbWidget("AAAAA", 1, true, "left behind by testA")); + session.getTransaction().commit(); + session.close(); + DEMO.info("testA committed a row and did nothing to clean it up"); + } + + @Test + @Order(2) + void testB_seesTestAsRow_becauseThereWasNoIsolationBetweenThem() { + Session session = pollutedSf.openSession(); + long count = session.createQuery("select count(w) from TestDbWidget w", Long.class).getSingleResult(); + session.close(); + DEMO.info("testB sees {} row(s) -- at least one is testA's leftover, proving pollution when isolation is skipped", count); + // The point: testB did not insert anything itself yet, but the row count is already + // >= 1, because testA's commit was never rolled back or dropped. + assertThat(count).isGreaterThanOrEqualTo(1); + } + + // ---- Now the same shape, but done correctly with @Transactional-style manual rollback ---- + + @Test + @Order(3) + void testC_insertsARow_thenRollsBackInsteadOfCommitting() { + Session session = pollutedSf.openSession(); + session.beginTransaction(); + session.persist(new TestDbWidget("BBBBB", 2, true, "rolled back by testC, must not survive")); + long duringTxn = session.createQuery("select count(w) from TestDbWidget w", Long.class).getSingleResult(); + session.getTransaction().rollback(); + session.close(); + DEMO.info("testC saw {} row(s) DURING its own transaction (includes its own uncommitted insert), then rolled back", duringTxn); + } + + @Test + @Order(4) + void testD_doesNotSeeTestCsRolledBackRow() { + Session session = pollutedSf.openSession(); + long count = session.createQuery( + "select count(w) from TestDbWidget w where w.sku = :sku", Long.class) + .setParameter("sku", "BBBBB") + .getSingleResult(); + session.close(); + DEMO.info("testD looks specifically for testC's SKU 'BBBBB' -- found {} (must be 0: rollback isolated it)", count); + assertThat(count).isZero(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/testdb/StartupTimingTest.java b/src/test/java/com/ankurm/hibernatedemo/testdb/StartupTimingTest.java new file mode 100755 index 0000000..9ae911a --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/testdb/StartupTimingTest.java @@ -0,0 +1,38 @@ +package com.ankurm.hibernatedemo.testdb; + +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Backs docs/09-testing-in-memory-databases.md, chapter "Startup time, with the caveat this is a shared + * container". NOT a benchmark -- three runs per database, plain {@code System.nanoTime()}, + * one shared, likely-noisy sandbox container. The numbers are indicative of relative order of + * magnitude only, and the docs/output file this writes says so explicitly. + */ +class StartupTimingTest { + + private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); + + private long timeOneBuild(TestDbSupport.Db db, String suffix) { + long start = System.nanoTime(); + SessionFactory sf = TestDbSupport.buildSessionFactory(db, suffix, "create-drop", TestDbWidget.class); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + sf.close(); + return elapsedMs; + } + + @Test + void measureStartupAcrossThreeRunsPerDatabase() { + TestDbSupport.Db[] dbs = {TestDbSupport.Db.H2, TestDbSupport.Db.HSQLDB, TestDbSupport.Db.DERBY}; + for (TestDbSupport.Db db : dbs) { + long[] times = new long[3]; + for (int i = 0; i < 3; i++) { + times[i] = timeOneBuild(db, "timing" + i); + } + DEMO.info("startup ms for {} over 3 runs: {}, {}, {} (sandbox container -- indicative only, not a benchmark)", + db, times[0], times[1], times[2]); + } + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/testdb/TestDbSupport.java b/src/test/java/com/ankurm/hibernatedemo/testdb/TestDbSupport.java new file mode 100755 index 0000000..a1fa56c --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/testdb/TestDbSupport.java @@ -0,0 +1,75 @@ +package com.ankurm.hibernatedemo.testdb; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; + +/** + * Shared plumbing for docs/09-testing-in-memory-databases.md. Builds a plain Hibernate {@link SessionFactory} -- + * no Spring involved -- against one of the three databases, with the SAME {@link TestDbWidget} + * mapping every time. Kept deliberately outside Spring Boot's own datasource auto-configuration + * so each database's dialect resolution, generated DDL, and generator behaviour can be observed + * directly and independently, one at a time. + */ +final class TestDbSupport { + + enum Db { + H2("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1", "org.h2.Driver", "sa", ""), + H2_POSTGRES_MODE("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", "org.h2.Driver", "sa", ""), + H2_ORACLE_MODE("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1;MODE=Oracle", "org.h2.Driver", "sa", ""), + HSQLDB("jdbc:hsqldb:mem:testdb-%s", "org.hsqldb.jdbc.JDBCDriver", "SA", ""), + DERBY("jdbc:derby:memory:testdb-%s;create=true", "org.apache.derby.jdbc.EmbeddedDriver", "APP", "APP"); + + final String urlTemplate; + final String driver; + final String user; + final String password; + + Db(String urlTemplate, String driver, String user, String password) { + this.urlTemplate = urlTemplate; + this.driver = driver; + this.user = user; + this.password = password; + } + + String url(String schemaSuffix) { + return urlTemplate.formatted(schemaSuffix); + } + } + + private TestDbSupport() { + } + + static StandardServiceRegistry buildRegistry(Db db, String schemaSuffix, String hbm2ddl) { + StandardServiceRegistryBuilder builder = new StandardServiceRegistryBuilder() + .applySetting("hibernate.connection.driver_class", db.driver) + .applySetting("hibernate.connection.url", db.url(schemaSuffix)) + .applySetting("hibernate.connection.username", db.user) + .applySetting("hibernate.connection.password", db.password) + .applySetting("hibernate.hbm2ddl.auto", hbm2ddl) + .applySetting("hibernate.show_sql", "true") + .applySetting("hibernate.format_sql", "false"); + if (db == Db.DERBY) { + // Hibernate 7 removed Derby dialect support from hibernate-core. Auto-detection + // against a live Derby connection fails outright ("Unable to determine Dialect for + // Apache Derby 10.16"), and the OLD FQCN org.hibernate.dialect.DerbyDialect no + // longer exists either -- it moved to the separate hibernate-community-dialects + // artifact under a NEW package, org.hibernate.community.dialect.DerbyDialect. + // See docs/output/testdb-derby-dialect-not-found.txt for both failures, verbatim. + builder.applySetting("hibernate.dialect", "org.hibernate.community.dialect.DerbyDialect"); + } + return builder.build(); + } + + static SessionFactory buildSessionFactory(Db db, String schemaSuffix, String hbm2ddl, Class... entities) { + StandardServiceRegistry registry = buildRegistry(db, schemaSuffix, hbm2ddl); + MetadataSources sources = new MetadataSources(registry); + for (Class e : entities) { + sources.addAnnotatedClass(e); + } + Metadata metadata = sources.buildMetadata(); + return metadata.buildSessionFactory(); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/validation/CdiValidationTest.java b/src/test/java/com/ankurm/hibernatedemo/validation/CdiValidationTest.java new file mode 100644 index 0000000..8b8879e --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/validation/CdiValidationTest.java @@ -0,0 +1,72 @@ +package com.ankurm.hibernatedemo.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import java.util.Set; +import org.jboss.weld.environment.se.Weld; +import org.jboss.weld.environment.se.WeldContainer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The other half of the measurement {@link PlainValidationNoCdiTest} started: run the identical + * {@link StockLevel}/{@link PositiveInventoryValidator} pair inside a real, standalone CDI + * container (Weld SE -- no Jakarta EE server) with {@code hibernate-validator-cdi} on the + * classpath, and see whether {@code @Inject} actually works this time. + * + *

It does, and the mechanism is worth naming rather than treating as magic: {@code + * hibernate-validator-cdi-9.1.3.Final.jar} registers {@code + * org.hibernate.validator.cdi.ValidationExtension} as a {@code jakarta.enterprise.inject.spi.Extension} + * (confirmed via its {@code META-INF/services/jakarta.enterprise.inject.spi.Extension} file). + * Once Weld picks that extension up, it contributes CDI beans for {@code Validator}/{@code + * ValidatorFactory} whose {@code ConstraintValidatorFactory} is {@code + * org.hibernate.validator.cdi.spi.InjectingConstraintValidatorFactory} -- a factory that builds + * each {@code ConstraintValidator} instance through the CDI {@code BeanManager} instead of plain + * reflection, so {@code @Inject} fields on it are resolved like any other managed bean's. + * + *

Docs: docs/20-hibernate-validator-cdi.md + */ +class CdiValidationTest { + + private WeldContainer container; + + @AfterEach + void tearDown() { + if (container != null) { + container.close(); + } + } + + @Test + void constraintValidatorsInjectField_isProperlyPopulated_insideARunningCdiContainer() { + container = new Weld().initialize(); + Validator validator = container.select(Validator.class).get(); + + Set> belowThreshold = validator.validate(new StockLevel(3)); + Set> atThreshold = validator.validate(new StockLevel(5)); + Set> aboveThreshold = validator.validate(new StockLevel(10)); + + System.out.println("RESULT[cdi-validation-injection-works]: validator obtained from a running " + + "Weld SE container | StockLevel(3) violations=" + belowThreshold.size() + + " | StockLevel(5) violations=" + atThreshold.size() + + " | StockLevel(10) violations=" + aboveThreshold.size() + + " -- InventoryPolicy.minimumThreshold()=5 was actually injected and actually used, " + + "no NullPointerException anywhere."); + + assertThat(belowThreshold) + .as("3 is below the injected policy's threshold of 5 -- a real constraint violation, " + + "not an exception") + .hasSize(1); + assertThat(atThreshold) + .as("5 meets the threshold exactly") + .isEmpty(); + assertThat(aboveThreshold) + .as("10 comfortably clears the threshold") + .isEmpty(); + + assertThat(belowThreshold.iterator().next().getMessage()) + .isEqualTo("quantity is below the minimum inventory threshold"); + } +} diff --git a/src/test/java/com/ankurm/hibernatedemo/validation/PlainValidationNoCdiTest.java b/src/test/java/com/ankurm/hibernatedemo/validation/PlainValidationNoCdiTest.java new file mode 100644 index 0000000..dd320dd --- /dev/null +++ b/src/test/java/com/ankurm/hibernatedemo/validation/PlainValidationNoCdiTest.java @@ -0,0 +1,77 @@ +package com.ankurm.hibernatedemo.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.Test; + +/** + * The baseline this chapter needs before the CDI test means anything: plain Jakarta Bean + * Validation, bootstrapped the ordinary way with no CDI container running at all. {@code + * Validation.buildDefaultValidatorFactory()} builds its {@code ConstraintValidatorFactory} by + * plain reflection ({@code Class.newInstance()}-equivalent) -- it does not scan for {@code + * @Inject} at all, so {@link PositiveInventoryValidator#policy} is left {@code null}. + * + *

Docs: docs/20-hibernate-validator-cdi.md + */ +class PlainValidationNoCdiTest { + + @Test + void constraintValidatorsInjectField_isNeverPopulated_withoutACdiContainer() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + Validator validator = factory.getValidator(); + + Throwable thrown = catchThrowable(() -> validator.validate(new StockLevel(3))); + + System.out.println("RESULT[cdi-plain-validation-no-injection]: validating StockLevel(3) with " + + "Validation.buildDefaultValidatorFactory() (no CDI container running) throws " + + describeChain(thrown)); + + assertThat(thrown).isNotNull(); + Throwable rootCause = rootCause(thrown); + assertThat(rootCause) + .as("the @Inject-annotated policy field was never populated, so calling " + + "policy.minimumThreshold() inside isValid() throws a plain NullPointerException " + + "-- not a validation failure, a validator bug caused by the missing CDI container") + .isInstanceOf(NullPointerException.class); + + factory.close(); + } + + private static Throwable catchThrowable(ThrowingRunnable runnable) { + try { + runnable.run(); + return null; + } catch (Throwable t) { + return t; + } + } + + private static Throwable rootCause(Throwable t) { + Throwable cause = t; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return cause; + } + + private static String describeChain(Throwable t) { + StringBuilder sb = new StringBuilder(); + Throwable current = t; + while (current != null) { + sb.append(current.getClass().getName()); + if (current.getCause() != null && current.getCause() != current) { + sb.append(" -> caused by "); + } + current = current.getCause() == current ? null : current.getCause(); + } + return sb.toString(); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/src/test/resources/META-INF/persistence.xml b/src/test/resources/META-INF/persistence.xml new file mode 100644 index 0000000..db9746d --- /dev/null +++ b/src/test/resources/META-INF/persistence.xml @@ -0,0 +1,32 @@ + + + + + + org.hibernate.jpa.HibernatePersistenceProvider + com.ankurm.hibernatedemo.bootstrap.BootstrapUser + + + + + + + + + + + + + diff --git a/src/test/resources/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml b/src/test/resources/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml new file mode 100755 index 0000000..c64badf --- /dev/null +++ b/src/test/resources/com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/test/resources/ehcache-chapter18-missing-timestamps.xml b/src/test/resources/ehcache-chapter18-missing-timestamps.xml new file mode 100644 index 0000000..6725bc8 --- /dev/null +++ b/src/test/resources/ehcache-chapter18-missing-timestamps.xml @@ -0,0 +1,15 @@ + + + + + 30 + + + 1000 + + + diff --git a/src/test/resources/ehcache-chapter18.xml b/src/test/resources/ehcache-chapter18.xml new file mode 100644 index 0000000..4b80a3b --- /dev/null +++ b/src/test/resources/ehcache-chapter18.xml @@ -0,0 +1,37 @@ + + + + + 30 + + + 1000 + 10 + + + + + + + + + + 5000 + + + + + + 10 + + + 200 + + + diff --git a/src/test/resources/mapping-xml-natural-id.xml b/src/test/resources/mapping-xml-natural-id.xml new file mode 100755 index 0000000..13b02b2 --- /dev/null +++ b/src/test/resources/mapping-xml-natural-id.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/orm-xml-metadata-complete.xml b/src/test/resources/orm-xml-metadata-complete.xml new file mode 100755 index 0000000..0fc9ef0 --- /dev/null +++ b/src/test/resources/orm-xml-metadata-complete.xml @@ -0,0 +1,17 @@ + + + + + + +
+ + + + + + + diff --git a/src/test/resources/orm-xml-only-mapping.xml b/src/test/resources/orm-xml-only-mapping.xml new file mode 100755 index 0000000..7a8601f --- /dev/null +++ b/src/test/resources/orm-xml-only-mapping.xml @@ -0,0 +1,17 @@ + + + +
+ + + + + + + + + + diff --git a/src/test/resources/orm-xml-override-mapping.xml b/src/test/resources/orm-xml-override-mapping.xml new file mode 100755 index 0000000..7cd31d9 --- /dev/null +++ b/src/test/resources/orm-xml-override-mapping.xml @@ -0,0 +1,14 @@ + + + +
+ + + + + + + diff --git a/src/test/resources/osiv-default-test.yml b/src/test/resources/osiv-default-test.yml new file mode 100755 index 0000000..66fedb8 --- /dev/null +++ b/src/test/resources/osiv-default-test.yml @@ -0,0 +1,23 @@ +# Used ONLY by OsivDefaultWarningTest, via spring.config.name=osiv-default-test. +# Deliberately omits spring.jpa.open-in-view entirely -- the point of that test is to observe +# Boot's real, unset-property default (true) and the startup warning that comes with it. The +# shared src/main/resources/application.yml sets open-in-view: false explicitly, which would +# suppress both the warning and the masking behaviour, so that file must not be in play here. +spring: + datasource: + url: jdbc:h2:mem:osiv-default-test;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + show_sql: true + +logging: + level: + root: WARN + DEMO: INFO + org.springframework.boot.jpa.autoconfigure: WARN