Skip to content

Commit bfbacbe

Browse files
committed
fix(entity): make @Version column nullable so hbm2ddl=update can add it to populated tables
BaseEntity.version was a primitive int -> NOT NULL column. Adopting BaseEntity/LongIdEntity/ UuidEntity/StringIdEntity on an existing populated table made hbm2ddl=update emit "add column version integer not null", which fails the H2/MySQL table rebuild on existing rows (logged as a WARNING and swallowed, leaving the column missing and later writes broken). - version is now Integer (nullable column), defaulted to 0 so a freshly constructed detached entity still carries version 0 and merges cleanly (createOrUpdate path). - public int getVersion() unchanged and null-safe (legacy rows read as 0); toString via getter. - @Version on Integer is permitted by the JPA spec; no @column(nullable=false)/columnDefinition. - Audited: LongIdEntity/UuidEntity/StringIdEntity don't shadow version. JEHibernateRevisionEntity keeps int id / long timestamp (dedicated Envers table created whole, PK/timestamp correctly non-null) — flagged, not changed. - VersionMigrationTest: update adds nullable column to a populated table, existing rows read 0, fresh schema starts at 0, optimistic locking still throws, stricter NOT NULL column still works. Bump to 4.0.1.
1 parent 5e22948 commit bfbacbe

5 files changed

Lines changed: 248 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ All notable changes to JEHibernate are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [4.0.1] — 2026-08-06
8+
9+
### Fixed
10+
11+
- **fix(entity): make @Version column nullable so hbm2ddl=update can add it to populated tables.**
12+
`BaseEntity.version` was a primitive `int`, which maps to a `NOT NULL` column. When a consumer
13+
adopted `BaseEntity`/`LongIdEntity`/`UuidEntity`/`StringIdEntity` on an existing table with rows,
14+
`hbm2ddl.auto=update` emitted `add column version integer not null`, which fails the H2/MySQL table
15+
rebuild on the existing rows (Hibernate logged it as a WARNING and continued, leaving the column
16+
missing and later inserts/updates broken). The field is now `Integer` (nullable column) defaulted
17+
to `0`; the public accessor stays `int getVersion()` and is null-safe (rows predating the column
18+
read as `0`). No API change. `@Version` on `Integer` is permitted by the JPA spec. Optimistic
19+
locking, fresh-schema creation, and pre-existing stricter (`NOT NULL DEFAULT 0`) columns are
20+
unaffected — covered by `VersionMigrationTest`.
21+
722
## [4.0.0] — 2026-06-02
823

