diff --git a/api/src/main/java/org/openmrs/ConceptReferenceRange.java b/api/src/main/java/org/openmrs/ConceptReferenceRange.java index 19e495009e07..802fdc8db6bf 100644 --- a/api/src/main/java/org/openmrs/ConceptReferenceRange.java +++ b/api/src/main/java/org/openmrs/ConceptReferenceRange.java @@ -46,7 +46,7 @@ public class ConceptReferenceRange extends BaseReferenceRange implements Openmrs @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer conceptReferenceRangeId; - @Column(name = "criteria", length = 65535) + @Column(name = "criteria", length = 255) private String criteria; @ManyToOne(fetch = FetchType.LAZY) 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..cdfdd5419473 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 @@ -27,11 +27,15 @@ import org.hibernate.boot.Metadata; import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.integrator.spi.Integrator; +import org.hibernate.service.ServiceRegistry; import org.hibernate.service.spi.SessionFactoryServiceRegistry; +import org.jspecify.annotations.NonNull; +import org.openmrs.api.APIException; import org.openmrs.api.cache.CacheConfig; 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; @@ -75,7 +79,7 @@ public class HibernateSessionFactoryBean extends LocalSessionFactoryBean impleme * as 'private' instead of 'protected' */ @Override - public void setMappingResources(String... mappingResources) { + public void setMappingResources(String @NonNull ... mappingResources) { Collections.addAll(this.mappingResources, mappingResources); super.setMappingResources(this.mappingResources.toArray(new String[] {})); @@ -87,7 +91,7 @@ public void setMappingResources(String... mappingResources) { * It adds to the set instead of overwriting it with each call. */ @Override - public void setPackagesToScan(String... packagesToScan) { + public void setPackagesToScan(String @NonNull ... packagesToScan) { this.packagesToScan.addAll(Arrays.asList(packagesToScan)); super.setPackagesToScan(this.packagesToScan.toArray(new String[0])); @@ -129,7 +133,7 @@ public void afterPropertiesSet() throws IOException { Object key = entry.getKey(); String prop = (String) key; String value = (String) entry.getValue(); - log.trace("Setting module property: " + prop + ":" + value); + log.trace("Setting module property: {}:{}", prop, value); config.setProperty(prop, value); if (!prop.startsWith("hibernate")) { config.setProperty("hibernate." + prop, value); @@ -143,7 +147,7 @@ public void afterPropertiesSet() throws IOException { Object key = entry.getKey(); String prop = (String) key; String value = (String) entry.getValue(); - log.trace("Setting property: " + prop + ":" + value); + log.trace("Setting property: {}:{}", prop, value); config.setProperty(prop, value); if (!prop.startsWith("hibernate")) { config.setProperty("hibernate." + prop, value); @@ -186,8 +190,8 @@ public void afterPropertiesSet() throws IOException { value = value.replace("%APPLICATION_DATA_DIRECTORY%", applicationDataDirectory); entry.setValue(value); } - - log.debug("Setting global Hibernate Session Interceptor for SessionFactory, Interceptor: " + chainingInterceptor); + + log.debug("Setting global Hibernate Session Interceptor for SessionFactory, Interceptor: {}", chainingInterceptor); // make sure all autowired interceptors are put onto our chaining interceptor // sort on the keys so that the devs/modules have some sort of control over the order of the interceptors @@ -221,6 +225,7 @@ public void destroy() throws HibernateException { public void integrate(Metadata metadata, SessionFactoryImplementor sessionFactory, SessionFactoryServiceRegistry serviceRegistry) { this.metadata = metadata; + generateEnversAuditTables(metadata, serviceRegistry); } @Override @@ -234,4 +239,13 @@ public void disintegrate(SessionFactoryImplementor sessionFactory, SessionFactor public Metadata getMetadata() { return metadata; } + + private void generateEnversAuditTables(Metadata metadata, ServiceRegistry serviceRegistry) { + try { + Properties hibernateProperties = getHibernateProperties(); + EnversAuditTableInitializer.initialize(metadata, hibernateProperties, serviceRegistry); + } catch (Exception e) { + throw new APIException("An error occurred while initializing the Envers audit tables", 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..2a43a4cae94d --- /dev/null +++ b/api/src/main/java/org/openmrs/util/EnversAuditTableInitializer.java @@ -0,0 +1,186 @@ +/** + * 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 java.util.concurrent.atomic.AtomicBoolean; + +import org.hibernate.boot.Metadata; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +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.SchemaFilterProvider; +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); + + private EnversAuditTableInitializer() { + + } + + /** + * 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"); + + Map settings = new HashMap<>((Map) hibernateProperties); + settings.put("hibernate.hbm2ddl.schema_filter_provider", buildSchemaFilterProvider(auditTablePrefix, auditTableSuffix)); + + AtomicBoolean hasErrors = new AtomicBoolean(false); + ExecutionOptions executionOptions = getExecutionOptions(settings, hasErrors); + SchemaMigrator schemaMigrator = serviceRegistry.getService(SchemaManagementTool.class).getSchemaMigrator(settings); + + schemaMigrator.doMigration(metadata, executionOptions, getTargetDescriptor()); + + if (hasErrors.get()) { + log.warn("Envers audit table migration completed with errors."); + } else { + log.info("Successfully created/updated Envers audit tables using Hibernate SchemaManagementTool."); + } + } + + private static SchemaFilterProvider buildSchemaFilterProvider(String auditTablePrefix, String auditTableSuffix) { + String lowerPrefix = auditTablePrefix.toLowerCase(); + String lowerSuffix = auditTableSuffix.toLowerCase(); + + SchemaFilter auditFilter = new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + String tableName = table.getName(); + if (tableName == null) { + return false; + } + + String lowerTableName = tableName.toLowerCase(); + + if (lowerTableName.contains("revision") || lowerTableName.equals("revinfo")) { + return true; + } + + boolean hasPrefix = lowerPrefix.isEmpty() || lowerTableName.startsWith(lowerPrefix); + boolean hasSuffix = lowerSuffix.isEmpty() || lowerTableName.endsWith(lowerSuffix); + + return hasPrefix && hasSuffix; + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + + return new SchemaFilterProvider() { + @Override public SchemaFilter getCreateFilter() { return auditFilter; } + @Override public SchemaFilter getDropFilter() { return auditFilter; } + @Override public SchemaFilter getMigrateFilter() { return auditFilter; } + @Override public SchemaFilter getValidateFilter() { return auditFilter; } + }; + } + + 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, AtomicBoolean hasErrors) { + return new ExecutionOptions() { + @Override + public Map getConfigurationValues() { + return settings; + } + + @Override + public boolean shouldManageNamespaces() { + return false; + } + + @Override + public ExceptionHandler getExceptionHandler() { + return throwable -> { + hasErrors.set(true); + log.warn("Schema migration encountered an issue: {}", throwable.getMessage()); + }; + } + }; + } +} \ No newline at end of file diff --git a/api/src/main/resources/hibernate.default.properties b/api/src/main/resources/hibernate.default.properties index 50e270874837..330cb9e5d50f 100644 --- a/api/src/main/resources/hibernate.default.properties +++ b/api/src/main/resources/hibernate.default.properties @@ -61,3 +61,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 37d2bc0b165a..0385cd31e29e 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..1164c32191e7 --- /dev/null +++ b/api/src/test/java/org/openmrs/util/databasechange/EnversAuditTableInitializerDatabaseIT.java @@ -0,0 +1,183 @@ +/** + * 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 javax.persistence.Entity; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +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.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.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. + */ +class EnversAuditTableInitializerDatabaseIT extends DatabaseIT { + + private static final Logger log = LoggerFactory.getLogger(EnversAuditTableInitializerDatabaseIT.class); + + @BeforeEach + 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 + void shouldCreateEnversAuditTablesWhenEnversIsEnabled() throws Exception { + try (SessionFactory sessionFactory = buildSessionFactoryWithEnvers(true, null)) { + assertTrue(tableExists("revinfo"), "revinfo table should exist"); + assertTrue(tableExists("patient_audit"), "patient_audit table should exist"); + assertTrue(tableExists("visit_type_audit"), "visit_type_audit table should exist"); + assertTrue(tableExists("location_tag_audit"), "location_tag_audit table should exist"); + } + + this.dropAllDatabaseObjects(); + } + + @Test + void shouldNotCreateAuditTablesWhenEnversIsDisabled() throws Exception { + try (SessionFactory sessionFactory = buildSessionFactoryWithEnvers(false, null)) { + assertFalse(tableExists("patient_audit"), "patient_audit table should not exist"); + assertFalse(tableExists("visit_type_audit"), "visit_type_audit table should not exist"); + assertFalse(tableExists("location_tag_audit"), "location_tag_audit table should not exist"); + } + this.dropAllDatabaseObjects(); + } + + @Test + void shouldRespectCustomAuditTableSuffix() throws Exception { + try (SessionFactory sessionFactory = buildSessionFactoryWithEnvers(true, "_Aaa")) { + assertTrue(tableExists("patient_Aaa"), "patient_Aaa table should exist"); + assertTrue(tableExists("visit_type_Aaa"), "visit_type_Aaa table should exist"); + assertTrue(tableExists("location_tag_Aaa"), "location_tag_Aaa table should exist"); + assertFalse(tableExists("patient_audit"), "patient_audit table should not exist"); + } + this.dropAllDatabaseObjects(); + } + + private SessionFactory buildSessionFactoryWithEnvers(boolean enversEnabled, String customSuffix) { + Integrator enversIntegrator = new Integrator() { + + @Override + public void integrate(Metadata metadata, SessionFactoryImplementor sessionFactoryImplementor, SessionFactoryServiceRegistry sessionFactoryServiceRegistry) { + 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, sessionFactoryServiceRegistry); + } + 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) { + // No cleanup needed: the audit table initialization performed in integrate() is a one-time + // schema operation with no resources to release when the SessionFactory is closed. + } + }; + + BootstrapServiceRegistry bootstrapRegistry = new BootstrapServiceRegistryBuilder().applyIntegrator(enversIntegrator) + .build(); + + Configuration configuration = new Configuration(bootstrapRegistry).configure(); + + Set> entityClasses = OpenmrsClassScanner.getInstance().getClassesWithAnnotation(Entity.class); + entityClasses.remove(OpenmrsRevisionEntity.class); + for (Class clazz : entityClasses) { + configuration.addAnnotatedClass(clazz); + } + configuration.setProperty(Environment.DIALECT, System.getProperty("databaseDialect")); + configuration.setProperty(Environment.URL, CONNECTION_URL); + configuration.setProperty(Environment.USER, USER_NAME); + configuration.setProperty(Environment.PASS, 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; + } +} \ No newline at end of file