diff --git a/api/src/main/java/org/openmrs/api/db/hibernate/HibernateSessionFactoryBean.java b/api/src/main/java/org/openmrs/api/db/hibernate/HibernateSessionFactoryBean.java index 19e69dd2f2db..e9b6c385e25f 100644 --- a/api/src/main/java/org/openmrs/api/db/hibernate/HibernateSessionFactoryBean.java +++ b/api/src/main/java/org/openmrs/api/db/hibernate/HibernateSessionFactoryBean.java @@ -32,6 +32,7 @@ import org.openmrs.api.context.Context; import org.openmrs.module.Module; import org.openmrs.module.ModuleFactory; +import org.openmrs.util.EnversAuditTableInitializer; import org.openmrs.util.OpenmrsUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -221,6 +222,7 @@ public void destroy() throws HibernateException { public void integrate(Metadata metadata, SessionFactoryImplementor sessionFactory, SessionFactoryServiceRegistry serviceRegistry) { this.metadata = metadata; + generateEnversAuditTables(metadata, serviceRegistry); } @Override @@ -234,4 +236,14 @@ public void disintegrate(SessionFactoryImplementor sessionFactory, SessionFactor public Metadata getMetadata() { return metadata; } + + private void generateEnversAuditTables(Metadata metadata, SessionFactoryServiceRegistry serviceRegistry) { + try { + Properties hibernateProperties = getHibernateProperties(); + EnversAuditTableInitializer.initialize(metadata, hibernateProperties, serviceRegistry); + } catch (Exception e) { + log.error("Failed to initialize Envers audit tables", e); + throw new RuntimeException(e); + } + } } diff --git a/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java b/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java new file mode 100644 index 000000000000..c2e80b693c48 --- /dev/null +++ b/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java @@ -0,0 +1,150 @@ +/** + * 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.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import org.hibernate.boot.Metadata; +import org.hibernate.service.ServiceRegistry; +import org.hibernate.tool.schema.TargetType; +import org.hibernate.tool.schema.spi.ExceptionHandler; +import org.hibernate.tool.schema.spi.ExecutionOptions; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaManagementTool; +import org.hibernate.tool.schema.spi.SchemaMigrator; +import org.hibernate.tool.schema.spi.ScriptTargetOutput; +import org.hibernate.tool.schema.spi.TargetDescriptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 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. + */ +public class EnversAuditTableInitializer { + + private static final Logger log = LoggerFactory.getLogger(EnversAuditTableInitializer.class); + + /** + * 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 + * + * @param metadata Hibernate metadata containing entity mappings + * @param hibernateProperties properties containing Envers configuration + * @param serviceRegistry Hibernate service registry + */ + public static void initialize(Metadata metadata, Properties hibernateProperties, + ServiceRegistry serviceRegistry) { + + if (!isEnversEnabled(hibernateProperties)) { + log.debug("Hibernate Envers is not enabled. Skipping audit table initialization."); + return; + } + + updateAuditTables(metadata, hibernateProperties, serviceRegistry); + } + + /** + * Checks if Hibernate Envers is enabled in the configuration. + * + * @param properties Hibernate properties + * @return true if Envers is enabled, false otherwise + */ + private static boolean isEnversEnabled(Properties properties) { + String enversEnabled = properties.getProperty("hibernate.integration.envers.enabled"); + return "true".equalsIgnoreCase(enversEnabled); + } + + /** + * Creates or updates audit tables using Hibernate's {@link SchemaMigrator}. This method filters + * to only process audit tables. + * + * @param metadata Hibernate metadata containing entity mappings (includes Envers audit + * entities) + * @param hibernateProperties Hibernate configuration properties + * @param serviceRegistry Hibernate service registry + */ + private static void updateAuditTables(Metadata metadata, Properties hibernateProperties, + ServiceRegistry serviceRegistry) { + String auditTablePrefix = hibernateProperties.getProperty("org.hibernate.envers.audit_table_prefix", ""); + String auditTableSuffix = hibernateProperties.getProperty("org.hibernate.envers.audit_table_suffix", "_audit"); + + ExecutionOptions executionOptions = getExecutionOptions((Map) (Map) hibernateProperties); + + SchemaMigrator schemaMigrator = serviceRegistry.getService(SchemaManagementTool.class).getSchemaMigrator((Map) (Map)hibernateProperties); + + TargetDescriptor targetDescriptor = getTargetDescriptor(); + + schemaMigrator.doMigration(metadata, executionOptions, contributed -> { + String tableName = contributed.getExportIdentifier(); + if (tableName == null) { + return false; + } + + String lowerTableName = tableName.toLowerCase(); + + if (lowerTableName.contains("revision") || lowerTableName.equals("revinfo")) { + return true; + } + + String lowerPrefix = auditTablePrefix.toLowerCase(); + String lowerSuffix = auditTableSuffix.toLowerCase(); + + boolean hasPrefix = lowerPrefix.isEmpty() || lowerTableName.startsWith(lowerPrefix); + boolean hasSuffix = lowerSuffix.isEmpty() || lowerTableName.endsWith(lowerSuffix); + + return hasPrefix && hasSuffix; + }, targetDescriptor); + + log.info("Successfully created/updated Envers audit tables using Hibernate SchemaManagementTool."); + } + + private static TargetDescriptor getTargetDescriptor() { + return new TargetDescriptor() { + @Override + public EnumSet getTargetTypes() { + return EnumSet.of(TargetType.DATABASE); + } + + @Override + public ScriptTargetOutput getScriptTargetOutput() { + return null; + } + }; + } + + private static ExecutionOptions getExecutionOptions(Map settings) { + return new ExecutionOptions() { + @Override + public Map getConfigurationValues() { + return settings; + } + + @Override + public boolean shouldManageNamespaces() { + return false; + } + + @Override + public ExceptionHandler getExceptionHandler() { + return throwable -> log.warn("Schema migration encountered an issue: {}", throwable.getMessage()); + } + + @Override + public SchemaFilter getSchemaFilter() { + return SchemaFilter.ALL; + } + }; + } +} diff --git a/api/src/main/resources/hibernate.default.properties b/api/src/main/resources/hibernate.default.properties index eee535ae30cd..fb0e2b53bfaf 100644 --- a/api/src/main/resources/hibernate.default.properties +++ b/api/src/main/resources/hibernate.default.properties @@ -60,3 +60,4 @@ hibernate.id.new_generator_mappings=false # Hibernate envers options hibernate.integration.envers.enabled=false org.hibernate.envers.revision_listener=org.openmrs.api.db.hibernate.envers.OpenmrsRevisionEntityListener +org.hibernate.envers.audit_table_suffix=_audit diff --git a/api/src/test/java/org/openmrs/util/DatabaseIT.java b/api/src/test/java/org/openmrs/util/DatabaseIT.java index 2f75e5b2af27..40bfc050b991 100644 --- a/api/src/test/java/org/openmrs/util/DatabaseIT.java +++ b/api/src/test/java/org/openmrs/util/DatabaseIT.java @@ -44,7 +44,7 @@ public class DatabaseIT implements LiquibaseProvider { protected static final String PASSWORD = "test"; @BeforeEach - public void setup() throws SQLException, ClassNotFoundException { + public void setup() throws Exception { this.initializeDatabase(); } diff --git a/api/src/test/java/org/openmrs/util/databasechange/EnversAuditTableInitializerDatabaseIT.java b/api/src/test/java/org/openmrs/util/databasechange/EnversAuditTableInitializerDatabaseIT.java new file mode 100644 index 000000000000..28304f5e29e5 --- /dev/null +++ b/api/src/test/java/org/openmrs/util/databasechange/EnversAuditTableInitializerDatabaseIT.java @@ -0,0 +1,241 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +import org.checkerframework.checker.initialization.qual.Initialized; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.UnknownKeyFor; +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.registry.BootstrapServiceRegistry; +import org.hibernate.boot.registry.BootstrapServiceRegistryBuilder; +import org.hibernate.cfg.Configuration; +import org.hibernate.cfg.Environment; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.envers.Audited; +import org.hibernate.integrator.spi.Integrator; +import org.hibernate.service.spi.SessionFactoryServiceRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openmrs.api.OrderServiceTest; +import org.openmrs.api.db.hibernate.envers.OpenmrsRevisionEntity; +import org.openmrs.liquibase.ChangeLogVersionFinder; +import org.openmrs.util.DatabaseIT; +import org.openmrs.util.EnversAuditTableInitializer; +import org.openmrs.util.OpenmrsClassScanner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates that Envers audit tables are correctly generated when auditing is enabled. + */ +public class EnversAuditTableInitializerDatabaseIT extends DatabaseIT { + + private static final Logger log = LoggerFactory.getLogger(EnversAuditTableInitializerDatabaseIT.class); + + @BeforeEach + public void beforeEach() throws Exception { + this.dropAllDatabaseObjects(); + + ChangeLogVersionFinder changeLogVersionFinder = new ChangeLogVersionFinder(); + String latestVersion = changeLogVersionFinder.getLatestSnapshotVersion() + .orElseThrow(() -> new RuntimeException("No snapshot version found")); + List snapshotFiles = changeLogVersionFinder.getSnapshotFilenames(latestVersion); + + this.initializeDatabase(); + + log.info("Liquibase files used for creating the OpenMRS database are: " + snapshotFiles); + + for (String fileName : snapshotFiles) { + log.info("processing " + fileName); + this.updateDatabase(fileName); + } + } + + @Test + public void shouldCreateEnversAuditTablesWhenEnversIsEnabled() throws Exception { + List> auditedEntities = getAuditedEntityClasses(); + log.info("Found {} @Audited entity classes", auditedEntities.size()); + + try (SessionFactory sessionFactory = buildSessionFactoryWithEnvers(true, null)) { + assertTrue(tableExists("revinfo"), "revinfo table should exist"); + List missingTables = new ArrayList<>(); + for (Class entityClass : auditedEntities) { + String expectedAuditTableName = getExpectedAuditTableName(entityClass, "_audit"); + if (!tableExists(expectedAuditTableName)) { + missingTables.add(expectedAuditTableName); + } + } + assertTrue(missingTables.isEmpty(), "Missing audit tables: " + missingTables); + } + + this.dropAllDatabaseObjects(); + } + + @Test + public void shouldNotCreateAuditTablesWhenEnversIsDisabled() throws Exception { + try (SessionFactory sessionFactory = buildSessionFactoryWithEnvers(false, null)) { + assertFalse(tableExists("patient_audit"), "patient_aud table should not exist"); + assertFalse(tableExists("encounter_audit"), "encounter_aud table should not exist"); + assertFalse(tableExists("concept_audit"), "concept_aud table should not exist"); + } + this.dropAllDatabaseObjects(); + } + + @Test + public void shouldRespectCustomAuditTableSuffix() throws Exception { + try (SessionFactory sessionFactory = buildSessionFactoryWithEnvers(true, "_Aaa")) { + assertTrue(tableExists("patient_Aaa"), "patient_AUDIT table should exist"); + assertTrue(tableExists("encounter_Aaa"), "encounter_AUDIT table should exist"); + assertTrue(tableExists("concept_Aaa"), "concept_AUDIT table should exist"); + assertFalse(tableExists("patient_audit"), "patient_aud table should not exist"); + } + this.dropAllDatabaseObjects(); + } + + private SessionFactory buildSessionFactoryWithEnvers(boolean enversEnabled, String customSuffix) { + Integrator enversIntegrator = new Integrator() { + + @Override + public void integrate(@UnknownKeyFor @NonNull @Initialized Metadata metadata, + @UnknownKeyFor @NonNull @Initialized SessionFactoryImplementor sessionFactory, + @UnknownKeyFor @NonNull @Initialized SessionFactoryServiceRegistry serviceRegistry) { + if (enversEnabled) { + try { + Properties properties = new java.util.Properties(); + properties.setProperty("hibernate.integration.envers.enabled", "true"); + String suffix = "_audit"; + if (customSuffix != null) { + suffix = customSuffix; + } + properties.setProperty("org.hibernate.envers.audit_table_suffix", suffix); + EnversAuditTableInitializer.initialize(metadata, properties, serviceRegistry); + } + catch (Exception e) { + throw new RuntimeException("Failed to initialize audit tables", e); + } + } + } + + @Override + public void disintegrate( + @UnknownKeyFor @NonNull @Initialized SessionFactoryImplementor sessionFactoryImplementor, + @UnknownKeyFor @NonNull @Initialized SessionFactoryServiceRegistry sessionFactoryServiceRegistry) { + } + }; + + BootstrapServiceRegistry bootstrapRegistry = new BootstrapServiceRegistryBuilder().applyIntegrator(enversIntegrator) + .build(); + + Configuration configuration = new Configuration(bootstrapRegistry).configure(); + + Set> entityClasses = OpenmrsClassScanner.getInstance().getClassesWithAnnotation(Entity.class); + entityClasses.remove(OrderServiceTest.SomeTestOrder.class); + entityClasses.remove(OpenmrsRevisionEntity.class); + for (Class clazz : entityClasses) { + configuration.addAnnotatedClass(clazz); + } + configuration.setProperty(Environment.DIALECT, System.getProperty("databaseDialect")); + configuration.setProperty(Environment.JAKARTA_JDBC_URL, CONNECTION_URL); + configuration.setProperty(Environment.JAKARTA_JDBC_USER, USER_NAME); + configuration.setProperty(Environment.JAKARTA_JDBC_PASSWORD, PASSWORD); + configuration.setProperty(Environment.USE_SECOND_LEVEL_CACHE, "false"); + configuration.setProperty(Environment.USE_QUERY_CACHE, "false"); + configuration.setProperty("hibernate.integration.envers.enabled", String.valueOf(enversEnabled)); + + String suffix = "_audit"; + if (customSuffix != null) { + suffix = customSuffix; + } + configuration.setProperty("org.hibernate.envers.audit_table_suffix", suffix); + + + configuration.setProperty("hibernate.search.backend.type", "lucene"); + configuration.setProperty("hibernate.search.backend.analysis.configurer", + "class:org.openmrs.api.db.hibernate.search.lucene.LuceneConfig"); + configuration.setProperty(Environment.HBM2DDL_AUTO, "none"); + return configuration.buildSessionFactory(); + } + + private boolean tableExists(String tableName) throws Exception { + try (Connection connection = getConnection()) { + DatabaseMetaData metaData = connection.getMetaData(); + + try (ResultSet rs = metaData.getTables(null, null, "%", new String[] { "TABLE" })) { + while (rs.next()) { + String existingTableName = rs.getString("TABLE_NAME"); + if (existingTableName.equalsIgnoreCase(tableName)) { + return true; + } + } + } + } + return false; + } + + /** + * Gets all entity classes that are annotated with @Audited. + */ + private List> getAuditedEntityClasses() { + Set> entityClasses = OpenmrsClassScanner.getInstance().getClassesWithAnnotation(Entity.class); + List> auditedClasses = new ArrayList<>(); + + for (Class entityClass : entityClasses) { + if (entityClass.equals(OrderServiceTest.SomeTestOrder.class)) { + continue; + } + if (isAudited(entityClass)) { + auditedClasses.add(entityClass); + } + } + return auditedClasses; + } + + /** + * Checks if a class or any of its superclasses is annotated with @Audited. + */ + private boolean isAudited(Class clazz) { + Class current = clazz; + while (current != null && current != Object.class) { + if (current.isAnnotationPresent(Audited.class)) { + return true; + } + current = current.getSuperclass(); + } + return false; + } + + /** + * Gets the expected audit table name for an entity class. + */ + private String getExpectedAuditTableName(Class entityClass, String suffix) { + Table tableAnnotation = entityClass.getAnnotation(Table.class); + String baseTableName; + if (tableAnnotation != null && !tableAnnotation.name().isEmpty()) { + baseTableName = tableAnnotation.name(); + } else { + baseTableName = entityClass.getSimpleName(); + } + return baseTableName + suffix; + } +}