Add inheritance module: SINGLE_TABLE vs JOINED vs TABLE_PER_CLASS on Hibernate 7.4.13.Final

Same payment hierarchy mapped three ways plus @MappedSuperclass, run with plain Hibernate on H2.
Tests assert and write the generated DDL, the INSERT/SELECT SQL, row/insert/FK/nullability counts and
the three failures (NOT NULL on a SINGLE_TABLE subclass column, IDENTITY with TABLE_PER_CLASS, querying a
@MappedSuperclass); Bench writes indicative polymorphic-query timings. No docs folder: depth lives in the post.
This commit is contained in:
2026-10-24 10:02:28 +00:00
parent 833f3e1c31
commit 807f681ed1
44 changed files with 1533 additions and 0 deletions
+9
View File
@@ -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`) |
+51
View File
@@ -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.
+61
View File
@@ -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;
@@ -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)
);
@@ -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;
@@ -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)
);
+31
View File
@@ -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;
@@ -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;
@@ -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;
+46
View File
@@ -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 <union-subclass> 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'
@@ -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
+57
View File
@@ -0,0 +1,57 @@
<?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>
<groupId>com.ankurm</groupId>
<artifactId>inheritance</artifactId>
<version>1.0.0</version>
<name>inheritance</name>
<description>Hibernate 7 inheritance mapping: SINGLE_TABLE vs JOINED vs TABLE_PER_CLASS, run with plain Hibernate (no Spring)</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hibernate.version>7.4.13.Final</hibernate.version>
</properties>
<dependencies>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.4.240</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
</project>
+7
View File
@@ -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
+9
View File
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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;
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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;
}
}
@@ -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<Double> 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<Object> 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();
}
}
@@ -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()));
}
}
}
}
@@ -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<String> 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<String> jdbc(String query) {
List<String> 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<String> 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<String> lines = Files.readAllLines(out);
Files.delete(out);
return lines;
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
@@ -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<String> 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<String> 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);
}
}
}
@@ -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";
}
@@ -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";
}
@@ -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;
}
@@ -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<Object> sample = s.sample();
db.sessionFactory.inTransaction(session -> sample.forEach(session::persist));
List<String> 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));
}
}
}
}
}
@@ -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<Object> 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<Object> 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<Object> 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<Object> sample();
}
@@ -0,0 +1,9 @@
package com.ankurm.inheritance;
import jakarta.persistence.*;
@Entity(name = "TpcIdentityCard")
class TpcIdentityCard extends TpcIdentityPayment {
String cardLast4;
}
@@ -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;
}
@@ -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<String> 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);
}
}
}