diff --git a/README.md b/README.md index cee30eed7d..84e6e2fd70 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ At this point you have the application running and admin account operational. To and at least one CDM database. They are covered in the respective documentation sections. ## License -OHDSI WebAPI is licensed under Apache License 2.0 +OHDSI WebAPI is licensed under Apache License 2.0 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 19882e0085..3bf51d8690 100644 --- a/pom.xml +++ b/pom.xml @@ -305,6 +305,11 @@ false + + + false + ./data/cache + @@ -420,6 +425,7 @@ false + false org.ohdsi.webapi.WebApi --add-opens java.base/java.lang=ALL-UNNAMED @@ -460,6 +466,9 @@ --add-opens java.naming/com.sun.jndi.ldap=ALL-UNNAMED --add-exports java.naming/com.sun.jndi.ldap=ALL-UNNAMED + + org.springframework.boot.logging.log4j2.Log4J2LoggingSystem + @@ -482,6 +491,9 @@ 3.5.2 ${skipITtests} + + org.springframework.boot.logging.log4j2.Log4J2LoggingSystem + @@ -559,6 +571,10 @@ repo.ohdsi.org https://repo.ohdsi.org/nexus/content/groups/public + + jitpack.io + https://jitpack.io + @@ -1240,9 +1256,31 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 + + com.github.p-hoffmann + trexsql-ext + v0.1.2 + + + tcache + + true + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + webapi-oracle diff --git a/src/main/java/org/ohdsi/webapi/WebApi.java b/src/main/java/org/ohdsi/webapi/WebApi.java index 2fed22d3fc..e4bbf74fcb 100644 --- a/src/main/java/org/ohdsi/webapi/WebApi.java +++ b/src/main/java/org/ohdsi/webapi/WebApi.java @@ -17,7 +17,6 @@ /** * OHDSI WebAPI Spring Boot Application * - * Supports both JAR and WAR deployment: * - JAR: java -jar WebAPI.jar (embedded Tomcat) * - WAR: Deploy to external servlet container (mvn package -Pwar) */ diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLConfig.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLConfig.java new file mode 100644 index 0000000000..40d3dd7dfb --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLConfig.java @@ -0,0 +1,65 @@ +package org.ohdsi.webapi.trexsql; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration properties for trexsql integration. + * Maps to trexsql.* in application properties. + */ +@Configuration +@ConfigurationProperties(prefix = "trexsql") +public class TrexSQLConfig { + + private boolean enabled = false; + private String cachePath = "./data/cache"; + private String extensionsPath; + private Map sources = new HashMap<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCachePath() { + return cachePath; + } + + public void setCachePath(String cachePath) { + this.cachePath = cachePath; + } + + public String getExtensionsPath() { + return extensionsPath; + } + + public void setExtensionsPath(String extensionsPath) { + this.extensionsPath = extensionsPath; + } + + public Map getSources() { + return sources; + } + + public void setSources(Map sources) { + this.sources = sources; + } + + public TrexSQLSourceConfig getSourceConfig(String sourceKey) { + return sources.get(sourceKey); + } + + public boolean isEnabledForSource(String sourceKey) { + if (!enabled) { + return false; + } + TrexSQLSourceConfig sourceConfig = sources.get(sourceKey); + return sourceConfig != null && sourceConfig.isEnabled(); + } +} diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLInstanceManager.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLInstanceManager.java new file mode 100644 index 0000000000..36a6997018 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLInstanceManager.java @@ -0,0 +1,105 @@ +package org.ohdsi.webapi.trexsql; + +import org.trex.Trexsql; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Singleton manager for the TrexSQL instance. + * Provides lazy initialization and graceful shutdown. + */ +@Component +@ConditionalOnProperty(name = "trexsql.enabled", havingValue = "true", matchIfMissing = false) +public class TrexSQLInstanceManager { + + private static final Logger log = LoggerFactory.getLogger(TrexSQLInstanceManager.class); + + private final TrexSQLConfig config; + private volatile Object trexsqlDb = null; + private final ReentrantLock initLock = new ReentrantLock(); + + public TrexSQLInstanceManager(TrexSQLConfig config) { + this.config = config; + } + + public Object getInstance() { + if (!config.isEnabled()) { + throw new IllegalStateException("TrexSQL is not enabled"); + } + + if (trexsqlDb == null) { + initLock.lock(); + try { + if (trexsqlDb == null) { + log.info("Initializing TrexSQL instance"); + trexsqlDb = Trexsql.init(buildConfig()); + log.info("TrexSQL instance initialized successfully"); + } + } finally { + initLock.unlock(); + } + } + return trexsqlDb; + } + + public boolean isAvailable() { + if (!config.isEnabled() || trexsqlDb == null) { + return false; + } + try { + return Trexsql.isRunning(trexsqlDb); + } catch (Exception e) { + log.warn("Error checking TrexSQL status: {}", e.getMessage()); + return false; + } + } + + public boolean isAttached(String databaseCode) { + if (trexsqlDb == null) { + return false; + } + try { + return Trexsql.isAttached(trexsqlDb, databaseCode); + } catch (Exception e) { + log.warn("Error checking if database {} is attached: {}", databaseCode, e.getMessage()); + return false; + } + } + + private Map buildConfig() { + Map initConfig = new HashMap<>(); + + if (config.getExtensionsPath() != null && !config.getExtensionsPath().isEmpty()) { + initConfig.put("extensions-path", config.getExtensionsPath()); + } + + return initConfig; + } + + @PreDestroy + public void shutdown() { + initLock.lock(); + try { + if (trexsqlDb != null) { + log.info("Shutting down TrexSQL instance"); + try { + Trexsql.shutdown(trexsqlDb); + log.info("TrexSQL instance shut down successfully"); + } catch (Exception e) { + log.error("Error shutting down TrexSQL instance: {}", e.getMessage(), e); + } finally { + trexsqlDb = null; + } + } + } finally { + initLock.unlock(); + } + } +} diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLSearchProvider.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLSearchProvider.java new file mode 100644 index 0000000000..842d8b9100 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLSearchProvider.java @@ -0,0 +1,122 @@ +package org.ohdsi.webapi.trexsql; + +import org.ohdsi.vocabulary.Concept; +import org.ohdsi.vocabulary.SearchProvider; +import org.ohdsi.vocabulary.SearchProviderConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * SearchProvider implementation using TrexSQL. + */ +@Component +@ConditionalOnProperty(name = "trexsql.enabled", havingValue = "true", matchIfMissing = false) +public class TrexSQLSearchProvider implements SearchProvider { + + private static final Logger log = LoggerFactory.getLogger(TrexSQLSearchProvider.class); + + private static final int TREXSQL_PRIORITY = 1; + + private final TrexSQLService trexsqlService; + private final TrexSQLConfig config; + + public TrexSQLSearchProvider(TrexSQLService trexsqlService, TrexSQLConfig config) { + this.trexsqlService = trexsqlService; + this.config = config; + } + + @Override + public boolean supports(String vocabularyVersionKey) { + return config.isEnabled() + && trexsqlService.isEnabledForSource(vocabularyVersionKey) + && trexsqlService.isCacheAvailable(vocabularyVersionKey); + } + + @Override + public int getPriority() { + return TREXSQL_PRIORITY; + } + + @Override + public Collection executeSearch(SearchProviderConfig searchConfig, String query, String rows) throws Exception { + String sourceKey = searchConfig.getSourceKey(); + + if (!trexsqlService.isEnabledForSource(sourceKey)) { + log.debug("TrexSQL not enabled for source {}", sourceKey); + throw new IllegalStateException("TrexSQL not enabled for source: " + sourceKey); + } + + if (!trexsqlService.isCacheAvailable(sourceKey)) { + log.debug("Cache not available for source {}", sourceKey); + throw new IllegalStateException("TrexSQL cache not available for source: " + sourceKey); + } + + int maxRows = parseRows(rows); + log.debug("TrexSQL search for source {} with query: {}", sourceKey, query); + + try { + List> results = trexsqlService.searchVocab(sourceKey, query, maxRows); + return mapToConcepts(results); + } catch (Exception e) { + log.error("TrexSQL search failed for source {}: {}", sourceKey, e.getMessage(), e); + throw new RuntimeException("TrexSQL search failed: " + e.getMessage(), e); + } + } + + private int parseRows(String rows) { + if (rows == null || rows.isEmpty()) { + return 1000; + } + try { + return Integer.parseInt(rows); + } catch (NumberFormatException e) { + return 1000; + } + } + + private Collection mapToConcepts(List> results) { + List concepts = new ArrayList<>(); + + for (Map row : results) { + Concept concept = new Concept(); + + Object conceptId = row.get("concept_id"); + if (conceptId != null) { + concept.conceptId = ((Number) conceptId).longValue(); + } + + concept.conceptName = (String) row.get("concept_name"); + concept.domainId = (String) row.get("domain_id"); + concept.vocabularyId = (String) row.get("vocabulary_id"); + concept.conceptClassId = (String) row.get("concept_class_id"); + concept.standardConcept = (String) row.get("standard_concept"); + concept.conceptCode = (String) row.get("concept_code"); + concept.invalidReason = (String) row.get("invalid_reason"); + + Object validStartDate = row.get("valid_start_date"); + if (validStartDate instanceof java.sql.Date) { + concept.validStartDate = new java.util.Date(((java.sql.Date) validStartDate).getTime()); + } else if (validStartDate instanceof java.util.Date) { + concept.validStartDate = (java.util.Date) validStartDate; + } + + Object validEndDate = row.get("valid_end_date"); + if (validEndDate instanceof java.sql.Date) { + concept.validEndDate = new java.util.Date(((java.sql.Date) validEndDate).getTime()); + } else if (validEndDate instanceof java.util.Date) { + concept.validEndDate = (java.util.Date) validEndDate; + } + + concepts.add(concept); + } + + return concepts; + } +} diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLService.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLService.java new file mode 100644 index 0000000000..7ca6a3a82c --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLService.java @@ -0,0 +1,76 @@ +package org.ohdsi.webapi.trexsql; + +import org.trex.Trexsql; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Service for TrexSQL operations used by SearchProvider. + */ +@Service +@ConditionalOnProperty(name = "trexsql.enabled", havingValue = "true", matchIfMissing = false) +public class TrexSQLService { + + private static final Logger log = LoggerFactory.getLogger(TrexSQLService.class); + + private final TrexSQLConfig config; + private final TrexSQLInstanceManager instanceManager; + + public TrexSQLService(TrexSQLConfig config, TrexSQLInstanceManager instanceManager) { + this.config = config; + this.instanceManager = instanceManager; + } + + public boolean isEnabledForSource(String sourceKey) { + return config.isEnabledForSource(sourceKey); + } + + public boolean isCacheAvailable(String sourceKey) { + TrexSQLSourceConfig sourceConfig = config.getSourceConfig(sourceKey); + if (sourceConfig == null) { + return false; + } + String databaseCode = sourceConfig.getDatabaseCode(); + if (databaseCode == null || databaseCode.isEmpty()) { + return false; + } + return Paths.get(config.getCachePath(), databaseCode + ".db") + .toFile().exists(); + } + + @SuppressWarnings("unchecked") + public List> searchVocab(String sourceKey, String searchTerm, int maxRows) { + log.debug("Searching vocabulary for source {} with term: {}", sourceKey, searchTerm); + + TrexSQLSourceConfig sourceConfig = config.getSourceConfig(sourceKey); + if (sourceConfig == null) { + throw new IllegalStateException("TrexSQL source configuration not found for key: " + sourceKey); + } + + String databaseCode = sourceConfig.getDatabaseCode(); + if (databaseCode == null || databaseCode.isEmpty()) { + throw new IllegalStateException("TrexSQL database code not configured for source: " + sourceKey); + } + + Map options = new HashMap<>(); + options.put("database-code", databaseCode); + options.put("max-rows", maxRows); + + try { + Object db = instanceManager.getInstance(); + List> results = Trexsql.searchVocab(db, searchTerm, options); + log.debug("Vocabulary search returned {} results", results.size()); + return results; + } catch (Exception e) { + log.error("Error searching vocabulary for source {}: {}", sourceKey, e.getMessage(), e); + throw new RuntimeException("Vocabulary search failed: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLServletConfig.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLServletConfig.java new file mode 100644 index 0000000000..1532ba874c --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLServletConfig.java @@ -0,0 +1,28 @@ +package org.ohdsi.webapi.trexsql; + +import org.trex.TrexServlet; +import jakarta.servlet.http.HttpServlet; +import org.ohdsi.webapi.source.SourceRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConditionalOnProperty(name = "trexsql.enabled", havingValue = "true") +public class TrexSQLServletConfig { + + @Bean + public ServletRegistrationBean trexServlet( + TrexSQLInstanceManager instanceManager, + SourceRepository sourceRepository) { + + TrexServlet servlet = new TrexServlet(); + servlet.initTrex(instanceManager.getInstance(), sourceRepository); + + ServletRegistrationBean registration = + new ServletRegistrationBean<>(servlet, "/WebAPI/trexsql/*"); + registration.setLoadOnStartup(1); + return registration; + } +} diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLSourceConfig.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLSourceConfig.java new file mode 100644 index 0000000000..f55ac5c734 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLSourceConfig.java @@ -0,0 +1,27 @@ +package org.ohdsi.webapi.trexsql; + +/** + * Per-source configuration for TrexSQL integration. + * Maps to trexsql.sources.{sourceKey} in application properties. + */ +public class TrexSQLSourceConfig { + + private boolean enabled = false; + private String databaseCode; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDatabaseCode() { + return databaseCode; + } + + public void setDatabaseCode(String databaseCode) { + this.databaseCode = databaseCode; + } +} diff --git a/src/main/java/org/ohdsi/webapi/vocabulary/VocabularySearchProviderType.java b/src/main/java/org/ohdsi/webapi/vocabulary/VocabularySearchProviderType.java index fe1205bf68..09607a3fdf 100644 --- a/src/main/java/org/ohdsi/webapi/vocabulary/VocabularySearchProviderType.java +++ b/src/main/java/org/ohdsi/webapi/vocabulary/VocabularySearchProviderType.java @@ -2,5 +2,6 @@ public enum VocabularySearchProviderType { DATABASE, - SOLR + SOLR, + TREXSQL } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cabbe0ecad..2a2537d2d8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,6 +12,8 @@ logging.level.org.springframework.orm=${logging.level.org.springframework.orm} logging.level.org.springframework.jdbc=${logging.level.org.springframework.jdbc} logging.level.org.apache.shiro=${logging.level.org.apache.shiro} +spring.jackson.serialization.write-dates-as-timestamps=true + #Primary DataSource datasource.driverClassName=${datasource.driverClassName} datasource.url=${datasource.url} @@ -275,4 +277,9 @@ versioning.maxAttempt=${versioning.maxAttempt} audit.trail.enabled=${audit.trail.enabled} audit.trail.log.file=${audit.trail.log.file} audit.trail.log.file.pattern=${audit.trail.log.file.pattern} -audit.trail.log.extraFile=${audit.trail.log.extraFile} \ No newline at end of file +audit.trail.log.extraFile=${audit.trail.log.extraFile} + +# Trexsql configuration +trexsql.enabled=${trexsql.enabled} +trexsql.cache-path=${trexsql.cache-path} +trexsql.extensions-path=${trexsql.extensions-path}