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)"); + } + } +}