From 0245c00033ae8cd13903776fd4929752e23dcd36 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Fri, 6 Nov 2026 01:05:17 +0530 Subject: [PATCH] New posts for Spring and Hibernate --- README.md | 1 + id-generation/README.md | 49 ++++ id-generation/output/00-javap.txt | 76 ++++++ id-generation/output/00b-style-by-version.txt | 7 + id-generation/output/01-uuid-styles.txt | 57 +++++ .../output/01b-uuid-generation-rate.txt | 7 + .../output/02-identity-vs-sequence.txt | 43 ++++ .../output/02b-default-sequence-naming.txt | 17 ++ id-generation/output/03-batch-inserts.txt | 19 ++ id-generation/output/03b-order-inserts.txt | 9 + .../output/04-sequence-increment-mismatch.txt | 30 +++ .../04b-sequence-step-bigger-than-mapping.txt | 12 + .../output/05-auto-and-uuid-per-dialect.txt | 49 ++++ id-generation/output/06-index-size-h2.txt | 8 + .../output/06b-index-size-postgres.txt | 2 + id-generation/output/07-spring-data-save.txt | 20 ++ id-generation/pom.xml | 73 ++++++ id-generation/scripts/run-all.sh | 27 ++ .../java/com/ankurm/idgen/AutoLongItem.java | 22 ++ .../java/com/ankurm/idgen/AutoUuidItem.java | 22 ++ .../src/main/java/com/ankurm/idgen/Child.java | 25 ++ .../com/ankurm/idgen/HandRolledV7Item.java | 22 ++ .../java/com/ankurm/idgen/IdentityItem.java | 22 ++ .../main/java/com/ankurm/idgen/Parent.java | 27 ++ .../java/com/ankurm/idgen/SeqDefaultItem.java | 22 ++ .../java/com/ankurm/idgen/SeqOneItem.java | 23 ++ .../java/com/ankurm/idgen/SeqPooledItem.java | 23 ++ .../main/java/com/ankurm/idgen/TableItem.java | 22 ++ .../java/com/ankurm/idgen/UuidCharItem.java | 25 ++ .../java/com/ankurm/idgen/UuidCreatorV7.java | 14 ++ .../com/ankurm/idgen/UuidCreatorV7Item.java | 22 ++ .../java/com/ankurm/idgen/UuidTimeItem.java | 22 ++ .../java/com/ankurm/idgen/UuidV4Item.java | 22 ++ .../java/com/ankurm/idgen/UuidV6Item.java | 22 ++ .../com/ankurm/idgen/UuidV7Generator.java | 42 ++++ .../java/com/ankurm/idgen/UuidV7Item.java | 22 ++ .../com/ankurm/idgen/spring/AssignedBook.java | 19 ++ .../idgen/spring/AssignedBookRepository.java | 7 + .../ankurm/idgen/spring/GeneratedBook.java | 21 ++ .../idgen/spring/GeneratedBookRepository.java | 7 + .../ankurm/idgen/spring/PersistableBook.java | 34 +++ .../spring/PersistableBookRepository.java | 7 + .../idgen/spring/SpringIdApplication.java | 8 + .../ankurm/idgen/spring/VersionedBook.java | 22 ++ .../idgen/spring/VersionedBookRepository.java | 7 + .../com/ankurm/idgen/A01_UuidStylesTest.java | 143 +++++++++++ .../idgen/A02_IdentityVsSequenceTest.java | 91 +++++++ .../com/ankurm/idgen/A03_BatchInsertTest.java | 121 +++++++++ .../ankurm/idgen/A04_MismatchAndAutoTest.java | 100 ++++++++ .../ankurm/idgen/A05_IndexLocalityTest.java | 100 ++++++++ .../com/ankurm/idgen/A06_SpringDataTest.java | 65 +++++ .../src/test/java/com/ankurm/idgen/Both.java | 29 +++ .../src/test/java/com/ankurm/idgen/Db.java | 232 ++++++++++++++++++ .../test/java/com/ankurm/idgen/DdlOnly.java | 29 +++ .../java/com/ankurm/idgen/JdbcCounter.java | 80 ++++++ .../test/java/com/ankurm/idgen/SqlLog.java | 24 ++ .../java/com/ankurm/idgen/Transcript.java | 40 +++ 57 files changed, 2113 insertions(+) create mode 100644 id-generation/README.md create mode 100644 id-generation/output/00-javap.txt create mode 100644 id-generation/output/00b-style-by-version.txt create mode 100644 id-generation/output/01-uuid-styles.txt create mode 100644 id-generation/output/01b-uuid-generation-rate.txt create mode 100644 id-generation/output/02-identity-vs-sequence.txt create mode 100644 id-generation/output/02b-default-sequence-naming.txt create mode 100644 id-generation/output/03-batch-inserts.txt create mode 100644 id-generation/output/03b-order-inserts.txt create mode 100644 id-generation/output/04-sequence-increment-mismatch.txt create mode 100644 id-generation/output/04b-sequence-step-bigger-than-mapping.txt create mode 100644 id-generation/output/05-auto-and-uuid-per-dialect.txt create mode 100644 id-generation/output/06-index-size-h2.txt create mode 100644 id-generation/output/06b-index-size-postgres.txt create mode 100644 id-generation/output/07-spring-data-save.txt create mode 100644 id-generation/pom.xml create mode 100644 id-generation/scripts/run-all.sh create mode 100644 id-generation/src/main/java/com/ankurm/idgen/AutoLongItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/AutoUuidItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/Child.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/HandRolledV7Item.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/IdentityItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/Parent.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/SeqDefaultItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/SeqOneItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/SeqPooledItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/TableItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidCharItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7Item.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidTimeItem.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidV4Item.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidV6Item.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidV7Generator.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/UuidV7Item.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBook.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBookRepository.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBook.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBookRepository.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBook.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBookRepository.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/SpringIdApplication.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBook.java create mode 100644 id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBookRepository.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/A01_UuidStylesTest.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/A02_IdentityVsSequenceTest.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/A03_BatchInsertTest.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/A04_MismatchAndAutoTest.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/A05_IndexLocalityTest.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/A06_SpringDataTest.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/Both.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/Db.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/DdlOnly.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/JdbcCounter.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/SqlLog.java create mode 100644 id-generation/src/test/java/com/ankurm/idgen/Transcript.java diff --git a/README.md b/README.md index f0909f5..76e5e3e 100755 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ Modules with their own `pom.xml` that run plain Hibernate without the Spring Boo | [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) | | [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) | | [`soft-delete`](soft-delete/) | `@SoftDelete` (`DELETED` / `ACTIVE` / `TIMESTAMP`) vs `@SQLDelete` + `@SQLRestriction`: generated SQL, restore, unique constraints, associations, native-query bypass and Spring Data JPA, on H2 and PostgreSQL 17 (Hibernate `7.4.14.Final`; unlike the others this module uses the Spring Boot 4.1.1 parent, for the Spring Data tests) | +| [`id-generation`](id-generation/) | IDENTITY vs SEQUENCE vs UUID (random, `Style.TIME`, v7): the SQL each produces, why IDENTITY disables JDBC batching, pooled optimizer and `allocationSize` mismatches, and Spring Data's extra SELECT on assigned ids, on H2 (PostgreSQL parts skipped, no Docker); see [the article](https://ankurm.com/hibernate-id-generation-identity-sequence-uuid-v7-batch-inserts/) | | [`migration-5-to-7`](migration-5-to-7/) | Hibernate `5.6.15.Final` vs `7.4.14.Final`: jar diff, Hibernate 5 code compiled against both, schema defaults, `hbm2ddl` validation of a Hibernate 5 schema, and cheat-sheet snippets | | [`multitenancy`](multitenancy/) | Hibernate 7 multi-tenancy on Spring Boot 4.1.2: `@TenantId` discriminator vs schema-per-tenant (shared pool with `search_path`, and pool per tenant), tenant from a JWT claim, the `search_path` leak and the no-tenant failure modes, on PostgreSQL 17 (Hibernate `7.4.14.Final`) | | [`json-columns`](json-columns/) | JSON columns in Hibernate 7.4: `@JdbcTypeCode(SqlTypes.JSON)` for Map / List / record / POJO / `@Embeddable`, `json` vs `jsonb` DDL, HQL JSON functions, native JSONB operators, GIN and expression indexes with `EXPLAIN`, dirty checking, Jackson 3 vs Jackson 2 `FormatMapper`, MySQL 8.0, on PostgreSQL 18 (Hibernate `7.4.14.Final`, Spring Boot `4.1.2`) | diff --git a/id-generation/README.md b/id-generation/README.md new file mode 100644 index 0000000..1e77b78 --- /dev/null +++ b/id-generation/README.md @@ -0,0 +1,49 @@ +# id-generation + +Companion module for the ankurm.com article **Hibernate ID Generation: IDENTITY vs SEQUENCE vs UUID v7, and Why Batch Inserts Break** +(https://ankurm.com/hibernate-id-generation-identity-sequence-uuid-v7-batch-inserts/). +Every claim in the article comes from a test here; each test writes what it observed to `output/` and asserts the same facts. + +## Versions + +| Component | Version | Notes | +|---|---|---| +| Hibernate ORM | `7.4.14.Final` | newest 7.x GA on Maven Central on 2026-11-05 (`8.0.0.Beta4` is listed but is not GA). Overrides the `7.4.5.Final` that Spring Boot 4.1.1 manages, same as `soft-delete` | +| Spring Boot parent | `4.1.1` | same parent as the sibling modules (`4.1.2` was already on Maven Central; not adopted so the modules stay comparable) | +| uuid-creator | `6.1.1` | only for one comparison generator | +| H2 | `2.4.240` | in memory, plus file databases for the index-size test | +| PostgreSQL | `17` (`postgres:17-alpine`) | Testcontainers. **Not run for the committed transcripts**, see below | +| Java | `25` (Temurin/Oracle 25.0.3) | | + +## Run + +```bash +./scripts/run-all.sh # javap dump + mvn clean test; regenerates everything in output/ +``` + +Tests that can use PostgreSQL run against it when a Docker daemon answers and otherwise write `SKIPPED: no Docker daemon ...` into the +transcript. **The committed transcripts were produced without Docker, so every PostgreSQL section says SKIPPED and the PostgreSQL code paths +(including `06b-index-size-postgres.txt`: `pg_relation_size`, `pgstatindex`) have not been executed.** Run the script on a machine with Docker to fill them in. + +## Output + +| File | What it shows | +|---|---| +| `00-javap.txt` | `javap` of `UuidGenerator`, `UuidGenerator$Style`, `UuidValueGenerator`, `UuidVersion7Strategy`, `SequenceMismatchStrategy`, `StandardOptimizerDescriptor` and the static members of `java.util.UUID` | +| `00b-style-by-version.txt` | the `Style` constants in hibernate-core 6.5.2, 6.6.8, 7.0.0, 7.4.0, 7.4.14 (hand-run `javap`, not regenerated by the script) | +| `01-uuid-styles.txt` | version nibble, variant and ordering of ids from every `Style`, the JDK's UUID factories, what `Style.TIME` really contains | +| `01b-uuid-generation-rate.txt` | in-memory cost of generating one id per generator (indicative) | +| `02-identity-vs-sequence.txt` | DDL per strategy, IDENTITY insert at `persist()`, sequence calls for `allocationSize` 1 vs 50, TABLE generator statements | +| `02b-default-sequence-naming.txt` | default sequence names and the `legacy` / `single` naming switch | +| `03-batch-inserts.txt` | 10,000 inserts per strategy: prepared statements, `addBatch`, `executeBatch` counts, indicative timings | +| `03b-order-inserts.txt` | `hibernate.order_inserts` on a parent/child graph | +| `04-sequence-increment-mismatch.txt` | mapping `allocationSize=50` over a sequence `INCREMENT BY 1`, under each mismatch strategy | +| `04b-sequence-step-bigger-than-mapping.txt` | mapping `allocationSize=1` over a sequence `INCREMENT BY 50` | +| `05-auto-and-uuid-per-dialect.txt` | what `@GeneratedValue` and UUID columns become on H2, PostgreSQL, MySQL, MariaDB, Oracle, SQL Server (DDL only) | +| `06-index-size-h2.txt` | 200,000 rows per key type in an H2 file database: file size | +| `06b-index-size-postgres.txt` | the same on PostgreSQL (`SKIPPED` without Docker) | +| `07-spring-data-save.txt` | `save()` on assigned vs generated ids: which ones cost a `SELECT` first | + +## Not covered (not run) + +MySQL / InnoDB clustered-index behaviour, SQL Server, Oracle (DDL only), PostgreSQL index measurements, multi-node clock skew, `hibernate.id.optimizer.pooled.preferred` variants. diff --git a/id-generation/output/00-javap.txt b/id-generation/output/00-javap.txt new file mode 100644 index 0000000..abd7215 --- /dev/null +++ b/id-generation/output/00-javap.txt @@ -0,0 +1,76 @@ +# javap of the shipped hibernate-core-7.4.14.Final.jar + +$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.UuidGenerator +Compiled from "UuidGenerator.java" +public interface org.hibernate.annotations.UuidGenerator extends java.lang.annotation.Annotation { + public abstract org.hibernate.annotations.UuidGenerator$Style style(); + public abstract java.lang.Class algorithm(); +} + +$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.UuidGenerator$Style +Compiled from "UuidGenerator.java" +public final class org.hibernate.annotations.UuidGenerator$Style extends java.lang.Enum { + public static final org.hibernate.annotations.UuidGenerator$Style AUTO; + public static final org.hibernate.annotations.UuidGenerator$Style RANDOM; + public static final org.hibernate.annotations.UuidGenerator$Style TIME; + public static final org.hibernate.annotations.UuidGenerator$Style VERSION_6; + public static final org.hibernate.annotations.UuidGenerator$Style VERSION_7; + public static org.hibernate.annotations.UuidGenerator$Style[] values(); + public static org.hibernate.annotations.UuidGenerator$Style valueOf(java.lang.String); + static {}; +} + +$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.uuid.UuidValueGenerator +Compiled from "UuidValueGenerator.java" +public interface org.hibernate.id.uuid.UuidValueGenerator { + public abstract java.util.UUID generateUuid(org.hibernate.engine.spi.SharedSessionContractImplementor); +} + +$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.uuid.UuidVersion7Strategy +Compiled from "UuidVersion7Strategy.java" +public class org.hibernate.id.uuid.UuidVersion7Strategy implements org.hibernate.id.UUIDGenerationStrategy,org.hibernate.id.uuid.UuidValueGenerator { + public static final org.hibernate.id.uuid.UuidVersion7Strategy INSTANCE; + public org.hibernate.id.uuid.UuidVersion7Strategy(); + public org.hibernate.id.uuid.UuidVersion7Strategy(java.time.Instant, long); + public int getGeneratedVersion(); + public java.util.UUID generateUUID(org.hibernate.engine.spi.SharedSessionContractImplementor); + public java.util.UUID generateUuid(org.hibernate.engine.spi.SharedSessionContractImplementor); + static {}; +} + +$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.SequenceMismatchStrategy +Compiled from "SequenceMismatchStrategy.java" +public final class org.hibernate.id.SequenceMismatchStrategy extends java.lang.Enum { + public static final org.hibernate.id.SequenceMismatchStrategy LOG; + public static final org.hibernate.id.SequenceMismatchStrategy EXCEPTION; + public static final org.hibernate.id.SequenceMismatchStrategy FIX; + public static final org.hibernate.id.SequenceMismatchStrategy NONE; + public static org.hibernate.id.SequenceMismatchStrategy[] values(); + public static org.hibernate.id.SequenceMismatchStrategy valueOf(java.lang.String); + public static org.hibernate.id.SequenceMismatchStrategy interpret(java.lang.Object); + static {}; +} + +$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.enhanced.StandardOptimizerDescriptor +Compiled from "StandardOptimizerDescriptor.java" +public final class org.hibernate.id.enhanced.StandardOptimizerDescriptor extends java.lang.Enum implements org.hibernate.id.enhanced.OptimizerDescriptor { + public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor NONE; + public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor HILO; + public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor LEGACY_HILO; + public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor POOLED; + public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor POOLED_LO; + public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor POOLED_LOTL; + public static org.hibernate.id.enhanced.StandardOptimizerDescriptor[] values(); + public static org.hibernate.id.enhanced.StandardOptimizerDescriptor valueOf(java.lang.String); + public java.lang.String getExternalName(); + public java.lang.Class getOptimizerClass(); + public boolean isPooled(); + public static org.hibernate.id.enhanced.OptimizerDescriptor fromExternalName(java.lang.String); + static {}; +} + +$ javap java.util.UUID (static members only) + public static java.util.UUID randomUUID(); + public static java.util.UUID nameUUIDFromBytes(byte[]); + public static java.util.UUID fromString(java.lang.String); + static {}; diff --git a/id-generation/output/00b-style-by-version.txt b/id-generation/output/00b-style-by-version.txt new file mode 100644 index 0000000..4f5394e --- /dev/null +++ b/id-generation/output/00b-style-by-version.txt @@ -0,0 +1,7 @@ +# UuidGenerator.Style constants per hibernate-core release (javap of each jar; produced by hand while writing the article, not by scripts/run-all.sh) + +hibernate-core-6.5.2.Final: AUTO RANDOM TIME | UuidVersion7Strategy in jar: 0 +hibernate-core-6.6.8.Final: AUTO RANDOM TIME | UuidVersion7Strategy in jar: 0 +hibernate-core-7.0.0.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1 +hibernate-core-7.4.0.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1 +hibernate-core-7.4.14.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1 diff --git a/id-generation/output/01-uuid-styles.txt b/id-generation/output/01-uuid-styles.txt new file mode 100644 index 0000000..a866dd7 --- /dev/null +++ b/id-generation/output/01-uuid-styles.txt @@ -0,0 +1,57 @@ +UuidGenerator.Style values and what the generated UUIDs look like. Hibernate 7.4.14.Final, JDK 25.0.3+9-LTS-195 + +== The enum and the annotation, from the shipped jar +Style values : [AUTO, RANDOM, TIME, VERSION_6, VERSION_7] +annotation attributes : [algorithm, style] + +== Does the JDK itself generate version 7? (public static methods of java.util.UUID) +static factories: [fromString, nameUUIDFromBytes, randomUUID] + +== GenerationType.UUID (JPA) (UuidV4Item, 2000 persists in one transaction) +first three : [5b257f75-63bc-4f48-905d-65bac3459d13, 8b636cac-72ba-4740-baa4-960522598e0f, 7563a4d5-dfe5-4be5-b1f5-55e71a83eaec] +version nibble of every id : [4], variant : [2] +string/byte order vs generation order : 1017 of 1999 neighbours are out of order + +== bare @GeneratedValue on a UUID (AUTO) (AutoUuidItem, 2000 persists in one transaction) +first three : [35ad5be5-7f04-4dc8-83b9-f2266e101ab0, 78a0cefb-b172-46e4-94aa-60212ad0b190, 162be54a-33b8-4018-8df9-5e43836cc2af] +version nibble of every id : [4], variant : [2] +string/byte order vs generation order : 1005 of 1999 neighbours are out of order + +== Style.TIME (UuidTimeItem, 2000 persists in one transaction) +first three : [c0a802bb-a1ad-1754-81a1-ad076a3225a0, c0a802bb-a1ad-1754-81a1-ad076a3225a1, c0a802bb-a1ad-1754-81a1-ad076a3225a2] +version nibble of every id : [1], variant : [2] +string/byte order vs generation order : 0 of 1999 neighbours are out of order +UUID.timestamp() of first / last id (100ns ticks since 1582) : 528224823942841019 / 528224823942841019 +most significant 32 bits (time_low) of first three : [c0a802bb, c0a802bb, c0a802bb] + +== Style.VERSION_6 (UuidV6Item, 2000 persists in one transaction) +first three : [1f1d9490-534d-6627-8be9-341e19966c04, 1f1d9490-534d-6627-8bea-9c10655ddb7b, 1f1d9490-534d-6627-8beb-b5f923905e48] +version nibble of every id : [6], variant : [2] +string/byte order vs generation order : 0 of 1999 neighbours are out of order + +== Style.VERSION_7 (UuidV7Item, 2000 persists in one transaction) +first three : [01a1ad07-6af9-7f60-91e5-a074a2e61b15, 01a1ad07-6af9-7f60-bafb-92ecaabdd57e, 01a1ad07-6af9-7f61-84be-68a7813aa2e1] +version nibble of every id : [7], variant : [2] +string/byte order vs generation order : 0 of 1999 neighbours are out of order +first 48 bits as Unix milliseconds : 1793904306937 -> 2026-11-05T18:45:06.937Z + +== uuid-creator getTimeOrderedEpoch() (UuidCreatorV7Item, 2000 persists in one transaction) +first three : [01a1ad07-6b4a-7727-8708-c7b485cd7cc5, 01a1ad07-6b4a-7727-8709-75a505ff49d2, 01a1ad07-6b4a-7727-870a-1991eca1f5b5] +version nibble of every id : [7], variant : [2] +string/byte order vs generation order : 0 of 1999 neighbours are out of order +first 48 bits as Unix milliseconds : 1793904307018 -> 2026-11-05T18:45:07.018Z + +== hand-written UuidV7Generator (HandRolledV7Item, 2000 persists in one transaction) +first three : [01a1ad07-6ba5-7041-a4e3-8df974d6a001, 01a1ad07-6ba5-7042-8be9-c72dadba0e18, 01a1ad07-6ba5-7043-87de-73e7de8dbb54] +version nibble of every id : [7], variant : [2] +string/byte order vs generation order : 0 of 1999 neighbours are out of order +first 48 bits as Unix milliseconds : 1793904307109 -> 2026-11-05T18:45:07.109Z + +== Style.TIME is not a clock-ordered v1: what changes between ids created 30 ms apart +c0a802bb-a1ad-1754-81a1-ad076be82d70 msb=c0a802bba1ad1754 lsb=81a1ad076be82d70 +c0a802bb-a1ad-1754-81a1-ad076c092d71 msb=c0a802bba1ad1754 lsb=81a1ad076c092d71 +c0a802bb-a1ad-1754-81a1-ad076c282d72 msb=c0a802bba1ad1754 lsb=81a1ad076c282d72 +c0a802bb-a1ad-1754-81a1-ad076c482d73 msb=c0a802bba1ad1754 lsb=81a1ad076c482d73 +distinct most-significant halves among them : 1 (IPv4 address of this host + a per-JVM value, fixed for the life of the JVM) +first 32 bits equal this host's IPv4 address : true +UUID.timestamp() (what a real version 1 would use as its clock) is the same for all four : 1 distinct value(s) diff --git a/id-generation/output/01b-uuid-generation-rate.txt b/id-generation/output/01b-uuid-generation-rate.txt new file mode 100644 index 0000000..9b0ceb1 --- /dev/null +++ b/id-generation/output/01b-uuid-generation-rate.txt @@ -0,0 +1,7 @@ +Cost of generating one UUID in memory (single thread, 2,000,000 ids after 500,000 warm-up; indicative, machine dependent) +UUID.randomUUID() 351 ns per id +Hibernate VERSION_7 769 ns per id +Hibernate VERSION_6 680 ns per id +Hibernate TIME (v1) 22 ns per id +uuid-creator v7 352 ns per id +UuidV7Generator (hand-written) 709 ns per id diff --git a/id-generation/output/02-identity-vs-sequence.txt b/id-generation/output/02-identity-vs-sequence.txt new file mode 100644 index 0000000..17e6589 --- /dev/null +++ b/id-generation/output/02-identity-vs-sequence.txt @@ -0,0 +1,43 @@ +IDENTITY vs SEQUENCE vs TABLE: generated DDL, SQL, and when the INSERT runs + +######## H2 ######## +database: H2Dialect / H2 2.4.240 (2025-09-22) + +== DDL Hibernate generates +create sequence seq_one_item start with 1 increment by 1; +create sequence seq_pooled_item start with 1 increment by 50; +create sequence SeqDefaultItem_SEQ start with 1 increment by 50; +create table hibernate_sequences (next_val bigint, sequence_name varchar(255) not null, primary key (sequence_name)); +insert into hibernate_sequences(sequence_name, next_val) values ('default',0); +create table IdentityItem (id bigint generated by default as identity, name varchar(255), primary key (id)); +create table SeqDefaultItem (id bigint not null, name varchar(255), primary key (id)); +create table SeqOneItem (id bigint not null, name varchar(255), primary key (id)); +create table SeqPooledItem (id bigint not null, name varchar(255), primary key (id)); +create table TableItem (id bigint not null, name varchar(255), primary key (id)); + +== IDENTITY: persist() runs the INSERT immediately +-- persist(item) -- no flush(), no commit() yet + sql> insert into IdentityItem (name,id) values (?,default) +item.getId() right after persist : 1 +rows visible after rollback : 0 + +== SEQUENCE, allocationSize = 1: one sequence call per entity +persist 120 entities, one transaction +sequence calls : 120 inserts : 120 +first ids : [1, 2, 3, 4, 5] ... last id : 120 + +== SEQUENCE, allocationSize = 50 (pooled optimizer): one sequence call per 50 entities +persist 120 entities, one transaction +sequence calls : 4 inserts : 120 +first ids : [1, 2, 3, 4, 5] ... last id : 120 +sequence statements : [select next value for seq_pooled_item] +(the first allocation of a fresh sequence costs two calls: one to learn the starting value, one to learn the upper bound of the first block of 50) +persist another 120 entities, same SessionFactory : sequence calls : 2 + +== TABLE generator: one read + one update of the generator row per allocation +persist 120 entities: statements other than the 120 INSERTs : 8 (seen by the JDBC counter; Hibernate's StatementInspector does not see them) + sql> select next_val from hibernate_sequences where sequence_name=? for update + sql> update hibernate_sequences set next_val=? where next_val=? and sequence_name=? + +######## POSTGRES ######## +SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run. diff --git a/id-generation/output/02b-default-sequence-naming.txt b/id-generation/output/02b-default-sequence-naming.txt new file mode 100644 index 0000000..f97a499 --- /dev/null +++ b/id-generation/output/02b-default-sequence-naming.txt @@ -0,0 +1,17 @@ +Sequence names Hibernate 7 picks when you name nothing, and the legacy switch + +######## H2 ######## +database: H2Dialect / H2 2.4.240 (2025-09-22) + +== hibernate.id.db_structure_naming_strategy = standard (entities: AutoLongItem, SeqDefaultItem) +create sequence AutoLongItem_SEQ start with 1 increment by 50; +create sequence SeqDefaultItem_SEQ start with 1 increment by 50; + +== hibernate.id.db_structure_naming_strategy = legacy (entities: AutoLongItem, SeqDefaultItem) +create sequence hibernate_sequence start with 1 increment by 1; + +== hibernate.id.db_structure_naming_strategy = single (entities: AutoLongItem, SeqDefaultItem) +create sequence hibernate_sequence start with 1 increment by 1; + +######## POSTGRES ######## +SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run. diff --git a/id-generation/output/03-batch-inserts.txt b/id-generation/output/03-batch-inserts.txt new file mode 100644 index 0000000..993f177 --- /dev/null +++ b/id-generation/output/03-batch-inserts.txt @@ -0,0 +1,19 @@ +10,000 inserts: IDENTITY vs SEQUENCE vs UUID, hibernate.jdbc.batch_size=50, flush+clear every 50 rows + +######## H2 ######## +database: H2Dialect / H2 2.4.240 (2025-09-22) +timings are wall-clock milliseconds on one developer machine, one run each after a warm-up pass: indicative, not a benchmark + +IDENTITY 331 ms prepared=10000 (insert=10000, sequence=0) addBatch=0 executeBatch=0 executeSingle=10000 +SEQUENCE allocationSize=1 226 ms prepared=10200 (insert=200, sequence=10000) addBatch=10000 executeBatch=200 executeSingle=10000 +SEQUENCE allocationSize=50 46 ms prepared=400 (insert=200, sequence=200) addBatch=10000 executeBatch=200 executeSingle=200 +UUID v4 (GenerationType.UUID) 98 ms prepared=200 (insert=200, sequence=0) addBatch=10000 executeBatch=200 executeSingle=0 +UUID v7 (Style.VERSION_7) 64 ms prepared=200 (insert=200, sequence=0) addBatch=10000 executeBatch=200 executeSingle=0 + +Hibernate Statistics for the whole session factory (all rows above, including the warm-up): entityInsertCount=55000 prepareStatementCount=23101 + +Same SEQUENCE allocationSize=50 entity, hibernate.jdbc.batch_size NOT set: +SEQUENCE allocationSize=50 68 ms prepared=10201 (insert=10000, sequence=201) addBatch=0 executeBatch=0 executeSingle=10201 + +######## POSTGRES ######## +SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run. diff --git a/id-generation/output/03b-order-inserts.txt b/id-generation/output/03b-order-inserts.txt new file mode 100644 index 0000000..b35365e --- /dev/null +++ b/id-generation/output/03b-order-inserts.txt @@ -0,0 +1,9 @@ +hibernate.order_inserts on a parent/child graph (1,000 parents x 3 children, batch_size=50, flush+clear every 50 parents) + +######## H2 ######## +database: H2Dialect / H2 2.4.240 (2025-09-22) +order_inserts=false 266 ms prepared=2082 (insert=2000, sequence=82) addBatch=4000 executeBatch=2000 executeSingle=82 +order_inserts=true 118 ms prepared=122 (insert=40, sequence=82) addBatch=4000 executeBatch=80 executeSingle=82 + +######## POSTGRES ######## +SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run. diff --git a/id-generation/output/04-sequence-increment-mismatch.txt b/id-generation/output/04-sequence-increment-mismatch.txt new file mode 100644 index 0000000..8d99e80 --- /dev/null +++ b/id-generation/output/04-sequence-increment-mismatch.txt @@ -0,0 +1,30 @@ +Mapping says allocationSize=50, the database sequence says INCREMENT BY 1 (schema created by hand) + +######## H2 ######## + +== hibernate.id.sequence.increment_size_mismatch_strategy = (unset) +SessionFactory startup FAILED: MappingException -> MappingException: The increment size of the [seq_pooled_item] sequence is set to [50] in the entity mapping but the mapped database sequence increment size is [1] + +== hibernate.id.sequence.increment_size_mismatch_strategy = EXCEPTION +SessionFactory startup FAILED: MappingException -> MappingException: The increment size of the [seq_pooled_item] sequence is set to [50] in the entity mapping but the mapped database sequence increment size is [1] + +== hibernate.id.sequence.increment_size_mismatch_strategy = LOG +SessionFactory started +application instance A : ids [1, -47, -46] +application instance B : FAILED after ids [-46, -45, -44] : ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.CONSTRAINT_D PRIMARY KEY ON PUBLIC.SEQPOOLEDITEM(ID) ( /* key:-46 */ CAST(-46 AS BIGINT), 'a2')"; SQL statement: +database sequence INCREMENT BY, as the database sees it : 1 + +== hibernate.id.sequence.increment_size_mismatch_strategy = FIX +SessionFactory started +application instance A : ids [1, 2, 3] +application instance B : ids [4, 5, 6] +database sequence INCREMENT BY, as the database sees it : 1 + +== hibernate.id.sequence.increment_size_mismatch_strategy = NONE +SessionFactory started +application instance A : ids [1, -47, -46] +application instance B : FAILED after ids [-46, -45, -44] : ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.CONSTRAINT_D PRIMARY KEY ON PUBLIC.SEQPOOLEDITEM(ID) ( /* key:-46 */ CAST(-46 AS BIGINT), 'a2')"; SQL statement: +database sequence INCREMENT BY, as the database sees it : 1 + +######## POSTGRES ######## +SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run. diff --git a/id-generation/output/04b-sequence-step-bigger-than-mapping.txt b/id-generation/output/04b-sequence-step-bigger-than-mapping.txt new file mode 100644 index 0000000..4582c24 --- /dev/null +++ b/id-generation/output/04b-sequence-step-bigger-than-mapping.txt @@ -0,0 +1,12 @@ +Mapping says allocationSize=1, the database sequence says INCREMENT BY 50 + +######## H2 ######## + +== hibernate.id.sequence.increment_size_mismatch_strategy = (unset) +ids : [1, 51, 101, 151] + +== hibernate.id.sequence.increment_size_mismatch_strategy = FIX +ids : [1, 51, 101, 151] + +######## POSTGRES ######## +SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run. diff --git a/id-generation/output/05-auto-and-uuid-per-dialect.txt b/id-generation/output/05-auto-and-uuid-per-dialect.txt new file mode 100644 index 0000000..828d060 --- /dev/null +++ b/id-generation/output/05-auto-and-uuid-per-dialect.txt @@ -0,0 +1,49 @@ +What @GeneratedValue (AUTO) and UUID ids become per dialect. DDL only: no database connection is opened for the dialects other than H2. Hibernate 7.4.14.Final + +== H2 (org.hibernate.dialect.H2Dialect) +create sequence AutoLongItem_SEQ start with 1 increment by 50; +create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)); +create table AutoUuidItem (id uuid not null, name varchar(255), primary key (id)); +create table IdentityItem (id bigint generated by default as identity, name varchar(255), primary key (id)); +create table UuidCharItem (id char(36) not null, name varchar(255), primary key (id)); +create table UuidV7Item (id uuid not null, name varchar(255), primary key (id)); + +== PostgreSQL (org.hibernate.dialect.PostgreSQLDialect) +create sequence AutoLongItem_SEQ start with 1 increment by 50; +create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)); +create table AutoUuidItem (id uuid not null, name varchar(255), primary key (id)); +create table IdentityItem (id bigint generated by default as identity, name varchar(255), primary key (id)); +create table UuidCharItem (id char(36) not null, name varchar(255), primary key (id)); +create table UuidV7Item (id uuid not null, name varchar(255), primary key (id)); + +== MySQL (org.hibernate.dialect.MySQLDialect) +create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)) engine=InnoDB; +create table AutoLongItem_SEQ (next_val bigint) engine=InnoDB; +create table AutoUuidItem (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB; +create table IdentityItem (id bigint not null auto_increment, name varchar(255), primary key (id)) engine=InnoDB; +create table UuidCharItem (id varchar(36) not null, name varchar(255), primary key (id)) engine=InnoDB; +create table UuidV7Item (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB; + +== MariaDB (org.hibernate.dialect.MariaDBDialect) +create sequence AutoLongItem_SEQ start with 1 increment by 50 nocache; +create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)) engine=InnoDB; +create table AutoUuidItem (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB; +create table IdentityItem (id bigint not null auto_increment, name varchar(255), primary key (id)) engine=InnoDB; +create table UuidCharItem (id varchar(36) not null, name varchar(255), primary key (id)) engine=InnoDB; +create table UuidV7Item (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB; + +== Oracle (org.hibernate.dialect.OracleDialect) +create sequence AutoLongItem_SEQ start with 1 increment by 50; +create table AutoLongItem (id number(19,0) not null, name varchar2(255 char), primary key (id)); +create table AutoUuidItem (id raw(16) not null, name varchar2(255 char), primary key (id)); +create table IdentityItem (id number(19,0) generated by default as identity, name varchar2(255 char), primary key (id)); +create table UuidCharItem (id char(36) not null, name varchar2(255 char), primary key (id)); +create table UuidV7Item (id raw(16) not null, name varchar2(255 char), primary key (id)); + +== SQL Server (org.hibernate.dialect.SQLServerDialect) +create sequence AutoLongItem_SEQ start with 1 increment by 50; +create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)); +create table AutoUuidItem (id uniqueidentifier not null, name varchar(255), primary key (id)); +create table IdentityItem (id bigint identity not null, name varchar(255), primary key (id)); +create table UuidCharItem (id char(36) not null, name varchar(255), primary key (id)); +create table UuidV7Item (id uniqueidentifier not null, name varchar(255), primary key (id)); diff --git a/id-generation/output/06-index-size-h2.txt b/id-generation/output/06-index-size-h2.txt new file mode 100644 index 0000000..4172a17 --- /dev/null +++ b/id-generation/output/06-index-size-h2.txt @@ -0,0 +1,8 @@ +Primary-key choice vs database file size, H2 file database (MVStore B-tree), file size after the database is closed, 200000 rows per variant, batch_size=50, flush+clear every 50 rows +H2 specific: this is H2's storage engine, not PostgreSQL's or InnoDB's. Timings are indicative (one run, one machine). + +variant load ms file KiB +bigint sequence (allocationSize 50) 1244 4944 +UUID v4 random 2280 101428 +UUID v7 time-ordered 1314 12960 +UUID v7 as CHAR(36) 1528 52948 diff --git a/id-generation/output/06b-index-size-postgres.txt b/id-generation/output/06b-index-size-postgres.txt new file mode 100644 index 0000000..e569219 --- /dev/null +++ b/id-generation/output/06b-index-size-postgres.txt @@ -0,0 +1,2 @@ +Primary-key choice vs index size and leaf density, PostgreSQL postgres:17-alpine, 200000 rows per variant, batch_size=50 +SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run. diff --git a/id-generation/output/07-spring-data-save.txt b/id-generation/output/07-spring-data-save.txt new file mode 100644 index 0000000..07a3b2a --- /dev/null +++ b/id-generation/output/07-spring-data-save.txt @@ -0,0 +1,20 @@ +Spring Data JPA save() with application-assigned vs generated ids. Spring Boot parent 4.1.1, Hibernate 7.4.14.Final, H2 + +== AssignedBook: UUID.randomUUID() assigned in the constructor, no @Version + sql> select ab1_0.id,ab1_0.title from assigned_book ab1_0 where ab1_0.id=? + sql> insert into assigned_book (title,id) values (?,?) + +== VersionedBook: same, plus a boxed @Version column + sql> insert into versioned_book (title,version,id) values (?,?,?) + +== PersistableBook: same, entity implements Persistable.isNew() + sql> insert into persistable_book (title,id) values (?,?) + +== GeneratedBook: @UuidGenerator(style = VERSION_7), id is null until persist + sql> insert into generated_book (title,id) values (?,?) + +== saveAll of 100 new AssignedBook rows: selects issued +select statements : 100, insert statements : 100 + +== saveAll of 100 new GeneratedBook rows: selects issued +select statements : 0, insert statements : 100 diff --git a/id-generation/pom.xml b/id-generation/pom.xml new file mode 100644 index 0000000..d153f0e --- /dev/null +++ b/id-generation/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + id-generation + 1.0.0 + id-generation + Hibernate 7 primary keys: IDENTITY vs SEQUENCE vs TABLE vs UUID (random and time-ordered), JDBC batching, and index locality on H2 and PostgreSQL + + + 25 + + 7.4.14.Final + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + + + org.postgresql + postgresql + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + com.github.f4b6a3 + uuid-creator + 6.1.1 + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -Xmx512m + 1 + + + + + diff --git a/id-generation/scripts/run-all.sh b/id-generation/scripts/run-all.sh new file mode 100644 index 0000000..47de29e --- /dev/null +++ b/id-generation/scripts/run-all.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Regenerates every file under output/. Needs JDK 25 and Maven 3.9 with access to Maven Central. +# PostgreSQL sections need a running Docker daemon (Testcontainers starts postgres:17-alpine); without one they are +# skipped and each transcript says so. H2 sections always run. +set -euo pipefail +cd "$(dirname "$0")/.." + +mkdir -p output +CP_FILE="$(mktemp)" +mvn -B -q dependency:build-classpath -Dmdep.outputFile="$CP_FILE" >/dev/null +SEP=':'; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) SEP=';';; esac +JAR="$(tr "$SEP" '\n' < "$CP_FILE" | grep 'hibernate-core-')" +{ + echo "# javap of the shipped $(basename "$JAR")" + for c in org.hibernate.annotations.UuidGenerator 'org.hibernate.annotations.UuidGenerator$Style' \ + org.hibernate.id.uuid.UuidValueGenerator org.hibernate.id.uuid.UuidVersion7Strategy \ + org.hibernate.id.SequenceMismatchStrategy org.hibernate.id.enhanced.StandardOptimizerDescriptor; do + echo; echo "\$ javap -cp $(basename "$JAR") $c" + javap -cp "$JAR" "$c" 2>&1 | grep -v '^Picked up' + done + echo; echo "\$ javap java.util.UUID (static members only)" + javap java.util.UUID | grep static | grep -v assertionsDisabled +} > output/00-javap.txt +rm -f "$CP_FILE" + +mvn -B clean test +ls -1 output diff --git a/id-generation/src/main/java/com/ankurm/idgen/AutoLongItem.java b/id-generation/src/main/java/com/ankurm/idgen/AutoLongItem.java new file mode 100644 index 0000000..ee6b444 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/AutoLongItem.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** Bare @GeneratedValue (strategy AUTO) on a Long. */ +@Entity +public class AutoLongItem { + @Id + @GeneratedValue + private Long id; + private String name; + + public AutoLongItem() {} + + public AutoLongItem(String name) { this.name = name; } + + public Long getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/AutoUuidItem.java b/id-generation/src/main/java/com/ankurm/idgen/AutoUuidItem.java new file mode 100644 index 0000000..70c490d --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/AutoUuidItem.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** Bare @GeneratedValue (strategy AUTO) on a UUID. */ +@Entity +public class AutoUuidItem { + @Id + @GeneratedValue + private UUID id; + private String name; + + public AutoUuidItem() {} + + public AutoUuidItem(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/Child.java b/id-generation/src/main/java/com/ankurm/idgen/Child.java new file mode 100644 index 0000000..c4b60ae --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/Child.java @@ -0,0 +1,25 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; + +/** Child of {@link Parent}. */ +@Entity +public class Child { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_child") + @SequenceGenerator(name = "seq_child", sequenceName = "seq_child", allocationSize = 50) + private Long id; + + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + private Parent parent; + + public Child() {} + + public Child(String name) { this.name = name; } + + void setParent(Parent p) { this.parent = p; } + + public Long getId() { return id; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/HandRolledV7Item.java b/id-generation/src/main/java/com/ankurm/idgen/HandRolledV7Item.java new file mode 100644 index 0000000..4d250c8 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/HandRolledV7Item.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** Version 7 from the hand-written {@link UuidV7Generator}, plugged in through @UuidGenerator(algorithm = ...). */ +@Entity +public class HandRolledV7Item { + @Id + @UuidGenerator(algorithm = UuidV7Generator.class) + private UUID id; + private String name; + + public HandRolledV7Item() {} + + public HandRolledV7Item(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/IdentityItem.java b/id-generation/src/main/java/com/ankurm/idgen/IdentityItem.java new file mode 100644 index 0000000..b75986c --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/IdentityItem.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** GenerationType.IDENTITY: the database assigns the id while the INSERT runs. */ +@Entity +public class IdentityItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + + public IdentityItem() {} + + public IdentityItem(String name) { this.name = name; } + + public Long getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/Parent.java b/id-generation/src/main/java/com/ankurm/idgen/Parent.java new file mode 100644 index 0000000..9050031 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/Parent.java @@ -0,0 +1,27 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.ArrayList; +import java.util.List; + +/** Parent of a small graph, used to show what hibernate.order_inserts changes. Sequence ids, so JDBC batching is possible. */ +@Entity +public class Parent { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_parent") + @SequenceGenerator(name = "seq_parent", sequenceName = "seq_parent", allocationSize = 50) + private Long id; + + private String name; + + @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST) + private List children = new ArrayList<>(); + + public Parent() {} + + public Parent(String name) { this.name = name; } + + public void add(Child c) { c.setParent(this); children.add(c); } + + public Long getId() { return id; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/SeqDefaultItem.java b/id-generation/src/main/java/com/ankurm/idgen/SeqDefaultItem.java new file mode 100644 index 0000000..8a515cf --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/SeqDefaultItem.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** SEQUENCE with no @SequenceGenerator at all: Hibernate chooses the sequence name and allocation size. */ +@Entity +public class SeqDefaultItem { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + private String name; + + public SeqDefaultItem() {} + + public SeqDefaultItem(String name) { this.name = name; } + + public Long getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/SeqOneItem.java b/id-generation/src/main/java/com/ankurm/idgen/SeqOneItem.java new file mode 100644 index 0000000..7f05204 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/SeqOneItem.java @@ -0,0 +1,23 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** SEQUENCE with allocationSize = 1: one sequence round trip per entity. */ +@Entity +public class SeqOneItem { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_one_item") + @SequenceGenerator(name = "seq_one_item", sequenceName = "seq_one_item", allocationSize = 1) + private Long id; + private String name; + + public SeqOneItem() {} + + public SeqOneItem(String name) { this.name = name; } + + public Long getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/SeqPooledItem.java b/id-generation/src/main/java/com/ankurm/idgen/SeqPooledItem.java new file mode 100644 index 0000000..41b5cad --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/SeqPooledItem.java @@ -0,0 +1,23 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** SEQUENCE with allocationSize = 50 (the JPA default): one sequence round trip per 50 entities (pooled optimizer). */ +@Entity +public class SeqPooledItem { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_pooled_item") + @SequenceGenerator(name = "seq_pooled_item", sequenceName = "seq_pooled_item", allocationSize = 50) + private Long id; + private String name; + + public SeqPooledItem() {} + + public SeqPooledItem(String name) { this.name = name; } + + public Long getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/TableItem.java b/id-generation/src/main/java/com/ankurm/idgen/TableItem.java new file mode 100644 index 0000000..dcbf219 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/TableItem.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** GenerationType.TABLE: ids come from a row in a generator table. */ +@Entity +public class TableItem { + @Id + @GeneratedValue(strategy = GenerationType.TABLE) + private Long id; + private String name; + + public TableItem() {} + + public TableItem(String name) { this.name = name; } + + public Long getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidCharItem.java b/id-generation/src/main/java/com/ankurm/idgen/UuidCharItem.java new file mode 100644 index 0000000..c6e2318 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidCharItem.java @@ -0,0 +1,25 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; + +/** Version 7 UUID stored as text, CHAR(36), instead of the dialect's native/binary uuid type. */ +@Entity +public class UuidCharItem { + @Id + @UuidGenerator(style = UuidGenerator.Style.VERSION_7) + @JdbcTypeCode(SqlTypes.CHAR) + private UUID id; + private String name; + + public UuidCharItem() {} + + public UuidCharItem(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7.java b/id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7.java new file mode 100644 index 0000000..3f348cb --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7.java @@ -0,0 +1,14 @@ +package com.ankurm.idgen; + +import com.github.f4b6a3.uuid.UuidCreator; +import java.util.UUID; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.id.uuid.UuidValueGenerator; + +/** Adapter: lets {@code @UuidGenerator(algorithm = UuidCreatorV7.class)} use the uuid-creator library's version 7 generator. */ +public class UuidCreatorV7 implements UuidValueGenerator { + @Override + public UUID generateUuid(SharedSessionContractImplementor session) { + return UuidCreator.getTimeOrderedEpoch(); + } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7Item.java b/id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7Item.java new file mode 100644 index 0000000..9a5b55a --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidCreatorV7Item.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** Version 7 from the uuid-creator library, plugged in through @UuidGenerator(algorithm = ...). */ +@Entity +public class UuidCreatorV7Item { + @Id + @UuidGenerator(algorithm = UuidCreatorV7.class) + private UUID id; + private String name; + + public UuidCreatorV7Item() {} + + public UuidCreatorV7Item(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidTimeItem.java b/id-generation/src/main/java/com/ankurm/idgen/UuidTimeItem.java new file mode 100644 index 0000000..2e123d1 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidTimeItem.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** @UuidGenerator(style = TIME): a version 1 (Gregorian time) UUID with Hibernate's own layout. Not version 7. */ +@Entity +public class UuidTimeItem { + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + private UUID id; + private String name; + + public UuidTimeItem() {} + + public UuidTimeItem(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidV4Item.java b/id-generation/src/main/java/com/ankurm/idgen/UuidV4Item.java new file mode 100644 index 0000000..f28c33e --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidV4Item.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** GenerationType.UUID (JPA 3.1): random, version 4. */ +@Entity +public class UuidV4Item { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + private String name; + + public UuidV4Item() {} + + public UuidV4Item(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidV6Item.java b/id-generation/src/main/java/com/ankurm/idgen/UuidV6Item.java new file mode 100644 index 0000000..0b36d70 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidV6Item.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** @UuidGenerator(style = VERSION_6): RFC 9562 version 6, the time-ordered re-arrangement of version 1. */ +@Entity +public class UuidV6Item { + @Id + @UuidGenerator(style = UuidGenerator.Style.VERSION_6) + private UUID id; + private String name; + + public UuidV6Item() {} + + public UuidV6Item(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidV7Generator.java b/id-generation/src/main/java/com/ankurm/idgen/UuidV7Generator.java new file mode 100644 index 0000000..fb19cd7 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidV7Generator.java @@ -0,0 +1,42 @@ +package com.ankurm.idgen; + +import java.security.SecureRandom; +import java.util.UUID; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.id.uuid.UuidValueGenerator; + +/** + * A hand-written RFC 9562 version 7 generator, about twenty lines of logic. It exists to show what the layout is + * (the shipped {@code Style.VERSION_7} does the same job); do not prefer it to the built-in one. + * + *
+ *  48 bits  unix_ts_ms      milliseconds since 1970-01-01T00:00Z, big-endian: this is what makes ids sort by creation time
+ *   4 bits  version = 0111
+ *  12 bits  rand_a          used here as a counter, so two ids created in the same millisecond still increase
+ *   2 bits  variant = 10
+ *  62 bits  rand_b          random
+ * 
+ */ +public class UuidV7Generator implements UuidValueGenerator { + + private static final SecureRandom RNG = new SecureRandom(); + private long lastMs = -1; + private int counter; + + @Override + public UUID generateUuid(SharedSessionContractImplementor session) { return next(); } + + public synchronized UUID next() { + long now = System.currentTimeMillis(); + if (now > lastMs) { + lastMs = now; + counter = RNG.nextInt(1 << 11); // start in the lower half so a burst has room to count up + } else { + counter++; // same millisecond (or the clock stepped back): keep counting + if (counter > 0xFFF) { lastMs++; counter = 0; } // counter exhausted: borrow the next millisecond + } + long msb = (lastMs << 16) | (0x7L << 12) | counter; + long lsb = (RNG.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | 0x8000_0000_0000_0000L; + return new UUID(msb, lsb); + } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/UuidV7Item.java b/id-generation/src/main/java/com/ankurm/idgen/UuidV7Item.java new file mode 100644 index 0000000..931c9da --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/UuidV7Item.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen; + +import jakarta.persistence.*; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** @UuidGenerator(style = VERSION_7): RFC 9562 version 7, Unix-millisecond timestamp first. */ +@Entity +public class UuidV7Item { + @Id + @UuidGenerator(style = UuidGenerator.Style.VERSION_7) + private UUID id; + private String name; + + public UuidV7Item() {} + + public UuidV7Item(String name) { this.name = name; } + + public UUID getId() { return id; } + + public String getName() { return name; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBook.java b/id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBook.java new file mode 100644 index 0000000..6c4ccd3 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBook.java @@ -0,0 +1,19 @@ +package com.ankurm.idgen.spring; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.util.UUID; + +/** Application-assigned UUID id, no @Version: Spring Data cannot tell a new instance from a detached one. */ +@Entity +public class AssignedBook { + @Id + private UUID id; + private String title; + + public AssignedBook() {} + + public AssignedBook(UUID id, String title) { this.id = id; this.title = title; } + + public UUID getId() { return id; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBookRepository.java b/id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBookRepository.java new file mode 100644 index 0000000..9ec1294 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/AssignedBookRepository.java @@ -0,0 +1,7 @@ +package com.ankurm.idgen.spring; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AssignedBookRepository extends JpaRepository { +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBook.java b/id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBook.java new file mode 100644 index 0000000..4d1e872 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBook.java @@ -0,0 +1,21 @@ +package com.ankurm.idgen.spring; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.util.UUID; +import org.hibernate.annotations.UuidGenerator; + +/** Generated version 7 id: id is null until persist, so Spring Data treats a fresh instance as new. */ +@Entity +public class GeneratedBook { + @Id + @UuidGenerator(style = UuidGenerator.Style.VERSION_7) + private UUID id; + private String title; + + public GeneratedBook() {} + + public GeneratedBook(String title) { this.title = title; } + + public UUID getId() { return id; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBookRepository.java b/id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBookRepository.java new file mode 100644 index 0000000..43f62f8 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/GeneratedBookRepository.java @@ -0,0 +1,7 @@ +package com.ankurm.idgen.spring; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GeneratedBookRepository extends JpaRepository { +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBook.java b/id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBook.java new file mode 100644 index 0000000..8d239e9 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBook.java @@ -0,0 +1,34 @@ +package com.ankurm.idgen.spring; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PostPersist; +import jakarta.persistence.Transient; +import java.util.UUID; +import org.springframework.data.domain.Persistable; + +/** Assigned UUID id; implements Persistable so the entity itself says whether it is new. */ +@Entity +public class PersistableBook implements Persistable { + @Id + private UUID id; + private String title; + + @Transient + private boolean isNew = true; + + public PersistableBook() {} + + public PersistableBook(UUID id, String title) { this.id = id; this.title = title; } + + @Override + public UUID getId() { return id; } + + @Override + public boolean isNew() { return isNew; } + + @PostLoad + @PostPersist + void markNotNew() { this.isNew = false; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBookRepository.java b/id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBookRepository.java new file mode 100644 index 0000000..e9bb6cb --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/PersistableBookRepository.java @@ -0,0 +1,7 @@ +package com.ankurm.idgen.spring; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PersistableBookRepository extends JpaRepository { +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/SpringIdApplication.java b/id-generation/src/main/java/com/ankurm/idgen/spring/SpringIdApplication.java new file mode 100644 index 0000000..4b3d001 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/SpringIdApplication.java @@ -0,0 +1,8 @@ +package com.ankurm.idgen.spring; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** Only used by the Spring Data test; the other tests bootstrap Hibernate directly. Scans just this package. */ +@SpringBootApplication +public class SpringIdApplication { +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBook.java b/id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBook.java new file mode 100644 index 0000000..29aad46 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBook.java @@ -0,0 +1,22 @@ +package com.ankurm.idgen.spring; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Version; +import java.util.UUID; + +/** Assigned UUID id plus a boxed @Version: a null version tells Spring Data the instance is new, so save() calls persist. */ +@Entity +public class VersionedBook { + @Id + private UUID id; + @Version + private Long version; + private String title; + + public VersionedBook() {} + + public VersionedBook(UUID id, String title) { this.id = id; this.title = title; } + + public UUID getId() { return id; } +} diff --git a/id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBookRepository.java b/id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBookRepository.java new file mode 100644 index 0000000..ed3cb80 --- /dev/null +++ b/id-generation/src/main/java/com/ankurm/idgen/spring/VersionedBookRepository.java @@ -0,0 +1,7 @@ +package com.ankurm.idgen.spring; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VersionedBookRepository extends JpaRepository { +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/A01_UuidStylesTest.java b/id-generation/src/test/java/com/ankurm/idgen/A01_UuidStylesTest.java new file mode 100644 index 0000000..072a2b7 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/A01_UuidStylesTest.java @@ -0,0 +1,143 @@ +package com.ankurm.idgen; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Modifier; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import org.hibernate.annotations.UuidGenerator; +import org.junit.jupiter.api.Test; + +/** What each UuidGenerator.Style really produces: version nibble, variant, time ordering. Read from persisted ids, not from documentation. */ +class A01_UuidStylesTest { + + record Probe(String label, Class entity, Function idOf, int expectedVersion) {} + + static final List PROBES = List.of( + new Probe("GenerationType.UUID (JPA)", UuidV4Item.class, o -> ((UuidV4Item) o).getId(), 4), + new Probe("bare @GeneratedValue on a UUID (AUTO)", AutoUuidItem.class, o -> ((AutoUuidItem) o).getId(), 4), + new Probe("Style.TIME", UuidTimeItem.class, o -> ((UuidTimeItem) o).getId(), 1), + new Probe("Style.VERSION_6", UuidV6Item.class, o -> ((UuidV6Item) o).getId(), 6), + new Probe("Style.VERSION_7", UuidV7Item.class, o -> ((UuidV7Item) o).getId(), 7), + new Probe("uuid-creator getTimeOrderedEpoch()", UuidCreatorV7Item.class, o -> ((UuidCreatorV7Item) o).getId(), 7), + new Probe("hand-written UuidV7Generator", HandRolledV7Item.class, o -> ((HandRolledV7Item) o).getId(), 7)); + + @Test + void whatEachStyleProduces() throws Exception { + Transcript t = new Transcript("01-uuid-styles.txt"); + t.line("UuidGenerator.Style values and what the generated UUIDs look like. Hibernate " + org.hibernate.Version.getVersionString() + + ", JDK " + Runtime.version()); + + t.heading("The enum and the annotation, from the shipped jar"); + t.line("Style values : " + Arrays.toString(UuidGenerator.Style.values())); + t.line("annotation attributes : " + Arrays.stream(UuidGenerator.class.getDeclaredMethods()).map(m -> m.getName()).sorted().toList()); + + t.heading("Does the JDK itself generate version 7? (public static methods of java.util.UUID)"); + List statics = Arrays.stream(UUID.class.getDeclaredMethods()) + .filter(m -> Modifier.isStatic(m.getModifiers()) && Modifier.isPublic(m.getModifiers())) + .map(m -> m.getName()).sorted().toList(); + t.line("static factories: " + statics); + assertThat(statics).containsExactlyInAnyOrder("fromString", "nameUUIDFromBytes", "randomUUID"); + + try (Db db = Db.of(Db.Kind.H2)) { + db.open(AutoUuidItem.class, UuidV4Item.class, UuidTimeItem.class, UuidV6Item.class, UuidV7Item.class, UuidCreatorV7Item.class, HandRolledV7Item.class); + final int n = 2000; + for (Probe p : PROBES) { + List ids = new ArrayList<>(); + long before = System.currentTimeMillis(); + db.txv(s -> { + for (int i = 0; i < n; i++) { + Object e = construct(p.entity(), "n" + i); + s.persist(e); + ids.add(p.idOf().apply(e)); + } + }); + long after = System.currentTimeMillis(); + + t.heading(p.label() + " (" + p.entity().getSimpleName() + ", " + n + " persists in one transaction)"); + t.line("first three : " + ids.subList(0, 3)); + int version = ids.get(0).version(); + t.line("version nibble of every id : " + ids.stream().map(UUID::version).distinct().toList() + ", variant : " + + ids.stream().map(UUID::variant).distinct().toList()); + assertThat(ids).allSatisfy(u -> assertThat(u.version()).isEqualTo(p.expectedVersion())); + assertThat(version).isEqualTo(p.expectedVersion()); + + // Sorted the way PostgreSQL / a B-tree sorts a uuid column: unsigned, byte by byte, i.e. the canonical string order. + int descents = 0; + for (int i = 1; i < n; i++) if (ids.get(i - 1).toString().compareTo(ids.get(i).toString()) > 0) descents++; + t.line("string/byte order vs generation order : " + descents + " of " + (n - 1) + " neighbours are out of order"); + if (p.expectedVersion() >= 6) assertThat(descents).isZero(); + else if (p.expectedVersion() == 4) assertThat(descents).isGreaterThan(n / 4); + assertThat(ids).doesNotHaveDuplicates(); + + if (p.expectedVersion() == 7) { + long ms = (ids.get(0).getMostSignificantBits() >>> 16); + Instant embedded = Instant.ofEpochMilli(ms); + t.line("first 48 bits as Unix milliseconds : " + ms + " -> " + embedded); + assertThat(ms).isBetween(before - 1000, after + 1000); + } + if (p.expectedVersion() == 1) { + t.line("UUID.timestamp() of first / last id (100ns ticks since 1582) : " + ids.get(0).timestamp() + " / " + ids.get(n - 1).timestamp()); + t.line("most significant 32 bits (time_low) of first three : " + + ids.subList(0, 3).stream().map(u -> String.format("%08x", u.getMostSignificantBits() >>> 32)).toList()); + } + } + + t.heading("Style.TIME is not a clock-ordered v1: what changes between ids created 30 ms apart"); + List spaced = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + spaced.add(db.tx(s -> { var e = new UuidTimeItem("x"); s.persist(e); return e.getId(); })); + Thread.sleep(30); + } + for (UUID u : spaced) { + t.line(u + " msb=" + String.format("%016x", u.getMostSignificantBits()) + " lsb=" + String.format("%016x", u.getLeastSignificantBits())); + } + long msbs = spaced.stream().map(UUID::getMostSignificantBits).distinct().count(); + t.line("distinct most-significant halves among them : " + msbs + " (IPv4 address of this host + a per-JVM value, fixed for the life of the JVM)"); + assertThat(msbs).isEqualTo(1); + byte[] ip = java.net.InetAddress.getLocalHost().getAddress(); + long ipAsInt = ((ip[0] & 0xffL) << 24) | ((ip[1] & 0xffL) << 16) | ((ip[2] & 0xffL) << 8) | (ip[3] & 0xffL); + t.line("first 32 bits equal this host's IPv4 address : " + (ip.length == 4 && (spaced.get(0).getMostSignificantBits() >>> 32) == ipAsInt)); + t.line("UUID.timestamp() (what a real version 1 would use as its clock) is the same for all four : " + + spaced.stream().map(UUID::timestamp).distinct().count() + " distinct value(s)"); + assertThat(spaced.stream().map(UUID::timestamp).distinct().count()).isEqualTo(1); + } + t.save(); + } + + @Test + void generationRate() throws Exception { + // indicative only: single thread, no database + Transcript t = new Transcript("01b-uuid-generation-rate.txt"); + t.line("Cost of generating one UUID in memory (single thread, 2,000,000 ids after 500,000 warm-up; indicative, machine dependent)"); + var v4 = (java.util.function.Supplier) UUID::randomUUID; + var v7 = (java.util.function.Supplier) () -> org.hibernate.id.uuid.UuidVersion7Strategy.INSTANCE.generateUuid(null); + var v6 = (java.util.function.Supplier) () -> org.hibernate.id.uuid.UuidVersion6Strategy.INSTANCE.generateUuid(null); + var v1 = (java.util.function.Supplier) () -> new org.hibernate.id.uuid.CustomVersionOneStrategy().generateUuid(null); + var lib = (java.util.function.Supplier) com.github.f4b6a3.uuid.UuidCreator::getTimeOrderedEpoch; + var mine = (java.util.function.Supplier) new UuidV7Generator()::next; + record Row(String label, java.util.function.Supplier s) {} + for (Row r : List.of(new Row("UUID.randomUUID()", v4), new Row("Hibernate VERSION_7", v7), new Row("Hibernate VERSION_6", v6), + new Row("Hibernate TIME (v1)", v1), new Row("uuid-creator v7", lib), new Row("UuidV7Generator (hand-written)", mine))) { + long sink = 0; + for (int i = 0; i < 500_000; i++) sink += r.s().get().getLeastSignificantBits(); + long t0 = System.nanoTime(); + for (int i = 0; i < 2_000_000; i++) sink += r.s().get().getLeastSignificantBits(); + long ns = System.nanoTime() - t0; + SINK = sink; t.line(String.format("%-32s %5d ns per id", r.label(), ns / 2_000_000)); + } + t.save(); + } + + static volatile long SINK; + + static Object construct(Class c, String name) { + try { + return c.getConstructor(String.class).newInstance(name); + } catch (ReflectiveOperationException e) { throw new IllegalStateException(e); } + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/A02_IdentityVsSequenceTest.java b/id-generation/src/test/java/com/ankurm/idgen/A02_IdentityVsSequenceTest.java new file mode 100644 index 0000000..d634d38 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/A02_IdentityVsSequenceTest.java @@ -0,0 +1,91 @@ +package com.ankurm.idgen; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; + +/** DDL and SQL for IDENTITY, SEQUENCE (allocationSize 1 and 50), the default sequence, TABLE; when each INSERT actually runs. */ +class A02_IdentityVsSequenceTest { + + static long count(List sql, String needle) { + return sql.stream().filter(s -> s.toLowerCase().contains(needle)).count(); + } + + @Test + void ddlAndSql() throws Exception { + Both.run("02-identity-vs-sequence.txt", "IDENTITY vs SEQUENCE vs TABLE: generated DDL, SQL, and when the INSERT runs", (db, t) -> { + db.open(IdentityItem.class, SeqOneItem.class, SeqPooledItem.class, SeqDefaultItem.class, TableItem.class); + Both.header(db, t); + + t.heading("DDL Hibernate generates"); + for (String l : db.ddl(IdentityItem.class, SeqOneItem.class, SeqPooledItem.class, SeqDefaultItem.class, TableItem.class)) { + if (l.startsWith("create table") || l.startsWith("create sequence") || l.startsWith("insert into")) t.line(l); + } + + // ---- IDENTITY: the INSERT happens at persist(), before any flush or commit + t.heading("IDENTITY: persist() runs the INSERT immediately"); + try (Session s = db.sf().openSession()) { + var tx = s.beginTransaction(); + var item = new IdentityItem("first"); + t.step("persist(item) -- no flush(), no commit() yet", db.sql(() -> s.persist(item))); + t.line("item.getId() right after persist : " + item.getId()); + assertThat(item.getId()).isNotNull(); + tx.rollback(); + } + t.line("rows visible after rollback : " + db.scalar("select count(*) from IdentityItem")); + + // ---- SEQUENCE allocationSize = 1: one sequence call per entity + t.heading("SEQUENCE, allocationSize = 1: one sequence call per entity"); + List one = new ArrayList<>(); + List oneIds = new ArrayList<>(); + one.addAll(db.sql(() -> db.txv(s -> { for (int i = 0; i < 120; i++) { var e = new SeqOneItem("n" + i); s.persist(e); oneIds.add(e.getId()); } }))); + t.line("persist 120 entities, one transaction"); + long oneCalls = count(one, "next value for") + count(one, "nextval"); + t.line("sequence calls : " + oneCalls + " inserts : " + count(one, "insert into")); + t.line("first ids : " + oneIds.subList(0, 5) + " ... last id : " + oneIds.get(119)); + assertThat(oneCalls).isEqualTo(120); + + // ---- SEQUENCE allocationSize = 50: the pooled optimizer + t.heading("SEQUENCE, allocationSize = 50 (pooled optimizer): one sequence call per 50 entities"); + List poolIds = new ArrayList<>(); + List pooled = db.sql(() -> db.txv(s -> { for (int i = 0; i < 120; i++) { var e = new SeqPooledItem("n" + i); s.persist(e); poolIds.add(e.getId()); } })); + long poolCalls = count(pooled, "next value for") + count(pooled, "nextval"); + t.line("persist 120 entities, one transaction"); + t.line("sequence calls : " + poolCalls + " inserts : " + count(pooled, "insert into")); + t.line("first ids : " + poolIds.subList(0, 5) + " ... last id : " + poolIds.get(119)); + t.line("sequence statements : " + pooled.stream().filter(x -> x.toLowerCase().contains("next value for") || x.toLowerCase().contains("nextval")).distinct().toList()); + assertThat(poolCalls).isEqualTo(4); + t.line("(the first allocation of a fresh sequence costs two calls: one to learn the starting value, one to learn the upper bound of the first block of 50)"); + List again = db.sql(() -> db.txv(s -> { for (int i = 0; i < 120; i++) s.persist(new SeqPooledItem("m" + i)); })); + long againCalls = count(again, "next value for") + count(again, "nextval"); + t.line("persist another 120 entities, same SessionFactory : sequence calls : " + againCalls); + assertThat(againCalls).isBetween(2L, 3L); + + // ---- TABLE + t.heading("TABLE generator: one read + one update of the generator row per allocation"); + JdbcCounter.reset(); + db.txv(s -> { for (int i = 0; i < 120; i++) s.persist(new TableItem("n" + i)); }); + List other = JdbcCounter.OTHER_SQL; + t.line("persist 120 entities: statements other than the 120 INSERTs : " + other.size() + " (seen by the JDBC counter; Hibernate's StatementInspector does not see them)"); + other.stream().distinct().forEach(x -> t.line(" sql> " + x)); + assertThat(other).isNotEmpty(); + }); + } + + @Test + void namingOfDefaultSequences() throws Exception { + Both.run("02b-default-sequence-naming.txt", "Sequence names Hibernate 7 picks when you name nothing, and the legacy switch", (db, t) -> { + db.open(AutoLongItem.class); + Both.header(db, t); + for (String strat : new String[] {"standard", "legacy", "single"}) { + t.heading("hibernate.id.db_structure_naming_strategy = " + strat + " (entities: AutoLongItem, SeqDefaultItem)"); + for (String l : db.ddl(new String[] {"hibernate.id.db_structure_naming_strategy", strat}, AutoLongItem.class, SeqDefaultItem.class)) { + if (l.startsWith("create sequence")) t.line(l); + } + } + }); + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/A03_BatchInsertTest.java b/id-generation/src/test/java/com/ankurm/idgen/A03_BatchInsertTest.java new file mode 100644 index 0000000..79b1fba --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/A03_BatchInsertTest.java @@ -0,0 +1,121 @@ +package com.ankurm.idgen; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Function; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; + +/** 10,000 inserts per id strategy with flush/clear every 50 rows and hibernate.jdbc.batch_size = 50, then the order_inserts effect on a parent/child graph. */ +class A03_BatchInsertTest { + + static final int N = 10_000; + static final int BATCH = 50; + + record Row(String label, Function factory) {} + + /** Persists n entities, flushing and clearing every BATCH rows. Returns elapsed milliseconds. Counters are reset first. */ + static long insert(Db db, int n, Function factory) { + JdbcCounter.reset(); + long t0 = System.nanoTime(); + try (Session s = db.sf().openSession()) { + var tx = s.beginTransaction(); + for (int i = 0; i < n; i++) { + s.persist(factory.apply(i)); + if ((i + 1) % BATCH == 0) { s.flush(); s.clear(); } + } + tx.commit(); + } + return (System.nanoTime() - t0) / 1_000_000; + } + + @Test + void tenThousandRows() throws Exception { + Both.run("03-batch-inserts.txt", "10,000 inserts: IDENTITY vs SEQUENCE vs UUID, hibernate.jdbc.batch_size=50, flush+clear every 50 rows", (db, t) -> { + String[] withBatch = {"hibernate.jdbc.batch_size", "" + BATCH}; + Class[] all = {IdentityItem.class, SeqOneItem.class, SeqPooledItem.class, UuidV4Item.class, UuidV7Item.class}; + db.open(withBatch, all); + Both.header(db, t); + t.line("timings are wall-clock milliseconds on one developer machine, one run each after a warm-up pass: indicative, not a benchmark"); + + // warm-up so class loading and JIT do not land on the first row of the table + for (Class c : all) { + insert(db, 1000, i -> make(c, "w" + i)); + } + + Row[] rows = { + new Row("IDENTITY", i -> new IdentityItem("n" + i)), + new Row("SEQUENCE allocationSize=1", i -> new SeqOneItem("n" + i)), + new Row("SEQUENCE allocationSize=50", i -> new SeqPooledItem("n" + i)), + new Row("UUID v4 (GenerationType.UUID)", i -> new UuidV4Item("n" + i)), + new Row("UUID v7 (Style.VERSION_7)", i -> new UuidV7Item("n" + i)), + }; + t.blank(); + for (Row r : rows) { + long ms = insert(db, N, r.factory()); + var stats = db.sf().getStatistics(); + t.line(String.format("%-30s %6d ms %s", r.label(), ms, JdbcCounter.summary())); + switch (r.label()) { + case "IDENTITY" -> { + assertThat(JdbcCounter.ADD_BATCH.get()).isZero(); + assertThat(JdbcCounter.EXECUTE_BATCH.get()).isZero(); + assertThat(JdbcCounter.INSERT_PREPARED.get()).isEqualTo(N); + } + case "SEQUENCE allocationSize=1" -> { + assertThat(JdbcCounter.EXECUTE_BATCH.get()).isEqualTo(N / BATCH); + assertThat(JdbcCounter.SEQUENCE_PREPARED.get()).isEqualTo(N); + } + case "SEQUENCE allocationSize=50" -> { + assertThat(JdbcCounter.EXECUTE_BATCH.get()).isEqualTo(N / BATCH); + assertThat(JdbcCounter.SEQUENCE_PREPARED.get()).isLessThanOrEqualTo(N / BATCH + 1); + } + default -> { + assertThat(JdbcCounter.EXECUTE_BATCH.get()).isEqualTo(N / BATCH); + assertThat(JdbcCounter.SEQUENCE_PREPARED.get()).isZero(); + } + } + } + t.blank(); + t.line("Hibernate Statistics for the whole session factory (all rows above, including the warm-up): entityInsertCount=" + + db.sf().getStatistics().getEntityInsertCount() + " prepareStatementCount=" + db.sf().getStatistics().getPrepareStatementCount()); + + // the same sequence entity with batching switched off, to show what batch_size itself buys + db.open(new String[] {}, SeqPooledItem.class); + long ms = insert(db, N, i -> new SeqPooledItem("n" + i)); + t.blank().line("Same SEQUENCE allocationSize=50 entity, hibernate.jdbc.batch_size NOT set:"); + t.line(String.format("%-30s %6d ms %s", "SEQUENCE allocationSize=50", ms, JdbcCounter.summary())); + assertThat(JdbcCounter.EXECUTE_BATCH.get()).isZero(); + }); + } + + @Test + void orderInserts() throws Exception { + Both.run("03b-order-inserts.txt", "hibernate.order_inserts on a parent/child graph (1,000 parents x 3 children, batch_size=50, flush+clear every 50 parents)", (db, t) -> { + for (String order : new String[] {"false", "true"}) { + db.open(new String[] {"hibernate.jdbc.batch_size", "" + BATCH, "hibernate.order_inserts", order}, Parent.class, Child.class); + if (order.equals("false")) Both.header(db, t); + JdbcCounter.reset(); + long t0 = System.nanoTime(); + try (Session s = db.sf().openSession()) { + var tx = s.beginTransaction(); + for (int i = 0; i < 1000; i++) { + Parent p = new Parent("p" + i); + for (int c = 0; c < 3; c++) p.add(new Child("c" + i + "." + c)); + s.persist(p); + if ((i + 1) % 50 == 0) { s.flush(); s.clear(); } + } + tx.commit(); + } + long ms = (System.nanoTime() - t0) / 1_000_000; + t.line(String.format("order_inserts=%-5s %5d ms %s", order, ms, JdbcCounter.summary())); + assertThat(db.scalar("select count(*) from Child")).isEqualTo("3000"); + if (order.equals("true")) assertThat(JdbcCounter.EXECUTE_BATCH.get()).isLessThan(200); + else assertThat(JdbcCounter.EXECUTE_BATCH.get()).isGreaterThan(1000); + } + }); + } + + static Object make(Class c, String name) { + try { return c.getConstructor(String.class).newInstance(name); } catch (ReflectiveOperationException e) { throw new IllegalStateException(e); } + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/A04_MismatchAndAutoTest.java b/id-generation/src/test/java/com/ankurm/idgen/A04_MismatchAndAutoTest.java new file mode 100644 index 0000000..93cefb0 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/A04_MismatchAndAutoTest.java @@ -0,0 +1,100 @@ +package com.ankurm.idgen; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.Test; + +/** What happens when the database sequence and the mapping disagree about the step; and what AUTO and UUID map to per dialect. */ +class A04_MismatchAndAutoTest { + + static final String[] TABLE_AND_SEQ_INC1 = { + "create table SeqPooledItem (id bigint not null primary key, name varchar(255))", + "create sequence seq_pooled_item start with 1 increment by 1"}; + + static String persistThree(SessionFactory sf, String tag) { + List ids = new ArrayList<>(); + try { + Db.tx(sf, s -> { for (int i = 0; i < 3; i++) { var e = new SeqPooledItem(tag + i); s.persist(e); ids.add(e.getId()); } return null; }); + return "ids " + ids; + } catch (RuntimeException e) { + return "FAILED after ids " + ids + " : " + Db.describe(e); + } + } + + @Test + void incrementMismatch() throws Exception { + Both.run("04-sequence-increment-mismatch.txt", "Mapping says allocationSize=50, the database sequence says INCREMENT BY 1 (schema created by hand)", (db, t) -> { + for (String strategy : new String[] {"(unset)", "EXCEPTION", "LOG", "FIX", "NONE"}) { + t.heading("hibernate.id.sequence.increment_size_mismatch_strategy = " + strategy); + String[] settings = strategy.equals("(unset)") ? new String[0] + : new String[] {"hibernate.id.sequence.increment_size_mismatch_strategy", strategy}; + SqlLog.clear(); + try { + db.openAfter(TABLE_AND_SEQ_INC1, settings, SeqPooledItem.class); + } catch (RuntimeException e) { + t.line("SessionFactory startup FAILED: " + Db.describe(e)); + continue; + } + if (strategy.equals("(unset)")) Both.header(db, t); + t.line("SessionFactory started"); + SessionFactory second = db.secondFactory(settings, SeqPooledItem.class); + try { + t.line("application instance A : " + persistThree(db.sf(), "a")); + t.line("application instance B : " + persistThree(second, "b")); + } finally { second.close(); } + t.line("database sequence INCREMENT BY, as the database sees it : " + (db.isPostgres() + ? db.scalar("select increment_by from pg_sequences where sequencename = 'seq_pooled_item'") + : db.scalar("select increment from information_schema.sequences where sequence_name = 'SEQ_POOLED_ITEM'"))); + } + }); + } + + @Test + void incrementMismatchOtherWay() throws Exception { + Both.run("04b-sequence-step-bigger-than-mapping.txt", "Mapping says allocationSize=1, the database sequence says INCREMENT BY 50", (db, t) -> { + String[] ddl = { + "create table SeqOneItem (id bigint not null primary key, name varchar(255))", + "create sequence seq_one_item start with 1 increment by 50"}; + for (String strategy : new String[] {"(unset)", "FIX"}) { + t.heading("hibernate.id.sequence.increment_size_mismatch_strategy = " + strategy); + String[] settings = strategy.equals("(unset)") ? new String[0] + : new String[] {"hibernate.id.sequence.increment_size_mismatch_strategy", strategy}; + try { + db.openAfter(ddl, settings, SeqOneItem.class); + } catch (RuntimeException e) { + t.line("SessionFactory startup FAILED: " + Db.describe(e)); + continue; + } + List ids = new ArrayList<>(); + db.txv(s -> { for (int i = 0; i < 4; i++) { var e = new SeqOneItem("x" + i); s.persist(e); ids.add(e.getId()); } }); + t.line("ids : " + ids); + } + }); + } + + @Test + void autoPerDialect() throws Exception { + Transcript t = new Transcript("05-auto-and-uuid-per-dialect.txt"); + t.line("What @GeneratedValue (AUTO) and UUID ids become per dialect. DDL only: no database connection is opened for the dialects other than H2. Hibernate " + + org.hibernate.Version.getVersionString()); + String[][] dialects = { + {"H2", "org.hibernate.dialect.H2Dialect"}, + {"PostgreSQL", "org.hibernate.dialect.PostgreSQLDialect"}, + {"MySQL", "org.hibernate.dialect.MySQLDialect"}, + {"MariaDB", "org.hibernate.dialect.MariaDBDialect"}, + {"Oracle", "org.hibernate.dialect.OracleDialect"}, + {"SQL Server", "org.hibernate.dialect.SQLServerDialect"}}; + for (String[] d : dialects) { + t.heading(d[0] + " (" + d[1] + ")"); + for (String l : DdlOnly.script(d[1], AutoLongItem.class, AutoUuidItem.class, UuidV7Item.class, UuidCharItem.class, IdentityItem.class)) { + if (l.startsWith("create sequence") || l.startsWith("create table")) t.line(l); + } + } + t.save(); + assertThat(DdlOnly.script("org.hibernate.dialect.PostgreSQLDialect", AutoLongItem.class)).anyMatch(l -> l.contains("create sequence AutoLongItem_SEQ")); + assertThat(DdlOnly.script("org.hibernate.dialect.MySQLDialect", AutoLongItem.class)).noneMatch(l -> l.startsWith("create sequence")); + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/A05_IndexLocalityTest.java b/id-generation/src/test/java/com/ankurm/idgen/A05_IndexLocalityTest.java new file mode 100644 index 0000000..4b54273 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/A05_IndexLocalityTest.java @@ -0,0 +1,100 @@ +package com.ankurm.idgen; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Function; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; + +/** + * Does the key type change how big the primary-key index gets? Same rows, same table shape, only the id differs. + * H2 (file database, MVStore B-tree) always runs. PostgreSQL (pg_relation_size, pgstatindex) runs only when Docker is available. + */ +class A05_IndexLocalityTest { + + static final int N = 200_000; + static final int BATCH = 50; + + record Variant(String label, String table, Function factory) {} + + static final Variant[] VARIANTS = { + new Variant("bigint sequence (allocationSize 50)", "SeqPooledItem", i -> new SeqPooledItem("row-" + i)), + new Variant("UUID v4 random", "UuidV4Item", i -> new UuidV4Item("row-" + i)), + new Variant("UUID v7 time-ordered", "UuidV7Item", i -> new UuidV7Item("row-" + i)), + new Variant("UUID v7 as CHAR(36)", "UuidCharItem", i -> new UuidCharItem("row-" + i)), + }; + + static long load(Db db, Function factory) { + long t0 = System.nanoTime(); + try (Session s = db.sf().openSession()) { + var tx = s.beginTransaction(); + for (int i = 0; i < N; i++) { + s.persist(factory.apply(i)); + if ((i + 1) % BATCH == 0) { s.flush(); s.clear(); } + } + tx.commit(); + } + return (System.nanoTime() - t0) / 1_000_000; + } + + static long dirSize(Path dir) throws IOException { + try (var st = Files.list(dir)) { return st.mapToLong(p -> p.toFile().length()).sum(); } + } + + @Test + void h2FileSize() throws Exception { + Transcript t = new Transcript("06-index-size-h2.txt"); + t.line("Primary-key choice vs database file size, H2 file database (MVStore B-tree), file size after the database is closed, " + N + " rows per variant, batch_size=" + BATCH + + ", flush+clear every " + BATCH + " rows"); + t.line("H2 specific: this is H2's storage engine, not PostgreSQL's or InnoDB's. Timings are indicative (one run, one machine)."); + t.blank(); + t.line(String.format("%-38s %10s %12s", "variant", "load ms", "file KiB")); + for (int v = 0; v < VARIANTS.length; v++) { + Variant var = VARIANTS[v]; + Path dir = Files.createTempDirectory("idgen-h2-"); + try { + String url = "jdbc:h2:file:" + dir.resolve("db").toString().replace('\\', '/'); + long ms; + try (Db db = Db.of(Db.Kind.H2).withUrl(url)) { + db.open(new String[] {"hibernate.jdbc.batch_size", "" + BATCH}, SeqPooledItem.class, UuidV4Item.class, UuidV7Item.class, UuidCharItem.class); + ms = load(db, var.factory()); + assertThat(db.scalar("select count(*) from " + var.table())).isEqualTo("" + N); + } + long raw = dirSize(dir); + t.line(String.format("%-38s %10d %12d", var.label(), ms, raw / 1024)); + } finally { + try (var st = Files.walk(dir)) { st.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); } + } + } + t.save(); + } + + @Test + void postgresIndex() throws Exception { + Transcript t = new Transcript("06b-index-size-postgres.txt"); + t.line("Primary-key choice vs index size and leaf density, PostgreSQL " + Db.PG_IMAGE + ", " + N + " rows per variant, batch_size=" + BATCH); + if (!Db.dockerAvailable()) { + t.line("SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run."); + t.save(); + return; + } + try (Db db = Db.of(Db.Kind.POSTGRES)) { + db.open(new String[] {"hibernate.jdbc.batch_size", "" + BATCH}, SeqPooledItem.class, UuidV4Item.class, UuidV7Item.class, UuidCharItem.class); + Both.header(db, t); + db.exec("create extension if not exists pgstattuple"); + t.blank().line(String.format("%-38s %9s %12s %12s %14s %14s", "variant", "load ms", "table KiB", "pkey KiB", "leaf density%", "leaf fragm.%")); + for (Variant v : VARIANTS) { + long ms = load(db, v.factory()); + String name = v.table().toLowerCase() + "_pkey"; + long tableKb = Long.parseLong(db.scalar("select pg_table_size('" + v.table().toLowerCase() + "')")) / 1024 - Long.parseLong(db.scalar("select pg_relation_size('" + name + "')")) / 1024; + long idxKb = Long.parseLong(db.scalar("select pg_relation_size('" + name + "')")) / 1024; + String[] st = db.scalar("select avg_leaf_density, leaf_fragmentation from pgstatindex('" + name + "')").split(" \\| "); + t.line(String.format("%-38s %9d %12d %12d %14s %14s", v.label(), ms, tableKb, idxKb, st[0], st[1])); + } + } + t.save(); + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/A06_SpringDataTest.java b/id-generation/src/test/java/com/ankurm/idgen/A06_SpringDataTest.java new file mode 100644 index 0000000..52aedac --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/A06_SpringDataTest.java @@ -0,0 +1,65 @@ +package com.ankurm.idgen; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.idgen.spring.*; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** save() on four entity shapes: which ones cost a SELECT before the INSERT. */ +@SpringBootTest(classes = SpringIdApplication.class, properties = { + "spring.datasource.url=jdbc:h2:mem:springdata;DB_CLOSE_DELAY=-1", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.open-in-view=false", + "spring.jpa.properties.hibernate.session_factory.statement_inspector=com.ankurm.idgen.SqlLog"}) +class A06_SpringDataTest { + + @Autowired AssignedBookRepository assigned; + @Autowired VersionedBookRepository versioned; + @Autowired PersistableBookRepository persistable; + @Autowired GeneratedBookRepository generated; + + static long selects(List sql) { return sql.stream().filter(s -> s.toLowerCase().startsWith("select")).count(); } + + @Test + void saveOnAssignedIds() throws Exception { + Transcript t = new Transcript("07-spring-data-save.txt"); + t.line("Spring Data JPA save() with application-assigned vs generated ids. Spring Boot parent 4.1.1, Hibernate " + org.hibernate.Version.getVersionString() + ", H2"); + + t.heading("AssignedBook: UUID.randomUUID() assigned in the constructor, no @Version"); + var a = Db.capture(() -> assigned.save(new AssignedBook(UUID.randomUUID(), "x"))); + a.forEach(s -> t.line(" sql> " + s)); + assertThat(selects(a)).isEqualTo(1); + + t.heading("VersionedBook: same, plus a boxed @Version column"); + var v = Db.capture(() -> versioned.save(new VersionedBook(UUID.randomUUID(), "x"))); + v.forEach(s -> t.line(" sql> " + s)); + assertThat(selects(v)).isZero(); + + t.heading("PersistableBook: same, entity implements Persistable.isNew()"); + var p = Db.capture(() -> persistable.save(new PersistableBook(UUID.randomUUID(), "x"))); + p.forEach(s -> t.line(" sql> " + s)); + assertThat(selects(p)).isZero(); + + t.heading("GeneratedBook: @UuidGenerator(style = VERSION_7), id is null until persist"); + var g = Db.capture(() -> generated.save(new GeneratedBook("x"))); + g.forEach(s -> t.line(" sql> " + s)); + assertThat(selects(g)).isZero(); + + t.heading("saveAll of 100 new AssignedBook rows: selects issued"); + List many = java.util.stream.IntStream.range(0, 100).mapToObj(i -> new AssignedBook(UUID.randomUUID(), "b" + i)).toList(); + var m = Db.capture(() -> assigned.saveAll(many)); + t.line("select statements : " + selects(m) + ", insert statements : " + m.stream().filter(s -> s.startsWith("insert")).count()); + assertThat(selects(m)).isEqualTo(100); + + t.heading("saveAll of 100 new GeneratedBook rows: selects issued"); + List many2 = java.util.stream.IntStream.range(0, 100).mapToObj(i -> new GeneratedBook("b" + i)).toList(); + var m2 = Db.capture(() -> generated.saveAll(many2)); + t.line("select statements : " + selects(m2) + ", insert statements : " + m2.stream().filter(s -> s.startsWith("insert")).count()); + assertThat(selects(m2)).isZero(); + t.save(); + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/Both.java b/id-generation/src/test/java/com/ankurm/idgen/Both.java new file mode 100644 index 0000000..2621111 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/Both.java @@ -0,0 +1,29 @@ +package com.ankurm.idgen; + +/** Runs one scenario body against H2 and, when Docker is available, PostgreSQL; writes a single transcript with every run. */ +public final class Both { + @FunctionalInterface + public interface Body { void run(Db db, Transcript t) throws Exception; } + + private Both() {} + + public static void run(String file, String title, Body body) throws Exception { + Transcript t = new Transcript(file); + t.line(title); + for (Db.Kind k : Db.Kind.values()) { + t.blank().line("######## " + k + " ########"); + if (k == Db.Kind.POSTGRES && !Db.dockerAvailable()) { + t.line("SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run."); + continue; + } + try (Db db = Db.of(k)) { + body.run(db, t); + } + } + t.save(); + } + + public static void header(Db db, Transcript t) { + t.line("database: " + db.productName()); + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/Db.java b/id-generation/src/test/java/com/ankurm/idgen/Db.java new file mode 100644 index 0000000..045119c --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/Db.java @@ -0,0 +1,232 @@ +package com.ankurm.idgen; + +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.cfg.Configuration; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** One database (H2 in memory, or a throw-away PostgreSQL 17 container) plus a plain Hibernate SessionFactory over it. */ +public final class Db implements AutoCloseable { + public enum Kind { H2, POSTGRES } + + public static final String PG_IMAGE = "postgres:17-alpine"; + private static PostgreSQLContainer pg; + private static Boolean docker; + + private final Kind kind; + private SessionFactory sf; + private String url; + private String fixedUrl; + + private Db(Kind kind) { this.kind = kind; } + + /** True when a Docker daemon answers; PostgreSQL scenarios are skipped (and the transcript says so) when it does not. */ + public static synchronized boolean dockerAvailable() { + if (docker == null) { + try { docker = DockerClientFactory.instance().isDockerAvailable(); } catch (Throwable t) { docker = false; } + } + return docker; + } + + private static synchronized PostgreSQLContainer pg() { + if (pg == null) { + pg = new PostgreSQLContainer(PG_IMAGE); + pg.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> pg.stop())); + } + return pg; + } + + public static Db of(Kind kind) { return new Db(kind); } + + /** H2 only: use this JDBC URL (for example a file database) instead of a fresh in-memory one. */ + public Db withUrl(String jdbcUrl) { this.fixedUrl = jdbcUrl; this.url = jdbcUrl; return this; } + + public boolean isPostgres() { return kind == Kind.POSTGRES; } + + public Kind kind() { return kind; } + + public Configuration config(Class... classes) { + Configuration c = new Configuration(); + if (kind == Kind.H2) { + if (url == null) url = "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"; + c.setProperty("jakarta.persistence.jdbc.url", url); + c.setProperty("jakarta.persistence.jdbc.user", "sa"); + c.setProperty("jakarta.persistence.jdbc.password", ""); + } else { + PostgreSQLContainer p = pg(); + c.setProperty("jakarta.persistence.jdbc.url", p.getJdbcUrl()); + c.setProperty("jakarta.persistence.jdbc.user", p.getUsername()); + c.setProperty("jakarta.persistence.jdbc.password", p.getPassword()); + } + c.setProperty("hibernate.session_factory.statement_inspector", SqlLog.class.getName()); + c.setProperty("hibernate.connection.provider_class", JdbcCounter.class.getName()); + c.setProperty("hibernate.generate_statistics", "true"); + for (Class k : classes) c.addAnnotatedClass(k); + return c; + } + + /** PostgreSQL: drops and recreates the public schema. H2: a brand-new in-memory database name. */ + public void wipe() { + if (!isPostgres()) { url = fixedUrl; return; } + Configuration bare = config(); + try (var cn = java.sql.DriverManager.getConnection(bare.getProperty("jakarta.persistence.jdbc.url"), + bare.getProperty("jakarta.persistence.jdbc.user"), bare.getProperty("jakarta.persistence.jdbc.password")); + var st = cn.createStatement()) { + st.execute("drop schema public cascade"); + st.execute("create schema public"); + } catch (SQLException e) { throw new IllegalStateException(e); } + } + + private static void apply(Configuration c, String[] settings) { + for (int i = 0; i + 1 < settings.length; i += 2) c.setProperty(settings[i], settings[i + 1]); + } + + /** Opens a fresh schema containing the tables of {@code classes}, with extra Hibernate settings (key, value, key, value ...). */ + public Db open(String[] settings, Class... classes) { + close(); + wipe(); + Configuration c = config(classes); + c.setProperty("hibernate.hbm2ddl.auto", "create"); + apply(c, settings); + sf = c.buildSessionFactory(); + return this; + } + + public Db open(Class... classes) { return open(new String[0], classes); } + + /** Opens a SessionFactory over a fresh, EMPTY database: the caller creates tables and sequences by hand. */ + public Db openEmpty(String[] settings, Class... classes) { + close(); + wipe(); + Configuration c = config(classes); + apply(c, settings); + sf = c.buildSessionFactory(); + return this; + } + + /** Wipes the database, runs hand-written DDL over plain JDBC, then opens a SessionFactory that does NOT touch the schema. */ + public Db openAfter(String[] preDdl, String[] settings, Class... classes) { + close(); + wipe(); + Configuration c = config(classes); + try (var cn = java.sql.DriverManager.getConnection(c.getProperty("jakarta.persistence.jdbc.url"), + c.getProperty("jakarta.persistence.jdbc.user"), c.getProperty("jakarta.persistence.jdbc.password")); + var st = cn.createStatement()) { + for (String d : preDdl) st.execute(d); + } catch (SQLException e) { throw new IllegalStateException(e); } + apply(c, settings); + sf = c.buildSessionFactory(); + return this; + } + + /** A second SessionFactory over the same database, as a second application instance would have. Caller closes it. */ + public SessionFactory secondFactory(String[] settings, Class... classes) { + Configuration c = config(classes); + apply(c, settings); + return c.buildSessionFactory(); + } + + public SessionFactory sf() { return sf; } + + public T tx(Function work) { return tx(sf, work); } + + public static T tx(SessionFactory factory, Function work) { + try (Session s = factory.openSession()) { + var t = s.beginTransaction(); + try { + T r = work.apply(s); + t.commit(); + return r; + } catch (RuntimeException e) { + if (t.isActive()) t.rollback(); + throw e; + } + } + } + + public void txv(java.util.function.Consumer work) { tx(s -> { work.accept(s); return null; }); } + + public List sql(Runnable action) { return capture(action); } + + public static List capture(Runnable action) { + SqlLog.clear(); + Transcript.parking = true; + try { action.run(); } finally { Transcript.parking = false; } + return SqlLog.snapshot(); + } + + /** Raw JDBC (invisible to the SqlLog): run DDL or any statement. */ + public int exec(String sql) { + return tx(s -> s.doReturningWork(cn -> { + try (var st = cn.createStatement()) { + st.execute(sql); + return st.getUpdateCount(); + } + })); + } + + public List rows(String sql) { + return tx(s -> s.doReturningWork(cn -> { + List out = new ArrayList<>(); + try (var st = cn.createStatement(); ResultSet rs = st.executeQuery(sql)) { + ResultSetMetaData md = rs.getMetaData(); + while (rs.next()) { + StringBuilder b = new StringBuilder(); + for (int i = 1; i <= md.getColumnCount(); i++) { + if (i > 1) b.append(" | "); + b.append(rs.getObject(i)); + } + out.add(b.toString()); + } + } + return out; + })); + } + + public String scalar(String sql) { return rows(sql).get(0); } + + public String productName() { + return sf.unwrap(org.hibernate.engine.spi.SessionFactoryImplementor.class) + .getJdbcServices().getDialect().getClass().getSimpleName() + " / " + + tx(s -> s.doReturningWork(cn -> cn.getMetaData().getDatabaseProductName() + " " + cn.getMetaData().getDatabaseProductVersion())); + } + + /** The CREATE script Hibernate would emit for these entities on this database (script only, nothing executed). */ + public List ddl(Class... classes) throws Exception { return ddl(new String[0], classes); } + + public List ddl(String[] settings, Class... classes) throws Exception { + java.nio.file.Path tmp = java.nio.file.Files.createTempFile("ddl", ".sql"); + wipe(); + Configuration c = config(classes); + apply(c, settings); + c.setProperty("jakarta.persistence.schema-generation.scripts.action", "create"); + c.setProperty("jakarta.persistence.schema-generation.scripts.create-target", tmp.toUri().toString()); + c.buildSessionFactory().close(); + List lines = java.nio.file.Files.readAllLines(tmp); + java.nio.file.Files.delete(tmp); + return lines; + } + + public static String describe(Throwable t) { + Throwable root = t; + while (root.getCause() != null && root.getCause() != root) root = root.getCause(); + String state = root instanceof SQLException se ? " SQLState " + se.getSQLState() + "," : ""; + String msg = String.valueOf(root.getMessage()).lines().findFirst().orElse(""); + return t.getClass().getSimpleName() + " -> " + root.getClass().getSimpleName() + ":" + state + " " + msg; + } + + @Override + public void close() { + if (sf != null && sf.isOpen()) sf.close(); + sf = null; + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/DdlOnly.java b/id-generation/src/test/java/com/ankurm/idgen/DdlOnly.java new file mode 100644 index 0000000..628f9e1 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/DdlOnly.java @@ -0,0 +1,29 @@ +package com.ankurm.idgen; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.hibernate.cfg.Configuration; + +/** + * The CREATE script for a dialect that has no database behind it: JDBC metadata access is switched off and only the + * JPA script target is used, so no connection is opened. Shows what AUTO / UUID map to per dialect. + */ +public final class DdlOnly { + private DdlOnly() {} + + public static List script(String dialect, Class... entities) throws Exception { + Path out = Files.createTempFile("ddl", ".sql"); + Configuration c = new Configuration(); + c.setProperty("hibernate.dialect", dialect); + c.setProperty("hibernate.boot.allow_jdbc_metadata_access", "false"); + c.setProperty("jakarta.persistence.schema-generation.database.action", "none"); + c.setProperty("jakarta.persistence.schema-generation.scripts.action", "create"); + c.setProperty("jakarta.persistence.schema-generation.scripts.create-target", out.toUri().toString()); + for (Class e : entities) c.addAnnotatedClass(e); + c.buildSessionFactory().close(); + List lines = Files.readAllLines(out); + Files.delete(out); + return lines; + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/JdbcCounter.java b/id-generation/src/test/java/com/ankurm/idgen/JdbcCounter.java new file mode 100644 index 0000000..58fc068 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/JdbcCounter.java @@ -0,0 +1,80 @@ +package com.ankurm.idgen; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProvider; + +/** + * A ConnectionProvider that wraps every Connection and PreparedStatement in a JDK proxy and counts what Hibernate asks of JDBC: + * statements prepared, rows queued with addBatch, executeBatch calls, and single executes. Hibernate's own Statistics API + * exposes prepareStatementCount but no batch counter, so this is how "did batching happen" is measured here. + */ +public class JdbcCounter extends DriverManagerConnectionProvider { + + public static final AtomicLong PREPARED = new AtomicLong(), INSERT_PREPARED = new AtomicLong(), SEQUENCE_PREPARED = new AtomicLong(), + ADD_BATCH = new AtomicLong(), EXECUTE_BATCH = new AtomicLong(), EXECUTE_SINGLE = new AtomicLong(); + + /** Every non-INSERT statement text prepared since the last reset (Hibernate's StatementInspector does not see the id generators' own JDBC work). */ + public static final java.util.List OTHER_SQL = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public static void reset() { + OTHER_SQL.clear(); + for (AtomicLong a : new AtomicLong[] {PREPARED, INSERT_PREPARED, SEQUENCE_PREPARED, ADD_BATCH, EXECUTE_BATCH, EXECUTE_SINGLE}) a.set(0); + } + + /** One-line summary used in transcripts. */ + public static String summary() { + return "prepared=" + PREPARED.get() + " (insert=" + INSERT_PREPARED.get() + ", sequence=" + SEQUENCE_PREPARED.get() + ")" + + " addBatch=" + ADD_BATCH.get() + " executeBatch=" + EXECUTE_BATCH.get() + " executeSingle=" + EXECUTE_SINGLE.get(); + } + + /** Statements sent to the database: one per executeBatch plus one per single execute. */ + public static long roundTrips() { return EXECUTE_BATCH.get() + EXECUTE_SINGLE.get(); } + + private final Map realOf = Collections.synchronizedMap(new IdentityHashMap<>()); + + @Override + public void closeConnection(Connection conn) throws SQLException { + Connection real = realOf.remove(conn); + super.closeConnection(real != null ? real : conn); + } + + @Override + public Connection getConnection() throws SQLException { + Connection real = super.getConnection(); + Connection proxy = (Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {Connection.class}, (p, m, a) -> { + try { + Object r = m.invoke(real, a); + if (m.getName().equals("prepareStatement") && a != null && a[0] instanceof String sql) { + PREPARED.incrementAndGet(); + String s = sql.toLowerCase(); + if (s.startsWith("insert")) INSERT_PREPARED.incrementAndGet(); else OTHER_SQL.add(sql.replaceAll("\s+", " ").trim()); + if (s.contains("nextval") || s.contains("next value for")) SEQUENCE_PREPARED.incrementAndGet(); + return wrap((PreparedStatement) r); + } + return r; + } catch (InvocationTargetException e) { throw e.getCause(); } + }); + realOf.put(proxy, real); + return proxy; + } + + private static PreparedStatement wrap(PreparedStatement real) { + return (PreparedStatement) Proxy.newProxyInstance(JdbcCounter.class.getClassLoader(), new Class[] {PreparedStatement.class}, (p, m, a) -> { + switch (m.getName()) { + case "addBatch" -> ADD_BATCH.incrementAndGet(); + case "executeBatch" -> EXECUTE_BATCH.incrementAndGet(); + case "execute", "executeUpdate", "executeQuery" -> EXECUTE_SINGLE.incrementAndGet(); + default -> { } + } + try { return m.invoke(real, a); } catch (InvocationTargetException e) { throw e.getCause(); } + }); + } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/SqlLog.java b/id-generation/src/test/java/com/ankurm/idgen/SqlLog.java new file mode 100644 index 0000000..8a02182 --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/SqlLog.java @@ -0,0 +1,24 @@ +package com.ankurm.idgen; + +import java.util.ArrayList; +import java.util.List; +import org.hibernate.resource.jdbc.spi.StatementInspector; + +/** + * Registered through {@code hibernate.session_factory.statement_inspector}. Records every SQL string Hibernate sends + * to JDBC (with {@code ?} placeholders, before binding). Raw JDBC issued by the tests themselves is not recorded, + * so the transcripts show only what Hibernate generated. + */ +public class SqlLog implements StatementInspector { + private static final List LOG = new ArrayList<>(); + + @Override + public String inspect(String sql) { + synchronized (LOG) { LOG.add(sql.replaceAll("\\s+", " ").trim()); } + return sql; + } + + public static void clear() { synchronized (LOG) { LOG.clear(); } } + + public static List snapshot() { synchronized (LOG) { return new ArrayList<>(LOG); } } +} diff --git a/id-generation/src/test/java/com/ankurm/idgen/Transcript.java b/id-generation/src/test/java/com/ankurm/idgen/Transcript.java new file mode 100644 index 0000000..2986fcf --- /dev/null +++ b/id-generation/src/test/java/com/ankurm/idgen/Transcript.java @@ -0,0 +1,40 @@ +package com.ankurm.idgen; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** Collects what a test observed and writes it to {@code output/NN-name.txt}; the tests assert the same facts. */ +public final class Transcript { + private final StringBuilder sb = new StringBuilder(); + private final String file; + /** While a Db.sql(..) action runs, lines are parked here and written after the step's SQL, so a step reads label / SQL / result. */ + static final StringBuilder PARKED = new StringBuilder(); + static boolean parking; + + public Transcript(String file) { this.file = file; } + + public Transcript line(String s) { (parking ? PARKED : sb).append(s).append('\n'); return this; } + + public Transcript blank() { return line(""); } + + public Transcript heading(String s) { return line("").line("== " + s); } + + /** A step: the label, then every SQL statement Hibernate issued during it. */ + public Transcript step(String label, List sql) { + line("-- " + label); + if (sql.isEmpty()) line(" (no SQL)"); + for (String s : sql) line(" sql> " + s); + sb.append(PARKED); + PARKED.setLength(0); + return this; + } + + public void save() throws IOException { + Path out = Path.of("output", file); + Files.createDirectories(out.getParent()); + Files.writeString(out, sb.toString(), StandardCharsets.UTF_8); + } +}