New posts for Spring and Hibernate
This commit is contained in:
@@ -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.
|
||||
@@ -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<? extends org.hibernate.id.uuid.UuidValueGenerator> 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<org.hibernate.annotations.UuidGenerator$Style> {
|
||||
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<org.hibernate.id.SequenceMismatchStrategy> {
|
||||
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<org.hibernate.id.enhanced.StandardOptimizerDescriptor> 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<? extends org.hibernate.id.enhanced.Optimizer> 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 {};
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>id-generation</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>id-generation</name>
|
||||
<description>Hibernate 7 primary keys: IDENTITY vs SEQUENCE vs TABLE vs UUID (random and time-ordered), JDBC batching, and index locality on H2 and PostgreSQL</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<!-- Boot 4.1.1 manages 7.4.5.Final; this module overrides to 7.4.14.Final, the newest 7.x GA on Maven Central (checked 2026-11-05), same as the soft-delete module. -->
|
||||
<hibernate.version>7.4.14.Final</hibernate.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.f4b6a3</groupId>
|
||||
<artifactId>uuid-creator</artifactId>
|
||||
<version>6.1.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<argLine>-Xmx512m</argLine>
|
||||
<forkCount>1</forkCount>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<Child> 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<AssignedBook, UUID> {
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<GeneratedBook, UUID> {
|
||||
}
|
||||
@@ -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<UUID> {
|
||||
@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; }
|
||||
}
|
||||
@@ -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<PersistableBook, UUID> {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<VersionedBook, UUID> {
|
||||
}
|
||||
@@ -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<Object, UUID> idOf, int expectedVersion) {}
|
||||
|
||||
static final List<Probe> 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<String> 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<UUID> 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<UUID> 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>) UUID::randomUUID;
|
||||
var v7 = (java.util.function.Supplier<UUID>) () -> org.hibernate.id.uuid.UuidVersion7Strategy.INSTANCE.generateUuid(null);
|
||||
var v6 = (java.util.function.Supplier<UUID>) () -> org.hibernate.id.uuid.UuidVersion6Strategy.INSTANCE.generateUuid(null);
|
||||
var v1 = (java.util.function.Supplier<UUID>) () -> new org.hibernate.id.uuid.CustomVersionOneStrategy().generateUuid(null);
|
||||
var lib = (java.util.function.Supplier<UUID>) com.github.f4b6a3.uuid.UuidCreator::getTimeOrderedEpoch;
|
||||
var mine = (java.util.function.Supplier<UUID>) new UuidV7Generator()::next;
|
||||
record Row(String label, java.util.function.Supplier<UUID> 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); }
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> one = new ArrayList<>();
|
||||
List<Long> 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<Long> poolIds = new ArrayList<>();
|
||||
List<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<Integer, Object> 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<Integer, Object> 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); }
|
||||
}
|
||||
}
|
||||
@@ -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<Long> 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<Long> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Integer, Object> 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<Integer, Object> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<AssignedBook> 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<GeneratedBook> 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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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> T tx(Function<Session, T> work) { return tx(sf, work); }
|
||||
|
||||
public static <T> T tx(SessionFactory factory, Function<Session, T> 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<Session> work) { tx(s -> { work.accept(s); return null; }); }
|
||||
|
||||
public List<String> sql(Runnable action) { return capture(action); }
|
||||
|
||||
public static List<String> 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<String> rows(String sql) {
|
||||
return tx(s -> s.doReturningWork(cn -> {
|
||||
List<String> 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<String> ddl(Class<?>... classes) throws Exception { return ddl(new String[0], classes); }
|
||||
|
||||
public List<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> lines = Files.readAllLines(out);
|
||||
Files.delete(out);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<Connection, Connection> 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(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> snapshot() { synchronized (LOG) { return new ArrayList<>(LOG); } }
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user