diff --git a/Src/java/cql-to-elm-cli/build.gradle.kts b/Src/java/cql-to-elm-cli/build.gradle.kts index ca47368a8..5e62d536e 100644 --- a/Src/java/cql-to-elm-cli/build.gradle.kts +++ b/Src/java/cql-to-elm-cli/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(project(":qdm")) implementation(project(":model-jaxb")) implementation(project(":elm-jaxb")) + implementation(project(":ucum")) implementation("net.sf.jopt-simple:jopt-simple:4.7") implementation("org.slf4j:slf4j-simple:1.7.36") implementation("org.glassfish.jaxb:jaxb-runtime:4.0.5") diff --git a/Src/java/cql-to-elm/build.gradle.kts b/Src/java/cql-to-elm/build.gradle.kts index 859e00ed6..ee6cb8b55 100644 --- a/Src/java/cql-to-elm/build.gradle.kts +++ b/Src/java/cql-to-elm/build.gradle.kts @@ -6,7 +6,6 @@ dependencies { api(project(":cql")) api(project(":model")) api(project(":elm")) - api("org.fhir:ucum:1.0.8") // TODO: This dependencies are required due the the fact that the CqlTranslatorOptionsMapper lives // in the cql-to-elm project. Ideally, we"d factor out all serialization dependencies into common @@ -17,6 +16,7 @@ dependencies { testImplementation(project(":model-jackson")) testImplementation(project(":quick")) testImplementation(project(":qdm")) + testImplementation(project(":ucum")) testImplementation("com.github.reinert:jjschema:1.16") testImplementation("com.tngtech.archunit:archunit:1.2.1") testImplementation("org.skyscreamer:jsonassert:1.5.1") diff --git a/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/LibraryManager.kt b/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/LibraryManager.kt index a6eea7205..100a3b56d 100644 --- a/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/LibraryManager.kt +++ b/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/LibraryManager.kt @@ -8,14 +8,11 @@ import kotlin.collections.ArrayList import kotlin.collections.HashMap import kotlin.collections.HashSet import org.cqframework.cql.cql2elm.model.CompiledLibrary +import org.cqframework.cql.cql2elm.ucum.UcumService +import org.cqframework.cql.cql2elm.ucum.UcumServiceFactory import org.cqframework.cql.elm.serializing.ElmLibraryReaderFactory -import org.fhir.ucum.UcumEssenceService -import org.fhir.ucum.UcumException -import org.fhir.ucum.UcumService import org.hl7.cql.model.NamespaceManager import org.hl7.elm.r1.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory /** * Manages a set of CQL libraries. As new library references are encountered during compilation, the @@ -39,26 +36,7 @@ constructor( var compiledLibraries: MutableMap = libraryCache ?: HashMap() val librarySourceLoader: LibrarySourceLoader = PriorityLibrarySourceLoader() - var ucumService: UcumService? = null - get() { - if (field == null) { - field = defaultUcumService - } - return field - } - - @get:Synchronized - private val defaultUcumService: UcumService? - get() { - try { - return UcumEssenceService( - UcumEssenceService::class.java.getResourceAsStream("/ucum-essence.xml") - ) - } catch (e: UcumException) { - logger.warn("Error creating shared UcumService", e) - } - return null - } + val ucumService: UcumService by lazy { UcumServiceFactory.load() } /* * A "well-known" library name is one that is allowed to resolve without a @@ -397,7 +375,6 @@ constructor( } companion object { - private val logger: Logger = LoggerFactory.getLogger(LibraryManager::class.java) private val supportedContentTypes: Array = arrayOf(LibraryContentType.JSON, LibraryContentType.XML, LibraryContentType.CQL) } diff --git a/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/ucum/UcumService.kt b/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/ucum/UcumService.kt new file mode 100644 index 000000000..5133ac0fb --- /dev/null +++ b/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/ucum/UcumService.kt @@ -0,0 +1,23 @@ +package org.cqframework.cql.cql2elm.ucum + +import java.math.BigDecimal + +interface UcumService { + /** + * Converts a quantity from one unit to another + * + * @param value the quantity to convert + * @param sourceUnit the unit of the quantity + * @param destUnit the unit to convert to + * @return the converted value in terms of the destination unit + */ + fun convert(value: BigDecimal, sourceUnit: String, destUnit: String): BigDecimal + + /** + * Validate checks that a string is valid ucum unit + * + * @param unit + * @return null if valid, error message if invalid + */ + fun validate(unit: String): String? +} diff --git a/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/ucum/UcumServiceFactory.kt b/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/ucum/UcumServiceFactory.kt new file mode 100644 index 000000000..4f2a1b1bc --- /dev/null +++ b/Src/java/cql-to-elm/src/main/java/org/cqframework/cql/cql2elm/ucum/UcumServiceFactory.kt @@ -0,0 +1,14 @@ +package org.cqframework.cql.cql2elm.ucum + +import java.util.* + +object UcumServiceFactory { + fun load(): UcumService { + return ServiceLoader.load(UcumService::class.java).firstOrNull() + ?: error( + """No UCUM service implementation found. + Please ensure a UCUM service implementation is available on the classpath. + The 'ucum' module is a reference implementation that can be used for this purpose.""" + ) + } +} diff --git a/Src/java/elm-jaxb/build.gradle.kts b/Src/java/elm-jaxb/build.gradle.kts index b66862e63..98110a1ff 100644 --- a/Src/java/elm-jaxb/build.gradle.kts +++ b/Src/java/elm-jaxb/build.gradle.kts @@ -5,5 +5,6 @@ plugins { dependencies { api(project(":elm")) testImplementation(project(":cql-to-elm")) + testImplementation(project(":ucum")) testImplementation(project(":model-jaxb")) } diff --git a/Src/java/engine/build.gradle.kts b/Src/java/engine/build.gradle.kts index 207d66b55..6a3a2e604 100644 --- a/Src/java/engine/build.gradle.kts +++ b/Src/java/engine/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { api(project(":elm")) api(project(":cql-to-elm")) + api(project(":ucum")) api("org.apache.commons:commons-text:1.10.0") testImplementation(project(":model-jackson")) diff --git a/Src/java/engine/src/main/java/org/opencds/cqf/cql/engine/elm/executing/ConvertQuantityEvaluator.java b/Src/java/engine/src/main/java/org/opencds/cqf/cql/engine/elm/executing/ConvertQuantityEvaluator.java index fd89b5ebc..dff6a3271 100644 --- a/Src/java/engine/src/main/java/org/opencds/cqf/cql/engine/elm/executing/ConvertQuantityEvaluator.java +++ b/Src/java/engine/src/main/java/org/opencds/cqf/cql/engine/elm/executing/ConvertQuantityEvaluator.java @@ -1,8 +1,6 @@ package org.opencds.cqf.cql.engine.elm.executing; -import java.math.BigDecimal; -import org.fhir.ucum.Decimal; -import org.fhir.ucum.UcumService; +import org.cqframework.cql.cql2elm.ucum.UcumService; import org.opencds.cqf.cql.engine.exception.InvalidOperatorArgument; import org.opencds.cqf.cql.engine.runtime.Quantity; @@ -37,13 +35,9 @@ public static Object convertQuantity(Object argument, Object unit, UcumService u return null; } try { - Decimal result = ucumService.convert( - new Decimal(String.valueOf(((Quantity) argument).getValue())), - ((Quantity) argument).getUnit(), - (String) unit); - return new Quantity() - .withValue(new BigDecimal(result.asDecimal())) - .withUnit((String) unit); + var result = ucumService.convert( + ((Quantity) argument).getValue(), ((Quantity) argument).getUnit(), (String) unit); + return new Quantity().withValue(result).withUnit((String) unit); } catch (Exception e) { return null; } diff --git a/Src/java/settings.gradle.kts b/Src/java/settings.gradle.kts index e21f50158..173801a6a 100644 --- a/Src/java/settings.gradle.kts +++ b/Src/java/settings.gradle.kts @@ -28,9 +28,9 @@ include( "cql-to-elm", "cql-to-elm-cli", "elm-fhir", + "ucum", "tools:cql-formatter", "tools:cql-parsetree", "tools:xsd-to-modelinfo" ) - - +include("ucum") diff --git a/Src/java/ucum/build.gradle.kts b/Src/java/ucum/build.gradle.kts new file mode 100644 index 000000000..27d9b1912 --- /dev/null +++ b/Src/java/ucum/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("cql.library-conventions") +} + +dependencies { + api(project(":cql-to-elm")) + api("org.fhir:ucum:1.0.8") +} \ No newline at end of file diff --git a/Src/java/ucum/src/main/java/org/cqframework/cql/ucum/DefaultUcumService.kt b/Src/java/ucum/src/main/java/org/cqframework/cql/ucum/DefaultUcumService.kt new file mode 100644 index 000000000..e7e77d470 --- /dev/null +++ b/Src/java/ucum/src/main/java/org/cqframework/cql/ucum/DefaultUcumService.kt @@ -0,0 +1,45 @@ +package org.cqframework.cql.ucum + +import org.cqframework.cql.cql2elm.ucum.UcumService +import org.fhir.ucum.Decimal +import org.fhir.ucum.UcumEssenceService + +@ConsistentCopyVisibility +data class DefaultUcumService private constructor(private val ucumService: UcumService) : + UcumService by ucumService { + constructor() : this(createUcumService()) + + companion object { + private fun createUcumService(): UcumService { + return try { + UcumEssenceService( + UcumEssenceService::class.java.getResourceAsStream("/ucum-essence.xml") + ) + .let { u -> + object : UcumService { + override fun convert( + value: java.math.BigDecimal, + sourceUnit: String, + destUnit: String + ): java.math.BigDecimal { + val ucumValue = Decimal(value.toString()) + val converted = u.convert(ucumValue, sourceUnit, destUnit) + return java.math.BigDecimal(converted.asDecimal()) + } + + override fun validate(unit: String): String? { + return u.validate(unit) + } + } + } + } catch (e: org.fhir.ucum.UcumException) { + throw IllegalStateException( + """Failed to create UCUM service. + Please ensure the 'ucum-essence.xml' file is available on the classpath. + The 'ucum' module is a reference implementation that can be used for this purpose.""", + e + ) + } + } + } +} diff --git a/Src/java/ucum/src/main/resources/META-INF/services/org.cqframework.cql.cql2elm.ucum.UcumService b/Src/java/ucum/src/main/resources/META-INF/services/org.cqframework.cql.cql2elm.ucum.UcumService new file mode 100644 index 000000000..3a8aa491b --- /dev/null +++ b/Src/java/ucum/src/main/resources/META-INF/services/org.cqframework.cql.cql2elm.ucum.UcumService @@ -0,0 +1 @@ +org.cqframework.cql.ucum.DefaultUcumService \ No newline at end of file diff --git a/Src/java/ucum/src/test/java/org/cqframework/cql/ucum/DefaultUcumServiceTest.kt b/Src/java/ucum/src/test/java/org/cqframework/cql/ucum/DefaultUcumServiceTest.kt new file mode 100644 index 000000000..4ed58c8f8 --- /dev/null +++ b/Src/java/ucum/src/test/java/org/cqframework/cql/ucum/DefaultUcumServiceTest.kt @@ -0,0 +1,24 @@ +package org.cqframework.cql.ucum + +import java.math.BigDecimal +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DefaultUcumServiceTest { + + @Test + fun testConvert() { + val ucumService = DefaultUcumService() + val result = ucumService.convert(BigDecimal("1"), "mg", "g") + assertEquals(BigDecimal("0.0010"), result) + } + + @Test + fun testValidate() { + val ucumService = DefaultUcumService() + assertNull(ucumService.validate("mg")) + assertTrue(ucumService.validate("foo")?.contains("The unit 'foo' is unknown") ?: false) + } +}