Skip to content

Commit 7e3c22f

Browse files
AUDIT-28: Move backfill into Liquibase CustomTaskChange
1 parent 09048e3 commit 7e3c22f

File tree

5 files changed

+530
-556
lines changed

5 files changed

+530
-556
lines changed

api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java

Lines changed: 7 additions & 307 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,12 @@
99
*/
1010
package org.openmrs.util;
1111

12-
import java.sql.Connection;
13-
import java.sql.DatabaseMetaData;
14-
import java.sql.PreparedStatement;
15-
import java.sql.ResultSet;
16-
import java.sql.SQLException;
17-
import java.sql.Statement;
18-
import java.util.ArrayList;
1912
import java.util.EnumSet;
20-
import java.util.List;
2113
import java.util.Map;
2214
import java.util.Properties;
2315
import java.util.concurrent.atomic.AtomicBoolean;
24-
import java.util.regex.Pattern;
2516

2617
import org.hibernate.boot.Metadata;
27-
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
28-
import org.hibernate.envers.Audited;
29-
import org.hibernate.envers.RevisionEntity;
30-
import org.hibernate.mapping.PersistentClass;
3118
import org.hibernate.service.ServiceRegistry;
3219
import org.hibernate.tool.schema.TargetType;
3320
import org.hibernate.tool.schema.spi.ExceptionHandler;
@@ -42,22 +29,23 @@
4229
/**
4330
* Initializes Hibernate Envers audit tables when auditing is enabled. This class is responsible for
4431
* conditionally creating audit tables only when hibernate.integration.envers.enabled=true.
32+
* <p>
33+
* Data backfill for pre-existing rows is handled separately by
34+
* {@link org.openmrs.util.databasechange.BackfillEnversAuditTablesChangeset}, which runs exactly
35+
* once via Liquibase.
4536
*/
4637
public class EnversAuditTableInitializer {
4738

4839
private static final Logger log = LoggerFactory.getLogger(EnversAuditTableInitializer.class);
4940

50-
private static final Pattern SAFE_SQL_IDENTIFIER = Pattern.compile("[a-zA-Z_]\\w*");
51-
5241
private EnversAuditTableInitializer() {
5342

5443
}
5544

5645
/**
57-
* Checks if Envers is enabled and creates/updates audit tables as needed. This will Create or
58-
* Update audit tables if they don't exist - Update existing audit tables if the schema has changed.
59-
* After schema updates, backfills pre-existing data so Envers can resolve references to entities
60-
* that existed before auditing was enabled.
46+
* Checks if Envers is enabled and creates/updates audit tables as needed. This will create or
47+
* update audit tables if they don't exist, and update existing audit tables if the schema has
48+
* changed.
6149
*
6250
* @param metadata Hibernate metadata containing entity mappings
6351
* @param hibernateProperties properties containing Envers configuration
@@ -71,7 +59,6 @@ public static void initialize(Metadata metadata, Properties hibernateProperties,
7159
}
7260

7361
updateAuditTables(metadata, hibernateProperties, serviceRegistry);
74-
backfillAuditTables(metadata, hibernateProperties, serviceRegistry);
7562
}
7663

7764
/**
@@ -133,293 +120,6 @@ private static void updateAuditTables(Metadata metadata, Properties hibernatePro
133120
}
134121
}
135122

136-
/**
137-
* Backfills pre-existing data into newly created audit tables. When auditing is enabled after data
138-
* already exists, audit tables are empty and Envers cannot resolve references to those pre-existing
139-
* entities, causing "Unable to read" in the audit UI. This method inserts all existing rows from
140-
* each source table into the corresponding audit table with REVTYPE=0 (ADD), but only when the
141-
* audit table is empty (i.e. it was just created).
142-
*
143-
* @param metadata Hibernate metadata containing entity mappings
144-
* @param hibernateProperties Hibernate configuration properties
145-
* @param serviceRegistry Hibernate service registry
146-
*/
147-
private static void backfillAuditTables(Metadata metadata, Properties hibernateProperties,
148-
ServiceRegistry serviceRegistry) {
149-
String auditTablePrefix = hibernateProperties.getProperty("org.hibernate.envers.audit_table_prefix", "");
150-
String auditTableSuffix = hibernateProperties.getProperty("org.hibernate.envers.audit_table_suffix", "_audit");
151-
152-
ConnectionProvider connectionProvider = serviceRegistry.getService(ConnectionProvider.class);
153-
Connection connection = null;
154-
155-
try {
156-
connection = connectionProvider.getConnection();
157-
boolean originalAutoCommit = connection.getAutoCommit();
158-
connection.setAutoCommit(false);
159-
160-
String revisionTableName = getRevisionEntityTableName(metadata);
161-
Integer revId = null;
162-
163-
for (PersistentClass persistentClass : metadata.getEntityBindings()) {
164-
Class<?> mappedClass = persistentClass.getMappedClass();
165-
if (mappedClass == null || !isAuditedClass(mappedClass)) {
166-
continue;
167-
}
168-
String sourceTable = persistentClass.getTable().getName();
169-
String auditTable = auditTablePrefix + sourceTable + auditTableSuffix;
170-
revId = tryBackfillEntity(connection, sourceTable, auditTable, revisionTableName, revId);
171-
}
172-
173-
if (revId != null) {
174-
connection.commit();
175-
log.info("Audit table backfill completed successfully with initial revision ID {}", revId);
176-
} else {
177-
log.debug("No audit tables needed backfilling.");
178-
}
179-
180-
connection.setAutoCommit(originalAutoCommit);
181-
} catch (SQLException e) {
182-
log.error("Failed to backfill audit tables", e);
183-
if (connection != null) {
184-
try {
185-
connection.rollback();
186-
} catch (SQLException ex) {
187-
log.error("Failed to rollback backfill transaction", ex);
188-
}
189-
}
190-
} finally {
191-
if (connection != null) {
192-
try {
193-
connectionProvider.closeConnection(connection);
194-
} catch (SQLException e) {
195-
log.error("Failed to close JDBC connection after audit backfill", e);
196-
}
197-
}
198-
}
199-
}
200-
201-
/**
202-
* Attempts to backfill a single entity's audit table. Skips if the audit table already has data or
203-
* the source table is empty. Returns the (possibly newly created) revision ID.
204-
*/
205-
private static Integer tryBackfillEntity(Connection connection, String sourceTable, String auditTable,
206-
String revisionTableName, Integer revId) {
207-
try {
208-
if (!isAuditTableEmpty(connection, auditTable) || isTableEmpty(connection, sourceTable)) {
209-
return revId;
210-
}
211-
if (revId == null) {
212-
revId = createBackfillRevision(connection, revisionTableName);
213-
}
214-
List<String> columns = getAuditTableDataColumns(connection, auditTable);
215-
if (!columns.isEmpty()) {
216-
backfillTable(connection, sourceTable, auditTable, columns, revId);
217-
}
218-
} catch (SQLException e) {
219-
log.warn("Failed to backfill audit table {}: {}", auditTable, e.getMessage());
220-
}
221-
return revId;
222-
}
223-
224-
/**
225-
* Creates a backfill revision entry in the revision entity table. Dynamically discovers the
226-
* timestamp column name from JDBC metadata to avoid hardcoding Hibernate-version-specific names.
227-
*
228-
* @param connection JDBC connection
229-
* @param revisionTableName name of the revision entity table
230-
* @return the generated revision ID
231-
* @throws SQLException if the revision entry cannot be created
232-
*/
233-
static int createBackfillRevision(Connection connection, String revisionTableName) throws SQLException {
234-
String pkColumn = getRevisionPrimaryKeyColumn(connection, revisionTableName);
235-
String timestampColumn = getRevisionTimestampColumn(connection, revisionTableName);
236-
int nextId;
237-
try (Statement stmt = connection.createStatement();
238-
ResultSet rs = stmt.executeQuery("SELECT COALESCE(MAX(" + requireSafeIdentifier(pkColumn) + "), 0) + 1 FROM "
239-
+ requireSafeIdentifier(revisionTableName))) {
240-
nextId = rs.next() ? rs.getInt(1) : 1;
241-
}
242-
String sql = "INSERT INTO " + requireSafeIdentifier(revisionTableName) + " (" + requireSafeIdentifier(pkColumn)
243-
+ ", " + requireSafeIdentifier(timestampColumn) + ") VALUES (?, ?)";
244-
try (PreparedStatement pstmt = connection.prepareStatement(sql)) {
245-
pstmt.setInt(1, nextId);
246-
pstmt.setLong(2, System.currentTimeMillis());
247-
pstmt.executeUpdate();
248-
return nextId;
249-
}
250-
}
251-
252-
/**
253-
* Discovers the primary key column name of the revision entity table.
254-
*
255-
* @param connection JDBC connection
256-
* @param revisionTableName name of the revision entity table
257-
* @return the primary key column name, falling back to "id" if not found
258-
* @throws SQLException if metadata cannot be read
259-
*/
260-
static String getRevisionPrimaryKeyColumn(Connection connection, String revisionTableName) throws SQLException {
261-
DatabaseMetaData metaData = connection.getMetaData();
262-
for (String name : new String[] { revisionTableName, revisionTableName.toUpperCase() }) {
263-
try (ResultSet rs = metaData.getPrimaryKeys(null, null, name)) {
264-
if (rs.next()) {
265-
return rs.getString("COLUMN_NAME");
266-
}
267-
}
268-
}
269-
return "id";
270-
}
271-
272-
/**
273-
* Discovers the timestamp column name in the revision entity table by finding the first BIGINT
274-
* column that is not the primary key. This avoids hardcoding Hibernate-version-specific names like
275-
* "REVTSTMP" which may differ across Hibernate versions.
276-
*
277-
* @param connection JDBC connection
278-
* @param revisionTableName name of the revision entity table
279-
* @return the timestamp column name, falling back to "REVTSTMP" if not found
280-
* @throws SQLException if metadata cannot be read
281-
*/
282-
static String getRevisionTimestampColumn(Connection connection, String revisionTableName) throws SQLException {
283-
DatabaseMetaData metaData = connection.getMetaData();
284-
String pkColumn = null;
285-
for (String name : new String[] { revisionTableName, revisionTableName.toUpperCase() }) {
286-
try (ResultSet pkRs = metaData.getPrimaryKeys(null, null, name)) {
287-
if (pkRs.next()) {
288-
pkColumn = pkRs.getString("COLUMN_NAME");
289-
break;
290-
}
291-
}
292-
}
293-
for (String name : new String[] { revisionTableName, revisionTableName.toUpperCase() }) {
294-
try (ResultSet colRs = metaData.getColumns(null, null, name, null)) {
295-
while (colRs.next()) {
296-
String colName = colRs.getString("COLUMN_NAME");
297-
int dataType = colRs.getInt("DATA_TYPE");
298-
if (dataType == java.sql.Types.BIGINT && !colName.equalsIgnoreCase(pkColumn)) {
299-
return colName;
300-
}
301-
}
302-
}
303-
}
304-
return "REVTSTMP";
305-
}
306-
307-
/**
308-
* Validates that a SQL identifier (table or column name) contains only safe characters, preventing
309-
* SQL injection when identifiers must be concatenated into queries.
310-
*
311-
* @param identifier the SQL identifier to validate
312-
* @return the identifier unchanged if safe
313-
* @throws IllegalArgumentException if the identifier contains unsafe characters
314-
*/
315-
private static String requireSafeIdentifier(String identifier) {
316-
if (identifier == null || !SAFE_SQL_IDENTIFIER.matcher(identifier).matches()) {
317-
throw new IllegalArgumentException("Unsafe SQL identifier rejected: " + identifier);
318-
}
319-
return identifier;
320-
}
321-
322-
/**
323-
* Returns true if the given audit table exists but contains no rows.
324-
*/
325-
static boolean isAuditTableEmpty(Connection connection, String tableName) {
326-
try (Statement stmt = connection.createStatement();
327-
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + requireSafeIdentifier(tableName))) {
328-
return rs.next() && rs.getLong(1) == 0;
329-
} catch (SQLException e) {
330-
log.debug("Audit table {} not accessible, skipping backfill: {}", tableName, e.getMessage());
331-
return false;
332-
}
333-
}
334-
335-
/**
336-
* Returns true if the given source table has no rows.
337-
*/
338-
static boolean isTableEmpty(Connection connection, String tableName) throws SQLException {
339-
try (Statement stmt = connection.createStatement();
340-
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + requireSafeIdentifier(tableName))) {
341-
return rs.next() && rs.getLong(1) == 0;
342-
}
343-
}
344-
345-
/**
346-
* Returns all column names from the given source table using JDBC metadata.
347-
*/
348-
static List<String> getSourceTableColumns(Connection connection, String tableName) throws SQLException {
349-
List<String> columns = new ArrayList<>();
350-
DatabaseMetaData metaData = connection.getMetaData();
351-
try (ResultSet rs = metaData.getColumns(null, null, tableName, null)) {
352-
while (rs.next()) {
353-
columns.add(rs.getString("COLUMN_NAME"));
354-
}
355-
}
356-
return columns;
357-
}
358-
359-
/**
360-
* Returns the data column names from the given audit table, excluding the Envers metadata columns
361-
* REV and REVTYPE. These are the columns that correspond to the audited entity fields and must
362-
* exist in the source table.
363-
*/
364-
static List<String> getAuditTableDataColumns(Connection connection, String auditTable) throws SQLException {
365-
List<String> columns = new ArrayList<>();
366-
DatabaseMetaData metaData = connection.getMetaData();
367-
try (ResultSet rs = metaData.getColumns(null, null, auditTable, null)) {
368-
while (rs.next()) {
369-
String colName = rs.getString("COLUMN_NAME");
370-
if (!colName.equalsIgnoreCase("REV") && !colName.equalsIgnoreCase("REVTYPE")) {
371-
columns.add(colName);
372-
}
373-
}
374-
}
375-
return columns;
376-
}
377-
378-
/**
379-
* Inserts all rows from the source table into the audit table with REVTYPE=0 (ADD).
380-
*/
381-
static void backfillTable(Connection connection, String sourceTable, String auditTable, List<String> columns, int revId)
382-
throws SQLException {
383-
requireSafeIdentifier(sourceTable);
384-
requireSafeIdentifier(auditTable);
385-
columns.forEach(EnversAuditTableInitializer::requireSafeIdentifier);
386-
String columnList = String.join(", ", columns);
387-
String sql = "INSERT INTO " + auditTable + " (REV, REVTYPE, " + columnList + ") " + "SELECT " + revId + ", 0, "
388-
+ columnList + " FROM " + sourceTable;
389-
try (Statement stmt = connection.createStatement()) {
390-
int rows = stmt.executeUpdate(sql);
391-
log.info("Backfilled {} rows from {} into {}", rows, sourceTable, auditTable);
392-
}
393-
}
394-
395-
/**
396-
* Resolves the revision entity table name dynamically from Hibernate metadata by finding the entity
397-
* annotated with {@link RevisionEntity}. Falls back to "revision_entity" if not found.
398-
*/
399-
private static String getRevisionEntityTableName(Metadata metadata) {
400-
for (PersistentClass persistentClass : metadata.getEntityBindings()) {
401-
Class<?> mappedClass = persistentClass.getMappedClass();
402-
if (mappedClass != null && mappedClass.isAnnotationPresent(RevisionEntity.class)) {
403-
return persistentClass.getTable().getName();
404-
}
405-
}
406-
return "revision_entity";
407-
}
408-
409-
/**
410-
* Returns true if the given class or any of its superclasses is annotated with {@link Audited}.
411-
*/
412-
private static boolean isAuditedClass(Class<?> clazz) {
413-
Class<?> current = clazz;
414-
while (current != null && current != Object.class) {
415-
if (current.isAnnotationPresent(Audited.class)) {
416-
return true;
417-
}
418-
current = current.getSuperclass();
419-
}
420-
return false;
421-
}
422-
423123
private static TargetDescriptor getTargetDescriptor() {
424124
return new TargetDescriptor() {
425125

0 commit comments

Comments
 (0)