99 */
1010package 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 ;
1912import java .util .EnumSet ;
20- import java .util .List ;
2113import java .util .Map ;
2214import java .util .Properties ;
2315import java .util .concurrent .atomic .AtomicBoolean ;
24- import java .util .regex .Pattern ;
2516
2617import 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 ;
3118import org .hibernate .service .ServiceRegistry ;
3219import org .hibernate .tool .schema .TargetType ;
3320import org .hibernate .tool .schema .spi .ExceptionHandler ;
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 */
4637public 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