diff --git a/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java b/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java
index 16a4229814f..778c72d63f1 100644
--- a/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java
+++ b/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java
@@ -29,6 +29,10 @@
/**
* Initializes Hibernate Envers audit tables when auditing is enabled. This class is responsible for
* conditionally creating audit tables only when hibernate.integration.envers.enabled=true.
+ *
+ * Data backfill for pre-existing rows is handled separately by
+ * {@link org.openmrs.util.databasechange.BackfillEnversAuditTablesChangeset}, which runs exactly
+ * once via Liquibase.
*/
public class EnversAuditTableInitializer {
@@ -39,8 +43,9 @@ private EnversAuditTableInitializer() {
}
/**
- * Checks if Envers is enabled and creates/updates audit tables as needed. This will Create or
- * Update audit tables if they don't exist - Update existing audit tables if the schema has changed
+ * Checks if Envers is enabled and creates/updates audit tables as needed. This will create or
+ * update audit tables if they don't exist, and update existing audit tables if the schema has
+ * changed.
*
* @param metadata Hibernate metadata containing entity mappings
* @param hibernateProperties properties containing Envers configuration
diff --git a/api/src/main/java/org/openmrs/util/databasechange/BackfillEnversAuditTablesChangeset.java b/api/src/main/java/org/openmrs/util/databasechange/BackfillEnversAuditTablesChangeset.java
new file mode 100644
index 00000000000..91b38240d31
--- /dev/null
+++ b/api/src/main/java/org/openmrs/util/databasechange/BackfillEnversAuditTablesChangeset.java
@@ -0,0 +1,294 @@
+/**
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.util.databasechange;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import liquibase.change.custom.CustomTaskChange;
+import liquibase.database.Database;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.exception.CustomChangeException;
+import liquibase.exception.SetupException;
+import liquibase.exception.ValidationErrors;
+import liquibase.resource.ResourceAccessor;
+
+/**
+ * Liquibase {@link CustomTaskChange} that backfills pre-existing rows into Envers audit tables.
+ *
+ * When Envers auditing is enabled after data already exists, audit tables are empty and Envers
+ * cannot resolve references to those pre-existing entities, causing "Unable to read" errors in the
+ * audit UI. This changeset inserts all existing rows from each source table into the corresponding
+ * {@code *_audit} table with {@code REVTYPE=0} (ADD) under a single backfill revision entry.
+ *
+ * Because this is a Liquibase changeset it is tracked in {@code databasechangelog} and runs exactly
+ * once per database, never on subsequent startups.
+ */
+public class BackfillEnversAuditTablesChangeset implements CustomTaskChange {
+
+ private static final Logger log = LoggerFactory.getLogger(BackfillEnversAuditTablesChangeset.class);
+
+ private static final Pattern SAFE_SQL_IDENTIFIER = Pattern.compile("[a-zA-Z_]\\w*");
+
+ private static final String AUDIT_SUFFIX = "_audit";
+
+ @Override
+ public void execute(Database database) throws CustomChangeException {
+ try {
+ Connection connection = ((JdbcConnection) database.getConnection()).getUnderlyingConnection();
+
+ String revisionTableName = findRevisionEntityTable(connection);
+ Integer revId = null;
+
+ DatabaseMetaData metaData = connection.getMetaData();
+ try (ResultSet tables = metaData.getTables(null, null, "%", new String[] { "TABLE" })) {
+ while (tables.next()) {
+ String tableName = tables.getString("TABLE_NAME");
+ if (!tableName.endsWith(AUDIT_SUFFIX)) {
+ continue;
+ }
+ String sourceTable = tableName.substring(0, tableName.length() - AUDIT_SUFFIX.length());
+ if (doesTableExist(connection, sourceTable)) {
+ revId = tryBackfillEntity(connection, sourceTable, tableName, revisionTableName, revId);
+ }
+ }
+ }
+
+ if (revId != null) {
+ log.info("Audit table backfill completed successfully with initial revision ID {}", revId);
+ } else {
+ log.debug("No audit tables needed backfilling.");
+ }
+ } catch (Exception e) {
+ throw new CustomChangeException("Failed to backfill Envers audit tables", e);
+ }
+ }
+
+ private Integer tryBackfillEntity(Connection connection, String sourceTable, String auditTable, String revisionTableName,
+ Integer revId) {
+ try {
+ if (!isAuditTableEmpty(connection, auditTable) || isTableEmpty(connection, sourceTable)) {
+ return revId;
+ }
+ if (revId == null) {
+ revId = createBackfillRevision(connection, revisionTableName);
+ }
+ List columns = getAuditTableDataColumns(connection, auditTable);
+ if (!columns.isEmpty()) {
+ backfillTable(connection, sourceTable, auditTable, columns, revId);
+ }
+ } catch (SQLException e) {
+ log.warn("Failed to backfill audit table {}: {}", auditTable, e.getMessage());
+ }
+ return revId;
+ }
+
+ /**
+ * Creates a backfill revision entry in the revision entity table. Dynamically discovers the primary
+ * key and timestamp column names from JDBC metadata to avoid hardcoding Hibernate-version-specific
+ * names.
+ *
+ * @param connection JDBC connection
+ * @param revisionTableName name of the revision entity table
+ * @return the generated revision ID
+ * @throws SQLException if the revision entry cannot be created
+ */
+ static int createBackfillRevision(Connection connection, String revisionTableName) throws SQLException {
+ String pkColumn = getRevisionPrimaryKeyColumn(connection, revisionTableName);
+ String timestampColumn = getRevisionTimestampColumn(connection, revisionTableName);
+ int nextId;
+ try (Statement stmt = connection.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT COALESCE(MAX(" + requireSafeIdentifier(pkColumn) + "), 0) + 1 FROM "
+ + requireSafeIdentifier(revisionTableName))) {
+ nextId = rs.next() ? rs.getInt(1) : 1;
+ }
+ String sql = "INSERT INTO " + requireSafeIdentifier(revisionTableName) + " (" + requireSafeIdentifier(pkColumn)
+ + ", " + requireSafeIdentifier(timestampColumn) + ") VALUES (?, ?)";
+ try (PreparedStatement pstmt = connection.prepareStatement(sql)) {
+ pstmt.setInt(1, nextId);
+ pstmt.setLong(2, System.currentTimeMillis());
+ pstmt.executeUpdate();
+ return nextId;
+ }
+ }
+
+ /**
+ * Discovers the primary key column name of the revision entity table via JDBC metadata.
+ *
+ * @param connection JDBC connection
+ * @param revisionTableName name of the revision entity table
+ * @return the primary key column name, falling back to "id" if not found
+ * @throws SQLException if metadata cannot be read
+ */
+ static String getRevisionPrimaryKeyColumn(Connection connection, String revisionTableName) throws SQLException {
+ DatabaseMetaData metaData = connection.getMetaData();
+ for (String name : new String[] { revisionTableName, revisionTableName.toUpperCase() }) {
+ try (ResultSet rs = metaData.getPrimaryKeys(null, null, name)) {
+ if (rs.next()) {
+ return rs.getString("COLUMN_NAME");
+ }
+ }
+ }
+ return "id";
+ }
+
+ /**
+ * Discovers the timestamp column name in the revision entity table by finding the first BIGINT
+ * column that is not the primary key. This avoids hardcoding Hibernate-version-specific names like
+ * "REVTSTMP" which may differ across Hibernate versions.
+ *
+ * @param connection JDBC connection
+ * @param revisionTableName name of the revision entity table
+ * @return the timestamp column name, falling back to "REVTSTMP" if not found
+ * @throws SQLException if metadata cannot be read
+ */
+ static String getRevisionTimestampColumn(Connection connection, String revisionTableName) throws SQLException {
+ DatabaseMetaData metaData = connection.getMetaData();
+ String pkColumn = null;
+ for (String name : new String[] { revisionTableName, revisionTableName.toUpperCase() }) {
+ try (ResultSet pkRs = metaData.getPrimaryKeys(null, null, name)) {
+ if (pkRs.next()) {
+ pkColumn = pkRs.getString("COLUMN_NAME");
+ break;
+ }
+ }
+ }
+ for (String name : new String[] { revisionTableName, revisionTableName.toUpperCase() }) {
+ try (ResultSet colRs = metaData.getColumns(null, null, name, null)) {
+ while (colRs.next()) {
+ String colName = colRs.getString("COLUMN_NAME");
+ int dataType = colRs.getInt("DATA_TYPE");
+ if (dataType == java.sql.Types.BIGINT && !colName.equalsIgnoreCase(pkColumn)) {
+ return colName;
+ }
+ }
+ }
+ }
+ return "REVTSTMP";
+ }
+
+ /**
+ * Validates that a SQL identifier (table or column name) contains only safe characters, preventing
+ * SQL injection when identifiers must be concatenated into queries.
+ *
+ * @param identifier the SQL identifier to validate
+ * @return the identifier unchanged if safe
+ * @throws IllegalArgumentException if the identifier contains unsafe characters
+ */
+ static String requireSafeIdentifier(String identifier) {
+ if (identifier == null || !SAFE_SQL_IDENTIFIER.matcher(identifier).matches()) {
+ throw new IllegalArgumentException("Unsafe SQL identifier rejected: " + identifier);
+ }
+ return identifier;
+ }
+
+ /**
+ * Returns true if the given audit table exists but contains no rows.
+ */
+ static boolean isAuditTableEmpty(Connection connection, String tableName) {
+ try (Statement stmt = connection.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + requireSafeIdentifier(tableName))) {
+ return rs.next() && rs.getLong(1) == 0;
+ } catch (SQLException e) {
+ log.debug("Audit table {} not accessible, skipping backfill: {}", tableName, e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Returns true if the given source table has no rows.
+ */
+ static boolean isTableEmpty(Connection connection, String tableName) throws SQLException {
+ try (Statement stmt = connection.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + requireSafeIdentifier(tableName))) {
+ return rs.next() && rs.getLong(1) == 0;
+ }
+ }
+
+ /**
+ * Returns the data column names from the given audit table, excluding the Envers metadata columns
+ * REV and REVTYPE. These are the columns that correspond to the audited entity fields and must
+ * exist in the source table.
+ */
+ static List getAuditTableDataColumns(Connection connection, String auditTable) throws SQLException {
+ List columns = new ArrayList<>();
+ DatabaseMetaData metaData = connection.getMetaData();
+ try (ResultSet rs = metaData.getColumns(null, null, auditTable, null)) {
+ while (rs.next()) {
+ String colName = rs.getString("COLUMN_NAME");
+ if (!colName.equalsIgnoreCase("REV") && !colName.equalsIgnoreCase("REVTYPE")) {
+ columns.add(colName);
+ }
+ }
+ }
+ return columns;
+ }
+
+ /**
+ * Inserts all rows from the source table into the audit table with REVTYPE=0 (ADD).
+ */
+ static void backfillTable(Connection connection, String sourceTable, String auditTable, List columns, int revId)
+ throws SQLException {
+ requireSafeIdentifier(sourceTable);
+ requireSafeIdentifier(auditTable);
+ columns.forEach(BackfillEnversAuditTablesChangeset::requireSafeIdentifier);
+ String columnList = String.join(", ", columns);
+ String sql = "INSERT INTO " + auditTable + " (REV, REVTYPE, " + columnList + ") SELECT " + revId + ", 0, "
+ + columnList + " FROM " + sourceTable;
+ try (Statement stmt = connection.createStatement()) {
+ int rows = stmt.executeUpdate(sql);
+ log.info("Backfilled {} rows from {} into {}", rows, sourceTable, auditTable);
+ }
+ }
+
+ private String findRevisionEntityTable(Connection connection) throws SQLException {
+ for (String name : new String[] { "revision_entity", "REVINFO" }) {
+ if (doesTableExist(connection, name)) {
+ return name;
+ }
+ }
+ return "revision_entity";
+ }
+
+ private boolean doesTableExist(Connection connection, String tableName) throws SQLException {
+ DatabaseMetaData metaData = connection.getMetaData();
+ try (ResultSet rs = metaData.getTables(null, null, tableName, new String[] { "TABLE" })) {
+ return rs.next();
+ }
+ }
+
+ @Override
+ public String getConfirmationMessage() {
+ return "Successfully backfilled pre-existing rows into Envers audit tables";
+ }
+
+ @Override
+ public void setUp() throws SetupException {
+ }
+
+ @Override
+ public void setFileOpener(ResourceAccessor resourceAccessor) {
+ }
+
+ @Override
+ public ValidationErrors validate(Database database) {
+ return null;
+ }
+}
diff --git a/api/src/main/resources/org/openmrs/liquibase/updates/liquibase-update-to-latest-3.0.x.xml b/api/src/main/resources/org/openmrs/liquibase/updates/liquibase-update-to-latest-3.0.x.xml
index 4ac1806e903..e53b5f011dd 100644
--- a/api/src/main/resources/org/openmrs/liquibase/updates/liquibase-update-to-latest-3.0.x.xml
+++ b/api/src/main/resources/org/openmrs/liquibase/updates/liquibase-update-to-latest-3.0.x.xml
@@ -141,6 +141,12 @@
-
+
+
+ Backfill pre-existing rows into Envers audit tables so that the audit UI can resolve
+ references to entities that existed before auditing was enabled
+
+
+
diff --git a/api/src/test/java/org/openmrs/api/context/DaemonTest.java b/api/src/test/java/org/openmrs/api/context/DaemonTest.java
index eb8fe79f83b..01edf57cccd 100644
--- a/api/src/test/java/org/openmrs/api/context/DaemonTest.java
+++ b/api/src/test/java/org/openmrs/api/context/DaemonTest.java
@@ -15,7 +15,6 @@
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
@@ -238,7 +237,7 @@ public void execute() throws InterruptedException {
// another.join(10000); doesn't actually work as runInNewDaemonThread doesn't use the Thread object rather it
// only executes the run method. The only way to determine it completed is to wait for wasRun to return true.
- await().atMost(10, TimeUnit.SECONDS).untilTrue(new AtomicBoolean(wasRun));
+ await().atMost(10, TimeUnit.SECONDS).until(() -> wasRun);
}
}
diff --git a/api/src/test/java/org/openmrs/util/DatabaseUpdaterDatabaseIT.java b/api/src/test/java/org/openmrs/util/DatabaseUpdaterDatabaseIT.java
index ed1c85d5e88..e3fb5fcd1d2 100644
--- a/api/src/test/java/org/openmrs/util/DatabaseUpdaterDatabaseIT.java
+++ b/api/src/test/java/org/openmrs/util/DatabaseUpdaterDatabaseIT.java
@@ -32,7 +32,7 @@ public class DatabaseUpdaterDatabaseIT extends DatabaseIT {
* This constant needs to be updated when adding new Liquibase update files to openmrs-core.
*/
- private static final int CHANGE_SET_COUNT_FOR_GREATER_THAN_2_1_X = 914;
+ private static final int CHANGE_SET_COUNT_FOR_GREATER_THAN_2_1_X = 915;
private static final int CHANGE_SET_COUNT_FOR_2_1_X = 870;
diff --git a/api/src/test/java/org/openmrs/util/EnversAuditTableInitializerTest.java b/api/src/test/java/org/openmrs/util/EnversAuditTableInitializerTest.java
new file mode 100644
index 00000000000..c498708282b
--- /dev/null
+++ b/api/src/test/java/org/openmrs/util/EnversAuditTableInitializerTest.java
@@ -0,0 +1,73 @@
+/**
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.util;
+
+import java.util.Collections;
+import java.util.Properties;
+
+import org.hibernate.boot.Metadata;
+import org.hibernate.service.ServiceRegistry;
+import org.hibernate.tool.schema.spi.SchemaManagementTool;
+import org.hibernate.tool.schema.spi.SchemaMigrator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link EnversAuditTableInitializer}, focused on the audit table schema creation
+ * behaviour. Backfill tests are in
+ * {@link org.openmrs.util.databasechange.BackfillEnversAuditTablesChangesetTest}.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class EnversAuditTableInitializerTest {
+
+ @Mock
+ private Metadata metadata;
+
+ @Mock
+ private ServiceRegistry serviceRegistry;
+
+ @Mock
+ private SchemaManagementTool schemaManagementTool;
+
+ @Mock
+ private SchemaMigrator schemaMigrator;
+
+ @Test
+ void initialize_shouldDoNothingWhenEnversIsDisabled() {
+ Properties disabledProps = new Properties();
+ disabledProps.setProperty("hibernate.integration.envers.enabled", "false");
+
+ EnversAuditTableInitializer.initialize(metadata, disabledProps, serviceRegistry);
+
+ verifyNoInteractions(serviceRegistry);
+ }
+
+ @Test
+ void initialize_shouldCompleteWithoutErrorWhenNoAuditedEntitiesAreFound() {
+ when(serviceRegistry.getService(SchemaManagementTool.class)).thenReturn(schemaManagementTool);
+ when(schemaManagementTool.getSchemaMigrator(any())).thenReturn(schemaMigrator);
+ when(metadata.getEntityBindings()).thenReturn(Collections.emptyList());
+
+ Properties props = new Properties();
+ props.setProperty("hibernate.integration.envers.enabled", "true");
+ props.setProperty("org.hibernate.envers.audit_table_suffix", "_audit");
+
+ EnversAuditTableInitializer.initialize(metadata, props, serviceRegistry);
+ }
+}
diff --git a/api/src/test/java/org/openmrs/util/databasechange/BackfillEnversAuditTablesChangesetTest.java b/api/src/test/java/org/openmrs/util/databasechange/BackfillEnversAuditTablesChangesetTest.java
new file mode 100644
index 00000000000..5b3f0fe599f
--- /dev/null
+++ b/api/src/test/java/org/openmrs/util/databasechange/BackfillEnversAuditTablesChangesetTest.java
@@ -0,0 +1,213 @@
+/**
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.util.databasechange;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link BackfillEnversAuditTablesChangeset}, focused on the backfill behaviour
+ * introduced to fix AUDIT-28: "Unable to read" values in audit tables for entities that existed
+ * before auditing was enabled.
+ *
+ * These tests call the package-private helper methods directly against a real H2 in-memory
+ * database.
+ */
+class BackfillEnversAuditTablesChangesetTest {
+
+ private static final String H2_URL = "jdbc:h2:mem:enversbackfilltest;DB_CLOSE_DELAY=-1;MODE=LEGACY";
+
+ private static final String REVISION_TABLE = "revision_entity";
+
+ private Connection connection;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ connection = DriverManager.getConnection(H2_URL, "sa", "");
+ connection.setAutoCommit(false);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("DROP ALL OBJECTS");
+ }
+ connection.commit();
+ connection.close();
+ }
+
+ @Test
+ void createBackfillRevision_shouldInsertRevisionAndReturnGeneratedId() throws Exception {
+ createRevisionTable();
+ connection.commit();
+
+ int revId = BackfillEnversAuditTablesChangeset.createBackfillRevision(connection, REVISION_TABLE);
+ connection.commit();
+
+ assertTrue(revId > 0, "Generated revision ID should be positive");
+ try (Statement stmt = connection.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + REVISION_TABLE)) {
+ rs.next();
+ assertEquals(1, rs.getInt(1), "Exactly one revision row should be present");
+ }
+ }
+
+ @Test
+ void isAuditTableEmpty_shouldReturnTrueWhenTableHasNoRows() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient_audit (REV INT, REVTYPE TINYINT, patient_id INT)");
+ }
+ connection.commit();
+
+ assertTrue(BackfillEnversAuditTablesChangeset.isAuditTableEmpty(connection, "patient_audit"));
+ }
+
+ @Test
+ void isAuditTableEmpty_shouldReturnFalseWhenTableHasRows() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient_audit (REV INT, REVTYPE TINYINT, patient_id INT)");
+ stmt.execute("INSERT INTO patient_audit VALUES (1, 0, 42)");
+ }
+ connection.commit();
+
+ assertFalse(BackfillEnversAuditTablesChangeset.isAuditTableEmpty(connection, "patient_audit"));
+ }
+
+ @Test
+ void isAuditTableEmpty_shouldReturnFalseWhenTableDoesNotExist() {
+ // Table doesn't exist — should not throw, should return false (skip backfill safely)
+ assertFalse(BackfillEnversAuditTablesChangeset.isAuditTableEmpty(connection, "nonexistent_audit"));
+ }
+
+ @Test
+ void isTableEmpty_shouldReturnTrueForEmptyTable() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient (patient_id INT PRIMARY KEY)");
+ }
+ connection.commit();
+
+ assertTrue(BackfillEnversAuditTablesChangeset.isTableEmpty(connection, "patient"));
+ }
+
+ @Test
+ void isTableEmpty_shouldReturnFalseWhenTableHasData() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient (patient_id INT PRIMARY KEY)");
+ stmt.execute("INSERT INTO patient VALUES (1)");
+ }
+ connection.commit();
+
+ assertFalse(BackfillEnversAuditTablesChangeset.isTableEmpty(connection, "patient"));
+ }
+
+ @Test
+ void getAuditTableDataColumns_shouldReturnColumnsExcludingRevAndRevtype() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient_audit (REV INT, REVTYPE TINYINT, patient_id INT, name VARCHAR(100))");
+ }
+ connection.commit();
+
+ List columns = BackfillEnversAuditTablesChangeset.getAuditTableDataColumns(connection, "PATIENT_AUDIT");
+
+ assertEquals(2, columns.size(), "Should return only non-Envers columns");
+ assertFalse(columns.stream().anyMatch(c -> c.equalsIgnoreCase("REV")), "REV should be excluded");
+ assertFalse(columns.stream().anyMatch(c -> c.equalsIgnoreCase("REVTYPE")), "REVTYPE should be excluded");
+ }
+
+ @Test
+ void backfillTable_shouldInsertAllSourceRowsIntoAuditTableWithRevtype0() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient (patient_id INT PRIMARY KEY, name VARCHAR(100))");
+ stmt.execute("INSERT INTO patient VALUES (1, 'Alice')");
+ stmt.execute("INSERT INTO patient VALUES (2, 'Bob')");
+ stmt.execute("CREATE TABLE patient_audit (REV INT, REVTYPE TINYINT, patient_id INT, name VARCHAR(100))");
+ }
+ connection.commit();
+
+ List columns = List.of("PATIENT_ID", "NAME");
+ BackfillEnversAuditTablesChangeset.backfillTable(connection, "patient", "patient_audit", columns, 1);
+ connection.commit();
+
+ try (Statement stmt = connection.createStatement()) {
+ try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM patient_audit WHERE REVTYPE = 0")) {
+ rs.next();
+ assertEquals(2, rs.getInt(1), "Both source rows should appear in audit table with REVTYPE=0 (ADD)");
+ }
+ try (ResultSet rs = stmt.executeQuery("SELECT name FROM patient_audit ORDER BY patient_id")) {
+ assertTrue(rs.next());
+ assertEquals("Alice", rs.getString(1));
+ assertTrue(rs.next());
+ assertEquals("Bob", rs.getString(1));
+ }
+ }
+ }
+
+ @Test
+ void backfillTable_shouldUseTheProvidedRevisionId() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient (patient_id INT PRIMARY KEY)");
+ stmt.execute("INSERT INTO patient VALUES (99)");
+ stmt.execute("CREATE TABLE patient_audit (REV INT, REVTYPE TINYINT, patient_id INT)");
+ }
+ connection.commit();
+
+ BackfillEnversAuditTablesChangeset.backfillTable(connection, "patient", "patient_audit", List.of("PATIENT_ID"), 42);
+ connection.commit();
+
+ try (Statement stmt = connection.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT REV FROM patient_audit")) {
+ assertTrue(rs.next());
+ assertEquals(42, rs.getInt(1), "Audit row should carry the supplied revision ID");
+ }
+ }
+
+ @Test
+ void backfillIsSkipped_whenAuditTableAlreadyHasData() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient (patient_id INT PRIMARY KEY, name VARCHAR(100))");
+ stmt.execute("INSERT INTO patient VALUES (1, 'Alice')");
+ stmt.execute("CREATE TABLE patient_audit (REV INT, REVTYPE TINYINT, patient_id INT, name VARCHAR(100))");
+ stmt.execute("INSERT INTO patient_audit VALUES (1, 0, 1, 'Alice')");
+ }
+ connection.commit();
+
+ assertFalse(BackfillEnversAuditTablesChangeset.isAuditTableEmpty(connection, "patient_audit"),
+ "Audit table with data should not trigger backfill");
+ }
+
+ @Test
+ void backfillIsSkipped_whenSourceTableIsEmpty() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE patient (patient_id INT PRIMARY KEY, name VARCHAR(100))");
+ stmt.execute("CREATE TABLE patient_audit (REV INT, REVTYPE TINYINT, patient_id INT, name VARCHAR(100))");
+ }
+ connection.commit();
+
+ assertTrue(BackfillEnversAuditTablesChangeset.isTableEmpty(connection, "patient"),
+ "Empty source table should not trigger backfill");
+ }
+
+ private void createRevisionTable() throws Exception {
+ try (Statement stmt = connection.createStatement()) {
+ stmt.execute("CREATE TABLE " + REVISION_TABLE + " (id INT NOT NULL PRIMARY KEY, timestamp BIGINT NOT NULL)");
+ }
+ }
+}