diff --git a/.gitignore b/.gitignore index 84746631..e25a829e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ docpath.txt *.log *.log.* +LOG_FILE_IS_UNDEFINED .idea *.iml *.vsix diff --git a/CHANGELOG.md b/CHANGELOG.md index 138587e7..bdbfe7fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,30 @@ ## v4.5.0 -Date: 2026-04-08 +Date: 2026-04-15 + +### Execute CQL + +Adds a JSON-RPC–based Execute CQL command (`org.opencds.cqf.cql.ls.executeCql`) that +replaces the previous CLI-based approach. + +* Accepts a structured `ExecuteCqlRequest` with per-library FHIR model paths, terminology URI, + context, and user-defined parameters +* Evaluates all test cases for a library in a single batch (compile once, evaluate per patient) +* `CqlEvaluator` — new evaluator that builds a shared engine per batch and runs each patient context +* `DelegatingRepository` — mutable repository wrapper that swaps the patient bundle between evaluations +* `ExecuteCqlCommandContribution` — registers the command and dispatches to `CqlEvaluator` +* Removed `CliCommand` and `CqlCommand` (picocli CLI) — no longer needed +* Returns structured JSON: expression results, server-side logs, and used-default-parameter metadata + +### Compilation Cache + Surgical Invalidation + +* `CqlCompilationManager` now caches compiled `CqlCompiler` results per source URI +* Reverse dependency index tracks which URIs depend on each library identifier +* `invalidate(uri)` evicts the URI and all dependents from the cache +* `DiagnosticsService.didChangeWatchedFiles` triggers surgical invalidation on `.cql` file changes +* `IgStandardRepository` adds a `typeResourceCache` — caches directory scans per resource + type/compartment combination, eliminating redundant filesystem walks across patients in a batch ### Module Consolidation diff --git a/ls/server/pom.xml b/ls/server/pom.xml index 33574115..8d9dc588 100644 --- a/ls/server/pom.xml +++ b/ls/server/pom.xml @@ -12,7 +12,7 @@ org.opencds.cqf.cql.ls cql-ls - 4.5.0-SNAPSHOT + 4.5.0 ../../pom.xml @@ -88,11 +88,6 @@ eventbus-java - - info.picocli - picocli - - jakarta.annotation @@ -153,31 +148,17 @@ + + + org.apache.maven.plugins + maven-checkstyle-plugin + + true + + - - org.jetbrains.kotlin - kotlin-maven-plugin - - - kapt - kapt - - - ${project.basedir}/src/main/kotlin - - - - info.picocli - picocli-codegen - ${picocli.version} - - - - - - org.apache.maven.plugins maven-compiler-plugin @@ -192,13 +173,6 @@ -Werror -implicit:none - - - info.picocli - picocli-codegen - ${picocli.version} - - diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CliCommand.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CliCommand.kt deleted file mode 100644 index b7c08de8..00000000 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CliCommand.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.opencds.cqf.cql.ls.server.command - -import org.opencds.cqf.cql.ls.server.manager.IgContextManager -import picocli.CommandLine.Command - -@Command(subcommands = [CqlCommand::class]) -class CliCommand(val igContextManager: IgContextManager) diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommand.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommand.kt deleted file mode 100644 index 9d3f9ea9..00000000 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommand.kt +++ /dev/null @@ -1,319 +0,0 @@ -package org.opencds.cqf.cql.ls.server.command - -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum -import ca.uhn.fhir.repository.IRepository -import kotlinx.io.files.Path -import org.cqframework.cql.cql2elm.CqlTranslatorOptions -import org.cqframework.cql.cql2elm.DefaultLibrarySourceProvider -import org.cqframework.cql.cql2elm.DefaultModelInfoProvider -import org.cqframework.fhir.npm.NpmProcessor -import org.cqframework.fhir.utilities.IGContext -import org.hl7.elm.r1.VersionedIdentifier -import org.hl7.fhir.instance.model.api.IBase -import org.hl7.fhir.instance.model.api.IBaseDatatype -import org.hl7.fhir.instance.model.api.IBaseResource -import org.hl7.fhir.r5.context.ILoggingService -import org.opencds.cqf.cql.ls.core.utility.Uris -import org.opencds.cqf.cql.ls.server.repository.ig.standard.IgStandardRepository -import org.opencds.cqf.fhir.cql.CqlOptions -import org.opencds.cqf.fhir.cql.Engines -import org.opencds.cqf.fhir.cql.EvaluationSettings -import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings -import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.PROFILE_MODE -import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.SEARCH_FILTER_MODE -import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.TERMINOLOGY_FILTER_MODE -import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings -import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.CODE_LOOKUP_MODE -import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_EXPANSION_MODE -import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_MEMBERSHIP_MODE -import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_PRE_EXPANSION_MODE -import org.opencds.cqf.fhir.utility.repository.ProxyRepository -import org.slf4j.LoggerFactory -import picocli.CommandLine -import picocli.CommandLine.ArgGroup -import picocli.CommandLine.Command -import picocli.CommandLine.Option -import java.nio.file.Paths -import java.util.concurrent.Callable - -@Command(name = "cql", mixinStandardHelpOptions = true) -class CqlCommand : Callable { - companion object { - private val log = LoggerFactory.getLogger(CqlCommand::class.java) - } - - @Option(names = ["-fv", "--fhir-version"], required = true) - var fhirVersion: String = "" - - @Option(names = ["-op", "--options-path"]) - var optionsPath: String? = null - - @ArgGroup(multiplicity = "0..1", exclusive = false) - var namespace: NamespaceParameter? = null - - class NamespaceParameter { - @Option(names = ["-nn", "--namespace-name"]) - var namespaceName: String? = null - - @Option(names = ["-nu", "--namespace-uri"]) - var namespaceUri: String? = null - } - - @Option(names = ["-rd", "--root-dir"]) - var rootDir: String? = null - - @Option(names = ["-ig", "--ig-path"]) - var igPath: String? = null - - @ArgGroup(multiplicity = "1..*", exclusive = false) - var libraries: MutableList = mutableListOf() - - class LibraryParameter { - @Option(names = ["-lu", "--library-url"], required = true) - var libraryUrl: String? = null - - @Option(names = ["-ln", "--library-name"], required = true) - var libraryName: String = "" - - @Option(names = ["-lv", "--library-version"]) - var libraryVersion: String? = null - - @Option(names = ["-t", "--terminology-url"]) - var terminologyUrl: String? = null - - @ArgGroup(multiplicity = "0..1", exclusive = false) - var model: ModelParameter? = null - - @ArgGroup(multiplicity = "0..*", exclusive = false) - var parameters: MutableList = mutableListOf() - - @Option(names = ["-e", "--expression"]) - var expression: Array? = null - - @ArgGroup(multiplicity = "0..1", exclusive = false) - var context: ContextParameter? = null - - class ContextParameter { - @Option(names = ["-c", "--context"]) - var contextName: String? = null - - @Option(names = ["-cv", "--context-value"]) - var contextValue: String? = null - } - - class ModelParameter { - @Option(names = ["-m", "--model"]) - var modelName: String? = null - - @Option(names = ["-mu", "--model-url"]) - var modelUrl: String? = null - } - - class ParameterParameter { - @Option(names = ["-p", "--parameter"]) - var parameterName: String? = null - - @Option(names = ["-pv", "--parameter-value"]) - var parameterValue: String? = null - } - } - - @Suppress("removal") // TODO: Missed a spot upstream in the CQL library - private class Logger : ILoggingService { - private val log = LoggerFactory.getLogger(Logger::class.java) - - override fun logMessage(s: String) { - log.warn(s) - } - - override fun logDebugMessage( - logCategory: ILoggingService.LogCategory, - s: String, - ) { - log.debug("{}: {}", logCategory, s) - } - - @Suppress("OVERRIDE_DEPRECATION") - override fun isDebugLogging(): Boolean = log.isDebugEnabled - } - - private fun toVersionNumber(fhirVersion: FhirVersionEnum): String { - return when (fhirVersion) { - FhirVersionEnum.R4 -> "4.0.1" - FhirVersionEnum.R5 -> "5.0.0-ballot" - FhirVersionEnum.DSTU3 -> "3.0.2" - else -> throw IllegalArgumentException("Unsupported FHIR version $fhirVersion") - } - } - - @CommandLine.ParentCommand - private var parentCommand: CliCommand? = null - - override fun call(): Int { - val fhirVersionEnum = FhirVersionEnum.valueOf(fhirVersion) - val fhirContext = FhirContext.forCached(fhirVersionEnum) - - var igContext: IGContext? = null - var npmProcessor: NpmProcessor? = null - if (rootDir != null && igPath != null) { - igContext = IGContext(Logger()) - igContext.initializeFromIg(rootDir, igPath, toVersionNumber(fhirVersionEnum)) - } else if (parentCommand != null && rootDir != null) { - val pc = parentCommand - val rd = rootDir - if (pc != null && rd != null) { - val rootUri = Uris.parseOrNull(rd) - val inputUri = rootUri?.let { Uris.addPath(it, "input") } - val cqlUri = inputUri?.let { Uris.addPath(it, "cql") } - npmProcessor = cqlUri?.let { pc.igContextManager.getContext(it) } - } - if (npmProcessor != null) { - igContext = npmProcessor.igContext - } - } - - if (npmProcessor == null) { - npmProcessor = NpmProcessor(igContext) - } - - val cqlOptions = CqlOptions.defaultOptions() - - val optionsPathVal = optionsPath - if (optionsPathVal != null) { - val optUri = - Uris.parseOrNull(optionsPathVal) - ?: run { - log.warn("Could not parse options path: $optionsPathVal") - return 1 - } - val op = Path(Paths.get(optUri).toString()) - val options = CqlTranslatorOptions.fromFile(Path(op)) - cqlOptions.setCqlCompilerOptions(options.cqlCompilerOptions) - } - - val terminologySettings = - TerminologySettings().apply { - setValuesetExpansionMode(VALUESET_EXPANSION_MODE.PERFORM_NAIVE_EXPANSION) - setValuesetPreExpansionMode(VALUESET_PRE_EXPANSION_MODE.USE_IF_PRESENT) - setValuesetMembershipMode(VALUESET_MEMBERSHIP_MODE.USE_EXPANSION) - setCodeLookupMode(CODE_LOOKUP_MODE.USE_CODESYSTEM_URL) - } - - val retrieveSettings = - RetrieveSettings().apply { - setTerminologyParameterMode(TERMINOLOGY_FILTER_MODE.FILTER_IN_MEMORY) - setSearchParameterMode(SEARCH_FILTER_MODE.FILTER_IN_MEMORY) - setProfileMode(PROFILE_MODE.DECLARED) - } - - val evaluationSettings = - EvaluationSettings.getDefault().apply { - setCqlOptions(cqlOptions) - setTerminologySettings(terminologySettings) - setRetrieveSettings(retrieveSettings) - setNpmProcessor(npmProcessor) - } - - for (library in libraries) { - // Paths are mixed types - // IgStandardRepository uses java nio path objects - // DefaultLibraryServiceProvider uses kotlin path objects - // Until the language server can be ported to kotlin, the differences will exist - val libraryUrlVal = library.libraryUrl - val libraryUri = if (libraryUrlVal != null) Uris.parseOrNull(libraryUrlVal) else null - - val libraryKotlinPath = if (libraryUri != null) Path(Paths.get(libraryUri).toString()) else null - - val modelPath = library.model?.modelUrl?.let { Uris.parseOrNull(it)?.let { u -> Paths.get(u) } } - - val terminologyUrl = library.terminologyUrl - val terminologyPath = terminologyUrl?.let { Uris.parseOrNull(it)?.let { u -> Paths.get(u) } } - - val repository = createRepository(fhirContext, terminologyPath, modelPath) - - val engine = Engines.forRepository(repository, evaluationSettings) - - val kPath = libraryKotlinPath - if (library.libraryUrl != null && kPath != null) { - val provider = DefaultLibrarySourceProvider(kPath) - engine.environment - .libraryManager?.librarySourceLoader - ?.registerProvider(provider) - - val modelProvider = DefaultModelInfoProvider(kPath) - engine.environment - .libraryManager?.modelManager - ?.modelInfoLoader - ?.registerModelInfoProvider(modelProvider) - } - - val identifier = VersionedIdentifier().withId(library.libraryName) - - val contextParameter: org.apache.commons.lang3.tuple.Pair? = - library.context?.let { ctx -> - org.apache.commons.lang3.tuple.Pair.of(ctx.contextName, ctx.contextValue) - } - - val expressions = library.expression?.toSet() - val result = - if (expressions != null) { - engine.evaluate(identifier, expressions, contextParameter) - } else { - engine.evaluate(identifier, contextParameter) - } - - writeResult(result) - } - - return 0 - } - - private fun createRepository( - fhirContext: FhirContext, - terminologyPath: java.nio.file.Path?, - modelPath: java.nio.file.Path?, - ): IRepository { - if (terminologyPath == null && modelPath == null) { - return NoOpRepository(fhirContext) - } - - val data: IRepository = if (modelPath != null) IgStandardRepository(fhirContext, modelPath) else NoOpRepository(fhirContext) - val terminology: IRepository = - if (terminologyPath != null) { - IgStandardRepository( - fhirContext, - terminologyPath, - ) - } else { - NoOpRepository(fhirContext) - } - - return ProxyRepository(data, data, terminology) - } - - @Suppress("java:S106") // We are intending to output to the console here as a CLI tool - private fun writeResult(result: org.opencds.cqf.cql.engine.execution.EvaluationResult) { - for ((key, value) in result.expressionResults) { - println("$key=${tempConvert(value?.value())}") - } - println() - } - - private fun tempConvert(value: Any?): String { - if (value == null) return "null" - - return when (value) { - is Iterable<*> -> { - val items = value.joinToString(", ") { tempConvert(it) } - "[$items]" - } - is IBaseResource -> - value.fhirType() + - if (value.idElement != null && value.idElement.hasIdPart()) "(id=${value.idElement.idPart})" else "" - is IBase -> value.fhirType() - is IBaseDatatype -> value.fhirType() - else -> value.toString() - } - } -} diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt new file mode 100644 index 00000000..fea7682c --- /dev/null +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/CqlEvaluator.kt @@ -0,0 +1,546 @@ +package org.opencds.cqf.cql.ls.server.command + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.repository.IRepository +import kotlinx.io.files.Path +import org.cqframework.cql.cql2elm.CqlTranslatorOptions +import org.cqframework.cql.cql2elm.DefaultLibrarySourceProvider +import org.cqframework.cql.cql2elm.DefaultModelInfoProvider +import org.cqframework.cql.cql2elm.model.CompiledLibrary +import org.cqframework.fhir.npm.NpmProcessor +import org.cqframework.fhir.utilities.IGContext +import org.hl7.elm.r1.VersionedIdentifier +import org.hl7.fhir.instance.model.api.IBase +import org.hl7.fhir.instance.model.api.IBaseDatatype +import org.hl7.fhir.instance.model.api.IBaseResource +import org.hl7.fhir.r5.context.ILoggingService +import org.opencds.cqf.cql.ls.core.ContentService +import org.opencds.cqf.cql.ls.core.utility.Uris +import org.opencds.cqf.cql.ls.server.manager.IgContextManager +import org.opencds.cqf.cql.ls.server.provider.ContentServiceModelInfoProvider +import org.opencds.cqf.cql.ls.server.provider.ContentServiceSourceProvider +import org.opencds.cqf.cql.ls.server.repository.ig.standard.IgStandardRepository +import org.opencds.cqf.fhir.cql.CqlOptions +import org.opencds.cqf.fhir.cql.Engines +import org.opencds.cqf.fhir.cql.EvaluationSettings +import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings +import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.PROFILE_MODE +import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.SEARCH_FILTER_MODE +import org.opencds.cqf.fhir.cql.engine.retrieve.RetrieveSettings.TERMINOLOGY_FILTER_MODE +import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings +import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.CODE_LOOKUP_MODE +import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_EXPANSION_MODE +import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_MEMBERSHIP_MODE +import org.opencds.cqf.fhir.cql.engine.terminology.TerminologySettings.VALUESET_PRE_EXPANSION_MODE +import org.opencds.cqf.fhir.utility.repository.FederatedRepository +import org.opencds.cqf.fhir.utility.repository.ProxyRepository +import org.opencds.cqf.cql.engine.runtime.Date as CqlDate +import org.opencds.cqf.cql.engine.runtime.DateTime as CqlDateTime +import org.opencds.cqf.cql.engine.runtime.Interval as CqlInterval +import org.opencds.cqf.cql.engine.runtime.Quantity as CqlQuantity +import org.opencds.cqf.cql.engine.runtime.Time as CqlTime +import org.slf4j.LoggerFactory +import java.math.BigDecimal +import java.nio.file.Files +import java.nio.file.Paths +import java.time.ZoneOffset +import java.util.concurrent.ConcurrentHashMap + +object CqlEvaluator { + private val log = LoggerFactory.getLogger(CqlEvaluator::class.java) + + private fun heapStats(): String { + val rt = Runtime.getRuntime() + val usedMB = (rt.totalMemory() - rt.freeMemory()) / 1_048_576 + val maxMB = rt.maxMemory() / 1_048_576 + return "heap=${usedMB}MB/${maxMB}MB" + } + + @Suppress("removal") // TODO: Missed a spot upstream in the CQL library + private class Logger : ILoggingService { + private val log = LoggerFactory.getLogger(Logger::class.java) + + override fun logMessage(s: String) { + log.warn(s) + } + + override fun logDebugMessage( + logCategory: ILoggingService.LogCategory, + s: String, + ) { + log.debug("{}: {}", logCategory, s) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun isDebugLogging(): Boolean = log.isDebugEnabled + } + + // Matches CQL interval literals: Interval[/( low, high ]/). Non-greedy to handle null endpoints. + private val intervalLiteralRegex = Regex("""^Interval([\[(])\s*(.*?)\s*,\s*(.*?)\s*([\])])$""") + + // Matches CQL quantity literals: e.g. 5 'mg', 1.5 'd' + private val quantityLiteralRegex = Regex("""^(\d+(?:\.\d+)?)\s+'([^']*)'$""") + + /** Strips the leading {@code @} from a CQL temporal literal and constructs a [CqlDateTime]. */ + private fun parseCqlDateTimeValue(literal: String): CqlDateTime = + CqlDateTime(literal.trimStart('@'), ZoneOffset.UTC) + + /** Strips the leading {@code @} from a CQL date literal and constructs a [CqlDate]. */ + private fun parseCqlDateValue(literal: String): CqlDate = + CqlDate(literal.trimStart('@')) + + /** + * Strips the leading {@code @} from a CQL time literal and constructs a [CqlTime]. + * The engine's [CqlTime] string constructor handles the {@code T} prefix. + */ + private fun parseCqlTimeValue(literal: String): CqlTime = + CqlTime(literal.trimStart('@')) + + /** + * Parses a CQL quantity literal ({@code 5 'mg'}) into a [CqlQuantity]. + * Throws [IllegalArgumentException] if the format does not match. + */ + private fun parseCqlQuantityValue(literal: String): CqlQuantity { + val match = + quantityLiteralRegex.find(literal.trim()) + ?: throw IllegalArgumentException("Expected format: '', got '$literal'") + return CqlQuantity().withValue(BigDecimal(match.groupValues[1])).withUnit(match.groupValues[2]) + } + + /** + * Parses a CQL interval literal (e.g. {@code Interval[@2024-01-01, @2024-12-31)}) into a + * [CqlInterval] whose endpoints are native CQL runtime objects. Falls back to the raw string + * and logs a warning if the literal cannot be parsed. + * + * @param pointType either {@code "datetime"} or {@code "date"} — used to pick the endpoint parser + */ + private fun parseCqlIntervalValue( + paramName: String, + value: String, + pointType: String, + ): Any { + val match = + intervalLiteralRegex.find(value.trim()) + ?: run { + log.warn( + "Parameter '$paramName': could not parse interval literal '$value'. Passing as String.", + ) + return value + } + val lowClosed = match.groupValues[1] == "[" + val highClosed = match.groupValues[4] == "]" + + fun parseEndpoint(s: String): Any? { + if (s.equals("null", ignoreCase = true)) return null + return try { + when (pointType) { + "datetime" -> parseCqlDateTimeValue(s) + "date" -> parseCqlDateValue(s) + else -> s + } + } catch (e: Exception) { + log.warn( + "Parameter '$paramName': could not parse interval endpoint '$s' as $pointType: ${e.message}. Passing as String.", + ) + s + } + } + + return CqlInterval(parseEndpoint(match.groupValues[2]), lowClosed, parseEndpoint(match.groupValues[3]), highClosed) + } + + private fun coerceParameters(parameters: List): MutableMap { + val result = mutableMapOf() + for (param in parameters) { + val value: Any? = + when (param.parameterType?.lowercase()) { + "integer" -> + param.parameterValue.toIntOrNull() + ?: run { + log.warn( + "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Integer. Passing as String.", + ) + param.parameterValue + } + "decimal" -> + param.parameterValue.toBigDecimalOrNull() + ?: run { + log.warn( + "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Decimal. Passing as String.", + ) + param.parameterValue + } + "boolean" -> + param.parameterValue.toBooleanStrictOrNull() + ?: run { + log.warn( + "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Boolean. Passing as String.", + ) + param.parameterValue + } + "datetime" -> + try { + parseCqlDateTimeValue(param.parameterValue) + } catch (e: Exception) { + log.warn( + "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as DateTime: ${e.message}. Passing as String.", + ) + param.parameterValue + } + "date" -> + try { + parseCqlDateValue(param.parameterValue) + } catch (e: Exception) { + log.warn( + "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Date: ${e.message}. Passing as String.", + ) + param.parameterValue + } + "time" -> + try { + parseCqlTimeValue(param.parameterValue) + } catch (e: Exception) { + log.warn( + "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Time: ${e.message}. Passing as String.", + ) + param.parameterValue + } + "quantity" -> + try { + parseCqlQuantityValue(param.parameterValue) + } catch (e: Exception) { + log.warn( + "Parameter '${param.parameterName}': could not parse '${param.parameterValue}' as Quantity: ${e.message}. Passing as String.", + ) + param.parameterValue + } + "interval" -> parseCqlIntervalValue(param.parameterName ?: "", param.parameterValue, "datetime") + "interval" -> parseCqlIntervalValue(param.parameterName ?: "", param.parameterValue, "date") + "string", null -> param.parameterValue + else -> { + log.warn( + "Parameter '${param.parameterName}': type '${param.parameterType}' is not a recognised CQL type. Passing as String.", + ) + param.parameterValue + } + } + result[param.parameterName] = value + } + return result + } + + private fun tempConvert(value: Any?): String { + if (value == null) return "null" + + return when (value) { + is Iterable<*> -> { + val items = value.joinToString(", ") { tempConvert(it) } + "[$items]" + } + is IBaseResource -> + value.fhirType() + + if (value.idElement != null && value.idElement.hasIdPart()) "(id=${value.idElement.idPart})" else "" + is IBase -> value.fhirType() + is IBaseDatatype -> value.fhirType() + else -> value.toString() + } + } + + private fun buildCqlOptions(optionsPath: String?): CqlOptions { + val cqlOptions = CqlOptions.defaultOptions() + if (optionsPath != null) { + val op = Path(Paths.get(Uris.parseOrNull(optionsPath)!!).toString()) + val options = CqlTranslatorOptions.fromFile(Path(op)) + cqlOptions.setCqlCompilerOptions(options.cqlCompilerOptions) + } + return cqlOptions + } + + private fun buildEvaluationSettings( + cqlOptions: CqlOptions, + npmProcessor: NpmProcessor, + ): EvaluationSettings { + val terminologySettings = + TerminologySettings().apply { + setValuesetExpansionMode(VALUESET_EXPANSION_MODE.PERFORM_NAIVE_EXPANSION) + setValuesetPreExpansionMode(VALUESET_PRE_EXPANSION_MODE.USE_IF_PRESENT) + setValuesetMembershipMode(VALUESET_MEMBERSHIP_MODE.USE_EXPANSION) + setCodeLookupMode(CODE_LOOKUP_MODE.USE_CODESYSTEM_URL) + } + + val retrieveSettings = + RetrieveSettings().apply { + setTerminologyParameterMode(TERMINOLOGY_FILTER_MODE.FILTER_IN_MEMORY) + setSearchParameterMode(SEARCH_FILTER_MODE.FILTER_IN_MEMORY) + setProfileMode(PROFILE_MODE.DECLARED) + } + + return EvaluationSettings.getDefault().apply { + setCqlOptions(cqlOptions) + setTerminologySettings(terminologySettings) + setRetrieveSettings(retrieveSettings) + setNpmProcessor(npmProcessor) + } + } + + /** + * Evaluates all libraries in the request. Libraries sharing the same libraryName, + * libraryUri, terminologyUri, and optionsPath are evaluated in a single batch using + * one engine — CQL is compiled once and the [LibraryManager] cache is reused for all + * subsequent patients in the batch. + * + * Across batches, two resources are shared when their discriminating key matches: + * - [IgStandardRepository] (terminology): keyed by terminologyUri — the ValueSet + * directory is scanned at most once per unique path for the entire request. + * - [EvaluationSettings] (libraryCache): keyed by optionsPath — compiled ELM for + * helper libraries (FHIRHelpers, QICore) produced by batch N is reused by batch N+1 + * without recompilation. Safe because [VersionedIdentifier] includes the library's + * own namespace URI, so libraries with the same name+version always share the same + * compiled ELM regardless of which source path they were loaded from. + */ + fun evaluate( + request: ExecuteCqlRequest, + contentService: ContentService, + igContextManager: IgContextManager, + ): ExecuteCqlResponse { + val fhirContext = FhirContext.forCached(FhirVersionEnum.valueOf(request.fhirVersion)) + + var igContext: IGContext? = null + var npmProcessor: NpmProcessor? = null + val rootDir = request.rootDir + if (rootDir != null) { + npmProcessor = + igContextManager.getContext( + Uris.addPath(Uris.addPath(Uris.parseOrNull(rootDir)!!, "input")!!, "cql")!!, + ) + if (npmProcessor != null) { + igContext = npmProcessor.igContext + } + } + + if (npmProcessor == null) { + npmProcessor = NpmProcessor(igContext) + } + + // Group by batch key — preserves insertion order via LinkedHashMap so results come + // back in the same order they were sent. + val grouped = LinkedHashMap>() + for (lib in request.libraries) { + val key = "${lib.libraryName}|${lib.libraryUri}|${lib.terminologyUri}|${request.optionsPath}" + grouped.getOrPut(key) { mutableListOf() }.add(lib) + } + + // Shared across batches — created once per unique key for the lifetime of this request. + // terminologyRepos: the IgStandardRepository.typeResourceCache on a shared instance + // means the ValueSet directory is scanned at most once per unique path. + // libraryCaches: compiled ELM (FHIRHelpers, QICore, etc.) produced by one batch is + // reused by subsequent batches without recompilation. Safe because VersionedIdentifier + // includes the library's own namespace URI, so same name+version always means same ELM. + // Each batch gets its own fresh EvaluationSettings; only the libraryCache map is injected + // as shared so that valueSetCache, modelCache, etc. remain independent per batch. + val terminologyRepos = mutableMapOf() + val libraryCaches = mutableMapOf>() + + val allResults = + grouped.values.flatMap { batch -> + val terminologyUri = batch.first().terminologyUri + val terminologyRepo = + terminologyRepos.getOrPut(terminologyUri) { + val path = terminologyUri?.let { Paths.get(Uris.parseOrNull(it)!!) } + if (path != null) { + IgStandardRepository(fhirContext, path) + } else { + NoOpRepository(fhirContext) + } + } + val sharedLibraryCache = libraryCaches.getOrPut(request.optionsPath) { ConcurrentHashMap() } + val evaluationSettings = + buildEvaluationSettings( + buildCqlOptions(request.optionsPath), + NpmProcessor(igContext), + ).withLibraryCache(sharedLibraryCache) + evaluateBatch(batch, fhirContext, npmProcessor, contentService, terminologyRepo, evaluationSettings, sharedLibraryCache) + } + + return ExecuteCqlResponse(allResults, emptyList()) + } + + /** + * Evaluates a batch of [LibraryRequest]s that all refer to the same CQL library. + * One engine is created for the batch; CQL is compiled on the first call and the + * [LibraryManager] cache serves all subsequent patients without recompilation. + * + * Terminology is loaded once and shared across all patients. Per-patient data is + * isolated by swapping [DelegatingRepository.current] before each evaluate call. + * If a `shared/` sibling directory exists alongside the patient UUID directories, + * its resources are made available as a fallback via [FederatedRepository]. + */ + private fun evaluateBatch( + batch: List, + fhirContext: FhirContext, + npmProcessor: NpmProcessor, + contentService: ContentService, + terminologyRepo: IRepository, + evaluationSettings: EvaluationSettings, + libraryCache: ConcurrentHashMap, + ): List { + val first = batch.first() + val batchStart = System.currentTimeMillis() + + // Shared data directory — sibling of patient UUID dirs, e.g. "shared/". + // Loaded once; individual patient repos fall back to it via FederatedRepository. + val firstModelPath = first.model?.modelUri?.let { Paths.get(Uris.parseOrNull(it)!!) } + val sharedDataPath = + firstModelPath?.parent?.resolve("shared") + ?.takeIf { Files.isDirectory(it) } + val sharedDataRepo: IRepository? = sharedDataPath?.let { IgStandardRepository(fhirContext, it) } + + // Mutable data slot — swapped per patient before each engine.evaluate() call. + val delegatingData = DelegatingRepository(NoOpRepository(fhirContext)) + val compositeRepo = ProxyRepository(delegatingData, delegatingData, terminologyRepo) + + val engineStart = System.currentTimeMillis() + val engine = Engines.forRepository(compositeRepo, evaluationSettings) + val setupMs = System.currentTimeMillis() - batchStart + val engineMs = System.currentTimeMillis() - engineStart + log.debug( + "[PERF] ${first.libraryName} batch setup (options+settings+repos+engine): ${setupMs}ms" + + " (engine only: ${engineMs}ms) ${heapStats()}", + ) + + // Register source / model providers once for the entire batch. + val libraryUri = first.libraryUri?.let { Uris.parseOrNull(it) } + val libraryKotlinPath = libraryUri?.let { Path(Paths.get(it).toString()) } + + if (libraryUri != null) { + engine.environment.libraryManager!!.librarySourceLoader.registerProvider( + ContentServiceSourceProvider(libraryUri, contentService), + ) + engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider( + ContentServiceModelInfoProvider(libraryUri, contentService), + ) + } else if (libraryKotlinPath != null) { + engine.environment.libraryManager!!.librarySourceLoader.registerProvider( + DefaultLibrarySourceProvider(libraryKotlinPath), + ) + engine.environment.libraryManager!!.modelManager.modelInfoLoader.registerModelInfoProvider( + DefaultModelInfoProvider(libraryKotlinPath), + ) + } + + val identifier = VersionedIdentifier().withId(first.libraryName) + val results = mutableListOf() + var patientIndex = 0 + + for (library in batch) { + val patientStart = System.currentTimeMillis() + patientIndex++ + try { + // Per-patient data repo — isolated to prevent resource ID collisions across patients. + val modelPath = library.model?.modelUri?.let { Paths.get(Uris.parseOrNull(it)!!) } + val patientRepo: IRepository = + if (modelPath != null) { + IgStandardRepository(fhirContext, modelPath) + } else { + NoOpRepository(fhirContext) + } + val repoCreatedAt = System.currentTimeMillis() + + // Swap in the patient repo (with shared fallback if available). + delegatingData.current = + if (sharedDataRepo != null) { + FederatedRepository(patientRepo, sharedDataRepo) + } else { + patientRepo + } + + val contextParameter: org.apache.commons.lang3.tuple.Pair? = + if (library.context != null) { + org.apache.commons.lang3.tuple.Pair.of( + library.context.contextName, + library.context.contextValue as Any?, + ) + } else { + null + } + + val coercedParams = coerceParameters(library.parameters) + val result = + engine.evaluate( + identifier, + expressions = null, + contextParameter = contextParameter, + parameters = coercedParams, + ) + val evaluatedAt = System.currentTimeMillis() + val repoMs = repoCreatedAt - patientStart + val evalMs = evaluatedAt - repoCreatedAt + val totalMs = evaluatedAt - patientStart + log.debug( + "[PERF] patient $patientIndex/${batch.size} (${library.context?.contextValue}): " + + "repoCreate=${repoMs}ms evaluate=${evalMs}ms total=${totalMs}ms ${heapStats()}", + ) + + val expressions = + result.expressionResults.map { (key, value) -> + ExpressionResult(key ?: "", tempConvert(value?.value())) + } + + // Detect parameters declared in the CQL library that were not supplied via config + // and therefore fell back to their CQL default value. After engine.evaluate() + // the resolved value is cached in engine.state.parameters under the key + // "${libraryId}.${paramName}" (set by ParameterRefEvaluator on first access). + // + // We look up the compiled library from the shared cache by name rather than + // calling resolveLibrary(identifier), because the engine stores the compiled library + // under its actual versioned identifier (e.g. id="MyLib", version="1.0.0"). An + // unversioned VersionedIdentifier lookup would miss the cached entry, potentially + // triggering a recompile that silently fails — causing usedDefaultParameters to be + // empty for any CQL library that declares a version. + val usedDefaultParameters: List = + try { + val compiledLib = + libraryCache.entries + .firstOrNull { it.key.id == identifier.id } + ?.value + compiledLib + ?.library + ?.parameters + ?.def + ?.filter { paramDef -> + paramDef.name != null && + paramDef.default != null && + !coercedParams.containsKey(paramDef.name) + } + ?.map { paramDef -> + val resolvedValue = + engine.state.parameters["${identifier.id}.${paramDef.name}"] + ?: engine.state.parameters[paramDef.name] + DefaultParameterResult(paramDef.name!!, tempConvert(resolvedValue)) + } + ?: emptyList() + } catch (e: Exception) { + log.debug("Could not inspect default parameters for ${library.libraryName}: ${e.message}") + emptyList() + } + + results.add(LibraryResult(library.libraryName, expressions, usedDefaultParameters)) + } catch (e: Exception) { + log.error("Error evaluating library ${library.libraryName} for context ${library.context?.contextValue}", e) + results.add( + LibraryResult( + library.libraryName, + listOf(ExpressionResult("Error", e.message ?: e.javaClass.simpleName)), + ), + ) + } + } + val batchTotalMs = System.currentTimeMillis() - batchStart + log.debug( + "[PERF] ${first.libraryName} batch total: ${batchTotalMs}ms (${batch.size} patients) ${heapStats()}", + ) + + return results + } +} diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContribution.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContribution.kt deleted file mode 100644 index 28c361d2..00000000 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContribution.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.opencds.cqf.cql.ls.server.command - -import com.google.gson.JsonElement -import org.eclipse.lsp4j.ExecuteCommandParams -import org.opencds.cqf.cql.ls.server.manager.IgContextManager -import org.opencds.cqf.cql.ls.server.plugin.CommandContribution -import picocli.CommandLine -import java.io.ByteArrayOutputStream -import java.io.FileDescriptor -import java.io.FileOutputStream -import java.io.PrintStream -import java.util.concurrent.CompletableFuture - -// TODO: This will be moved to the debug plugin once that's more fully baked.. -class DebugCqlCommandContribution(private val igContextManager: IgContextManager) : CommandContribution { - companion object { - // TODO: Delete once the plugin is fully supported - const val START_DEBUG_COMMAND = "org.opencds.cqf.cql.ls.plugin.debug.startDebugSession" - } - - override fun getCommands(): Set = setOf(START_DEBUG_COMMAND) - - override fun executeCommand(params: ExecuteCommandParams): CompletableFuture = - when (params.command) { - START_DEBUG_COMMAND -> executeCql(params) - else -> super.executeCommand(params) - } - - private fun executeCql(params: ExecuteCommandParams): CompletableFuture { - try { - val arguments = - params.arguments - .mapNotNull { it as? JsonElement } - .map { it.asString } - - // Temporarily redirect std out, because uh... I didn't do that very smart. - val baosOut = ByteArrayOutputStream() - System.setOut(PrintStream(baosOut)) - - val baosErr = ByteArrayOutputStream() - System.setErr(PrintStream(baosErr)) - - try { - val cli = CommandLine(CliCommand(igContextManager)) - cli.execute(*arguments.toTypedArray()) - } catch (e: Exception) { - System.err.println("Exception occurred attempting to evaluate:") - System.err.println(e.message) - } - - var out = baosOut.toString() - val err = baosErr.toString() - - if (err.isNotEmpty()) { - out += "\nEvaluation logs:\n" - out += err - } - - return CompletableFuture.completedFuture(out) - } finally { - System.setOut(PrintStream(FileOutputStream(FileDescriptor.out))) - System.setErr(PrintStream(FileOutputStream(FileDescriptor.err))) - } - } -} diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepository.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepository.kt new file mode 100644 index 00000000..3e122c5a --- /dev/null +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepository.kt @@ -0,0 +1,69 @@ +package org.opencds.cqf.cql.ls.server.command + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.model.api.IQueryParameterType +import ca.uhn.fhir.repository.IRepository +import ca.uhn.fhir.rest.api.MethodOutcome +import com.google.common.collect.Multimap +import org.hl7.fhir.instance.model.api.IBaseBundle +import org.hl7.fhir.instance.model.api.IBaseParameters +import org.hl7.fhir.instance.model.api.IBaseResource +import org.hl7.fhir.instance.model.api.IIdType + +/** + * Mutable [IRepository] wrapper. The engine is created once holding this wrapper; before each + * patient's [evaluate][org.opencds.cqf.fhir.cql.CqlEngine.evaluate] call, [current] is + * reassigned to that patient's repository. This allows a single engine — and its cached + * [LibraryManager][org.cqframework.cql.cql2elm.LibraryManager] — to serve all patients in a + * batch without recompiling CQL on every iteration. + */ +class DelegatingRepository(initial: IRepository) : IRepository { + var current: IRepository = initial + + override fun fhirContext(): FhirContext = current.fhirContext() + + override fun read( + aClass: Class, + i: I, + map: Map, + ): T = current.read(aClass, i, map) + + override fun create( + t: T, + map: Map, + ): MethodOutcome = current.create(t, map) + + override fun update( + t: T, + map: Map, + ): MethodOutcome = current.update(t, map) + + override fun delete( + aClass: Class, + i: I, + map: Map, + ): MethodOutcome = current.delete(aClass, i, map) + + override fun search( + aClass: Class, + aClass1: Class, + multimap: Multimap>, + map: Map, + ): B = current.search(aClass, aClass1, multimap, map) + + override fun invoke( + aClass: Class, + s: String, + p: P, + aClass1: Class, + map: Map, + ): R = current.invoke(aClass, s, p, aClass1, map) + + override fun invoke( + i: I, + s: String, + p: P, + aClass: Class, + map: Map, + ): R = current.invoke(i, s, p, aClass, map) +} diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContribution.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContribution.kt new file mode 100644 index 00000000..5160f2a7 --- /dev/null +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContribution.kt @@ -0,0 +1,30 @@ +package org.opencds.cqf.cql.ls.server.command + +import com.google.gson.Gson +import com.google.gson.JsonElement +import org.eclipse.lsp4j.ExecuteCommandParams +import org.opencds.cqf.cql.ls.core.ContentService +import org.opencds.cqf.cql.ls.server.manager.IgContextManager +import org.opencds.cqf.cql.ls.server.plugin.CommandContribution +import java.util.concurrent.CompletableFuture + +class ExecuteCqlCommandContribution( + private val igContextManager: IgContextManager, + private val contentService: ContentService, +) : CommandContribution { + companion object { + const val EXECUTE_CQL_COMMAND = "org.opencds.cqf.cql.ls.executeCql" + } + + override fun getCommands(): Set = setOf(EXECUTE_CQL_COMMAND) + + override fun executeCommand(params: ExecuteCommandParams): CompletableFuture { + return if (EXECUTE_CQL_COMMAND == params.command) { + val element = params.arguments[0] as JsonElement + val request = Gson().fromJson(element, ExecuteCqlRequest::class.java) + CompletableFuture.completedFuture(CqlEvaluator.evaluate(request, contentService, igContextManager)) + } else { + super.executeCommand(params) + } + } +} diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlRequest.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlRequest.kt new file mode 100644 index 00000000..cf69693c --- /dev/null +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlRequest.kt @@ -0,0 +1,43 @@ +package org.opencds.cqf.cql.ls.server.command + +data class ExecuteCqlRequest( + val fhirVersion: String, + val rootDir: String?, + val optionsPath: String?, + val libraries: List, +) + +data class LibraryRequest( + val libraryName: String, + val libraryUri: String, + val libraryVersion: String?, + val terminologyUri: String?, + val model: ModelRequest?, + val context: ContextRequest?, + // TODO: parameter passing deferred to PR #6 (Execute CQL optimization) + val parameters: List, +) + +data class ModelRequest(val modelName: String, val modelUri: String) + +data class ContextRequest(val contextName: String, val contextValue: String) + +data class ParameterRequest(val parameterName: String, val parameterType: String?, val parameterValue: String) + +data class ExecuteCqlResponse(val results: List, val logs: List) + +/** + * A CQL parameter that was not supplied via config and fell back to its CQL-declared default + * expression. [value] is the string representation of the resolved runtime value. + * [source] is always `"default"` — present for consistency with the config-sourced parameter shape. + */ +data class DefaultParameterResult(val name: String, val value: String, val source: String = "default") + +data class LibraryResult( + val libraryName: String, + val expressions: List, + /** Parameters declared in the CQL library that were not supplied via config and fell back to their CQL default, with the resolved value. */ + val usedDefaultParameters: List = emptyList(), +) + +data class ExpressionResult(val name: String, val value: String) diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt index 29b40916..6981850b 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManager.kt @@ -8,6 +8,7 @@ import org.cqframework.cql.cql2elm.quick.FhirLibrarySourceProvider import org.fhir.ucum.UcumEssenceService import org.fhir.ucum.UcumService import org.hl7.cql.model.ModelIdentifier +import org.hl7.elm.r1.VersionedIdentifier import org.opencds.cqf.cql.ls.core.ContentService import org.opencds.cqf.cql.ls.core.utility.Converters import org.opencds.cqf.cql.ls.core.utility.Uris @@ -16,6 +17,8 @@ import org.opencds.cqf.cql.ls.server.provider.ContentServiceSourceProvider import org.slf4j.LoggerFactory import java.io.InputStream import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantReadWriteLock class CqlCompilationManager( private val contentService: ContentService, @@ -40,7 +43,20 @@ class CqlCompilationManager( private val globalCache = HashMap() + // Cache: compiled result per source URI + private val compilationCache = ConcurrentHashMap() + + // Forward index: URI → what library identifier it compiled to + private val uriToIdentifier = ConcurrentHashMap() + + // Reverse index: library identifier → which URIs depend on it + private val reverseDeps = ConcurrentHashMap>() + + // Protects atomic reads/writes across all three maps + private val indexLock = ReentrantReadWriteLock() + fun compile(uri: URI): CqlCompiler? { + compilationCache[uri]?.let { return it } val input = contentService.read(uri) ?: return null return compile(uri, input) } @@ -53,9 +69,56 @@ class CqlCompilationManager( val libraryManager = createLibraryManager(Uris.getHead(uri), modelManager) val compiler = CqlCompiler(null, null, libraryManager) compiler.run(Converters.inputStreamToString(stream)) + compilationCache[uri] = compiler + updateIndex(uri, compiler) return compiler } + fun invalidate(uri: URI) { + indexLock.writeLock().lock() + try { + compilationCache.remove(uri) + val id = uriToIdentifier[uri] + if (id != null) { + reverseDeps[id]?.forEach { compilationCache.remove(it) } + } + } finally { + indexLock.writeLock().unlock() + } + } + + fun getDependentUris(identifier: VersionedIdentifier): Set { + indexLock.readLock().lock() + return try { + reverseDeps[identifier]?.toSet() ?: emptySet() + } finally { + indexLock.readLock().unlock() + } + } + + private fun updateIndex( + uri: URI, + compiler: CqlCompiler, + ) { + val library = compiler.compiledLibrary?.library ?: return + val identifier = library.identifier ?: return + indexLock.writeLock().lock() + try { + val oldId = uriToIdentifier[uri] + if (oldId != null) reverseDeps[oldId]?.remove(uri) + uriToIdentifier[uri] = identifier + library.includes?.def?.forEach { includeDef -> + val depId = + VersionedIdentifier() + .withId(includeDef.path) + .withVersion(includeDef.version) + reverseDeps.getOrPut(depId) { ConcurrentHashMap.newKeySet() }.add(uri) + } + } finally { + indexLock.writeLock().unlock() + } + } + private fun createModelManager() = ModelManager(globalCache) private fun createLibraryManager( diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt index e3234aab..8bc94a09 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/IgStandardRepository.kt @@ -81,6 +81,17 @@ open class IgStandardRepository : IRepository { .maximumSize(500) .build() + /** + * Directory-level cache: caches the full result of scanning a directory for a given + * resource type + compartment combination. Keyed by "${resourceType}|${compartmentPath}". + * + * Without this cache, every `search()` call (e.g. ValueSet lookup during terminology + * expansion) re-walks the entire directory (600+ files for a terminology bundle), even + * when the result has already been computed for a prior patient in the same batch. + * This cache makes the scan happen at most once per resource type per repository instance. + */ + private val typeResourceCache = ConcurrentHashMap>() + /** * Creates a new IgRepository with auto-detected conventions and default encoding behavior. */ @@ -108,6 +119,7 @@ open class IgStandardRepository : IRepository { fun clearCache() { resourceCache.invalidateAll() + typeResourceCache.clear() } private fun isExternalPath(path: Path): Boolean = path.parent != null && path.parent.toString().lowercase().endsWith(EXTERNAL_DIRECTORY) @@ -193,16 +205,16 @@ open class IgStandardRepository : IRepository { } protected open fun readResource(path: Path): IBaseResource? { - log.info("IgStandardRepository.readResource - Attempting to read resource from path: {}", path) + log.debug("IgStandardRepository.readResource - Attempting to read resource from path: {}", path) val file = path.toFile() if (!file.exists()) { - log.info("IgStandardRepository.readResource - Didn't find file") + log.debug("IgStandardRepository.readResource - Didn't find file") return null } val extension = fileExtension(path) ?: run { - log.info("IgStandardRepository.readResource - Extension check failed") + log.debug("IgStandardRepository.readResource - Extension check failed") return null } @@ -213,7 +225,7 @@ open class IgStandardRepository : IRepository { val resource = parserForEncoding(fhirContext, encoding).parseResource(s) resource.setUserData(SOURCE_PATH_TAG, path) IgStandardCqlContent.loadCqlContent(resource, path.parent) - log.info("IgStandardRepository.readResource - Returning resource: {}", resource) + log.debug("IgStandardRepository.readResource - Returning resource: {}", resource) resource } catch (e: FileNotFoundException) { null @@ -227,14 +239,14 @@ open class IgStandardRepository : IRepository { protected open fun cachedReadResource(path: Path): IBaseResource? { val cached = resourceCache.getIfPresent(path) if (cached != null) { - log.info("IgStandardRepository.cachedReadResource - Returning cached resource: {}", cached) + log.debug("IgStandardRepository.cachedReadResource - Returning cached resource: {}", cached) return cached } val resource = readResource(path) if (resource != null) { resourceCache.put(path, resource) } - log.info("IgStandardRepository.cachedReadResource - Returning freshly loaded resource: {}", resource) + log.debug("IgStandardRepository.cachedReadResource - Returning freshly loaded resource: {}", resource) return resource } @@ -285,14 +297,25 @@ open class IgStandardRepository : IRepository { return path.fileName.toString().lowercase().startsWith(prefix.lowercase() + "-") } + @Suppress("UNCHECKED_CAST") protected open fun readDirectoryForResourceType( resourceClass: Class, igRepositoryCompartment: IgStandardRepositoryCompartment, ): Map { + val cacheKey = "${resourceClass.simpleName}|${pathForCompartment(igRepositoryCompartment)}" + return typeResourceCache.computeIfAbsent(cacheKey) { + readDirectoryForResourceTypeUncached(resourceClass, igRepositoryCompartment) + } as Map + } + + private fun readDirectoryForResourceTypeUncached( + resourceClass: Class, + igRepositoryCompartment: IgStandardRepositoryCompartment, + ): Map { val path = directoryForResource(resourceClass, igRepositoryCompartment) if (!path.toFile().exists()) return emptyMap() - val resources = ConcurrentHashMap() + val resources = ConcurrentHashMap() val resourceFileFilter: (Path) -> Boolean = when (conventions.filenameMode) { IgStandardConventions.FilenameMode.ID_ONLY -> ::acceptByFileExtension @@ -308,8 +331,8 @@ open class IgStandardRepository : IRepository { .map { it!! } .forEach { r -> if (r.fhirType() != resourceClass.simpleName) return@forEach - val validated = validateResource(resourceClass, r, r.idElement) - resources[r.idElement.toUnqualifiedVersionless()] = validated + if (!r.idElement.hasIdPart()) return@forEach + resources[r.idElement.toUnqualifiedVersionless()] = r } } } catch (e: IOException) { @@ -328,26 +351,26 @@ open class IgStandardRepository : IRepository { ): T { requireNotNull(resourceType) { "resourceType cannot be null" } requireNotNull(id) { "id cannot be null" } - log.info("IgStandardRepository.read - Attempting to read resource [{}].", id) - log.info("IgStandardRepository.read - headers: {}", headers) + log.debug("IgStandardRepository.read - Attempting to read resource [{}].", id) + log.debug("IgStandardRepository.read - headers: {}", headers) val compartment = compartmentFrom(headers) val paths = potentialPathsForResource(resourceType, id, compartment) for (path in paths) { - log.info("IgStandardRepository.read - potentialPathsForResource path: {}", path) + log.debug("IgStandardRepository.read - potentialPathsForResource path: {}", path) if (!Files.exists(path)) { - log.info("IgStandardRepository.read - File doesn't exist at [{}]. Continuing loop.", path) + log.debug("IgStandardRepository.read - File doesn't exist at [{}]. Continuing loop.", path) continue } val resource = cachedReadResource(path) if (resource != null) { - log.info("IgStandardRepository.read - Found resource [{}].", id) + log.debug("IgStandardRepository.read - Found resource [{}].", id) return validateResource(resourceType, resource, id) } } - log.info("IgStandardRepository.read - Unable to find resource [{}]. Throwing Exception", id) + log.debug("IgStandardRepository.read - Unable to find resource [{}]. Throwing Exception", id) throw ResourceNotFoundException(id) } diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt index 347f3bc9..8e129f58 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsService.kt @@ -13,6 +13,7 @@ import org.hl7.elm.r1.VersionedIdentifier import org.opencds.cqf.cql.ls.core.ContentService import org.opencds.cqf.cql.ls.core.utility.Uris import org.opencds.cqf.cql.ls.server.event.DidChangeTextDocumentEvent +import org.opencds.cqf.cql.ls.server.event.DidChangeWatchedFilesEvent import org.opencds.cqf.cql.ls.server.event.DidCloseTextDocumentEvent import org.opencds.cqf.cql.ls.server.event.DidOpenTextDocumentEvent import org.opencds.cqf.cql.ls.server.manager.CqlCompilationManager @@ -165,11 +166,23 @@ class DiagnosticsService( @Subscribe fun didChange(e: DidChangeTextDocumentEvent) { log.debug("didChange: {}", e.params().textDocument.uri) + Uris.parseOrNull(e.params().textDocument.uri)?.let { cqlCompilationManager.invalidate(it) } debounce(BOUNCE_DELAY) { doLint(listOfNotNull(Uris.parseOrNull(e.params().textDocument.uri))) } } + @Subscribe + fun didChangeWatchedFiles(e: DidChangeWatchedFilesEvent) { + val uris = + e.params().changes + .mapNotNull { Uris.parseOrNull(it.uri) } + .filter { it.toString().endsWith(".cql") } + uris.forEach { cqlCompilationManager.invalidate(it) } + doLint(uris) + } + + internal fun debounce( delay: Long, task: Runnable, diff --git a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt index 88f5c6f5..7b5202f1 100644 --- a/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt +++ b/ls/server/src/main/kotlin/org/opencds/cqf/cql/ls/service/Main.kt @@ -8,7 +8,7 @@ import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Logger.JavaLogger import org.opencds.cqf.cql.engine.execution.CqlEngine import org.opencds.cqf.cql.ls.server.CqlLanguageServer -import org.opencds.cqf.cql.ls.server.command.DebugCqlCommandContribution +import org.opencds.cqf.cql.ls.server.command.ExecuteCqlCommandContribution import org.opencds.cqf.cql.ls.server.command.ViewElmCommandContribution import org.opencds.cqf.cql.ls.server.manager.CompilerOptionsManager import org.opencds.cqf.cql.ls.server.manager.CqlCompilationManager @@ -57,7 +57,7 @@ fun main(args: Array) { val contributions = mutableListOf() contributions.add(ViewElmCommandContribution(compilationManager)) - contributions.add(DebugCqlCommandContribution(igContextManager)) + contributions.add(ExecuteCqlCommandContribution(igContextManager, federatedContentService)) commandsFuture.complete(contributions) val server = CqlLanguageServer(languageClientFuture, workspaceService, textDocumentService) diff --git a/ls/server/src/main/resources/logback.xml b/ls/server/src/main/resources/logback.xml index e7b7598d..fc5e6440 100644 --- a/ls/server/src/main/resources/logback.xml +++ b/ls/server/src/main/resources/logback.xml @@ -1,16 +1,27 @@ - System.err %-4relative [%thread] %-5level %logger{35} %msg %n - + + ${java.io.tmpdir}/cql-ls.log + + ${java.io.tmpdir}/cql-ls.%d{yyyy-MM-dd}.%i.log + 10MB + 7 + 50MB + + + %-4relative [%thread] %-5level %logger{35} %msg %n + + + + - + - \ No newline at end of file + diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommandTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommandTest.kt deleted file mode 100644 index 4bb1b9c2..00000000 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/CqlCommandTest.kt +++ /dev/null @@ -1,273 +0,0 @@ -package org.opencds.cqf.cql.ls.server.command - -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.MethodOrderer -import org.junit.jupiter.api.Order -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestMethodOrder -import org.junit.jupiter.api.assertThrows -import org.opencds.cqf.cql.ls.core.utility.Uris -import java.io.ByteArrayOutputStream -import java.io.PrintStream - -@TestMethodOrder(MethodOrderer.OrderAnnotation::class) -class CqlCommandTest { - private lateinit var originalOut: PrintStream - private lateinit var capturedOut: ByteArrayOutputStream - - @BeforeEach - fun captureStdout() { - originalOut = System.out - capturedOut = ByteArrayOutputStream() - System.setOut(PrintStream(capturedOut)) - } - - @AfterEach - fun restoreStdout() { - System.setOut(originalOut) - } - - private fun output(): String = capturedOut.toString() - - /** - * Returns a file: URI pointing to the directory containing the CQL test fixtures. - * - * Uses .toURI() (not URI.create()) so the path is correct on all platforms: - * macOS/Linux: file:///home/user/... - * Windows: file:///C:/Users/... - */ - private fun cqlFixtureDirectoryUrl(): String = - CqlCommandTest::class.java - .getResource("/org/opencds/cqf/cql/ls/server/One.cql")!! - .toURI() - .resolve(".") - .toString() - - private fun buildCommand( - libraryName: String, - libraryVersion: String? = null, - expressions: Array? = null, - fhirVersion: String = "R4", - ): CqlCommand { - val cmd = CqlCommand() - cmd.fhirVersion = fhirVersion - val lib = CqlCommand.LibraryParameter() - lib.libraryUrl = cqlFixtureDirectoryUrl() - lib.libraryName = libraryName - lib.libraryVersion = libraryVersion - lib.expression = expressions - cmd.libraries = mutableListOf(lib) - return cmd - } - - // ------------------------------------------------------------------------- - // Tests 1–12: execution paths, output formatting, and error cases - // ------------------------------------------------------------------------- - - @Test - @Order(1) - fun `happy path - One evaluates One=1`() { - val cmd = buildCommand("One") - val result = cmd.call() - assertEquals(0, result) - assertTrue(output().contains("One=1")) - } - - @Test - @Order(2) - fun `happy path - Two with version 1-0-0 evaluates Two=2`() { - val cmd = buildCommand("Two", libraryVersion = "1.0.0") - val result = cmd.call() - assertEquals(0, result) - assertTrue(output().contains("Two=2")) - } - - @Test - @Order(3) - fun `expression filter - only requested expression appears`() { - val cmd = buildCommand("Two", expressions = arrayOf("Two")) - cmd.call() - val out = output() - assertTrue(out.contains("Two=2"), "Expected 'Two=2' in output") - assertFalse(out.contains("Two List"), "Expected 'Two List' to be filtered out when -e Two is set") - } - - @Test - @Order(4) - fun `no expression filter - all defines appear`() { - val cmd = buildCommand("Two") - cmd.call() - val out = output() - assertTrue(out.contains("Two=2")) - assertTrue(out.contains("Two List")) - } - - @Test - @Order(5) - fun `Two List is formatted as bracket list`() { - val cmd = buildCommand("Two") - cmd.call() - assertTrue(output().contains("Two List=[1, 2, 3]")) - } - - @Test - @Order(6) - fun `blank line appears after expression results`() { - val cmd = buildCommand("One") - cmd.call() - // writeResult() calls println() after iterating results, producing a trailing blank line. - // Use System.lineSeparator() so the check works on Windows (\r\n) and Unix (\n). - val out = output() - val sep = System.lineSeparator() - assertTrue(out.contains("$sep$sep") || out.endsWith("$sep$sep")) - } - - @Test - @Order(7) - fun `null value is rendered as string null`() { - val cmd = buildCommand("NullResult") - cmd.call() - assertTrue(output().contains("NullDef=null")) - } - - @Test - @Order(8) - fun `call returns 0 on success`() { - val cmd = buildCommand("One") - assertEquals(0, cmd.call()) - } - - @Test - @Order(9) - fun `NoOpRepository path - evaluation succeeds without terminology or model`() { - // buildCommand sets no terminologyUrl or modelUrl, so createRepository returns NoOpRepository - val cmd = buildCommand("One") - val result = cmd.call() - assertEquals(0, result) - assertTrue(output().contains("One=1")) - } - - @Test - @Order(10) - fun `R5 fhir version - evaluation succeeds`() { - val cmd = buildCommand("One", fhirVersion = "R5") - val result = cmd.call() - assertEquals(0, result) - assertTrue(output().contains("One=1")) - } - - @Test - @Order(11) - fun `DSTU3 fhir version - evaluation succeeds`() { - val cmd = buildCommand("One", fhirVersion = "DSTU3") - val result = cmd.call() - assertEquals(0, result) - assertTrue(output().contains("One=1")) - } - - @Test - @Order(12) - fun `invalid fhir version throws IllegalArgumentException`() { - val cmd = buildCommand("One", fhirVersion = "INVALID") - assertThrows { cmd.call() } - } - - // ------------------------------------------------------------------------- - // Tests 16–17: additional execution paths - // ------------------------------------------------------------------------- - - @Test - @Order(16) - fun `expression filter with multiple expressions includes all specified`() { - // Both "Two" and "Two List" are declared in Two.cql; requesting both explicitly - // should keep both — the filter is an inclusion list, not an exclusion list. - val cmd = buildCommand("Two", expressions = arrayOf("Two", "Two List")) - cmd.call() - val out = output() - assertTrue(out.contains("Two=2"), "Expected 'Two=2' in filtered output") - assertTrue(out.contains("Two List=[1, 2, 3]"), "Expected 'Two List' in filtered output") - } - - @Test - @Order(17) - fun `multiple libraries - each is evaluated in the same call`() { - // cmd.libraries holds two LibraryParameters; the for-loop should evaluate both - // and print results for each, separated by a blank line. - val cmd = CqlCommand() - cmd.fhirVersion = "R4" - val dir = cqlFixtureDirectoryUrl() - val lib1 = CqlCommand.LibraryParameter() - lib1.libraryUrl = dir - lib1.libraryName = "One" - val lib2 = CqlCommand.LibraryParameter() - lib2.libraryUrl = dir - lib2.libraryName = "Two" - cmd.libraries = mutableListOf(lib1, lib2) - assertEquals(0, cmd.call()) - val out = output() - assertTrue(out.contains("One=1"), "Expected 'One=1' from first library") - assertTrue(out.contains("Two=2"), "Expected 'Two=2' from second library") - } - - // ------------------------------------------------------------------------- - // Tests 13–15: cross-platform path handling via Uris.parseOrNull() - // No CQL engine involvement — pure unit tests of URI parsing behaviour. - // - // Platform behaviour matrix: - // Format | Example | parseOrNull result - // ------------------------------|-------------------------------|------------------- - // Unix / macOS | file:///tmp/cql/ | non-null URI - // Windows forward-slash | file:///C:/Users/cql/ | non-null URI (any platform) - // Windows backslash | file:///C:\Users\cql\ | null (URISyntaxException) - // - // The backslash case documents a latent NPE in CqlCommand: libraryKotlinPath!! will - // throw NullPointerException if a backslash path reaches libraryUrl. - // ------------------------------------------------------------------------- - - @Test - @Order(13) - fun `unix style file URI parses to valid path`() { - val uri = Uris.parseOrNull("file:///tmp/cql/") - assertNotNull(uri) - assertEquals("file", uri!!.scheme) - } - - @Test - @Order(14) - fun `windows forward slash URI parses to valid URI`() { - val uri = Uris.parseOrNull("file:///C:/Users/cql/") - assertNotNull(uri) - assertEquals("file", uri!!.scheme) - } - - @Test - @Order(15) - fun `windows backslash URI returns null from parseOrNull`() { - // Backslashes are illegal in URIs per RFC 3986; URI(String) throws URISyntaxException, - // which parseOrNull catches and converts to null. This documents the latent NPE risk: - // CqlCommand line `libraryKotlinPath!!` will throw if this reaches libraryUrl. - val uri = Uris.parseOrNull("file:///C:\\Users\\cql\\") - assertNull(uri, "Backslash paths are not valid URIs; parseOrNull should return null") - } - - // ----------------------------------------------------------------------- - // Tests 18–19: optionsPath and context parameter paths - // ----------------------------------------------------------------------- - - @Test - @Order(18) - fun `optionsPath with invalid URI returns 1`() { - // Uris.parseOrNull returns null for an invalid URI (spaces, etc.), - // causing CqlCommand.call() to log a warning and return 1. - val cmd = buildCommand("One") - cmd.optionsPath = "not a valid uri has spaces and %%% invalid chars" - val result = cmd.call() - assertEquals(1, result) - } -} diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContributionTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContributionTest.kt deleted file mode 100644 index a75d17a7..00000000 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DebugCqlCommandContributionTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package org.opencds.cqf.cql.ls.server.command - -import com.google.gson.JsonElement -import com.google.gson.JsonPrimitive -import org.eclipse.lsp4j.ExecuteCommandParams -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotSame -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow -import org.junit.jupiter.api.assertThrows -import org.opencds.cqf.cql.ls.server.command.DebugCqlCommandContribution.Companion.START_DEBUG_COMMAND -import org.opencds.cqf.cql.ls.server.manager.IgContextManager -import org.opencds.cqf.cql.ls.server.service.TestContentService -import java.io.ByteArrayOutputStream -import java.io.PrintStream - -class DebugCqlCommandContributionTest { - companion object { - private lateinit var contribution: DebugCqlCommandContribution - - @BeforeAll - @JvmStatic - fun beforeAll() { - val cs = TestContentService() - contribution = DebugCqlCommandContribution(IgContextManager(cs)) - } - } - - /** - * Returns a file: URI pointing to the directory containing the CQL test fixtures. - * Uses .toURI() so the path is correct on macOS/Linux and Windows. - */ - private fun cqlFixtureDirectoryUrl(): String = - DebugCqlCommandContributionTest::class.java - .getResource("/org/opencds/cqf/cql/ls/server/One.cql")!! - .toURI() - .resolve(".") - .toString() - - /** Wraps each string as a JsonPrimitive, matching the format executeCql() expects. */ - private fun jsonArgs(vararg args: String): List = args.map { JsonPrimitive(it) } - - private fun debugParams(vararg args: String): ExecuteCommandParams = ExecuteCommandParams(START_DEBUG_COMMAND, jsonArgs(*args)) - - // ------------------------------------------------------------------------- - // Command registration - // ------------------------------------------------------------------------- - - @Test - fun `getCommands returns the start debug session command`() { - assertEquals(setOf(START_DEBUG_COMMAND), contribution.getCommands()) - } - - // ------------------------------------------------------------------------- - // Dispatch - // ------------------------------------------------------------------------- - - @Test - fun `executeCommand with unknown command throws RuntimeException`() { - val params = ExecuteCommandParams("org.unknown.command", emptyList()) - assertThrows { contribution.executeCommand(params) } - } - - // ------------------------------------------------------------------------- - // Successful execution - // ------------------------------------------------------------------------- - - @Test - fun `successful evaluation returns CQL output in the future result`() { - val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "One") - val result = contribution.executeCommand(params).join() as String - assertTrue(result.contains("One=1")) - } - - @Test - fun `CQL output does not bleed to caller System dot out`() { - // executeCql() redirects System.out internally; output should go to the future result, - // not to whatever System.out the caller had set. - val callerCapture = ByteArrayOutputStream() - val originalOut = System.out - System.setOut(PrintStream(callerCapture)) - try { - val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "One") - val result = contribution.executeCommand(params).join() as String - assertEquals("", callerCapture.toString(), "CQL output should not appear in caller's stdout") - assertTrue(result.contains("One=1"), "CQL output should be in the future result") - } finally { - System.setOut(originalOut) - } - } - - @Test - fun `stdout and stderr are restored to console streams after successful execution`() { - val preCallOut = System.out - val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "One") - contribution.executeCommand(params).join() - // finally block always replaces System.out with new PrintStream(FileDescriptor.out) - assertNotSame(preCallOut, System.out, "stdout should be replaced with a new console stream") - assertDoesNotThrow { System.out.println("stdout is functional after execution") } - } - - // ------------------------------------------------------------------------- - // Failed execution - // ------------------------------------------------------------------------- - - @Test - fun `failed evaluation includes Evaluation logs section in result`() { - // "NonExistentLibrary" is not present in the fixture directory; the engine will throw, - // picocli will write the error to the redirected System.err, and executeCql() appends - // it to the result under "Evaluation logs:". - val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "NonExistentLibrary") - val result = contribution.executeCommand(params).join() as String - assertTrue(result.contains("Evaluation logs:"), "Expected 'Evaluation logs:' section when CQL evaluation fails") - } - - @Test - fun `stdout and stderr are restored after failed evaluation`() { - val params = debugParams("cql", "-fv", "R4", "-lu", cqlFixtureDirectoryUrl(), "-ln", "NonExistentLibrary") - contribution.executeCommand(params).join() - // finally block runs even on error paths - assertDoesNotThrow { System.out.println("stdout still functional after failure") } - assertDoesNotThrow { System.err.println("stderr still functional after failure") } - } -} diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepositoryTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepositoryTest.kt new file mode 100644 index 00000000..00bcbe34 --- /dev/null +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/DelegatingRepositoryTest.kt @@ -0,0 +1,79 @@ +package org.opencds.cqf.cql.ls.server.command + +import ca.uhn.fhir.context.FhirContext +import com.google.common.collect.ArrayListMultimap +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Patient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.opencds.cqf.fhir.utility.repository.ProxyRepository + +class DelegatingRepositoryTest { + private val fhirContext = FhirContext.forR4Cached() + + @Test + fun `fhirContext delegates to current`() { + val repo = DelegatingRepository(NoOpRepository(fhirContext)) + assertEquals(fhirContext, repo.fhirContext()) + } + + @Test + fun `read delegates to current and throws when current is NoOpRepository`() { + val repo = DelegatingRepository(NoOpRepository(fhirContext)) + assertThrows(UnsupportedOperationException::class.java) { + repo.read(Patient::class.java, Patient().apply { id = "1" }.idElement, emptyMap()) + } + } + + @Test + fun `search delegates to current and returns empty bundle from NoOpRepository`() { + val repo = DelegatingRepository(NoOpRepository(fhirContext)) + val result = repo.search(Bundle::class.java, Patient::class.java, ArrayListMultimap.create(), emptyMap()) + // NoOpRepository returns an empty bundle + assertTrue(result.entry.isEmpty()) + } + + @Test + fun `swapping current changes fhirContext`() { + val ctx1 = FhirContext.forR4Cached() + val ctx2 = FhirContext.forR5Cached() + val repo = DelegatingRepository(NoOpRepository(ctx1)) + assertEquals(ctx1, repo.fhirContext()) + + repo.current = NoOpRepository(ctx2) + assertEquals(ctx2, repo.fhirContext()) + } + + @Test + fun `swapping current changes read behavior`() { + val repo = DelegatingRepository(NoOpRepository(fhirContext)) + // First repo throws on read + assertThrows(UnsupportedOperationException::class.java) { + repo.read(Patient::class.java, Patient().apply { id = "1" }.idElement, emptyMap()) + } + + // Swap to a second NoOpRepository — still throws, but via the new delegate + val noOp2 = NoOpRepository(fhirContext) + repo.current = noOp2 + assertThrows(UnsupportedOperationException::class.java) { + repo.read(Patient::class.java, Patient().apply { id = "1" }.idElement, emptyMap()) + } + } + + @Test + fun `ProxyRepository backed by DelegatingRepository reflects current swap`() { + val delegating = DelegatingRepository(NoOpRepository(fhirContext)) + val proxy = ProxyRepository(delegating, delegating, delegating) + + // Search via the proxy should pass through the delegate to NoOp (empty bundle) + val result = proxy.search(Bundle::class.java, Patient::class.java, ArrayListMultimap.create(), emptyMap()) + assertTrue(result.entry.isEmpty()) + + // Swap to another NoOp — proxy still delegates correctly + delegating.current = NoOpRepository(fhirContext) + val result2 = proxy.search(Bundle::class.java, Patient::class.java, ArrayListMultimap.create(), emptyMap()) + assertTrue(result2.entry.isEmpty()) + } +} diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContributionTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContributionTest.kt new file mode 100644 index 00000000..4d5f26cc --- /dev/null +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/command/ExecuteCqlCommandContributionTest.kt @@ -0,0 +1,400 @@ +package org.opencds.cqf.cql.ls.server.command + +import com.google.gson.Gson +import org.eclipse.lsp4j.ExecuteCommandParams +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.opencds.cqf.cql.ls.server.manager.IgContextManager +import org.opencds.cqf.cql.ls.server.service.TestContentService + +class ExecuteCqlCommandContributionTest { + companion object { + private lateinit var contribution: ExecuteCqlCommandContribution + + @BeforeAll + @JvmStatic + fun beforeAll() { + val cs = TestContentService() + contribution = ExecuteCqlCommandContribution(IgContextManager(cs), cs) + } + } + + @Test + fun `getCommands returns executeCql command`() { + assertEquals(setOf("org.opencds.cqf.cql.ls.executeCql"), contribution.getCommands()) + } + + @Test + fun `executeCommand returns structured response for One library`() { + // TestContentService resolves "One" from classpath regardless of the libraryUri root + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "One", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = emptyList(), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + + assertEquals(1, response.results.size) + assertEquals("One", response.results[0].libraryName) + assertTrue( + response.results[0].expressions.any { it.name == "One" }, + "Expected expression 'One' in results", + ) + assertTrue(response.logs.isEmpty()) + } + + @Test + fun `evaluates two patients for same library using one batch`() { + // Both entries share libraryName + libraryUri — they must land in the same batch and + // both results must be returned in order. + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "One", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = emptyList(), + ), + LibraryRequest( + libraryName = "One", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = emptyList(), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + + assertEquals(2, response.results.size) + assertEquals("One", response.results[0].libraryName) + assertEquals("One", response.results[1].libraryName) + assertTrue(response.results[0].expressions.any { it.name == "One" }) + assertTrue(response.results[1].expressions.any { it.name == "One" }) + } + + @Test + fun `String parameter is passed to engine without type coercion error`() { + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "One", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = listOf(ParameterRequest(parameterName = "Unused", parameterType = "String", parameterValue = "hello")), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + // The "One" library does not declare a "Unused" parameter, so the engine ignores it. + // The important invariant is that no exception is thrown during coercion or evaluation. + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + assertEquals(1, response.results.size) + assertEquals("One", response.results[0].libraryName) + } + + @Test + fun `null parameterType is accepted and treated as String`() { + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "One", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = listOf(ParameterRequest(parameterName = "Unused", parameterType = null, parameterValue = "value")), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + assertEquals(1, response.results.size) + } + + @Test + fun `Interval_DateTime parameter is coerced to native Interval and evaluated correctly`() { + // WithParam.cql declares `parameter "Measurement Period" Interval` and + // defines `"Period Start": start of "Measurement Period"`. If the parameter is passed + // as a plain String instead of a native CqlInterval, the engine throws + // "Expected Start(Interval), Found Start(java.lang.String)". + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "WithParam", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = + listOf( + ParameterRequest( + parameterName = "Measurement Period", + parameterType = "Interval", + parameterValue = "Interval[@2024-01-01T00:00:00.000Z, @2025-01-01T00:00:00.000Z)", + ), + ), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + + assertEquals(1, response.results.size) + val expressions = response.results[0].expressions + val periodStart = expressions.find { it.name == "Period Start" } + assertNotNull(periodStart, "Expected 'Period Start' expression in results") + assertFalse( + expressions.any { it.name == "Error" }, + "Unexpected Error expression in results: $expressions", + ) + } + + @Test + fun `parameter not in config is reported as usedDefaultParameter when CQL declares a default`() { + // WithParam.cql declares "Measurement Period" with a default expression. + // When no parameter is supplied via config, usedDefaultParameters should contain it. + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "WithParam", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = emptyList(), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + + assertEquals(1, response.results.size) + val defaultParam = response.results[0].usedDefaultParameters.find { it.name == "Measurement Period" } + assertNotNull(defaultParam, "Expected 'Measurement Period' in usedDefaultParameters, got: ${response.results[0].usedDefaultParameters}") + // The CQL default is Interval[@2023-01-01T00:00:00.000, @2024-01-01T00:00:00.000) — value must be non-null/non-empty + assertFalse(defaultParam!!.value.isBlank(), "Expected non-blank resolved value for default parameter, got: '${defaultParam.value}'") + assertEquals("default", defaultParam.source, "Expected source 'default' for a CQL-declared default parameter") + } + + @Test + fun `parameter supplied via config is not reported as usedDefaultParameter`() { + // When the parameter IS supplied, it must NOT appear in usedDefaultParameters. + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "WithParam", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = + listOf( + ParameterRequest( + parameterName = "Measurement Period", + parameterType = "Interval", + parameterValue = "Interval[@2024-01-01T00:00:00.000Z, @2025-01-01T00:00:00.000Z)", + ), + ), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + + assertEquals(1, response.results.size) + assertFalse( + response.results[0].usedDefaultParameters.any { it.name == "Measurement Period" }, + "Expected 'Measurement Period' NOT in usedDefaultParameters when explicitly provided", + ) + } + + @Test + fun `unrelated config param does not suppress usedDefaultParameter detection`() { + // Regression test: when config.json contains a parameter that the CQL library does NOT + // declare (e.g. "Measurement Period Sample"), the CQL-declared default ("Measurement Period") + // must still appear in usedDefaultParameters. + // + // Root cause: previously we used LibraryManager.resolveLibrary(unversionedIdentifier) to + // inspect ParameterDef entries. For libraries with a version declaration the engine stores + // the compiled library under a versioned VersionedIdentifier, so the unversioned lookup + // misses the cache and the inner try/catch silently returns emptyList(). + // WithParam.cql has no version so the bug was masked in other tests. + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "WithParam", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + // Unrelated parameter not declared in WithParam.cql + parameters = listOf(ParameterRequest(parameterName = "Measurement Period Sample", parameterType = "String", parameterValue = "some-value")), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + + assertEquals(1, response.results.size) + assertTrue( + response.results[0].usedDefaultParameters.any { it.name == "Measurement Period" }, + "Expected 'Measurement Period' in usedDefaultParameters even when an unrelated param is in config, got: ${response.results[0].usedDefaultParameters}", + ) + } + + @Test + fun `malformed Interval_DateTime literal falls back gracefully without throwing`() { + // The coercion logs a warning and passes the raw string to the engine. + // The engine then produces an Error expression result — but no exception must propagate. + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "WithParam", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = null, + parameters = + listOf( + ParameterRequest( + parameterName = "Measurement Period", + parameterType = "Interval", + parameterValue = "NOT_AN_INTERVAL", + ), + ), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + // Must not throw + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + assertEquals(1, response.results.size) + } + + @Test + fun `error in one patient does not prevent evaluation of other patients`() { + // First entry has an invalid libraryUri — the evaluator catches the exception and + // returns an Error expression. The second (valid) entry must still be evaluated. + val request = + ExecuteCqlRequest( + fhirVersion = "R4", + rootDir = null, + optionsPath = null, + libraries = + listOf( + LibraryRequest( + libraryName = "One", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = ModelRequest(modelName = "FHIR", modelUri = "file:///nonexistent/path/that/does/not/exist"), + context = ContextRequest(contextName = "Patient", contextValue = "bad-patient"), + parameters = emptyList(), + ), + LibraryRequest( + libraryName = "One", + libraryUri = "file:///any/path", + libraryVersion = null, + terminologyUri = null, + model = null, + context = ContextRequest(contextName = "Patient", contextValue = "good-patient"), + parameters = emptyList(), + ), + ), + ) + val element = Gson().toJsonTree(request) + val params = ExecuteCommandParams("org.opencds.cqf.cql.ls.executeCql", listOf(element)) + val response = contribution.executeCommand(params).join() as ExecuteCqlResponse + + assertEquals(2, response.results.size) + // Second patient — same library + libraryUri, so it shares the batch with the first. + // After the first patient errors out (bad model path), the engine may be in an + // indeterminate state; the important invariant is that both results are returned and + // neither call throws out of the command. + assertEquals("One", response.results[0].libraryName) + assertEquals("One", response.results[1].libraryName) + } +} diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt index c9998655..0cc9406b 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/manager/CqlCompilationManagerTest.kt @@ -4,7 +4,9 @@ import org.cqframework.cql.cql2elm.CqlCompilerException import org.hl7.elm.r1.VersionedIdentifier import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNotSame import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test @@ -23,6 +25,8 @@ class CqlCompilationManagerTest { private val TWO_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/Two.cql")!! private val SYNTAX_ERROR_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/SyntaxError.cql")!! private val MISSING_INCLUDE_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/MissingInclude.cql")!! + private val FUNCTION_LIB_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/FunctionLib.cql")!! + private val FUNCTION_CALLER_URI: URI = Uris.parseOrNull("/org/opencds/cqf/cql/ls/server/FunctionCaller.cql")!! @BeforeAll @JvmStatic @@ -131,4 +135,75 @@ class CqlCompilationManagerTest { } assertTrue(errors.isEmpty(), "Expected no errors when compiling One.cql from stream") } + + // ----------------------------------------------------------------------- + // Compilation cache — cache hits and surgical invalidation + // Each test uses its own manager instance to avoid inter-test cache bleed. + // ----------------------------------------------------------------------- + + @Test + fun compile_cachedResult_returnsSameInstance() { + val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs)) + val first = localManager.compile(ONE_URI) + val second = localManager.compile(ONE_URI) + assertSame(first, second, "Expected cache hit to return the same CqlCompiler instance") + } + + @Test + fun invalidate_evictsChangedFile() { + val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs)) + val first = localManager.compile(ONE_URI) + localManager.invalidate(ONE_URI) + val second = localManager.compile(ONE_URI) + assertNotSame(first, second, "Expected a new compiler instance after invalidation") + } + + @Test + fun invalidate_evictsDependentFile_butNotUnrelated() { + val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs)) + localManager.compile(FUNCTION_LIB_URI) + val caller = localManager.compile(FUNCTION_CALLER_URI) + val unrelated = localManager.compile(ONE_URI) + + localManager.invalidate(FUNCTION_LIB_URI) + + // FunctionCaller depends on FunctionLib → evicted → new instance + val callerAfter = localManager.compile(FUNCTION_CALLER_URI) + assertNotSame(caller, callerAfter, "Dependent file should be evicted when its dependency is invalidated") + + // One.cql is unrelated → still cached → same instance + val unrelatedAfter = localManager.compile(ONE_URI) + assertSame(unrelated, unrelatedAfter, "Unrelated file should remain in cache") + } + + @Test + fun compile_stream_populatesCache() { + val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs)) + val stream = cs.read(ONE_URI)!! + val fromStream = localManager.compile(ONE_URI, stream) + val fromCache = localManager.compile(ONE_URI) + assertSame(fromStream, fromCache, "compile(uri) should return the instance cached by compile(uri, stream)") + } + + @Test + fun invalidate_onUncachedUri_isNoOp() { + val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs)) + // Should not throw — URI was never compiled + localManager.invalidate(ONE_URI) + // Cache miss → fresh compile + assertNotNull(localManager.compile(ONE_URI)) + } + + @Test + fun getDependentUris_returnsCallers() { + val localManager = CqlCompilationManager(cs, CompilerOptionsManager(cs), IgContextManager(cs)) + + val libIdentifier = localManager.compile(FUNCTION_LIB_URI)!!.compiledLibrary!!.library!!.identifier!! + localManager.compile(FUNCTION_CALLER_URI) + + val deps = localManager.getDependentUris(libIdentifier) + + assertTrue(FUNCTION_CALLER_URI in deps, "FunctionCaller should be listed as a dependent of FunctionLib") + assertFalse(ONE_URI in deps, "One.cql should not appear as a dependent of FunctionLib") + } } diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/TypeResourceCacheTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/TypeResourceCacheTest.kt new file mode 100644 index 00000000..65b9310b --- /dev/null +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/repository/ig/standard/TypeResourceCacheTest.kt @@ -0,0 +1,83 @@ +package org.opencds.cqf.cql.ls.server.repository.ig.standard + +import ca.uhn.fhir.context.FhirContext +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Library +import org.hl7.fhir.r4.model.ValueSet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.opencds.cqf.fhir.test.Resources +import org.opencds.cqf.fhir.utility.search.Searches +import java.nio.file.Files +import java.nio.file.Path + +/** + * Tests for the directory-level [IgStandardRepository.typeResourceCache]. + * + * The cache prevents repeated [Files.walk] scans when [IgStandardRepository.search] + * is called multiple times for the same resource type (e.g., ValueSet lookups during + * terminology expansion across many patients). + */ +class TypeResourceCacheTest { + private val fhirContext = FhirContext.forR4Cached() + + @TempDir + lateinit var tempDir: Path + + private fun createRepository(): IgStandardRepository { + Resources.copyFromJar("/sampleIgs/ig/standard/directoryPerType/standard", tempDir) + return IgStandardRepository(fhirContext, tempDir) + } + + @Test + fun `repeated search returns same results from cache`() { + val repo = createRepository() + + val first = repo.search(Bundle::class.java, Library::class.java, Searches.ALL) + val second = repo.search(Bundle::class.java, Library::class.java, Searches.ALL) + + assertEquals(first.entry.size, second.entry.size) + } + + @Test + fun `new resource on disk is not visible until clearCache is called`() { + val repo = createRepository() + + // Warm the cache for Library. + val beforeCount = repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size + + // Write a new Library file directly to disk, bypassing the repo API so resourceCache + // is NOT updated — only the filesystem changes. + val libDir = tempDir.resolve("resources/library") + val newFile = libDir.resolve("Library-extra.json") + newFile.toFile().writeText("""{"resourceType":"Library","id":"extra","type":{"coding":[{"code":"logic-library"}]},"content":[]}""") + + // Cache is still warm — new file is invisible to search(). + val cachedCount = repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size + assertEquals(beforeCount, cachedCount, "typeResourceCache should hide the new file") + + // After clearCache(), the directory is re-scanned and the new file is visible. + repo.clearCache() + val afterCount = repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size + assertEquals(beforeCount + 1, afterCount, "clearCache() should expose the newly written file") + } + + @Test + fun `cache is independent per resource type`() { + val repo = createRepository() + + val libraries = repo.search(Bundle::class.java, Library::class.java, Searches.ALL) + val valueSets = repo.search(Bundle::class.java, ValueSet::class.java, Searches.ALL) + + // Both types cached independently — counts are not mixed. + assertEquals( + repo.search(Bundle::class.java, Library::class.java, Searches.ALL).entry.size, + libraries.entry.size, + ) + assertEquals( + repo.search(Bundle::class.java, ValueSet::class.java, Searches.ALL).entry.size, + valueSets.entry.size, + ) + } +} diff --git a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt index efacc77b..58803979 100644 --- a/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt +++ b/ls/server/src/test/kotlin/org/opencds/cqf/cql/ls/server/service/DiagnosticsServiceTest.kt @@ -2,8 +2,11 @@ package org.opencds.cqf.cql.ls.server.service import org.eclipse.lsp4j.Diagnostic import org.eclipse.lsp4j.DiagnosticSeverity +import org.eclipse.lsp4j.DidChangeWatchedFilesParams import org.eclipse.lsp4j.DidCloseTextDocumentParams import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.FileChangeType +import org.eclipse.lsp4j.FileEvent import org.eclipse.lsp4j.Position import org.eclipse.lsp4j.PublishDiagnosticsParams import org.eclipse.lsp4j.Range @@ -20,6 +23,7 @@ import org.junit.jupiter.api.Test import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.opencds.cqf.cql.ls.core.utility.Uris +import org.opencds.cqf.cql.ls.server.event.DidChangeWatchedFilesEvent import org.opencds.cqf.cql.ls.server.event.DidCloseTextDocumentEvent import org.opencds.cqf.cql.ls.server.event.DidOpenTextDocumentEvent import org.opencds.cqf.cql.ls.server.manager.CompilerOptionsManager @@ -214,6 +218,34 @@ class DiagnosticsServiceTest { // debounce() — task execution and cancellation // ------------------------------------------------------------------------- + @Test + fun didChangeWatchedFiles_publishesDiagnosticsForCqlFiles() { + val mockClient = Mockito.mock(LanguageClient::class.java) + val svc = buildService(mockClient) + + val fileEvent = FileEvent("/org/opencds/cqf/cql/ls/server/One.cql", FileChangeType.Changed) + val params = DidChangeWatchedFilesParams(listOf(fileEvent)) + svc.didChangeWatchedFiles(DidChangeWatchedFilesEvent(params)) + + Mockito.verify(mockClient, Mockito.atLeastOnce()).publishDiagnostics(Mockito.any()) + } + + @Test + fun didChangeWatchedFiles_ignoresNonCqlFiles() { + val mockClient = Mockito.mock(LanguageClient::class.java) + val svc = buildService(mockClient) + + val fileEvent = FileEvent("/some/path/options.json", FileChangeType.Changed) + val params = DidChangeWatchedFilesParams(listOf(fileEvent)) + svc.didChangeWatchedFiles(DidChangeWatchedFilesEvent(params)) + + Mockito.verify(mockClient, Mockito.never()).publishDiagnostics(Mockito.any()) + } + + // ------------------------------------------------------------------------- + // debounce() — task execution and cancellation + // ------------------------------------------------------------------------- + @Test fun debounce_taskExecutesAfterDelay() { val svc = buildService() diff --git a/ls/server/src/test/resources/logback-test.xml b/ls/server/src/test/resources/logback-test.xml new file mode 100644 index 00000000..f0aa031a --- /dev/null +++ b/ls/server/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + System.err + + %-4relative [%thread] %-5level %logger{35} %msg %n + + + + + + + diff --git a/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionCaller.cql b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionCaller.cql new file mode 100644 index 00000000..1e9c201a --- /dev/null +++ b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionCaller.cql @@ -0,0 +1,9 @@ +library FunctionCaller version '1.0.0' + +include FunctionLib version '1.0.0' called FL + +define "UseValue": + FL."MyValue" + 1 + +define "UseFunction": + FL."Double"(3) diff --git a/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionLib.cql b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionLib.cql new file mode 100644 index 00000000..bddff9e7 --- /dev/null +++ b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/FunctionLib.cql @@ -0,0 +1,7 @@ +library FunctionLib version '1.0.0' + +define "MyValue": + 42 + +define function "Double"(x Integer): + x * 2 diff --git a/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/WithParam.cql b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/WithParam.cql new file mode 100644 index 00000000..da1573cd --- /dev/null +++ b/ls/server/src/test/resources/org/opencds/cqf/cql/ls/server/WithParam.cql @@ -0,0 +1,6 @@ +library WithParam + +parameter "Measurement Period" Interval + default Interval[@2023-01-01T00:00:00.000, @2024-01-01T00:00:00.000) + +define "Period Start": start of "Measurement Period" diff --git a/pom.xml b/pom.xml index b2f08076..d7ea7b3a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.opencds.cqf.cql.ls cql-ls pom - 4.5.0-SNAPSHOT + 4.5.0 CQL Language Server A Language Server for CQL implementing the LSP @@ -24,7 +24,6 @@ 4.2.0 1.2.13 1.7.36 - 4.6.1 2.18.6 2.18.6 2.24.1