Skip to content

Commit 6af3ad8

Browse files
AUDIT-28: Backfill pre-existing rows into Envers audit tables to fix 'Unable to read' in audit UI
1 parent bbf3317 commit 6af3ad8

File tree

2 files changed

+539
-1
lines changed

2 files changed

+539
-1
lines changed

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

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,25 @@
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;
1219
import java.util.EnumSet;
20+
import java.util.List;
1321
import java.util.Map;
1422
import java.util.Properties;
1523
import java.util.concurrent.atomic.AtomicBoolean;
24+
import java.util.regex.Pattern;
1625

1726
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;
1831
import org.hibernate.service.ServiceRegistry;
1932
import org.hibernate.tool.schema.TargetType;
2033
import org.hibernate.tool.schema.spi.ExceptionHandler;
@@ -34,13 +47,17 @@ public class EnversAuditTableInitializer {
3447

3548
private static final Logger log = LoggerFactory.getLogger(EnversAuditTableInitializer.class);
3649

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

3954
}
4055

4156
/**
4257
* Checks if Envers is enabled and creates/updates audit tables as needed. This will Create or
43-
* Update audit tables if they don't exist - Update existing audit tables if the schema has changed
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.
4461
*
4562
* @param metadata Hibernate metadata containing entity mappings
4663
* @param hibernateProperties properties containing Envers configuration
@@ -54,6 +71,7 @@ public static void initialize(Metadata metadata, Properties hibernateProperties,
5471
}
5572

5673
updateAuditTables(metadata, hibernateProperties, serviceRegistry);
74+
backfillAuditTables(metadata, hibernateProperties, serviceRegistry);
5775
}
5876

5977
/**
@@ -115,6 +133,214 @@ private static void updateAuditTables(Metadata metadata, Properties hibernatePro
115133
}
116134
}
117135

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 = getSourceTableColumns(connection, sourceTable);
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. Uses the REVTSTMP column which is
226+
* explicitly defined in Hibernate's RevisionMapping with @Column(name = "REVTSTMP").
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 sql = "INSERT INTO " + requireSafeIdentifier(revisionTableName) + " (REVTSTMP) VALUES (?)";
235+
try (PreparedStatement pstmt = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
236+
pstmt.setLong(1, System.currentTimeMillis());
237+
pstmt.executeUpdate();
238+
try (ResultSet rs = pstmt.getGeneratedKeys()) {
239+
if (rs.next()) {
240+
return rs.getInt(1);
241+
}
242+
}
243+
}
244+
throw new SQLException("Failed to create backfill revision entry in " + revisionTableName);
245+
}
246+
247+
/**
248+
* Validates that a SQL identifier (table or column name) contains only safe characters, preventing
249+
* SQL injection when identifiers must be concatenated into queries.
250+
*
251+
* @param identifier the SQL identifier to validate
252+
* @return the identifier unchanged if safe
253+
* @throws IllegalArgumentException if the identifier contains unsafe characters
254+
*/
255+
private static String requireSafeIdentifier(String identifier) {
256+
if (identifier == null || !SAFE_SQL_IDENTIFIER.matcher(identifier).matches()) {
257+
throw new IllegalArgumentException("Unsafe SQL identifier rejected: " + identifier);
258+
}
259+
return identifier;
260+
}
261+
262+
/**
263+
* Returns true if the given audit table exists but contains no rows.
264+
*/
265+
static boolean isAuditTableEmpty(Connection connection, String tableName) {
266+
try (Statement stmt = connection.createStatement();
267+
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + requireSafeIdentifier(tableName))) {
268+
return rs.next() && rs.getLong(1) == 0;
269+
} catch (SQLException e) {
270+
log.debug("Audit table {} not accessible, skipping backfill: {}", tableName, e.getMessage());
271+
return false;
272+
}
273+
}
274+
275+
/**
276+
* Returns true if the given source table has no rows.
277+
*/
278+
static boolean isTableEmpty(Connection connection, String tableName) throws SQLException {
279+
try (Statement stmt = connection.createStatement();
280+
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + requireSafeIdentifier(tableName))) {
281+
return rs.next() && rs.getLong(1) == 0;
282+
}
283+
}
284+
285+
/**
286+
* Returns all column names from the given source table using JDBC metadata.
287+
*/
288+
static List<String> getSourceTableColumns(Connection connection, String tableName) throws SQLException {
289+
List<String> columns = new ArrayList<>();
290+
DatabaseMetaData metaData = connection.getMetaData();
291+
try (ResultSet rs = metaData.getColumns(null, null, tableName, null)) {
292+
while (rs.next()) {
293+
columns.add(rs.getString("COLUMN_NAME"));
294+
}
295+
}
296+
return columns;
297+
}
298+
299+
/**
300+
* Inserts all rows from the source table into the audit table with REVTYPE=0 (ADD).
301+
*/
302+
static void backfillTable(Connection connection, String sourceTable, String auditTable, List<String> columns, int revId)
303+
throws SQLException {
304+
requireSafeIdentifier(sourceTable);
305+
requireSafeIdentifier(auditTable);
306+
columns.forEach(EnversAuditTableInitializer::requireSafeIdentifier);
307+
String columnList = String.join(", ", columns);
308+
String sql = "INSERT INTO " + auditTable + " (REV, REVTYPE, " + columnList + ") " + "SELECT " + revId + ", 0, "
309+
+ columnList + " FROM " + sourceTable;
310+
try (Statement stmt = connection.createStatement()) {
311+
int rows = stmt.executeUpdate(sql);
312+
log.info("Backfilled {} rows from {} into {}", rows, sourceTable, auditTable);
313+
}
314+
}
315+
316+
/**
317+
* Resolves the revision entity table name dynamically from Hibernate metadata by finding the entity
318+
* annotated with {@link RevisionEntity}. Falls back to "revision_entity" if not found.
319+
*/
320+
private static String getRevisionEntityTableName(Metadata metadata) {
321+
for (PersistentClass persistentClass : metadata.getEntityBindings()) {
322+
Class<?> mappedClass = persistentClass.getMappedClass();
323+
if (mappedClass != null && mappedClass.isAnnotationPresent(RevisionEntity.class)) {
324+
return persistentClass.getTable().getName();
325+
}
326+
}
327+
return "revision_entity";
328+
}
329+
330+
/**
331+
* Returns true if the given class or any of its superclasses is annotated with {@link Audited}.
332+
*/
333+
private static boolean isAuditedClass(Class<?> clazz) {
334+
Class<?> current = clazz;
335+
while (current != null && current != Object.class) {
336+
if (current.isAnnotationPresent(Audited.class)) {
337+
return true;
338+
}
339+
current = current.getSuperclass();
340+
}
341+
return false;
342+
}
343+
118344
private static TargetDescriptor getTargetDescriptor() {
119345
return new TargetDescriptor() {
120346

0 commit comments

Comments
 (0)