924
Major release. The library is now a **multi-module build** and the connection-pool and

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ plugins {
55

66
allprojects {
77
group = "de.jexcellence.hibernate"
8-
version = "4.0.0"
8+
version = "4.0.1"
99
}
1010

1111
// ── Shared configuration for every JEHibernate module ───────────────────────

jehibernate-core/src/main/java/de/jexcellence/jehibernate/entity/base/BaseEntity.java

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,23 @@ public abstract class BaseEntity<I> implements Identifiable<I>, Serializable {
8787
*/
8888
private final transient UUID identityToken = UUID.randomUUID();
8989

90+
/**
91+
* Optimistic-locking version. Nullable ({@code Integer}, not {@code int}) so that
92+
* {@code hbm2ddl.auto=update} can add this column to an already-populated table: a primitive
93+
* {@code int} maps to {@code NOT NULL}, which H2/MySQL/MariaDB/PostgreSQL cannot satisfy for the
94+
* existing rows during the table rebuild. Hibernate assigns {@code 0} on persist, so {@code null}
95+
* only ever appears on rows that predate the column. {@code @Version} on {@code Integer} is
96+
* permitted by the JPA spec.
97+
* <p>
98+
* Defaulted to {@code 0} (not left {@code null}) so a freshly constructed, detached entity —
99+
* e.g. {@code new X(); x.setId(existingId)} passed to {@code createOrUpdate}/{@code merge} —
100+
* carries version {@code 0} and matches an existing row's {@code where … and version=0}. The
101+
* default does not affect column nullability (the {@code Integer} type does); rows loaded from
102+
* the DB with a {@code null} version overwrite this default and read as {@code 0} via the getter.
103+
*/
90104
@Version
91-
private int version;
92-
105+
private Integer version = 0;
106+
93107
@CreationTimestamp
94108
@Column(updatable = false)
95109
private Instant createdAt;
@@ -116,10 +130,16 @@ protected void onPreUpdate() {
116130
updatedAt = Instant.now();
117131
}
118132

133+
/**
134+
* Returns the optimistic-locking version. Null-safe: rows that predate the version column
135+
* (added later via {@code hbm2ddl.auto=update}) read as {@code 0}.
136+
*
137+
* @return the version, or {@code 0} if not yet set
138+
*/
119139
public int getVersion() {
120-
return version;
140+
return version == null ? 0 : version;
121141
}
122-
142+
123143
protected void setVersion(int version) {
124144
this.version = version;
125145
}
@@ -162,7 +182,7 @@ public int hashCode() {
162182
public String toString() {
163183
return getClass().getSimpleName() + "{" +
164184
"id=" + getId() +
165-
", version=" + version +
185+
", version=" + getVersion() +
166186
", createdAt=" + createdAt +
167187
", updatedAt=" + updatedAt +
168188
'}';
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package de.jexcellence.versiontest;
2+
3+
import de.jexcellence.jehibernate.config.DatabaseType;
4+
import de.jexcellence.jehibernate.core.JEHibernate;
5+
import de.jexcellence.jehibernate.migration.MigrationConfig;
6+
import de.jexcellence.jehibernate.migration.MigrationTool;
7+
import jakarta.persistence.EntityManager;
8+
import jakarta.persistence.EntityManagerFactory;
9+
import jakarta.persistence.OptimisticLockException;
10+
import jakarta.persistence.RollbackException;
11+
import org.junit.jupiter.api.Test;
12+
13+
import java.sql.Connection;
14+
import java.sql.DriverManager;
15+
import java.sql.ResultSet;
16+
import java.sql.SQLException;
17+
import java.sql.Statement;
18+
import java.util.function.Consumer;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
22+
23+
/**
24+
* Verifies that the {@code @Version} column is nullable so {@code hbm2ddl.auto=update} can add it to
25+
* an already-populated table (the primitive {@code int} → {@code NOT NULL} DDL would otherwise fail
26+
* the H2 table rebuild), while optimistic locking and fresh-schema creation keep working.
27+
* <p>
28+
* Isolated package so {@code VersionedThing} does not leak into other suites' package scans.
29+
*/
30+
class VersionMigrationTest {
31+
32+
private static final MigrationConfig MIGRATION_OFF =
33+
new MigrationConfig(false, MigrationTool.NONE, "classpath:db/migration");
34+
35+
private static JEHibernate build(String url, String ddlAuto) {
36+
return JEHibernate.builder()
37+
.configuration(config -> config
38+
.database(DatabaseType.H2)
39+
.url(url)
40+
.credentials("sa", "")
41+
.ddlAuto(ddlAuto)
42+
.migration(MIGRATION_OFF))
43+
.scanPackages("de.jexcellence.versiontest")
44+
.build();
45+
}
46+
47+
private static void jdbc(String url, Consumer<Statement> work) {
48+
try (Connection connection = DriverManager.getConnection(url, "sa", "");
49+
Statement statement = connection.createStatement()) {
50+
work.accept(statement);
51+
} catch (SQLException e) {
52+
throw new IllegalStateException(e);
53+
}
54+
}
55+
56+
private static String isNullable(String url, String table, String column) {
57+
try (Connection connection = DriverManager.getConnection(url, "sa", "");
58+
Statement statement = connection.createStatement();
59+
ResultSet rs = statement.executeQuery(
60+
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='"
61+
+ table + "' AND COLUMN_NAME='" + column + "'")) {
62+
return rs.next() ? rs.getString(1) : null;
63+
} catch (SQLException e) {
64+
throw new IllegalStateException(e);
65+
}
66+
}
67+
68+
@Test
69+
void updateAddsNullableVersionColumnToPopulatedTable() {
70+
String url = "jdbc:h2:mem:versionmig;DB_CLOSE_DELAY=-1";
71+
72+
// A pre-existing, populated table WITHOUT the version column (as an older schema would have).
73+
jdbc(url, s -> {
74+
try {
75+
s.execute("CREATE TABLE versioned_thing ("
76+
+ "id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))");
77+
s.execute("INSERT INTO versioned_thing (name) VALUES ('alice')");
78+
s.execute("INSERT INTO versioned_thing (name) VALUES ('bob')");
79+
} catch (SQLException e) {
80+
throw new IllegalStateException(e);
81+
}
82+
});
83+
84+
try (JEHibernate jeHibernate = build(url, "update")) {
85+
var repo = jeHibernate.repositories().get(VersionedThingRepository.class);
86+
87+
// The ALTER succeeded (no silent DDL warning), the two existing rows survived, and they
88+
// read version 0 (the DB value is null; the null-safe getter returns 0).
89+
var all = repo.findAll();
90+
assertThat(all).hasSize(2);
91+
assertThat(all).allMatch(t -> t.getVersion() == 0);
92+
93+
// The column exists and is nullable.
94+
assertThat(isNullable(url, "VERSIONED_THING", "VERSION")).isEqualTo("YES");
95+
}
96+
}
97+
98+
@Test
99+
void freshSchemaCreatesNullableVersionStartingAtZero() {
100+
String url = "jdbc:h2:mem:versionfresh;DB_CLOSE_DELAY=-1";
101+
102+
try (JEHibernate jeHibernate = build(url, "create-drop")) {
103+
var repo = jeHibernate.repositories().get(VersionedThingRepository.class);
104+
105+
VersionedThing saved = repo.create(new VersionedThing("fresh"));
106+
assertThat(saved.getVersion()).isZero(); // first persist starts at 0
107+
assertThat(isNullable(url, "VERSIONED_THING", "VERSION")).isEqualTo("YES");
108+
}
109+
}
110+
111+
@Test
112+
void optimisticLockingStillWorks() {
113+
String url = "jdbc:h2:mem:versionlock;DB_CLOSE_DELAY=-1";
114+
115+
try (JEHibernate jeHibernate = build(url, "create-drop")) {
116+
var repo = jeHibernate.repositories().get(VersionedThingRepository.class);
117+
Long id = repo.create(new VersionedThing("x")).getId();
118+
119+
EntityManagerFactory emf = jeHibernate.getEntityManagerFactory();
120+
EntityManager em1 = emf.createEntityManager();
121+
EntityManager em2 = emf.createEntityManager();
122+
try {
123+
VersionedThing v1 = em1.find(VersionedThing.class, id);
124+
VersionedThing v2 = em2.find(VersionedThing.class, id);
125+
126+
em1.getTransaction().begin();
127+
v1.setName("one");
128+
em1.getTransaction().commit(); // version 0 -> 1
129+
130+
em2.getTransaction().begin();
131+
v2.setName("two"); // still holds version 0
132+
// The stale update matches 0 rows via "where id=? and version=?" and fails. Hibernate
133+
// may surface this as OptimisticLockException directly or wrapped in RollbackException.
134+
assertThatThrownBy(() -> em2.getTransaction().commit())
135+
.isInstanceOfAny(OptimisticLockException.class, RollbackException.class);
136+
} finally {
137+
em1.close();
138+
em2.close();
139+
}
140+
141+
assertThat(repo.findById(id)).get().extracting(VersionedThing::getVersion).isEqualTo(1);
142+
}
143+
}
144+
145+
@Test
146+
void stricterExistingNotNullVersionColumnStillWorks() {
147+
String url = "jdbc:h2:mem:versionstrict;DB_CLOSE_DELAY=-1";
148+
149+
// A DB where the column already exists as NOT NULL DEFAULT 0 — Hibernate never writes null on
150+
// persist, so a stricter existing column is harmless.
151+
jdbc(url, s -> {
152+
try {
153+
s.execute("CREATE TABLE versioned_thing ("
154+
+ "id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255), "
155+
+ "version INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP, updated_at TIMESTAMP)");
156+
s.execute("INSERT INTO versioned_thing (name) VALUES ('legacy')");
157+
} catch (SQLException e) {
158+
throw new IllegalStateException(e);
159+
}
160+
});
161+
162+
try (JEHibernate jeHibernate = build(url, "update")) {
163+
var repo = jeHibernate.repositories().get(VersionedThingRepository.class);
164+
assertThat(repo.count()).isEqualTo(1);
165+
166+
VersionedThing created = repo.create(new VersionedThing("new"));
167+
assertThat(created.getVersion()).isZero();
168+
assertThat(repo.count()).isEqualTo(2);
169+
}
170+
}
171+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package de.jexcellence.versiontest;
2+
3+
import de.jexcellence.jehibernate.entity.base.LongIdEntity;
4+
import de.jexcellence.jehibernate.repository.base.AbstractCrudRepository;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.EntityManagerFactory;
7+
import jakarta.persistence.Table;
8+
9+
import java.util.concurrent.ExecutorService;
10+
11+
@Entity
12+
@Table(name = "versioned_thing")
13+
public class VersionedThing extends LongIdEntity {
14+
15+
private String name;
16+
17+
protected VersionedThing() {}
18+
19+
public VersionedThing(String name) {
20+
this.name = name;
21+
}
22+
23+
public String getName() {
24+
return name;
25+
}
26+
27+
public void setName(String name) {
28+
this.name = name;
29+
}
30+
}
31+
32+
class VersionedThingRepository extends AbstractCrudRepository<VersionedThing, Long> {
33+
VersionedThingRepository(ExecutorService executor, EntityManagerFactory emf, Class<VersionedThing> entityClass) {
34+
super(executor, emf, entityClass);
35+
}
36+
}

0 commit comments

Comments
 (0)