diff --git a/README.md b/README.md index 608dd47..6184857 100755 --- a/README.md +++ b/README.md @@ -334,3 +334,12 @@ hibernate-demo/ ## License MIT — see [LICENSE](LICENSE). + +## Standalone modules + +Modules with their own `pom.xml` that run plain Hibernate without the Spring Boot parent above: + +| Module | What it covers | +|---|---| +| [`h8-preview`](h8-preview/) | Hibernate ORM 8 beta against Hibernate 7: what breaks | +| [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) | diff --git a/inheritance/README.md b/inheritance/README.md new file mode 100644 index 0000000..4d1c99a --- /dev/null +++ b/inheritance/README.md @@ -0,0 +1,51 @@ +# inheritance + +Companion module for the ankurm.com article on **Hibernate 7 inheritance mapping: `SINGLE_TABLE` vs `JOINED` vs +`TABLE_PER_CLASS`**. One payment hierarchy (a root with card, bank-transfer and wallet subclasses, plus an order that +points at the root) mapped three ways, run against plain Hibernate (no Spring) and in-memory H2, with every statement +Hibernate generates captured in `output/`. + +## Versions + +| Component | Version | Notes | +|---|---|---| +| Hibernate ORM | `7.4.13.Final` | latest 7.x GA on Maven Central on 2026-10-24 (`8.0.0.Beta3` is also listed; it is not GA) | +| Jakarta Persistence | `3.2.0` | | +| H2 | `2.4.240` | in memory, `OPTIMIZE_REUSE_RESULTS=0` (see `Db.java`) | +| JUnit Jupiter / AssertJ | `6.0.3` / `3.27.7` | | +| Java | `25` (Temurin 25.0.5+8) | | + +This module has its own `pom.xml` and does not use the Spring Boot parent of the main `hibernate-demo` project, so its +Hibernate patch version differs from the one that project pins. + +## Run + +```bash +./scripts/run-all.sh # JUnit tests, then the timing run; regenerates everything in output/ +./scripts/bench.sh # only the timing run +``` + +## Layout + +| Path | What it is | +|---|---| +| `src/main/java/com/ankurm/inheritance/single` | `Payment` hierarchy, `SINGLE_TABLE`, discriminator column, `@Check` constraint | +| `src/main/java/com/ankurm/inheritance/joined` | the same hierarchy, `JOINED` | +| `src/main/java/com/ankurm/inheritance/tpc` | the same hierarchy, `TABLE_PER_CLASS` (sequence ids, abstract root) | +| `src/main/java/com/ankurm/inheritance/mapped` | `@MappedSuperclass` `AuditedBase` with two unrelated entities | +| `src/test/java/.../Strategies.java` | the three hierarchies side by side | +| `src/test/java/.../DdlTest.java` | writes `output/01-*` | +| `src/test/java/.../SqlTest.java` | writes `output/02-*` | +| `src/test/java/.../ContractsTest.java` | writes `output/03-contracts.txt`; also holds the two deliberately wrong mappings (`Sn*`, `TpcIdentity*`) | +| `src/test/java/.../Bench.java` | writes `output/04-polymorphic-timings.txt` | + +## Output + +| File | What it shows | +|---|---| +| `output/01-ddl-single-table.txt`, `01-ddl-joined.txt`, `01-ddl-table-per-class.txt`, `01-ddl-mapped-superclass.txt` | the schema Hibernate generates | +| `output/02-sql-single-table.txt`, `02-sql-joined.txt`, `02-sql-table-per-class.txt` | INSERTs and SELECTs for the same four operations | +| `output/03-contracts.txt` | row counts, INSERT counts, nullability, foreign keys, and the three failures | +| `output/04-polymorphic-timings.txt` | indicative timings on 150,000 rows (not asserted; drift between runs) | + +Everything in `01`-`03` is produced by tests that assert the numbers they print. diff --git a/inheritance/output/01-ddl-joined.txt b/inheritance/output/01-ddl-joined.txt new file mode 100644 index 0000000..7df993a --- /dev/null +++ b/inheritance/output/01-ddl-joined.txt @@ -0,0 +1,61 @@ +-- DDL generated by Hibernate 7.4.13.Final for H2, strategy JOINED +-- tables created: 5 + + + create sequence jn_order_seq start with 1 increment by 50; + + create sequence jn_payment_seq start with 1 increment by 50; + + create table jn_bank_transfer_payment ( + payment_id bigint not null, + bank_code varchar(11), + iban varchar(34) not null, + primary key (payment_id) + ); + + create table jn_card_payment ( + card_last4 varchar(4) not null, + payment_id bigint not null, + network varchar(16), + primary key (payment_id) + ); + + create table jn_order ( + id bigint not null, + payment_id bigint not null, + reference varchar(20) not null, + primary key (id) + ); + + create table jn_payment ( + amount numeric(12,2) not null, + currency varchar(3) not null, + id bigint not null, + primary key (id) + ); + + create table jn_wallet_payment ( + payment_id bigint not null, + provider varchar(24) not null, + primary key (payment_id) + ); + + alter table if exists jn_bank_transfer_payment + add constraint FK7mx6hp714jwtc7cbp5oboi9b5 + foreign key (payment_id) + references jn_payment; + + alter table if exists jn_card_payment + add constraint FK2pe4xdfpn9xujq97a21nj00nr + foreign key (payment_id) + references jn_payment; + + alter table if exists jn_order + add constraint FKschxw26qcnlfcgeyirv55ttkl + foreign key (payment_id) + references jn_payment; + + alter table if exists jn_wallet_payment + add constraint FK9cf00xcddbti8l83t1s5ptu3h + foreign key (payment_id) + references jn_payment; diff --git a/inheritance/output/01-ddl-mapped-superclass.txt b/inheritance/output/01-ddl-mapped-superclass.txt new file mode 100644 index 0000000..6950c7e --- /dev/null +++ b/inheritance/output/01-ddl-mapped-superclass.txt @@ -0,0 +1,22 @@ +-- DDL generated by Hibernate 7.4.13.Final for H2, @MappedSuperclass AuditedBase + Invoice + Receipt + + + create sequence mp_invoice_SEQ start with 1 increment by 50; + + create sequence mp_receipt_SEQ start with 1 increment by 50; + + create table mp_invoice ( + version integer not null, + created_at timestamp(6) with time zone not null, + id bigint not null, + number varchar(20) not null, + primary key (id) + ); + + create table mp_receipt ( + version integer not null, + created_at timestamp(6) with time zone not null, + id bigint not null, + note varchar(60), + primary key (id) + ); diff --git a/inheritance/output/01-ddl-single-table.txt b/inheritance/output/01-ddl-single-table.txt new file mode 100644 index 0000000..5f6fcfe --- /dev/null +++ b/inheritance/output/01-ddl-single-table.txt @@ -0,0 +1,33 @@ +-- DDL generated by Hibernate 7.4.13.Final for H2, strategy SINGLE_TABLE +-- tables created: 2 + + + create sequence st_order_seq start with 1 increment by 50; + + create sequence st_payment_seq start with 1 increment by 50; + + create table st_order ( + id bigint not null, + payment_id bigint not null, + reference varchar(20) not null, + primary key (id) + ); + + create table st_payment ( + amount numeric(12,2) not null, + card_last4 varchar(4), + currency varchar(3) not null, + id bigint not null, + bank_code varchar(11), + payment_type varchar(12) not null check ((payment_type in ('CARD','BANK','WALLET'))), + network varchar(16), + provider varchar(24), + iban varchar(34), + primary key (id), + constraint st_payment_subclass_columns check ((payment_type <> 'CARD' or card_last4 is not null) and (payment_type <> 'BANK' or iban is not null) and (payment_type <> 'WALLET' or provider is not null)) + ); + + alter table if exists st_order + add constraint FKdi3y406uyk3cm6po29lmjirt5 + foreign key (payment_id) + references st_payment; diff --git a/inheritance/output/01-ddl-table-per-class.txt b/inheritance/output/01-ddl-table-per-class.txt new file mode 100644 index 0000000..4682a02 --- /dev/null +++ b/inheritance/output/01-ddl-table-per-class.txt @@ -0,0 +1,40 @@ +-- DDL generated by Hibernate 7.4.13.Final for H2, strategy TABLE_PER_CLASS +-- tables created: 4 + + + create sequence tp_order_seq start with 1 increment by 50; + + create sequence tp_payment_seq start with 1 increment by 50; + + create table tp_bank_transfer_payment ( + amount numeric(12,2) not null, + currency varchar(3) not null, + id bigint not null, + bank_code varchar(11), + iban varchar(34) not null, + primary key (id) + ); + + create table tp_card_payment ( + amount numeric(12,2) not null, + card_last4 varchar(4) not null, + currency varchar(3) not null, + id bigint not null, + network varchar(16), + primary key (id) + ); + + create table tp_order ( + id bigint not null, + payment_id bigint not null, + reference varchar(20) not null, + primary key (id) + ); + + create table tp_wallet_payment ( + amount numeric(12,2) not null, + currency varchar(3) not null, + id bigint not null, + provider varchar(24) not null, + primary key (id) + ); diff --git a/inheritance/output/02-sql-joined.txt b/inheritance/output/02-sql-joined.txt new file mode 100644 index 0000000..92d4593 --- /dev/null +++ b/inheritance/output/02-sql-joined.txt @@ -0,0 +1,31 @@ +-- strategy JOINED, Hibernate 7.4.13.Final + +== persist 3 payments and 1 order: 7 INSERT statements +insert into jn_payment (amount,currency,id) values (?,?,?); +insert into jn_card_payment (card_last4,network,payment_id) values (?,?,?); +insert into jn_payment (amount,currency,id) values (?,?,?); +insert into jn_bank_transfer_payment (bank_code,iban,payment_id) values (?,?,?); +insert into jn_payment (amount,currency,id) values (?,?,?); +insert into jn_wallet_payment (provider,payment_id) values (?,?); +insert into jn_order (payment_id,reference,id) values (?,?,?); + +== select p from JnPayment p (polymorphic, returns 3 rows) +select p1_0.id,case when p1_2.payment_id is not null then 1 when p1_1.payment_id is not null then 2 when p1_3.payment_id is not null then 3 end,p1_0.amount,p1_0.currency,p1_1.bank_code,p1_1.iban,p1_2.card_last4,p1_2.network,p1_3.provider +from jn_payment p1_0 + left join jn_bank_transfer_payment p1_1 on p1_0.id=p1_1.payment_id + left join jn_card_payment p1_2 on p1_0.id=p1_2.payment_id + left join jn_wallet_payment p1_3 on p1_0.id=p1_3.payment_id + order by p1_0.id; + +== from JnCardPayment (one subclass, returns 1 row) +select cp1_0.payment_id,cp1_1.amount,cp1_1.currency,cp1_0.card_last4,cp1_0.network +from jn_card_payment cp1_0 + join jn_payment cp1_1 on cp1_0.payment_id=cp1_1.id; + +== select o from JnOrder o join fetch o.payment (association to the root) +select po1_0.id,po1_0.payment_id,p1_0.id,case when p1_2.payment_id is not null then 1 when p1_1.payment_id is not null then 2 when p1_3.payment_id is not null then 3 end,p1_0.amount,p1_0.currency,p1_1.bank_code,p1_1.iban,p1_2.card_last4,p1_2.network,p1_3.provider,po1_0.reference +from jn_order po1_0 + join jn_payment p1_0 on p1_0.id=po1_0.payment_id + left join jn_bank_transfer_payment p1_1 on p1_0.id=p1_1.payment_id + left join jn_card_payment p1_2 on p1_0.id=p1_2.payment_id + left join jn_wallet_payment p1_3 on p1_0.id=p1_3.payment_id; diff --git a/inheritance/output/02-sql-single-table.txt b/inheritance/output/02-sql-single-table.txt new file mode 100644 index 0000000..76794e7 --- /dev/null +++ b/inheritance/output/02-sql-single-table.txt @@ -0,0 +1,22 @@ +-- strategy SINGLE_TABLE, Hibernate 7.4.13.Final + +== persist 3 payments and 1 order: 4 INSERT statements +insert into st_payment (amount,currency,card_last4,network,payment_type,id) values (?,?,?,?,'CARD',?); +insert into st_payment (amount,currency,bank_code,iban,payment_type,id) values (?,?,?,?,'BANK',?); +insert into st_payment (amount,currency,provider,payment_type,id) values (?,?,?,'WALLET',?); +insert into st_order (payment_id,reference,id) values (?,?,?); + +== select p from StPayment p (polymorphic, returns 3 rows) +select p1_0.id,p1_0.payment_type,p1_0.amount,p1_0.currency,p1_0.bank_code,p1_0.iban,p1_0.card_last4,p1_0.network,p1_0.provider +from st_payment p1_0 + order by p1_0.id; + +== from StCardPayment (one subclass, returns 1 row) +select cp1_0.id,cp1_0.amount,cp1_0.currency,cp1_0.card_last4,cp1_0.network +from st_payment cp1_0 + where cp1_0.payment_type='CARD'; + +== select o from StOrder o join fetch o.payment (association to the root) +select po1_0.id,po1_0.payment_id,p1_0.id,p1_0.payment_type,p1_0.amount,p1_0.currency,p1_0.bank_code,p1_0.iban,p1_0.card_last4,p1_0.network,p1_0.provider,po1_0.reference +from st_order po1_0 + join st_payment p1_0 on p1_0.id=po1_0.payment_id; diff --git a/inheritance/output/02-sql-table-per-class.txt b/inheritance/output/02-sql-table-per-class.txt new file mode 100644 index 0000000..17ee7d3 --- /dev/null +++ b/inheritance/output/02-sql-table-per-class.txt @@ -0,0 +1,35 @@ +-- strategy TABLE_PER_CLASS, Hibernate 7.4.13.Final + +== persist 3 payments and 1 order: 4 INSERT statements +insert into tp_card_payment (amount,currency,card_last4,network,id) values (?,?,?,?,?); +insert into tp_bank_transfer_payment (amount,currency,bank_code,iban,id) values (?,?,?,?,?); +insert into tp_wallet_payment (amount,currency,provider,id) values (?,?,?,?); +insert into tp_order (payment_id,reference,id) values (?,?,?); + +== select p from TpPayment p (polymorphic, returns 3 rows) +select p1_0.id,p1_0.clazz_,p1_0.amount,p1_0.currency,p1_0.bank_code,p1_0.iban,p1_0.card_last4,p1_0.network,p1_0.provider +from (select amount, card_last4, currency, id, network, null as bank_code, null as iban, null as provider, 1 as clazz_ +from tp_card_payment +union all +select amount, null as card_last4, currency, id, null as network, bank_code, iban, null as provider, 2 as clazz_ +from tp_bank_transfer_payment +union all +select amount, null as card_last4, currency, id, null as network, null as bank_code, null as iban, provider, 3 as clazz_ +from tp_wallet_payment) p1_0 + order by p1_0.id; + +== from TpCardPayment (one subclass, returns 1 row) +select cp1_0.id,cp1_0.amount,cp1_0.currency,cp1_0.card_last4,cp1_0.network +from tp_card_payment cp1_0; + +== select o from TpOrder o join fetch o.payment (association to the root) +select po1_0.id,po1_0.payment_id,p1_0.id,p1_0.clazz_,p1_0.amount,p1_0.currency,p1_0.bank_code,p1_0.iban,p1_0.card_last4,p1_0.network,p1_0.provider,po1_0.reference +from tp_order po1_0 + join (select amount, card_last4, currency, id, network, null as bank_code, null as iban, null as provider, 1 as clazz_ +from tp_card_payment +union all +select amount, null as card_last4, currency, id, null as network, bank_code, iban, null as provider, 2 as clazz_ +from tp_bank_transfer_payment +union all +select amount, null as card_last4, currency, id, null as network, null as bank_code, null as iban, provider, 3 as clazz_ +from tp_wallet_payment) p1_0 on p1_0.id=po1_0.payment_id; diff --git a/inheritance/output/03-contracts.txt b/inheritance/output/03-contracts.txt new file mode 100644 index 0000000..a384476 --- /dev/null +++ b/inheritance/output/03-contracts.txt @@ -0,0 +1,46 @@ +Hibernate 7.4.13.Final, H2 in memory + +== SINGLE_TABLE +INSERT statements to save 3 payments + 1 order: 4 +tables and row counts: + st_order = 1 + st_payment = 3 +IS_NULLABLE for card_last4 in st_payment: YES +foreign keys on st_order: 1 + +discriminator values stored: [BANK, CARD, WALLET] +raw INSERT of a CARD row with no card_last4: Check constraint violation: "ST_PAYMENT_SUBCLASS_COLUMNS: "; SQL statement: +insert into st_payment (id, amount, currency, payment_type) values (900, 1.00, 'EUR', 'CARD') [23513-240] + +== JOINED +INSERT statements to save 3 payments + 1 order: 7 +tables and row counts: + jn_bank_transfer_payment = 1 + jn_card_payment = 1 + jn_order = 1 + jn_payment = 3 + jn_wallet_payment = 1 +IS_NULLABLE for card_last4 in jn_card_payment: NO +foreign keys on jn_order: 1 + +== TABLE_PER_CLASS +INSERT statements to save 3 payments + 1 order: 4 +tables and row counts: + tp_bank_transfer_payment = 1 + tp_card_payment = 1 + tp_order = 1 + tp_wallet_payment = 1 +IS_NULLABLE for card_last4 in tp_card_payment: NO +foreign keys on tp_order: 0 + +== SINGLE_TABLE with nullable = false on subclass columns +IS_NULLABLE: [A_VALUE | NO, B_VALUE | NO, ID | NO, KIND | NO] +persist(new SnA()): org.hibernate.exception.ConstraintViolationException: could not execute statement [NULL not allowed for column "B_VALUE"; SQL statement: +insert into sn_root (a_value,kind,id) values (?,'A',?) [23502-240]] [insert into sn_root (a_value,kind,id) values (?,'A',?)] + +== TABLE_PER_CLASS with GenerationType.IDENTITY +org.hibernate.MappingException: Cannot use identity column key generation with mapping for: com.ankurm.inheritance.TpcIdentityCard + +== querying a @MappedSuperclass +tables: [mp_invoice, mp_receipt] +org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'AuditedBase' diff --git a/inheritance/output/04-polymorphic-timings.txt b/inheritance/output/04-polymorphic-timings.txt new file mode 100644 index 0000000..c3dd970 --- /dev/null +++ b/inheritance/output/04-polymorphic-timings.txt @@ -0,0 +1,20 @@ +INDICATIVE ONLY - timings drift between runs and machines; nothing here is asserted. +Hibernate 7.4.13.Final, H2 in memory, JDK 25.0.5+8-LTS, 2 vCPU +150000 payments (50000 card, 50000 bank transfer, 50000 wallet), read through a StatelessSession, median of 15 runs after 5 warm-ups + +strategy query rows median ms +single-table all payments (polymorphic) 150000 247.2 +single-table one subclass (CardPayment) 50000 82.4 +single-table count where amount > 500 (root col) 1 18.4 +single-table first 50 by id (polymorphic) 50 0.3 + +joined all payments (polymorphic) 150000 401.5 +joined one subclass (CardPayment) 50000 107.1 +joined count where amount > 500 (root col) 1 21.6 +joined first 50 by id (polymorphic) 50 0.3 + +table-per-class all payments (polymorphic) 150000 422.0 +table-per-class one subclass (CardPayment) 50000 82.1 +table-per-class count where amount > 500 (root col) 1 56.0 +table-per-class first 50 by id (polymorphic) 50 165.8 + diff --git a/inheritance/pom.xml b/inheritance/pom.xml new file mode 100644 index 0000000..fbbd1bf --- /dev/null +++ b/inheritance/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + com.ankurm + inheritance + 1.0.0 + inheritance + Hibernate 7 inheritance mapping: SINGLE_TABLE vs JOINED vs TABLE_PER_CLASS, run with plain Hibernate (no Spring) + + + 25 + UTF-8 + 7.4.13.Final + + + + + org.hibernate.orm + hibernate-core + ${hibernate.version} + + + jakarta.persistence + jakarta.persistence-api + 3.2.0 + + + com.h2database + h2 + 2.4.240 + + + org.junit.jupiter + junit-jupiter + 6.0.3 + test + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + maven-surefire-plugin + 3.6.0 + + + + diff --git a/inheritance/scripts/bench.sh b/inheritance/scripts/bench.sh new file mode 100755 index 0000000..19ea403 --- /dev/null +++ b/inheritance/scripts/bench.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Writes output/04-polymorphic-timings.txt. Timings are indicative: they drift between runs and machines. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -B -q test-compile dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=test +java -cp "target/classes:target/test-classes:$(cat target/cp.txt)" com.ankurm.inheritance.Bench +cat output/04-polymorphic-timings.txt diff --git a/inheritance/scripts/run-all.sh b/inheritance/scripts/run-all.sh new file mode 100755 index 0000000..d8692b4 --- /dev/null +++ b/inheritance/scripts/run-all.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Regenerates every file under output/. Needs JDK 25 and Maven 3.9 with access to Maven Central. +# 01-ddl-*, 02-sql-*, 03-contracts : the JUnit tests (they assert the numbers they print) +# 04-polymorphic-timings : scripts/bench.sh (indicative timings, not asserted) +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -B clean test +./scripts/bench.sh +ls -1 output diff --git a/inheritance/src/main/java/com/ankurm/inheritance/joined/BankTransferPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/joined/BankTransferPayment.java new file mode 100644 index 0000000..09fa212 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/joined/BankTransferPayment.java @@ -0,0 +1,25 @@ +package com.ankurm.inheritance.joined; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "JnBankTransferPayment") +@Table(name = "jn_bank_transfer_payment") +@PrimaryKeyJoinColumn(name = "payment_id") +public class BankTransferPayment extends Payment { + + @Column(nullable = false, length = 34) + private String iban; + + @Column(name = "bank_code", length = 11) + private String bankCode; + + protected BankTransferPayment() { + } + + public BankTransferPayment(BigDecimal amount, String currency, String iban, String bankCode) { + super(amount, currency); + this.iban = iban; + this.bankCode = bankCode; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/joined/CardPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/joined/CardPayment.java new file mode 100644 index 0000000..efea431 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/joined/CardPayment.java @@ -0,0 +1,25 @@ +package com.ankurm.inheritance.joined; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "JnCardPayment") +@Table(name = "jn_card_payment") +@PrimaryKeyJoinColumn(name = "payment_id") +public class CardPayment extends Payment { + + @Column(name = "card_last4", nullable = false, length = 4) + private String cardLast4; + + @Column(length = 16) + private String network; + + protected CardPayment() { + } + + public CardPayment(BigDecimal amount, String currency, String cardLast4, String network) { + super(amount, currency); + this.cardLast4 = cardLast4; + this.network = network; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/joined/Payment.java b/inheritance/src/main/java/com/ankurm/inheritance/joined/Payment.java new file mode 100644 index 0000000..56f1688 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/joined/Payment.java @@ -0,0 +1,34 @@ +package com.ankurm.inheritance.joined; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +/** JOINED: one table per class; a subclass row is the root row plus a row in the subclass table, joined on the primary key. */ +@Entity(name = "JnPayment") +@Table(name = "jn_payment") +@Inheritance(strategy = InheritanceType.JOINED) +public abstract class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "jn_payment_seq") + @SequenceGenerator(name = "jn_payment_seq", sequenceName = "jn_payment_seq", allocationSize = 50) + private Long id; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal amount; + + @Column(nullable = false, length = 3) + private String currency; + + protected Payment() { + } + + protected Payment(BigDecimal amount, String currency) { + this.amount = amount; + this.currency = currency; + } + + public Long getId() { return id; } + public BigDecimal getAmount() { return amount; } + public String getCurrency() { return currency; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/joined/PaymentOrder.java b/inheritance/src/main/java/com/ankurm/inheritance/joined/PaymentOrder.java new file mode 100644 index 0000000..58be456 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/joined/PaymentOrder.java @@ -0,0 +1,33 @@ +package com.ankurm.inheritance.joined; + +import jakarta.persistence.*; + +/** An order that points at the hierarchy root; what that foreign key looks like depends on the strategy. */ +@Entity(name = "JnOrder") +@Table(name = "jn_order") +public class PaymentOrder { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "jn_order_seq") + @SequenceGenerator(name = "jn_order_seq", sequenceName = "jn_order_seq", allocationSize = 50) + private Long id; + + @Column(nullable = false, length = 20) + private String reference; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "payment_id") + private Payment payment; + + protected PaymentOrder() { + } + + public PaymentOrder(String reference, Payment payment) { + this.reference = reference; + this.payment = payment; + } + + public Long getId() { return id; } + public String getReference() { return reference; } + public Payment getPayment() { return payment; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/joined/WalletPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/joined/WalletPayment.java new file mode 100644 index 0000000..fba362d --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/joined/WalletPayment.java @@ -0,0 +1,21 @@ +package com.ankurm.inheritance.joined; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "JnWalletPayment") +@Table(name = "jn_wallet_payment") +@PrimaryKeyJoinColumn(name = "payment_id") +public class WalletPayment extends Payment { + + @Column(nullable = false, length = 24) + private String provider; + + protected WalletPayment() { + } + + public WalletPayment(BigDecimal amount, String currency, String provider) { + super(amount, currency); + this.provider = provider; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/mapped/AuditedBase.java b/inheritance/src/main/java/com/ankurm/inheritance/mapped/AuditedBase.java new file mode 100644 index 0000000..b5f366c --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/mapped/AuditedBase.java @@ -0,0 +1,31 @@ +package com.ankurm.inheritance.mapped; + +import jakarta.persistence.*; +import java.time.Instant; + +/** + * @MappedSuperclass: shared columns and mappings, but not an entity. It has no table, cannot be + * queried, and cannot be the target of an association. + */ +@MappedSuperclass +public abstract class AuditedBase { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + @Version + private int version; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void stamp() { + this.createdAt = Instant.now(); + } + + public Long getId() { return id; } + public int getVersion() { return version; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/mapped/Invoice.java b/inheritance/src/main/java/com/ankurm/inheritance/mapped/Invoice.java new file mode 100644 index 0000000..9efdd3e --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/mapped/Invoice.java @@ -0,0 +1,20 @@ +package com.ankurm.inheritance.mapped; + +import jakarta.persistence.*; + +@Entity(name = "MpInvoice") +@Table(name = "mp_invoice") +public class Invoice extends AuditedBase { + + @Column(nullable = false, length = 20) + private String number; + + protected Invoice() { + } + + public Invoice(String number) { + this.number = number; + } + + public String getNumber() { return number; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/mapped/Receipt.java b/inheritance/src/main/java/com/ankurm/inheritance/mapped/Receipt.java new file mode 100644 index 0000000..3868f9a --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/mapped/Receipt.java @@ -0,0 +1,20 @@ +package com.ankurm.inheritance.mapped; + +import jakarta.persistence.*; + +@Entity(name = "MpReceipt") +@Table(name = "mp_receipt") +public class Receipt extends AuditedBase { + + @Column(length = 60) + private String note; + + protected Receipt() { + } + + public Receipt(String note) { + this.note = note; + } + + public String getNote() { return note; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/single/BankTransferPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/single/BankTransferPayment.java new file mode 100644 index 0000000..ad59f2f --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/single/BankTransferPayment.java @@ -0,0 +1,24 @@ +package com.ankurm.inheritance.single; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "StBankTransferPayment") +@DiscriminatorValue("BANK") +public class BankTransferPayment extends Payment { + + @Column(length = 34) + private String iban; + + @Column(name = "bank_code", length = 11) + private String bankCode; + + protected BankTransferPayment() { + } + + public BankTransferPayment(BigDecimal amount, String currency, String iban, String bankCode) { + super(amount, currency); + this.iban = iban; + this.bankCode = bankCode; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/single/CardPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/single/CardPayment.java new file mode 100644 index 0000000..c6a6e93 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/single/CardPayment.java @@ -0,0 +1,24 @@ +package com.ankurm.inheritance.single; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "StCardPayment") +@DiscriminatorValue("CARD") +public class CardPayment extends Payment { + + @Column(name = "card_last4", length = 4) + private String cardLast4; + + @Column(length = 16) + private String network; + + protected CardPayment() { + } + + public CardPayment(BigDecimal amount, String currency, String cardLast4, String network) { + super(amount, currency); + this.cardLast4 = cardLast4; + this.network = network; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/single/Payment.java b/inheritance/src/main/java/com/ankurm/inheritance/single/Payment.java new file mode 100644 index 0000000..2a53939 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/single/Payment.java @@ -0,0 +1,37 @@ +package com.ankurm.inheritance.single; + +import jakarta.persistence.*; +import org.hibernate.annotations.Check; +import java.math.BigDecimal; + +/** SINGLE_TABLE: one table for the whole hierarchy, a discriminator column says which subclass a row is. */ +@Entity(name = "StPayment") +@Table(name = "st_payment") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "payment_type", length = 12) +@Check(name = "st_payment_subclass_columns", constraints = "(payment_type <> 'CARD' or card_last4 is not null) and (payment_type <> 'BANK' or iban is not null) and (payment_type <> 'WALLET' or provider is not null)") +public abstract class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "st_payment_seq") + @SequenceGenerator(name = "st_payment_seq", sequenceName = "st_payment_seq", allocationSize = 50) + private Long id; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal amount; + + @Column(nullable = false, length = 3) + private String currency; + + protected Payment() { + } + + protected Payment(BigDecimal amount, String currency) { + this.amount = amount; + this.currency = currency; + } + + public Long getId() { return id; } + public BigDecimal getAmount() { return amount; } + public String getCurrency() { return currency; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/single/PaymentOrder.java b/inheritance/src/main/java/com/ankurm/inheritance/single/PaymentOrder.java new file mode 100644 index 0000000..5543626 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/single/PaymentOrder.java @@ -0,0 +1,33 @@ +package com.ankurm.inheritance.single; + +import jakarta.persistence.*; + +/** An order that points at the hierarchy root; what that foreign key looks like depends on the strategy. */ +@Entity(name = "StOrder") +@Table(name = "st_order") +public class PaymentOrder { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "st_order_seq") + @SequenceGenerator(name = "st_order_seq", sequenceName = "st_order_seq", allocationSize = 50) + private Long id; + + @Column(nullable = false, length = 20) + private String reference; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "payment_id") + private Payment payment; + + protected PaymentOrder() { + } + + public PaymentOrder(String reference, Payment payment) { + this.reference = reference; + this.payment = payment; + } + + public Long getId() { return id; } + public String getReference() { return reference; } + public Payment getPayment() { return payment; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/single/WalletPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/single/WalletPayment.java new file mode 100644 index 0000000..53484be --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/single/WalletPayment.java @@ -0,0 +1,20 @@ +package com.ankurm.inheritance.single; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "StWalletPayment") +@DiscriminatorValue("WALLET") +public class WalletPayment extends Payment { + + @Column(length = 24) + private String provider; + + protected WalletPayment() { + } + + public WalletPayment(BigDecimal amount, String currency, String provider) { + super(amount, currency); + this.provider = provider; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/tpc/BankTransferPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/tpc/BankTransferPayment.java new file mode 100644 index 0000000..df261ce --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/tpc/BankTransferPayment.java @@ -0,0 +1,24 @@ +package com.ankurm.inheritance.tpc; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "TpBankTransferPayment") +@Table(name = "tp_bank_transfer_payment") +public class BankTransferPayment extends Payment { + + @Column(nullable = false, length = 34) + private String iban; + + @Column(name = "bank_code", length = 11) + private String bankCode; + + protected BankTransferPayment() { + } + + public BankTransferPayment(BigDecimal amount, String currency, String iban, String bankCode) { + super(amount, currency); + this.iban = iban; + this.bankCode = bankCode; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/tpc/CardPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/tpc/CardPayment.java new file mode 100644 index 0000000..b6dd8c9 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/tpc/CardPayment.java @@ -0,0 +1,24 @@ +package com.ankurm.inheritance.tpc; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "TpCardPayment") +@Table(name = "tp_card_payment") +public class CardPayment extends Payment { + + @Column(name = "card_last4", nullable = false, length = 4) + private String cardLast4; + + @Column(length = 16) + private String network; + + protected CardPayment() { + } + + public CardPayment(BigDecimal amount, String currency, String cardLast4, String network) { + super(amount, currency); + this.cardLast4 = cardLast4; + this.network = network; + } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/tpc/Payment.java b/inheritance/src/main/java/com/ankurm/inheritance/tpc/Payment.java new file mode 100644 index 0000000..a0f62ac --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/tpc/Payment.java @@ -0,0 +1,33 @@ +package com.ankurm.inheritance.tpc; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +/** TABLE_PER_CLASS: one table per concrete class, each carrying every inherited column; the abstract root has no table. */ +@Entity(name = "TpPayment") +@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) +public abstract class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "tp_payment_seq") + @SequenceGenerator(name = "tp_payment_seq", sequenceName = "tp_payment_seq", allocationSize = 50) + private Long id; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal amount; + + @Column(nullable = false, length = 3) + private String currency; + + protected Payment() { + } + + protected Payment(BigDecimal amount, String currency) { + this.amount = amount; + this.currency = currency; + } + + public Long getId() { return id; } + public BigDecimal getAmount() { return amount; } + public String getCurrency() { return currency; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/tpc/PaymentOrder.java b/inheritance/src/main/java/com/ankurm/inheritance/tpc/PaymentOrder.java new file mode 100644 index 0000000..5a56912 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/tpc/PaymentOrder.java @@ -0,0 +1,33 @@ +package com.ankurm.inheritance.tpc; + +import jakarta.persistence.*; + +/** An order that points at the hierarchy root; what that foreign key looks like depends on the strategy. */ +@Entity(name = "TpOrder") +@Table(name = "tp_order") +public class PaymentOrder { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "tp_order_seq") + @SequenceGenerator(name = "tp_order_seq", sequenceName = "tp_order_seq", allocationSize = 50) + private Long id; + + @Column(nullable = false, length = 20) + private String reference; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "payment_id") + private Payment payment; + + protected PaymentOrder() { + } + + public PaymentOrder(String reference, Payment payment) { + this.reference = reference; + this.payment = payment; + } + + public Long getId() { return id; } + public String getReference() { return reference; } + public Payment getPayment() { return payment; } +} diff --git a/inheritance/src/main/java/com/ankurm/inheritance/tpc/WalletPayment.java b/inheritance/src/main/java/com/ankurm/inheritance/tpc/WalletPayment.java new file mode 100644 index 0000000..2529293 --- /dev/null +++ b/inheritance/src/main/java/com/ankurm/inheritance/tpc/WalletPayment.java @@ -0,0 +1,20 @@ +package com.ankurm.inheritance.tpc; + +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity(name = "TpWalletPayment") +@Table(name = "tp_wallet_payment") +public class WalletPayment extends Payment { + + @Column(nullable = false, length = 24) + private String provider; + + protected WalletPayment() { + } + + public WalletPayment(BigDecimal amount, String currency, String provider) { + super(amount, currency); + this.provider = provider; + } +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/Bench.java b/inheritance/src/test/java/com/ankurm/inheritance/Bench.java new file mode 100644 index 0000000..2957bc0 --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/Bench.java @@ -0,0 +1,95 @@ +package com.ankurm.inheritance; + +import org.hibernate.StatelessSession; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +/** + * Not a test: timings drift between runs and machines, so nothing here is asserted. + * Writes output/04-polymorphic-timings.txt. Run it with scripts/bench.sh. + */ +public final class Bench { + + static final int PER_TYPE = 50_000; + static final int WARMUP = 5; + static final int RUNS = 15; + + public static void main(String[] args) { + try (Transcript t = new Transcript("04-polymorphic-timings")) { + t.line("INDICATIVE ONLY - timings drift between runs and machines; nothing here is asserted."); + t.line("Hibernate " + org.hibernate.Version.getVersionString() + ", H2 in memory, JDK " + Runtime.version() + + ", " + Runtime.getRuntime().availableProcessors() + " vCPU"); + t.line(3 * PER_TYPE + " payments (" + PER_TYPE + " card, " + PER_TYPE + " bank transfer, " + PER_TYPE + + " wallet), read through a StatelessSession, median of " + RUNS + " runs after " + WARMUP + " warm-ups"); + t.blank(); + t.line(String.format("%-16s %-44s %8s %10s", "strategy", "query", "rows", "median ms")); + for (Strategies s : Strategies.values()) { + try (Db db = new Db(s.classes())) { + load(db, s); + db.clear(); + String p = s.prefix; + query(t, db, s, "all payments (polymorphic)", "select p from " + p + "Payment p", null); + query(t, db, s, "one subclass (CardPayment)", "from " + p + "CardPayment", null); + query(t, db, s, "count where amount > 500 (root col)", "select count(p) from " + p + "Payment p where p.amount > 500", null); + query(t, db, s, "first 50 by id (polymorphic)", "select p from " + p + "Payment p order by p.id", 50); + } + t.blank(); + } + } + } + + private static void query(Transcript t, Db db, Strategies s, String label, String hql, Integer max) { + List times = new ArrayList<>(); + int rows = 0; + try (StatelessSession session = db.sessionFactory.openStatelessSession()) { + for (int i = 0; i < WARMUP + RUNS; i++) { + long start = System.nanoTime(); + var q = session.createSelectionQuery(hql, Object.class); + if (max != null) q.setMaxResults(max); + rows = q.getResultList().size(); + double ms = (System.nanoTime() - start) / 1e6; + if (i >= WARMUP) times.add(ms); + } + } + Collections.sort(times); + t.line(String.format("%-16s %-44s %8d %10.1f", s.label, label, rows, times.get(times.size() / 2))); + } + + private static void load(Db db, Strategies s) { + try (StatelessSession session = db.sessionFactory.openStatelessSession()) { + session.getTransaction().begin(); + for (int i = 0; i < PER_TYPE; i++) { + BigDecimal amount = BigDecimal.valueOf(1 + (i % 1000)); + session.insert(make(s, "card", amount, i)); + session.insert(make(s, "bank", amount, i)); + session.insert(make(s, "wallet", amount, i)); + } + session.getTransaction().commit(); + } + } + + private static Object make(Strategies s, String type, BigDecimal amount, int i) { + Supplier f = switch (s) { + case SINGLE_TABLE -> switch (type) { + case "card" -> () -> new com.ankurm.inheritance.single.CardPayment(amount, "EUR", "4242", "VISA"); + case "bank" -> () -> new com.ankurm.inheritance.single.BankTransferPayment(amount, "EUR", "DE89370400440532013000", "COBADEFF"); + default -> () -> new com.ankurm.inheritance.single.WalletPayment(amount, "EUR", "PAYPAL"); + }; + case JOINED -> switch (type) { + case "card" -> () -> new com.ankurm.inheritance.joined.CardPayment(amount, "EUR", "4242", "VISA"); + case "bank" -> () -> new com.ankurm.inheritance.joined.BankTransferPayment(amount, "EUR", "DE89370400440532013000", "COBADEFF"); + default -> () -> new com.ankurm.inheritance.joined.WalletPayment(amount, "EUR", "PAYPAL"); + }; + case TABLE_PER_CLASS -> switch (type) { + case "card" -> () -> new com.ankurm.inheritance.tpc.CardPayment(amount, "EUR", "4242", "VISA"); + case "bank" -> () -> new com.ankurm.inheritance.tpc.BankTransferPayment(amount, "EUR", "DE89370400440532013000", "COBADEFF"); + default -> () -> new com.ankurm.inheritance.tpc.WalletPayment(amount, "EUR", "PAYPAL"); + }; + }; + return f.get(); + } +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/ContractsTest.java b/inheritance/src/test/java/com/ankurm/inheritance/ContractsTest.java new file mode 100644 index 0000000..903d8d9 --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/ContractsTest.java @@ -0,0 +1,97 @@ +package com.ankurm.inheritance; + +import com.ankurm.inheritance.mapped.AuditedBase; +import com.ankurm.inheritance.mapped.Invoice; +import com.ankurm.inheritance.mapped.Receipt; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Writes output/03-contracts.txt: row counts, statement counts, nullability, foreign keys and the two famous failures. */ +class ContractsTest { + + @Test + void contracts() { + try (Transcript t = new Transcript("03-contracts")) { + t.line("Hibernate " + org.hibernate.Version.getVersionString() + ", H2 in memory"); + t.blank(); + + for (Strategies s : Strategies.values()) { + try (Db db = new Db(s.classes())) { + db.sessionFactory.inTransaction(session -> s.sample().forEach(session::persist)); + long inserts = db.sql.stream().filter(q -> q.startsWith("insert")).count(); + t.line("== " + s.name()); + t.line("INSERT statements to save 3 payments + 1 order: " + inserts); + t.line("tables and row counts:"); + for (String table : db.jdbc("select table_name from information_schema.tables where table_schema = 'PUBLIC' and table_name like '" + s.tablePrefix.toUpperCase() + "\\_%' escape '\\' order by table_name")) { + t.line(" " + table.toLowerCase() + " = " + db.jdbc("select count(*) from " + table).get(0)); + } + String card = s.tablePrefix + (s == Strategies.SINGLE_TABLE ? "_payment" : "_card_payment"); + t.line("IS_NULLABLE for card_last4 in " + card + ": " + + db.jdbc("select is_nullable from information_schema.columns where table_name = '" + card.toUpperCase() + "' and column_name = 'CARD_LAST4'").get(0)); + t.line("foreign keys on " + s.tablePrefix + "_order: " + + db.jdbc("select count(*) from information_schema.table_constraints where table_name = '" + (s.tablePrefix + "_order").toUpperCase() + "' and constraint_type = 'FOREIGN KEY'").get(0)); + t.blank(); + + switch (s) { + case SINGLE_TABLE -> { + assertThat(inserts).isEqualTo(4); + assertThat(db.jdbc("select payment_type from st_payment order by payment_type")).containsExactly("BANK", "CARD", "WALLET"); + assertThat(db.jdbc("select is_nullable from information_schema.columns where table_name = 'ST_PAYMENT' and column_name = 'CARD_LAST4'")).containsExactly("YES"); + assertThat(db.jdbc("select count(*) from information_schema.table_constraints where table_name = 'ST_ORDER' and constraint_type = 'FOREIGN KEY'")).containsExactly("1"); + t.line("discriminator values stored: " + db.jdbc("select payment_type from st_payment order by payment_type")); + String bad = db.tryExec("insert into st_payment (id, amount, currency, payment_type) values (900, 1.00, 'EUR', 'CARD')"); + t.line("raw INSERT of a CARD row with no card_last4: " + bad); + assertThat(bad).contains("st_payment_subclass_columns".toUpperCase()); + t.blank(); + } + case JOINED -> { + assertThat(inserts).isEqualTo(7); + assertThat(db.jdbc("select is_nullable from information_schema.columns where table_name = 'JN_CARD_PAYMENT' and column_name = 'CARD_LAST4'")).containsExactly("NO"); + assertThat(db.jdbc("select count(*) from information_schema.table_constraints where table_name = 'JN_ORDER' and constraint_type = 'FOREIGN KEY'")).containsExactly("1"); + } + case TABLE_PER_CLASS -> { + assertThat(inserts).isEqualTo(4); + assertThat(db.jdbc("select is_nullable from information_schema.columns where table_name = 'TP_CARD_PAYMENT' and column_name = 'CARD_LAST4'")).containsExactly("NO"); + assertThat(db.jdbc("select count(*) from information_schema.table_constraints where table_name = 'TP_ORDER' and constraint_type = 'FOREIGN KEY'")).containsExactly("0"); + } + } + } + } + + // SINGLE_TABLE and nullable=false on a subclass column + t.line("== SINGLE_TABLE with nullable = false on subclass columns"); + try (Db db = new Db(SnRoot.class, SnA.class, SnB.class)) { + t.line("IS_NULLABLE: " + db.jdbc("select column_name, is_nullable from information_schema.columns where table_name = 'SN_ROOT' order by column_name")); + assertThatThrownBy(() -> db.sessionFactory.inTransaction(s -> s.persist(new SnA()))) + .satisfies(e -> t.line("persist(new SnA()): " + e.getClass().getName() + ": " + e.getMessage())); + } + t.blank(); + + // TABLE_PER_CLASS cannot use IDENTITY: a row in any of the three tables must not reuse an id from another + t.line("== TABLE_PER_CLASS with GenerationType.IDENTITY"); + assertThatThrownBy(() -> new Db(TpcIdentityPayment.class, TpcIdentityCard.class)) + .satisfies(e -> { + Throwable root = e; + while (root.getCause() != null) root = root.getCause(); + t.line(e.getClass().getName() + ": " + e.getMessage()); + }); + t.blank(); + + // a @MappedSuperclass is not an entity + t.line("== querying a @MappedSuperclass"); + try (Db db = new Db(AuditedBase.class, Invoice.class, Receipt.class)) { + db.sessionFactory.inTransaction(s -> { + s.persist(new Invoice("INV-1")); + s.persist(new Receipt("paid")); + }); + t.line("tables: " + db.jdbc("select table_name from information_schema.tables where table_schema = 'PUBLIC' and table_name like 'MP\\_%' escape '\\' order by 1").stream().map(String::toLowerCase).toList()); + assertThatThrownBy(() -> db.sessionFactory.inSession(s -> s.createSelectionQuery("select a from AuditedBase a", AuditedBase.class).getResultList())) + .satisfies(e -> t.line(e.getClass().getName() + ": " + e.getMessage())); + } + } + } +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/Db.java b/inheritance/src/test/java/com/ankurm/inheritance/Db.java new file mode 100644 index 0000000..2cf6beb --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/Db.java @@ -0,0 +1,122 @@ +package com.ankurm.inheritance; + +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.resource.jdbc.spi.StatementInspector; +import org.hibernate.SessionFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** Plain-Hibernate bootstrap against in-memory H2, recording every SQL string Hibernate prepares. */ +final class Db implements AutoCloseable { + + private static final AtomicInteger SEQ = new AtomicInteger(); + + /** Every statement Hibernate prepared since the last {@link #clear()}. */ + final List sql = new ArrayList<>(); + final SessionFactory sessionFactory; + private final String url; + + Db(Class... entities) { + // OPTIMIZE_REUSE_RESULTS=0: by default H2 hands back the previous result of an identical query when no table + // changed, which makes a repeated-query benchmark report ~0.1 ms for a 150,000-row union. + this.url = "jdbc:h2:mem:inh" + SEQ.incrementAndGet() + ";DB_CLOSE_DELAY=-1;OPTIMIZE_REUSE_RESULTS=0"; + StatementInspector inspector = s -> { + sql.add(s); + return s; + }; + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting(AvailableSettings.JAKARTA_JDBC_URL, url) + .applySetting(AvailableSettings.JAKARTA_JDBC_USER, "sa") + .applySetting(AvailableSettings.JAKARTA_JDBC_PASSWORD, "") + .applySetting(AvailableSettings.HBM2DDL_AUTO, "create-drop") + .applySetting(AvailableSettings.STATEMENT_INSPECTOR, inspector) + .build(); + MetadataSources sources = new MetadataSources(registry); + for (Class e : entities) { + sources.addAnnotatedClass(e); + } + this.sessionFactory = sources.buildMetadata().buildSessionFactory(); + } + + void clear() { + sql.clear(); + } + + /** Raw JDBC against the same in-memory database. */ + List jdbc(String query) { + List rows = new ArrayList<>(); + try (Connection c = java.sql.DriverManager.getConnection(url, "sa", ""); + Statement st = c.createStatement(); + ResultSet rs = st.executeQuery(query)) { + int cols = rs.getMetaData().getColumnCount(); + while (rs.next()) { + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= cols; i++) { + if (i > 1) sb.append(" | "); + sb.append(rs.getString(i)); + } + rows.add(sb.toString()); + } + } catch (SQLException e) { + throw new IllegalStateException(e); + } + return rows; + } + + /** Raw JDBC statement; returns the database's error message if it is rejected, or "accepted". */ + String tryExec(String statement) { + try (Connection c = java.sql.DriverManager.getConnection(url, "sa", ""); + Statement st = c.createStatement()) { + st.executeUpdate(statement); + return "accepted"; + } catch (SQLException e) { + return e.getMessage(); + } + } + + @Override + public void close() { + sessionFactory.close(); + } + + /** + * The DDL Hibernate would run for exactly these entities, as Hibernate itself writes it. Uses the standard + * JPA script-generation settings: org.hibernate.tool.hbm2ddl.SchemaExport is not in hibernate-core 7.4.13. + */ + static List ddl(Class... entities) { + try { + Path out = Files.createTempFile("ddl", ".sql"); + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting(AvailableSettings.JAKARTA_JDBC_URL, "jdbc:h2:mem:ddl" + SEQ.incrementAndGet() + ";DB_CLOSE_DELAY=-1") + .applySetting(AvailableSettings.JAKARTA_JDBC_USER, "sa") + .applySetting(AvailableSettings.JAKARTA_JDBC_PASSWORD, "") + .applySetting(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, "none") + .applySetting(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_ACTION, "create") + .applySetting(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_CREATE_TARGET, out.toString()) + .applySetting(AvailableSettings.FORMAT_SQL, "true") + .build(); + MetadataSources sources = new MetadataSources(registry); + for (Class e : entities) { + sources.addAnnotatedClass(e); + } + sources.buildMetadata().buildSessionFactory().close(); + List lines = Files.readAllLines(out); + Files.delete(out); + return lines; + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/DdlTest.java b/inheritance/src/test/java/com/ankurm/inheritance/DdlTest.java new file mode 100644 index 0000000..3184e52 --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/DdlTest.java @@ -0,0 +1,44 @@ +package com.ankurm.inheritance; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Writes output/01-ddl-*.txt: the schema Hibernate generates for each strategy. */ +class DdlTest { + + @Test + void ddlPerStrategy() { + for (Strategies s : Strategies.values()) { + List ddl = Db.ddl(s.classes()); + long tables = ddl.stream().filter(l -> l.stripLeading().startsWith("create table")).count(); + // the contract this chapter relies on: how many tables each strategy creates for 4 payment classes + 1 order + assertThat(tables).as(s.label).isEqualTo(switch (s) { + case SINGLE_TABLE -> 2L; // st_payment, st_order + case JOINED -> 5L; // jn_payment, jn_card_payment, jn_bank_transfer_payment, jn_wallet_payment, jn_order + case TABLE_PER_CLASS -> 4L; // three concrete tables + tp_order; the abstract root has none + }); + try (Transcript t = new Transcript("01-ddl-" + s.label)) { + t.line("-- DDL generated by Hibernate " + org.hibernate.Version.getVersionString() + " for H2, strategy " + s.name()); + t.line("-- tables created: " + tables); + t.blank(); + ddl.forEach(t::line); + } + } + } + + @Test + void mappedSuperclassDdl() { + List ddl = Db.ddl(com.ankurm.inheritance.mapped.AuditedBase.class, + com.ankurm.inheritance.mapped.Invoice.class, com.ankurm.inheritance.mapped.Receipt.class); + // two entities, two tables: the @MappedSuperclass contributes columns but no table of its own + assertThat(ddl.stream().filter(l -> l.stripLeading().startsWith("create table")).count()).isEqualTo(2); + try (Transcript t = new Transcript("01-ddl-mapped-superclass")) { + t.line("-- DDL generated by Hibernate " + org.hibernate.Version.getVersionString() + " for H2, @MappedSuperclass AuditedBase + Invoice + Receipt"); + t.blank(); + ddl.forEach(t::line); + } + } +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/SnA.java b/inheritance/src/test/java/com/ankurm/inheritance/SnA.java new file mode 100644 index 0000000..41d86d9 --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/SnA.java @@ -0,0 +1,11 @@ +package com.ankurm.inheritance; + +import jakarta.persistence.*; + +@Entity(name = "SnA") +@DiscriminatorValue("A") +class SnA extends SnRoot { + + @Column(name = "a_value", nullable = false) + String aValue = "a"; +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/SnB.java b/inheritance/src/test/java/com/ankurm/inheritance/SnB.java new file mode 100644 index 0000000..e9346b6 --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/SnB.java @@ -0,0 +1,11 @@ +package com.ankurm.inheritance; + +import jakarta.persistence.*; + +@Entity(name = "SnB") +@DiscriminatorValue("B") +class SnB extends SnRoot { + + @Column(name = "b_value", nullable = false) + String bValue = "b"; +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/SnRoot.java b/inheritance/src/test/java/com/ankurm/inheritance/SnRoot.java new file mode 100644 index 0000000..f1972fe --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/SnRoot.java @@ -0,0 +1,15 @@ +package com.ankurm.inheritance; + +import jakarta.persistence.*; + +/** Deliberately wrong: SINGLE_TABLE where each subclass declares a mandatory column. Only ContractsTest uses it. */ +@Entity(name = "SnRoot") +@Table(name = "sn_root") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "kind", length = 4) +abstract class SnRoot { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + Long id; +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/SqlTest.java b/inheritance/src/test/java/com/ankurm/inheritance/SqlTest.java new file mode 100644 index 0000000..b4d3f2c --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/SqlTest.java @@ -0,0 +1,59 @@ +package com.ankurm.inheritance; + +import org.hibernate.Session; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Writes output/02-sql-*.txt: the INSERTs and SELECTs Hibernate emits for the same four operations on each strategy. */ +class SqlTest { + + @Test + void sqlPerStrategy() { + for (Strategies s : Strategies.values()) { + try (Db db = new Db(s.classes()); Transcript t = new Transcript("02-sql-" + s.label)) { + t.line("-- strategy " + s.name() + ", Hibernate " + org.hibernate.Version.getVersionString()); + t.blank(); + + // 1. persist one CardPayment, one BankTransferPayment, one WalletPayment, one order + List sample = s.sample(); + db.sessionFactory.inTransaction(session -> sample.forEach(session::persist)); + List inserts = db.sql.stream().filter(q -> q.startsWith("insert")).toList(); + t.line("== persist 3 payments and 1 order: " + inserts.size() + " INSERT statements"); + inserts.forEach(q -> t.sql("insert", q)); + t.blank(); + + // 2. polymorphic query over the root + db.clear(); + try (Session session = db.sessionFactory.openSession()) { + List all = session.createSelectionQuery("select p from " + s.prefix + "Payment p order by p.id", Object.class).getResultList(); + assertThat(all).hasSize(3); + t.line("== select p from " + s.prefix + "Payment p (polymorphic, returns 3 rows)"); + db.sql.stream().filter(q -> q.startsWith("select")).forEach(q -> t.sql("select", q)); + } + t.blank(); + + // 3. query a single subclass + db.clear(); + try (Session session = db.sessionFactory.openSession()) { + List cards = session.createSelectionQuery("from " + s.prefix + "CardPayment", Object.class).getResultList(); + assertThat(cards).hasSize(1); + t.line("== from " + s.prefix + "CardPayment (one subclass, returns 1 row)"); + db.sql.stream().filter(q -> q.startsWith("select")).forEach(q -> t.sql("select", q)); + } + t.blank(); + + // 4. an association that points at the root + db.clear(); + try (Session session = db.sessionFactory.openSession()) { + List orders = session.createSelectionQuery("select o from " + s.prefix + "Order o join fetch o.payment", Object.class).getResultList(); + assertThat(orders).hasSize(1); + t.line("== select o from " + s.prefix + "Order o join fetch o.payment (association to the root)"); + db.sql.stream().filter(q -> q.startsWith("select")).forEach(q -> t.sql("select", q)); + } + } + } + } +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/Strategies.java b/inheritance/src/test/java/com/ankurm/inheritance/Strategies.java new file mode 100644 index 0000000..d0cc043 --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/Strategies.java @@ -0,0 +1,81 @@ +package com.ankurm.inheritance; + +import java.math.BigDecimal; +import java.util.List; + +/** The three hierarchies side by side: same shape, different strategy, different package and entity-name prefix. */ +enum Strategies { + + SINGLE_TABLE("single-table", "St", "st") { + @Override + Class[] classes() { + return new Class[]{ + com.ankurm.inheritance.single.Payment.class, + com.ankurm.inheritance.single.CardPayment.class, + com.ankurm.inheritance.single.BankTransferPayment.class, + com.ankurm.inheritance.single.WalletPayment.class, + com.ankurm.inheritance.single.PaymentOrder.class}; + } + + @Override + List sample() { + var card = new com.ankurm.inheritance.single.CardPayment(new BigDecimal("49.90"), "EUR", "4242", "VISA"); + var bank = new com.ankurm.inheritance.single.BankTransferPayment(new BigDecimal("1200.00"), "EUR", "DE89370400440532013000", "COBADEFF"); + var wallet = new com.ankurm.inheritance.single.WalletPayment(new BigDecimal("15.00"), "EUR", "PAYPAL"); + return List.of(card, bank, wallet, new com.ankurm.inheritance.single.PaymentOrder("ORD-1", card)); + } + }, + JOINED("joined", "Jn", "jn") { + @Override + Class[] classes() { + return new Class[]{ + com.ankurm.inheritance.joined.Payment.class, + com.ankurm.inheritance.joined.CardPayment.class, + com.ankurm.inheritance.joined.BankTransferPayment.class, + com.ankurm.inheritance.joined.WalletPayment.class, + com.ankurm.inheritance.joined.PaymentOrder.class}; + } + + @Override + List sample() { + var card = new com.ankurm.inheritance.joined.CardPayment(new BigDecimal("49.90"), "EUR", "4242", "VISA"); + var bank = new com.ankurm.inheritance.joined.BankTransferPayment(new BigDecimal("1200.00"), "EUR", "DE89370400440532013000", "COBADEFF"); + var wallet = new com.ankurm.inheritance.joined.WalletPayment(new BigDecimal("15.00"), "EUR", "PAYPAL"); + return List.of(card, bank, wallet, new com.ankurm.inheritance.joined.PaymentOrder("ORD-1", card)); + } + }, + TABLE_PER_CLASS("table-per-class", "Tp", "tp") { + @Override + Class[] classes() { + return new Class[]{ + com.ankurm.inheritance.tpc.Payment.class, + com.ankurm.inheritance.tpc.CardPayment.class, + com.ankurm.inheritance.tpc.BankTransferPayment.class, + com.ankurm.inheritance.tpc.WalletPayment.class, + com.ankurm.inheritance.tpc.PaymentOrder.class}; + } + + @Override + List sample() { + var card = new com.ankurm.inheritance.tpc.CardPayment(new BigDecimal("49.90"), "EUR", "4242", "VISA"); + var bank = new com.ankurm.inheritance.tpc.BankTransferPayment(new BigDecimal("1200.00"), "EUR", "DE89370400440532013000", "COBADEFF"); + var wallet = new com.ankurm.inheritance.tpc.WalletPayment(new BigDecimal("15.00"), "EUR", "PAYPAL"); + return List.of(card, bank, wallet, new com.ankurm.inheritance.tpc.PaymentOrder("ORD-1", card)); + } + }; + + final String label; + final String prefix; + final String tablePrefix; + + Strategies(String label, String prefix, String tablePrefix) { + this.label = label; + this.prefix = prefix; + this.tablePrefix = tablePrefix; + } + + abstract Class[] classes(); + + /** One card, one bank transfer, one wallet payment, and an order pointing at the card payment. */ + abstract List sample(); +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/TpcIdentityCard.java b/inheritance/src/test/java/com/ankurm/inheritance/TpcIdentityCard.java new file mode 100644 index 0000000..a6dfc66 --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/TpcIdentityCard.java @@ -0,0 +1,9 @@ +package com.ankurm.inheritance; + +import jakarta.persistence.*; + +@Entity(name = "TpcIdentityCard") +class TpcIdentityCard extends TpcIdentityPayment { + + String cardLast4; +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/TpcIdentityPayment.java b/inheritance/src/test/java/com/ankurm/inheritance/TpcIdentityPayment.java new file mode 100644 index 0000000..b689e9d --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/TpcIdentityPayment.java @@ -0,0 +1,13 @@ +package com.ankurm.inheritance; + +import jakarta.persistence.*; + +/** Deliberately wrong: TABLE_PER_CLASS with IDENTITY. Only ContractsTest touches this; it never joins the main mappings. */ +@Entity(name = "TpcIdentityPayment") +@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) +abstract class TpcIdentityPayment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; +} diff --git a/inheritance/src/test/java/com/ankurm/inheritance/Transcript.java b/inheritance/src/test/java/com/ankurm/inheritance/Transcript.java new file mode 100644 index 0000000..963c4fd --- /dev/null +++ b/inheritance/src/test/java/com/ankurm/inheritance/Transcript.java @@ -0,0 +1,52 @@ +package com.ankurm.inheritance; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Collects lines and writes them to output/NN-name.txt when closed. The tests assert the same + * numbers they print, so every figure in the transcripts is an assertion that fails the build + * when it stops being true. + */ +final class Transcript implements AutoCloseable { + + private final Path file; + private final List lines = new ArrayList<>(); + + Transcript(String name) { + this.file = Path.of("output", name + ".txt"); + } + + Transcript line(String s) { + lines.add(s); + return this; + } + + Transcript blank() { + lines.add(""); + return this; + } + + /** Hibernate emits one long line per statement; break it at the keywords so a reader can follow the joins. */ + Transcript sql(String ignoredLabel, String sql) { + String pretty = sql + .replace(" union all select", "\nunion all\nselect") + .replaceAll(" (left join|inner join|cross join|join|where|group by|order by) ", "\n $1 ") + .replaceAll(" from ", "\nfrom "); + lines.add(pretty + ";"); + return this; + } + + @Override + public void close() { + try { + Files.createDirectories(file.getParent()); + Files.write(file, lines); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +}