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 ;
1219import java .util .EnumSet ;
20+ import java .util .List ;
1321import java .util .Map ;
1422import java .util .Properties ;
1523import java .util .concurrent .atomic .AtomicBoolean ;
24+ import java .util .regex .Pattern ;
1625
1726import 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 ;
1831import org .hibernate .service .ServiceRegistry ;
1932import org .hibernate .tool .schema .TargetType ;
2033import 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