diff --git a/.vscode/launch.json b/.vscode/launch.json index 787b44f834..22900d3eec 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,5 +3,18 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", - "configurations": [] + "configurations": [ + { + "type": "java", + "name": "Launch Java Program", + "request": "launch", + "mainClass": "org.opencds.cqf.fhir.cr.cli.Main", + "projectName": "cqf-fhir-cr-cli", + "args": [ + "argfile", + "args.txt" + ], + } + + ] } \ No newline at end of file diff --git a/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/CqlEngineOptions.java b/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/CqlEngineOptions.java index e337b771c6..80c98178cb 100644 --- a/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/CqlEngineOptions.java +++ b/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/CqlEngineOptions.java @@ -2,6 +2,7 @@ import java.util.EnumSet; import java.util.Set; +import java.util.StringJoiner; import org.opencds.cqf.cql.engine.execution.CqlEngine; // TODO: Eventually, the cql-engine needs to expose these itself. @@ -12,6 +13,7 @@ public class CqlEngineOptions { private Integer pageSize; private Integer maxCodesPerQuery; private Integer queryBatchThreshold; + private boolean enableHedisCompatibilityMode = false; public Set getOptions() { return this.options; @@ -61,9 +63,30 @@ public void setQueryBatchThreshold(Integer value) { this.queryBatchThreshold = value; } + public void setEnableHedisCompatibilityMode(boolean enableHedisCompatibilityMode) { + this.enableHedisCompatibilityMode = enableHedisCompatibilityMode; + } + + public boolean isEnableHedisCompatibilityMode() { + return this.enableHedisCompatibilityMode; + } + public static CqlEngineOptions defaultOptions() { CqlEngineOptions result = new CqlEngineOptions(); result.options.add(CqlEngine.Options.EnableExpressionCaching); return result; } + + @Override + public String toString() { + return new StringJoiner(", ", CqlEngineOptions.class.getSimpleName() + "[", "]") + .add("options=" + options) + .add("isDebugLoggingEnabled=" + isDebugLoggingEnabled) + .add("shouldExpandValueSets=" + shouldExpandValueSets) + .add("pageSize=" + pageSize) + .add("maxCodesPerQuery=" + maxCodesPerQuery) + .add("queryBatchThreshold=" + queryBatchThreshold) + .add("enableHedisCompatibilityMode=" + enableHedisCompatibilityMode) + .toString(); + } } diff --git a/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/engine/retrieve/RepositoryRetrieveProvider.java b/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/engine/retrieve/RepositoryRetrieveProvider.java index 5ddf06e692..46fcaa1284 100644 --- a/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/engine/retrieve/RepositoryRetrieveProvider.java +++ b/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/engine/retrieve/RepositoryRetrieveProvider.java @@ -5,6 +5,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.model.api.IQueryParameterType; import ca.uhn.fhir.repository.IRepository; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -16,6 +17,7 @@ import org.opencds.cqf.cql.engine.runtime.Interval; import org.opencds.cqf.cql.engine.terminology.TerminologyProvider; import org.opencds.cqf.fhir.utility.iterable.BundleMappingIterable; +import org.opencds.cqf.fhir.utility.repository.ig.IgRepository; public class RepositoryRetrieveProvider extends BaseRetrieveProvider { private final IRepository repository; @@ -54,12 +56,23 @@ public Iterable retrieve( this.configureProfile(config, dataType, templateId); this.configureDates(config, dataType, datePath, dateLowPath, dateHighPath, dateRange); - var resources = this.repository.search(bt, resourceType, config.searchParams); + Map headers = headersForContext(context, contextValue); + + var resources = this.repository.search(bt, resourceType, config.searchParams, headers); var iter = new BundleMappingIterable<>(repository, resources, p -> p.getResource()); return iter.toStream().filter(config.filter).collect(Collectors.toList()); } + // Create headers for the FHIR compartment search (e.g. X-FHIR-Compartment: Patient/123) + private Map headersForContext(String context, Object contextValue) { + if (context == null || contextValue == null) { + return Collections.emptyMap(); + } + + return Map.of(IgRepository.FHIR_COMPARTMENT_HEADER, context + "/" + contextValue.toString()); + } + private void configureProfile(SearchConfig config, String dataType, String templateId) { var mode = this.getRetrieveSettings().getSearchParameterMode(); switch (mode) { diff --git a/cqf-fhir-cr-cli/pom.xml b/cqf-fhir-cr-cli/pom.xml index 4a2d03bb1b..9d379a8521 100644 --- a/cqf-fhir-cr-cli/pom.xml +++ b/cqf-fhir-cr-cli/pom.xml @@ -72,6 +72,12 @@ 3.23.0-SNAPSHOT test + + org.opencds.cqf.fhir + cqf-fhir-cr + 3.23.0-SNAPSHOT + compile + diff --git a/cqf-fhir-cr-cli/src/main/java/org/opencds/cqf/fhir/cr/cli/command/CqlCommand.java b/cqf-fhir-cr-cli/src/main/java/org/opencds/cqf/fhir/cr/cli/command/CqlCommand.java index 4072b80b71..723e5e5cca 100644 --- a/cqf-fhir-cr-cli/src/main/java/org/opencds/cqf/fhir/cr/cli/command/CqlCommand.java +++ b/cqf-fhir-cr-cli/src/main/java/org/opencds/cqf/fhir/cr/cli/command/CqlCommand.java @@ -2,12 +2,31 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import com.google.common.base.Stopwatch; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import org.apache.commons.lang3.tuple.Pair; +import org.cqframework.cql.cql2elm.CqlCompilerOptions.Options; import org.cqframework.cql.cql2elm.CqlTranslatorOptions; import org.cqframework.cql.cql2elm.CqlTranslatorOptionsMapper; import org.cqframework.cql.cql2elm.DefaultLibrarySourceProvider; @@ -17,7 +36,9 @@ 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.r4.model.Measure; import org.hl7.fhir.r5.context.ILoggingService; +import org.opencds.cqf.cql.engine.execution.CqlEngine; import org.opencds.cqf.cql.engine.execution.EvaluationResult; import org.opencds.cqf.cql.engine.execution.ExpressionResult; import org.opencds.cqf.fhir.cql.CqlOptions; @@ -32,6 +53,12 @@ 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.cr.cli.command.CqlCommand.EvaluationParameter.ModelParameter; +import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions; +import org.opencds.cqf.fhir.cr.measure.SubjectProviderOptions; +import org.opencds.cqf.fhir.cr.measure.r4.R4MeasureProcessor; +import org.opencds.cqf.fhir.cr.measure.r4.R4RepositorySubjectProvider; +import org.opencds.cqf.fhir.cr.measure.r4.utils.R4MeasureServiceUtils; import org.opencds.cqf.fhir.utility.repository.ProxyRepository; import org.opencds.cqf.fhir.utility.repository.ig.IgRepository; import org.slf4j.LoggerFactory; @@ -41,6 +68,8 @@ @Command(name = "cql", mixinStandardHelpOptions = true) public class CqlCommand implements Callable { + private static final org.slf4j.Logger log = LoggerFactory.getLogger(CqlCommand.class); + @Option( names = {"-fv", "--fhir-version"}, required = true) @@ -52,24 +81,51 @@ public class CqlCommand implements Callable { @ArgGroup(multiplicity = "0..1", exclusive = false) public NamespaceParameter namespace; - static class NamespaceParameter { - @Option(names = {"-nn", "--namespace-name"}) - public String namespaceName; - - @Option(names = {"-nu", "--namespace-uri"}) - public String namespaceUri; - } - @Option(names = {"-rd", "--root-dir"}) public String rootDir; @Option(names = {"-ig", "--ig-path"}) public String igPath; - @ArgGroup(multiplicity = "1..*", exclusive = false) - List libraries; + @Option(names = {"-t", "--terminology-url"}) + public String terminologyUrl; + + @Option(names = {"-measure"}) + public String measureName; + + @Option(names = {"-periodStart"}) + public String periodStart; + + @Option(names = {"-periodEnd"}) + public String periodEnd; + + @Option(names = {"-measurePath"}) + public String measurePath; + + @Option(names = {"-singleFile"}) + public boolean singleFile = false; + + @Option(names = {"-resultsPath"}) + public String resultsPath; + + @ArgGroup(multiplicity = "1..1", exclusive = false) + LibraryParameter library; - static class LibraryParameter { + @ArgGroup(multiplicity = "0..1", exclusive = false) + public ModelParameter model; + + @ArgGroup(multiplicity = "0..*", exclusive = false) + public List evaluations; + + public static class NamespaceParameter { + @Option(names = {"-nn", "--namespace-name"}) + public String namespaceName; + + @Option(names = {"-nu", "--namespace-uri"}) + public String namespaceUri; + } + + public static class LibraryParameter { @Option( names = {"-lu", "--library-url"}, required = true) @@ -83,22 +139,18 @@ static class LibraryParameter { @Option(names = {"-lv", "--library-version"}) public String libraryVersion; - @Option(names = {"-t", "--terminology-url"}) - public String terminologyUrl; - - @ArgGroup(multiplicity = "0..1", exclusive = false) - public ModelParameter model; + @Option(names = {"-e", "--expression"}) + public String[] expression; + } + public static class EvaluationParameter { @ArgGroup(multiplicity = "0..*", exclusive = false) public List parameters; - @Option(names = {"-e", "--expression"}) - public String[] expression; - @ArgGroup(multiplicity = "0..1", exclusive = false) public ContextParameter context; - static class ContextParameter { + public static class ContextParameter { @Option(names = {"-c", "--context"}) public String contextName; @@ -106,7 +158,7 @@ static class ContextParameter { public String contextValue; } - static class ModelParameter { + public static class ModelParameter { @Option(names = {"-m", "--model"}) public String modelName; @@ -114,7 +166,7 @@ static class ModelParameter { public String modelUrl; } - static class ParameterParameter { + public static class ParameterParameter { @Option(names = {"-p", "--parameter"}) public String parameterName; @@ -123,7 +175,6 @@ static class ParameterParameter { } } - @SuppressWarnings("removal") private static class Logger implements ILoggingService { private final org.slf4j.Logger log = LoggerFactory.getLogger(Logger.class); @@ -145,25 +196,160 @@ public boolean isDebugLogging() { } private String toVersionNumber(FhirVersionEnum fhirVersion) { - switch (fhirVersion) { - case R4: - return "4.0.1"; - case R5: - return "5.0.0-ballot"; - case DSTU3: - return "3.0.2"; - default: - throw new IllegalArgumentException("Unsupported FHIR version %s".formatted(fhirVersion)); - } + return switch (fhirVersion) { + case R4 -> "4.0.1"; + case R5 -> "5.0.0-ballot"; + case DSTU3 -> "3.0.2"; + default -> throw new IllegalArgumentException("Unsupported FHIR version %s".formatted(fhirVersion)); + }; } @Override public Integer call() throws Exception { - + var watch = Stopwatch.createStarted(); FhirVersionEnum fhirVersionEnum = FhirVersionEnum.valueOf(fhirVersion); FhirContext fhirContext = FhirContext.forCached(fhirVersionEnum); + var evaluationSettings = setupOptions(fhirVersionEnum); + + var repository = createRepository(fhirContext, terminologyUrl, model.modelUrl); + VersionedIdentifier identifier = new VersionedIdentifier().withId(library.libraryName); + + var measureProcessor = getR4MeasureProcessor(evaluationSettings, repository); + + // hack to bring in Measure + IParser parser = fhirContext.newJsonParser(); + + var measure = getMeasure(parser); + + var initTime = watch.elapsed().toMillis(); + log.info("initialized in {} millis", initTime); + AtomicInteger counter = new AtomicInteger(0); + for (var e : evaluations) { + String basePath = resultsPath; + Path filepath = Path.of(basePath + this.library.libraryName, e.context.contextValue + ".txt"); + + // ✅ Skip if already written + if (Files.exists(filepath)) { + log.info("⏭️ Skipping {} (already processed)", e.context.contextValue); + continue; + } + var engine = Engines.forRepository(repository, evaluationSettings); + // enable return all and equivalence + engine.getState().getEngineOptions().add(CqlEngine.Options.EnableHedisCompatibilityMode); + if (library.libraryUrl != null) { + var provider = new DefaultLibrarySourceProvider(Path.of(library.libraryUrl)); + engine.getEnvironment() + .getLibraryManager() + .getLibrarySourceLoader() + .registerProvider(provider); + } + var subjectId = e.context.contextName + "/" + e.context.contextValue; + log.info("evaluating: {}", subjectId); + + var evalStart = watch.elapsed().toMillis(); + var contextParameter = Pair.of(e.context.contextName, e.context.contextValue); + var cqlResult = engine.evaluate(identifier, contextParameter); + + Map result = new HashMap<>(); + result.put(subjectId, cqlResult); + + // generate MeasureReport from ExpressionResult + if (measure != null) { + String jsonReport; + if (periodStart != null && periodEnd != null) { + var report = measureProcessor.evaluateMeasureResults( + measure, + LocalDate.parse(periodStart, DateTimeFormatter.ISO_LOCAL_DATE) + .atStartOfDay(ZoneId.systemDefault()), + LocalDate.parse(periodEnd, DateTimeFormatter.ISO_LOCAL_DATE) + .atTime(LocalTime.MAX) + .atZone(ZoneId.systemDefault()), + "subject", + Collections.singletonList(subjectId), + result); + + jsonReport = parser.encodeResourceToString(report); + } else { + var report = measureProcessor.evaluateMeasureResults( + measure, null, null, "subject", Collections.singletonList(subjectId), result); + jsonReport = parser.encodeResourceToString(report); + } + + writeJsonToFile( + jsonReport, + e.context.contextValue, + getResultsPath(basePath).resolve("measurereports")); + } + + if (singleFile) { + // ✅ Write TXT result + writeResultToFile( + cqlResult, + e.context.contextValue, + getResultsPath(basePath).resolve("txtresults")); + } else { + writeResultToStdOut(cqlResult); + } + var count = counter.incrementAndGet(); + + var evalEnd = watch.elapsed().toMillis(); + log.info("evaluated #{} in {} millis", count, evalEnd - evalStart); + log.info("avg (amortized across threads) {} millis", (evalEnd - initTime) / count); + } + + var finalTime = watch.elapsed().toMillis(); + var elapsedTime = finalTime - initTime; + log.info("evaluated in {} millis", elapsedTime); + log.info("total time in {} millis", finalTime); + log.info("per patient time in {} millis", elapsedTime / evaluations.size()); + + return 0; + } + + @Nonnull + private Path getResultsPath(String basePath) { + if (basePath == null || basePath.isBlank()) { + basePath = System.getProperty("user.dir"); + } + + return Path.of(basePath, this.library.libraryName); + } + + @Nullable + private Measure getMeasure(IParser parser) { + Measure measure = null; + if (measureName != null && !measureName.contains("null")) { + var measureJsonFilePath = Path.of(this.measurePath, measureName + ".json"); + try (var is = Files.newInputStream(measureJsonFilePath)) { + measure = (Measure) parser.parseResource(is); + if (measure == null) { + throw new IllegalArgumentException("measureName: %s not found".formatted(measureName)); + } + } catch (IOException e) { + throw new IllegalArgumentException("measurePath: %s not found".formatted(measureJsonFilePath)); + } + } + return measure; + } + + @Nonnull + private R4MeasureProcessor getR4MeasureProcessor(EvaluationSettings evaluationSettings, IRepository repository) { + + MeasureEvaluationOptions evaluationOptions = new MeasureEvaluationOptions(); + evaluationOptions.setApplyScoringSetMembership(false); + evaluationOptions.setEvaluationSettings(evaluationSettings); + + return new R4MeasureProcessor( + repository, + evaluationOptions, + new R4RepositorySubjectProvider(new SubjectProviderOptions()), + new R4MeasureServiceUtils(repository)); + } + + @Nonnull + private EvaluationSettings setupOptions(FhirVersionEnum fhirVersionEnum) { IGContext igContext = null; if (rootDir != null && igPath != null) { igContext = new IGContext(new Logger()); @@ -172,11 +358,16 @@ public Integer call() throws Exception { CqlOptions cqlOptions = CqlOptions.defaultOptions(); + final CqlTranslatorOptions options; if (optionsPath != null) { - CqlTranslatorOptions options = CqlTranslatorOptionsMapper.fromFile(optionsPath); - cqlOptions.setCqlCompilerOptions(options.getCqlCompilerOptions()); + options = CqlTranslatorOptionsMapper.fromFile(optionsPath); + } else { + options = CqlTranslatorOptions.defaultOptions(); } + options.getCqlCompilerOptions().getOptions().add(Options.EnableResultTypes); + cqlOptions.setCqlCompilerOptions(options.getCqlCompilerOptions()); + var terminologySettings = new TerminologySettings(); terminologySettings.setValuesetExpansionMode(VALUESET_EXPANSION_MODE.PERFORM_NAIVE_EXPANSION); terminologySettings.setValuesetPreExpansionMode(VALUESET_PRE_EXPANSION_MODE.USE_IF_PRESENT); @@ -194,37 +385,10 @@ public Integer call() throws Exception { evaluationSettings.setRetrieveSettings(retrieveSettings); evaluationSettings.setNpmProcessor(new NpmProcessor(igContext)); - for (LibraryParameter library : libraries) { - var repository = createRepository( - fhirContext, library.terminologyUrl, library.model.modelUrl, library.context.contextValue); - var engine = Engines.forRepository(repository, evaluationSettings); - - if (library.libraryUrl != null) { - var provider = new DefaultLibrarySourceProvider(Path.of(library.libraryUrl)); - engine.getEnvironment() - .getLibraryManager() - .getLibrarySourceLoader() - .registerProvider(provider); - } - - VersionedIdentifier identifier = new VersionedIdentifier().withId(library.libraryName); - - Pair contextParameter = null; - - if (library.context != null) { - contextParameter = Pair.of(library.context.contextName, library.context.contextValue); - } - - EvaluationResult result = engine.evaluate(identifier, contextParameter); - - writeResult(result); - } - - return 0; + return evaluationSettings; } - private IRepository createRepository( - FhirContext fhirContext, String terminologyUrl, String modelUrl, String contextValue) { + private IRepository createRepository(FhirContext fhirContext, String terminologyUrl, String modelUrl) { IRepository data = null; IRepository terminology = null; @@ -241,13 +405,64 @@ private IRepository createRepository( } @SuppressWarnings("java:S106") // We are intending to output to the console here as a CLI tool - private void writeResult(EvaluationResult result) { - for (Map.Entry libraryEntry : result.expressionResults.entrySet()) { - System.out.println(libraryEntry.getKey() + "=" - + this.tempConvert(libraryEntry.getValue().value())); + private void writeResultToStdOut(EvaluationResult result) { + synchronized (System.out) { + for (Map.Entry libraryEntry : result.expressionResults.entrySet()) { + System.out.println(libraryEntry.getKey() + "=" + + this.tempConvert(libraryEntry.getValue().value())); + } + + System.out.println(); } + } + + private void writeJsonToFile(String json, String patientId, Path path) { + Path outputPath = path.resolve(patientId + ".json"); - System.out.println(); + try { + // Ensure parent directories exist + Files.createDirectories(outputPath.getParent()); + + // Write JSON to file + try (OutputStream out = Files.newOutputStream(outputPath)) { + out.write(json.getBytes()); + log.info("✅ Saved MeasureReport to: {}", outputPath.toAbsolutePath()); + } + + } catch (IOException exception) { + log.error("❌ Failed to write result for patient: {} to outputPath: {}", patientId, outputPath); + + throw new InvalidRequestException( + "Failed to write result for patient: %s to outputPath: %s".formatted(patientId, outputPath), + exception); + } + } + + private void writeResultToFile(EvaluationResult result, String patientId, Path path) { + Path outputPath = path.resolve(patientId + ".txt"); + + try { + // Ensure parent directories exist + Files.createDirectories(outputPath.getParent()); + + try (BufferedWriter writer = Files.newBufferedWriter(outputPath)) { + for (Map.Entry libraryEntry : result.expressionResults.entrySet()) { + String key = libraryEntry.getKey(); + Object value = this.tempConvert(libraryEntry.getValue().value()); + writer.write(key + "=" + value); + writer.newLine(); + } + } + + log.info("✅ Wrote result to: {}", outputPath.toAbsolutePath()); + + } catch (IOException exception) { + log.error("❌ Failed to write result for patient: {} to outputPath: {}", patientId, outputPath); + + throw new InvalidRequestException( + "Failed to write result for patient: %s to outputPath: %s".formatted(patientId, outputPath), + exception); + } } private String tempConvert(Object value) { @@ -255,32 +470,28 @@ private String tempConvert(Object value) { return "null"; } - String result = ""; if (value instanceof Iterable values) { - result += "["; - for (Object o : values) { - result += (tempConvert(o) + ", "); - } - - if (result.length() > 1) { - result = result.substring(0, result.length() - 2); - } + return StreamSupport.stream(values.spliterator(), false) + .map(this::tempConvert) + .collect(Collectors.joining(", ", "[", "]")); + } - result += "]"; - } else if (value instanceof IBaseResource resource) { - result = resource.fhirType() + if (value instanceof IBaseResource resource) { + return resource.fhirType() + (resource.getIdElement() != null && resource.getIdElement().hasIdPart() ? "(id=" + resource.getIdElement().getIdPart() + ")" : ""); - } else if (value instanceof IBase base) { - result = base.fhirType(); - } else if (value instanceof IBaseDatatype datatype) { - result = datatype.fhirType(); - } else { - result = value.toString(); } - return result; + if (value instanceof IBaseDatatype datatype) { + return datatype.fhirType(); + } + + if (value instanceof IBase base) { + return base.fhirType(); + } + + return value.toString(); } } diff --git a/cqf-fhir-cr-cli/src/test/java/org/opencds/cqf/fhir/cr/cli/CliTest.java b/cqf-fhir-cr-cli/src/test/java/org/opencds/cqf/fhir/cr/cli/CliTest.java index 94643385ca..17feb638cc 100644 --- a/cqf-fhir-cr-cli/src/test/java/org/opencds/cqf/fhir/cr/cli/CliTest.java +++ b/cqf-fhir-cr-cli/src/test/java/org/opencds/cqf/fhir/cr/cli/CliTest.java @@ -1,14 +1,30 @@ package org.opencds.cqf.fhir.cr.cli; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.IParser; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ListMultimap; +import jakarta.annotation.Nonnull; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintStream; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.lang3.tuple.Pair; +import org.hl7.fhir.r4.model.MeasureReport; +import org.hl7.fhir.r4.model.MeasureReport.MeasureReportStatus; +import org.hl7.fhir.r4.model.MeasureReport.MeasureReportType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -17,11 +33,18 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance.Lifecycle; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.opencds.cqf.fhir.test.Resources; +@SuppressWarnings("squid:S1135") @TestInstance(Lifecycle.PER_CLASS) class CliTest { + private static final IParser JSON_PARSER = FhirContext.forR4().newJsonParser(); + private static final String MEASUREREPORTS_FOLDER = "measurereports"; + private static final String TXTRESULTS_FOLDER = "txtresults"; + @TempDir private static Path tempDir; @@ -31,12 +54,14 @@ class CliTest { private final PrintStream originalErr = System.err; private static String testResourcePath = null; + private static String testResultsPath = null; @BeforeAll void setup() throws URISyntaxException, IOException, ClassNotFoundException { Resources.copyFromJar("/", tempDir); testResourcePath = tempDir.toAbsolutePath().toString(); System.out.printf("Test resource directory: %s%n", testResourcePath); + testResultsPath = testResourcePath + "/results"; } @BeforeEach @@ -73,7 +98,6 @@ void help() { Main.run(args); String output = outContent.toString(); assertTrue(output.startsWith("Usage:")); - // assertTrue(output.endsWith("Patient=123\n")); } @Test @@ -82,19 +106,13 @@ void empty() { Main.run(args); String output = errContent.toString(); assertTrue(output.startsWith("Missing required subcommand")); - // assertTrue(output.endsWith("Patient=123\n")); } @Test void testNull() { - assertThrows(NullPointerException.class, () -> { - Main.run(null); - }); + assertThrows(NullPointerException.class, () -> Main.run(null)); } - @Test - void dstu3() {} - @Test void argFile() { String[] args = new String[] {"argfile", testResourcePath + "/argfile/args.txt"}; @@ -226,12 +244,9 @@ void qICore() { assertTrue(output.contains("TestPatientDeceasedAsDateTime=null")); // TODO: This is because the engine is not validating on profile-based // retrieve... - // assertTrue(output.contains("TestSlices=[Observation(id=blood-pressure)]")); assertTrue(output.contains("TestSimpleExtensions=Patient(id=example)")); assertTrue(output.contains("TestComplexExtensions=Patient(id=example)")); assertTrue(output.contains("TestEncounterDiagnosisCardinality=true")); - // assertTrue(output.contains("TestProcedureNotDoneElements=[Procedure(id=negation-example), - // Procedure(id=negation-with-code-example)]")); // NOTE: Testing combinations here because ordering is not guaranteed assertTrue( output.contains( @@ -510,6 +525,93 @@ void qICoreEXM124Numer() { assertTrue(output.contains("Numerator=true")); } + @Test + void compartmentalizedTests() { + String[] args = new String[] { + "cql", + "-fv=R4", + "-lu=" + testResourcePath + "/compartment/cql", + "-ln=Example", + "-m=FHIR", + "-mu=" + testResourcePath + "/compartment", + "-c=Patient", + "-cv=123", + "-c=Patient", + "-cv=456" + }; + + Main.run(args); + + String output = outContent.toString(); + assertTrue(output.contains("Patient=Patient(id=123)")); + assertTrue(output.contains("Encounters=[Encounter(id=ABC)]")); + assertTrue(output.contains("Patient=Patient(id=456)")); + assertTrue(output.contains("Encounters=[Encounter(id=DEF)]")); + } + + @ParameterizedTest + @CsvSource({"ABC-LIB,ABC", "DEF-LIB,DEF"}) + void measureEvaluationTest(String libraryName, String measureId) { + var expectedMeasureId = "http://example.com/Measure/%s".formatted(measureId); + var subjectId1 = "Patient/123"; + var subjectId2 = "Patient/456"; + + var expectedTxtResult123 = + """ + Encounters=[Encounter(id=ABC)] + Patient=Patient(id=123) + """; + + var expectedTxtResult456 = + """ + Encounters=[Encounter(id=DEF)] + Patient=Patient(id=456) + """; + + String[] args = new String[] { + "cql", + "-fv=R4", + "-lu=" + testResourcePath + "/compartment/cql", + "-ln=%s".formatted(libraryName), + "-m=FHIR", + "-mu=" + testResourcePath + "/compartment", + "-c=Patient", + "-cv=123", + "-c=Patient", + "-cv=456", + "-singleFile", + "-measurePath=" + testResourcePath + "/compartment/resources/measure/", + "-measure=%s".formatted(measureId), + "-resultsPath=" + testResultsPath + }; + + Main.run(args); + + var resultsMap = getFilenameToTxtResultsMap(libraryName, MEASUREREPORTS_FOLDER, TXTRESULTS_FOLDER); + + final List> measureReportJsons = resultsMap.get(MEASUREREPORTS_FOLDER); + assertEquals(2, measureReportJsons.size()); + + final Optional measureReport123 = getMeasureReportForSubject(measureReportJsons, "123.json"); + assertTrue(measureReport123.isPresent()); + assertMeasureReport(measureReport123.get(), expectedMeasureId, subjectId1); + + final Optional measureReport456 = getMeasureReportForSubject(measureReportJsons, "456.json"); + assertTrue(measureReport456.isPresent()); + assertMeasureReport(measureReport456.get(), expectedMeasureId, subjectId2); + + final List> txtResults = resultsMap.get(TXTRESULTS_FOLDER); + assertEquals(2, txtResults.size()); + + final Optional txtResult123 = getTxtResultsForSubject(txtResults, "123.txt"); + assertTrue(txtResult123.isPresent()); + assertEquals(expectedTxtResult123.trim(), txtResult123.get().trim()); + + final Optional txtResult456 = getTxtResultsForSubject(txtResults, "456.txt"); + assertTrue(txtResult456.isPresent()); + assertEquals(expectedTxtResult456.trim(), txtResult456.get().trim()); + } + @Test @Disabled("This test is failing on the CI Server for reasons unknown. Need to debug that.") void sampleContentIG() { @@ -540,4 +642,54 @@ void sampleContentIG() { assertFalse(output.contains("Observation(id=blood-glucose)")); assertFalse(output.contains("Observation(id=blood-pressure)")); } + + @Nonnull + private Optional getTxtResultsForSubject(List> txtResults, String file) { + return txtResults.stream() + .filter(pair -> Path.of(file).equals(pair.getKey())) + .map(Pair::getValue) + .findFirst(); + } + + @Nonnull + private Optional getMeasureReportForSubject( + List> measureReportJsons, String first) { + return measureReportJsons.stream() + .filter(pair -> Path.of(first).equals(pair.getKey())) + .map(Pair::getValue) + .map(json -> JSON_PARSER.parseResource(MeasureReport.class, json)) + .findFirst(); + } + + private void assertMeasureReport(MeasureReport measureReport, String expectedMeasureId, String expectedSubjectId) { + assertEquals(expectedMeasureId, measureReport.getMeasure()); + assertEquals(MeasureReportStatus.COMPLETE, measureReport.getStatus()); + assertEquals(expectedSubjectId, measureReport.getSubject().getReference()); + assertEquals(MeasureReportType.INDIVIDUAL, measureReport.getType()); + } + + private ListMultimap> getFilenameToTxtResultsMap( + String libraryName, String... resultTypes) { + final ImmutableListMultimap.Builder> multimapBuilder = + ImmutableListMultimap.builder(); + try { + for (String resultType : resultTypes) { + var resultsPath = Path.of(testResultsPath, libraryName, resultType); + assertTrue(Files.exists(resultsPath)); + assertTrue(Files.isDirectory(resultsPath)); + try (Stream pathsStream = Files.list(resultsPath)) { + for (Path filePath : pathsStream.toList()) { + try (var lines = Files.lines(filePath, StandardCharsets.UTF_8)) { + var fileContents = lines.collect(Collectors.joining("\n")); + multimapBuilder.put(resultType, Pair.of(filePath.getFileName(), fileContents)); + } + } + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + return multimapBuilder.build(); + } } diff --git a/cqf-fhir-cr-cli/src/test/resources/argfile/args.txt b/cqf-fhir-cr-cli/src/test/resources/argfile/args.txt index cf6137055d..016a44c4f8 100644 --- a/cqf-fhir-cr-cli/src/test/resources/argfile/args.txt +++ b/cqf-fhir-cr-cli/src/test/resources/argfile/args.txt @@ -1,16 +1,11 @@ cql -fv=R4 +-t=src/test/resources/r4/vocabulary/valueset -lu=src/test/resources/r4 -ln=TestFHIR -m=FHIR -mu=src/test/resources/r4/example --t=src/test/resources/r4/vocabulary/valueset -c=Patient -cv=example --lu=src/test/resources/r4 --ln=TestFHIR --m=FHIR --mu=src/test/resources/r4/example --t=src/test/resources/r4/vocabulary/valueset -c=Patient -cv=example \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/cql/ABC-LIB.cql b/cqf-fhir-cr-cli/src/test/resources/compartment/cql/ABC-LIB.cql new file mode 100644 index 0000000000..f70b26fad4 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/cql/ABC-LIB.cql @@ -0,0 +1,7 @@ +library ABCLIB + +using FHIR version '4.0.1' + +context Patient + +define "Encounters": [Encounter] diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/cql/DEF-LIB.cql b/cqf-fhir-cr-cli/src/test/resources/compartment/cql/DEF-LIB.cql new file mode 100644 index 0000000000..9db34bd6a3 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/cql/DEF-LIB.cql @@ -0,0 +1,7 @@ +library DEFLIB + +using FHIR version '4.0.1' + +context Patient + +define "Encounters": [Encounter] diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/cql/Example.cql b/cqf-fhir-cr-cli/src/test/resources/compartment/cql/Example.cql new file mode 100644 index 0000000000..f391cc70d9 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/cql/Example.cql @@ -0,0 +1,7 @@ +library Example + +using FHIR version '4.0.1' + +context Patient + +define "Encounters": [Encounter] \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/resources/library/ABC-LIB.json b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/library/ABC-LIB.json new file mode 100644 index 0000000000..49fe90ad98 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/library/ABC-LIB.json @@ -0,0 +1,17 @@ +{ + "resourceType": "Library", + "id": "ABC-LIB", + "url": "http://example.com/Library/ABC-LIB", + "name": "ABC-LIB", + "status": "active", + "type": { + "coding": [ { + "system": "http://terminology.hl7.org/CodeSystem/library-type", + "code": "logic-library" + } ] + }, + "content": [ { + "contentType": "text/cql", + "url": "../../cql/ABC-LIB.cql" + } ] +} \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/resources/library/DEF-LIB.json b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/library/DEF-LIB.json new file mode 100644 index 0000000000..fe96fc7bc8 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/library/DEF-LIB.json @@ -0,0 +1,17 @@ +{ + "resourceType": "Library", + "id": "DEF-LIB", + "url": "http://example.com/Library/DEF-LIB", + "name": "DEF-LIB", + "status": "active", + "type": { + "coding": [ { + "system": "http://terminology.hl7.org/CodeSystem/library-type", + "code": "logic-library" + } ] + }, + "content": [ { + "contentType": "text/cql", + "url": "../../cql/DEF-LIB.cql" + } ] +} \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/resources/measure/ABC.json b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/measure/ABC.json new file mode 100644 index 0000000000..c373bd6e50 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/measure/ABC.json @@ -0,0 +1,8 @@ +{ + "id": "ABC", + "url": "http://example.com/Measure/ABC", + "resourceType" : "Measure", + "library": [ + "http://example.com/Library/ABC-LIB" + ] +} \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/resources/measure/DEF.json b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/measure/DEF.json new file mode 100644 index 0000000000..33b9a52d14 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/resources/measure/DEF.json @@ -0,0 +1,8 @@ +{ + "id": "DEF", + "url": "http://example.com/Measure/DEF", + "resourceType" : "Measure", + "library": [ + "http://example.com/Library/DEF-LIB" + ] +} \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/123/encounter/ABC.json b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/123/encounter/ABC.json new file mode 100644 index 0000000000..881455a85b --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/123/encounter/ABC.json @@ -0,0 +1,7 @@ +{ + "resourceType": "Encounter", + "id": "ABC", + "subject": { + "reference": "Patient/123" + } +} \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/123/patient/123.json b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/123/patient/123.json new file mode 100644 index 0000000000..7445fac010 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/123/patient/123.json @@ -0,0 +1,4 @@ +{ + "resourceType": "Patient", + "id": "123" +} diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/456/encounter/DEF.json b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/456/encounter/DEF.json new file mode 100644 index 0000000000..5ef8a50744 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/456/encounter/DEF.json @@ -0,0 +1,7 @@ +{ + "resourceType": "Encounter", + "id": "DEF", + "subject": { + "reference": "Patient/456" + } +} \ No newline at end of file diff --git a/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/456/patient/456.json b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/456/patient/456.json new file mode 100644 index 0000000000..4f8e71f552 --- /dev/null +++ b/cqf-fhir-cr-cli/src/test/resources/compartment/tests/patient/456/patient/456.json @@ -0,0 +1,4 @@ +{ + "resourceType": "Patient", + "id": "456" +} diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java index 8e8e9f3d32..274565422e 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java @@ -15,7 +15,6 @@ import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; import org.opencds.cqf.cql.engine.execution.EvaluationResult; import org.opencds.cqf.cql.engine.execution.ExpressionResult; import org.opencds.cqf.fhir.cr.measure.r4.R4MeasureScoringTypePopulations; @@ -38,7 +37,7 @@ * @see http://www.hl7.org/implement/standards/product_brief.cfm?product_id=97 */ -@SuppressWarnings("removal") +@SuppressWarnings({"removal", "squid:S1135", "squid:S3776"}) public class MeasureEvaluator { private final PopulationBasisValidator populationBasisValidator; @@ -57,8 +56,7 @@ public MeasureDef evaluate( Objects.requireNonNull(subjectId, "subjectIds is a required argument"); switch (measureEvalType) { - case PATIENT: - case SUBJECT: + case PATIENT, SUBJECT: return this.evaluateSubject( measureDef, subjectType, @@ -174,8 +172,7 @@ protected void evaluateProportion( boolean applyScoring) { // check populations R4MeasureScoringTypePopulations.validateScoringTypePopulations( - groupDef.populations().stream().map(PopulationDef::type).collect(Collectors.toList()), - groupDef.measureScoring()); + groupDef.populations().stream().map(PopulationDef::type).toList(), groupDef.measureScoring()); PopulationDef initialPopulation = groupDef.getSingle(INITIALPOPULATION); PopulationDef numerator = groupDef.getSingle(NUMERATOR); @@ -287,8 +284,7 @@ protected void evaluateContinuousVariable( PopulationDef measurePopulationExclusion = groupDef.getSingle(MEASUREPOPULATIONEXCLUSION); // Validate Required Populations are Present R4MeasureScoringTypePopulations.validateScoringTypePopulations( - groupDef.populations().stream().map(PopulationDef::type).collect(Collectors.toList()), - MeasureScoring.CONTINUOUSVARIABLE); + groupDef.populations().stream().map(PopulationDef::type).toList(), MeasureScoring.CONTINUOUSVARIABLE); initialPopulation = evaluatePopulationMembership(subjectType, subjectId, initialPopulation, evaluationResult); if (initialPopulation.getSubjects().contains(subjectId)) { @@ -313,8 +309,7 @@ protected void evaluateCohort( PopulationDef initialPopulation = groupDef.getSingle(INITIALPOPULATION); // Validate Required Populations are Present R4MeasureScoringTypePopulations.validateScoringTypePopulations( - groupDef.populations().stream().map(PopulationDef::type).collect(Collectors.toList()), - MeasureScoring.COHORT); + groupDef.populations().stream().map(PopulationDef::type).toList(), MeasureScoring.COHORT); // Evaluate Population evaluatePopulationMembership(subjectType, subjectId, initialPopulation, evaluationResult); } @@ -335,8 +330,7 @@ protected void evaluateGroup( var scoring = groupDef.measureScoring(); switch (scoring) { - case PROPORTION: - case RATIO: + case PROPORTION, RATIO: evaluateProportion(groupDef, subjectType, subjectId, reportType, evaluationResult, applyScoring); break; case CONTINUOUSVARIABLE: diff --git a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/BundleHelper.java b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/BundleHelper.java index 6a034d976b..0d2496c497 100644 --- a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/BundleHelper.java +++ b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/BundleHelper.java @@ -4,6 +4,7 @@ import ca.uhn.fhir.context.RuntimeSearchParam; import ca.uhn.fhir.context.RuntimeSearchParam.RuntimeSearchParamStatusEnum; import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import jakarta.annotation.Nonnull; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -16,6 +17,7 @@ import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; import org.hl7.fhir.r4.model.Bundle.BundleEntryRequestComponent; import org.hl7.fhir.r5.model.PrimitiveType; @@ -75,7 +77,7 @@ public static IBaseResource getEntryResourceFirstRep(IBaseBundle bundle) { * Returns a list of resources from the Bundle entries * * @param bundle IBaseBundle type - * @return + * @return List of IBaseResource */ public static List getEntryResources(IBaseBundle bundle) { List resources = new ArrayList<>(); @@ -83,27 +85,21 @@ public static List getEntryResources(IBaseBundle bundle) { switch (fhirVersion) { case DSTU3: var dstu3Entry = ((org.hl7.fhir.dstu3.model.Bundle) bundle).getEntry(); - for (var entry : dstu3Entry) { - if (entry.hasResource()) { - resources.add(entry.getResource()); - } - } + dstu3Entry.stream() + .filter(Bundle.BundleEntryComponent::hasResource) + .forEach(entry -> resources.add(entry.getResource())); break; case R4: var r4Entry = ((org.hl7.fhir.r4.model.Bundle) bundle).getEntry(); - for (var entry : r4Entry) { - if (entry.hasResource()) { - resources.add(entry.getResource()); - } - } + r4Entry.stream() + .filter(BundleEntryComponent::hasResource) + .forEach(entry -> resources.add(entry.getResource())); break; case R5: var r5Entry = ((org.hl7.fhir.r5.model.Bundle) bundle).getEntry(); - for (var entry : r5Entry) { - if (entry.hasResource()) { - resources.add(entry.getResource()); - } - } + r5Entry.stream() + .filter(org.hl7.fhir.r5.model.Bundle.BundleEntryComponent::hasResource) + .forEach(entry -> resources.add(entry.getResource())); break; default: @@ -303,7 +299,7 @@ public static String getEntryRequestUrl(FhirVersionEnum fhirVersion, IBaseBackbo .getRequest() .getUrl(); default -> throw new IllegalArgumentException( - String.format(UNSUPPORTED_VERSION_OF_FHIR, fhirVersion.getFhirVersionString())); + UNSUPPORTED_VERSION_OF_FHIR.formatted(fhirVersion.getFhirVersionString())); }; } @@ -387,35 +383,11 @@ public static IBaseBundle newBundle(FhirVersionEnum fhirVersion) { public static IBaseBundle newBundle(FhirVersionEnum fhirVersion, String id, String type) { switch (fhirVersion) { case DSTU3: - var dstu3Bundle = new org.hl7.fhir.dstu3.model.Bundle(); - if (id != null && !id.isEmpty()) { - dstu3Bundle.setId(id); - } - dstu3Bundle.setType( - type == null || type.isEmpty() - ? org.hl7.fhir.dstu3.model.Bundle.BundleType.COLLECTION - : org.hl7.fhir.dstu3.model.Bundle.BundleType.fromCode(type)); - return dstu3Bundle; + return newDstu3Bundle(id, type); case R4: - var r4Bundle = new org.hl7.fhir.r4.model.Bundle(); - if (id != null && !id.isEmpty()) { - r4Bundle.setId(id); - } - r4Bundle.setType( - type == null || type.isEmpty() - ? org.hl7.fhir.r4.model.Bundle.BundleType.COLLECTION - : org.hl7.fhir.r4.model.Bundle.BundleType.fromCode(type)); - return r4Bundle; + return newR4Bundle(id, type); case R5: - var r5Bundle = new org.hl7.fhir.r5.model.Bundle(); - if (id != null && !id.isEmpty()) { - r5Bundle.setId(id); - } - r5Bundle.setType( - type == null || type.isEmpty() - ? org.hl7.fhir.r5.model.Bundle.BundleType.COLLECTION - : org.hl7.fhir.r5.model.Bundle.BundleType.fromCode(type)); - return r5Bundle; + return newR5Bundle(id, type); default: throw new IllegalArgumentException( @@ -423,6 +395,43 @@ public static IBaseBundle newBundle(FhirVersionEnum fhirVersion, String id, Stri } } + @Nonnull + private static org.hl7.fhir.r5.model.Bundle newR5Bundle(String id, String type) { + var r5Bundle = new org.hl7.fhir.r5.model.Bundle(); + if (id != null && !id.isEmpty()) { + r5Bundle.setId(id); + } + r5Bundle.setType( + type == null || type.isEmpty() + ? org.hl7.fhir.r5.model.Bundle.BundleType.COLLECTION + : org.hl7.fhir.r5.model.Bundle.BundleType.fromCode(type)); + return r5Bundle; + } + + @Nonnull + private static org.hl7.fhir.r4.model.Bundle newR4Bundle(String id, String type) { + var r4Bundle = new org.hl7.fhir.r4.model.Bundle(); + if (id != null && !id.isEmpty()) { + r4Bundle.setId(id); + } + r4Bundle.setType( + type == null || type.isEmpty() + ? org.hl7.fhir.r4.model.Bundle.BundleType.COLLECTION + : org.hl7.fhir.r4.model.Bundle.BundleType.fromCode(type)); + return r4Bundle; + } + + @Nonnull + private static Bundle newDstu3Bundle(String id, String type) { + var dstu3Bundle = new Bundle(); + if (id != null && !id.isEmpty()) { + dstu3Bundle.setId(id); + } + dstu3Bundle.setType( + type == null || type.isEmpty() ? Bundle.BundleType.COLLECTION : Bundle.BundleType.fromCode(type)); + return dstu3Bundle; + } + /** * Sets the BundleType of the Bundle and returns the Bundle * @param bundle @@ -752,7 +761,7 @@ public static RuntimeSearchParam resourceToRuntimeSearchParam(IBaseResource reso null, res.getTarget().stream().map(StringType::toString).collect(Collectors.toSet()), RuntimeSearchParamStatusEnum.ACTIVE, - res.getBase().stream().map(StringType::toString).collect(Collectors.toList())); + res.getBase().stream().map(StringType::toString).toList()); case R4: var resR4 = (org.hl7.fhir.r4.model.SearchParameter) resource; return new RuntimeSearchParam( @@ -769,7 +778,7 @@ public static RuntimeSearchParam resourceToRuntimeSearchParam(IBaseResource reso RuntimeSearchParamStatusEnum.ACTIVE, resR4.getBase().stream() .map(org.hl7.fhir.r4.model.StringType::toString) - .collect(Collectors.toList())); + .toList()); case R5: var resR5 = (org.hl7.fhir.r5.model.SearchParameter) resource; return new RuntimeSearchParam( @@ -782,7 +791,7 @@ public static RuntimeSearchParam resourceToRuntimeSearchParam(IBaseResource reso null, resR5.getTarget().stream().map(PrimitiveType::toString).collect(Collectors.toSet()), RuntimeSearchParamStatusEnum.ACTIVE, - resR5.getBase().stream().map(PrimitiveType::toString).collect(Collectors.toList())); + resR5.getBase().stream().map(PrimitiveType::toString).toList()); default: throw new IllegalArgumentException( diff --git a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventions.java b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventions.java index c01065be8f..e0be6b94b5 100644 --- a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventions.java +++ b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventions.java @@ -1,23 +1,30 @@ package org.opencds.cqf.fhir.utility.repository.ig; -import com.google.common.io.Files; -import java.io.File; -import java.io.FilenameFilter; +import jakarta.annotation.Nonnull; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.hl7.fhir.r4.model.Enumerations.FHIRAllTypes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * This class represents the different file structures for an IG repository. The main differences between the - * various configurations are whether or not the files are organized by resource type and/or category, and whether - * or not the files are prefixed with the resource type. + * This class represents the different file structures for an IG repository. The main differences + * between the various configurations are whether or not the files are organized by resource type + * and/or category, and whether or not the files are prefixed with the resource type. */ -public final class IgConventions { +public record IgConventions( + org.opencds.cqf.fhir.utility.repository.ig.IgConventions.FhirTypeLayout typeLayout, + org.opencds.cqf.fhir.utility.repository.ig.IgConventions.CategoryLayout categoryLayout, + org.opencds.cqf.fhir.utility.repository.ig.IgConventions.CompartmentLayout compartmentLayout, + org.opencds.cqf.fhir.utility.repository.ig.IgConventions.FilenameMode filenameMode) { + + private static final Logger logger = LoggerFactory.getLogger(IgConventions.class); /** * Whether or not the files are organized by resource type. @@ -35,6 +42,15 @@ public enum CategoryLayout { FLAT } + /** + * Whether or not the files are organized by compartment. This is primarily used for tests to + * provide isolation between test cases. + */ + public enum CompartmentLayout { + DIRECTORY_PER_COMPARTMENT, + FLAT + } + /** * Whether or not the files are prefixed with the resource type. */ @@ -43,55 +59,30 @@ public enum FilenameMode { ID_ONLY } - public static final IgConventions FLAT = - new IgConventions(FhirTypeLayout.FLAT, CategoryLayout.FLAT, FilenameMode.TYPE_AND_ID); + public static final IgConventions FLAT = new IgConventions( + FhirTypeLayout.FLAT, CategoryLayout.FLAT, CompartmentLayout.FLAT, FilenameMode.TYPE_AND_ID); public static final IgConventions STANDARD = new IgConventions( - FhirTypeLayout.DIRECTORY_PER_TYPE, CategoryLayout.DIRECTORY_PER_CATEGORY, FilenameMode.ID_ONLY); + FhirTypeLayout.DIRECTORY_PER_TYPE, + CategoryLayout.DIRECTORY_PER_CATEGORY, + CompartmentLayout.FLAT, + FilenameMode.ID_ONLY); - private static final Logger LOG = LoggerFactory.getLogger(IgConventions.class); + private static final List FHIR_TYPE_NAMES = Stream.of(FHIRAllTypes.values()) + .map(FHIRAllTypes::name) + .map(String::toLowerCase) + .distinct() + .toList(); /** - * Creates new IGConventions with the given typeLayout, categoryLayout, and filenameMode. - * - * NOTE: The preferred way to create an IGConventions is to use the autoDetect method or one of the static instances, STANDARD or FLAT. The only cases where this constructor should be used is if the IG repository configuration is known ahead of time and is non-standard. - * - * @param typeLayout - * @param categoryLayout - * @param filenameMode - */ - public IgConventions(FhirTypeLayout typeLayout, CategoryLayout categoryLayout, FilenameMode filenameMode) { - this.typeLayout = typeLayout; - this.categoryLayout = categoryLayout; - this.filenameMode = filenameMode; - } - - private final FhirTypeLayout typeLayout; - private final CategoryLayout categoryLayout; - private final FilenameMode filenameMode; - - FhirTypeLayout typeLayout() { - return typeLayout; - } - - CategoryLayout categoryLayout() { - return categoryLayout; - } - - FilenameMode filenameMode() { - return filenameMode; - } - - /** - * Auto-detect the IG conventions based on the structure of the IG. - * If the path is null or the convention can not be reliably detected, - * the default configuration is returned. + * Auto-detect the IG conventions based on the structure of the IG. If the path is null or the + * convention can not be reliably detected, the default configuration is returned. * * @param path The path to the IG. - * * @return The IG conventions. */ public static IgConventions autoDetect(Path path) { - if (path == null || !path.toFile().exists()) { + + if (path == null || !Files.exists(path)) { return STANDARD; } @@ -103,6 +94,7 @@ public static IgConventions autoDetect(Path path) { // // Check all possible category paths and grab the first that exists, // or use the IG path if none exist. + var categoryPath = Stream.of("tests", "vocabulary", "resources") .map(path::resolve) .filter(x -> x.toFile().exists()) @@ -111,6 +103,39 @@ public static IgConventions autoDetect(Path path) { var hasCategoryDirectory = !path.equals(categoryPath); + var hasCompartmentDirectory = false; + + // Compartments can only exist for test data + if (hasCategoryDirectory) { + var tests = path.resolve("tests"); + // A compartment under the tests looks like a set of subdirectories + // e.g. "input/tests/Patient", "input/tests/Practitioner" + // that themselves contain subdirectories for each test case. + // e.g. "input/tests/Patient/test1", "input/tests/Patient/test2" + // Then within those, the structure may be flat (e.g. "input/tests/Patient/test1/123.json") + // or grouped by type (e.g. "input/tests/Patient/test1/Patient/123.json"). + // + // The trick is that the in the case that the test cases are + // grouped by type, the compartment directory will be the same as the type directory. + // so we need to look at the resource type directory and check if the contents are files + // or more directories. If more directories exist, and the directory name is not a + // FHIR type, then we have a compartment directory. + + if (tests.toFile().exists()) { + var compartments = FHIR_TYPE_NAMES.stream().map(tests::resolve).filter(x -> x.toFile() + .exists()); + + final List compartmentsList = compartments.toList(); + + // Check if any of the potential compartment directories + // have subdirectories that are not FHIR types (e.g. "input/tests/Patient/test1). + hasCompartmentDirectory = compartmentsList.stream() + .flatMap(IgConventions::listFiles) + .filter(Files::isDirectory) + .anyMatch(IgConventions::matchesAnyResource); + } + } + // A "type" may also exist in the igs file structure, where resources // are grouped by type into subdirectories. // @@ -118,58 +143,89 @@ public static IgConventions autoDetect(Path path) { // // Check all possible type paths and grab the first that exists, // or use the category directory if none exist - var typePath = Stream.of(FHIRAllTypes.values()) - .map(FHIRAllTypes::name) - .map(String::toLowerCase) + var typePath = FHIR_TYPE_NAMES.stream() .map(categoryPath::resolve) - .filter(x -> x.toFile().exists()) + .filter(Files::exists) .findFirst() .orElse(categoryPath); var hasTypeDirectory = !categoryPath.equals(typePath); - // Potential resource files are files that contain a "." and have a valid FHIR file extension. - FilenameFilter resourceFileFilter = (dir, name) -> name.contains(".") - && IgRepository.FILE_EXTENSIONS.containsValue(name.toLowerCase().substring(name.lastIndexOf('.') + 1)); - var potentialResourceFiles = typePath.toFile().listFiles(resourceFileFilter); - // A file "claims" to be a FHIR resource type if its filename starts with a valid FHIR type name. // For files that "claim" to be a FHIR resource type, we check to see if the contents of the file // have a resource that matches the claimed type. - var hasTypeFilename = Stream.of(potentialResourceFiles) - .filter(file -> claimedFhirType(file) != FHIRAllTypes.NULL) - .anyMatch(file -> contentsMatchClaimedType(file, claimedFhirType(file))); + var hasTypeFilename = hasTypeFilename(typePath); var config = new IgConventions( hasTypeDirectory ? FhirTypeLayout.DIRECTORY_PER_TYPE : FhirTypeLayout.FLAT, hasCategoryDirectory ? CategoryLayout.DIRECTORY_PER_CATEGORY : CategoryLayout.FLAT, + hasCompartmentDirectory ? CompartmentLayout.DIRECTORY_PER_COMPARTMENT : CompartmentLayout.FLAT, hasTypeFilename ? FilenameMode.TYPE_AND_ID : FilenameMode.ID_ONLY); - LOG.info("Auto-detected repository configuration: {}", config); + logger.info("Auto-detected repository configuration: {}", config); return config; } + private static boolean hasTypeFilename(Path typePath) { + try (var fileStream = Files.list(typePath)) { + return fileStream + .filter(IgConventions::fileNameMatchesType) + .filter(filePath -> claimedFhirType(filePath) != FHIRAllTypes.NULL) + .anyMatch(filePath -> contentsMatchClaimedType(filePath, claimedFhirType(filePath))); + } catch (IOException exception) { + logger.error("Error listing files in path: {}", typePath, exception); + return false; + } + } + + private static boolean fileNameMatchesType(Path innerFile) { + Objects.requireNonNull(innerFile); + var fileName = innerFile.getFileName().toString(); + return FHIR_TYPE_NAMES.stream().anyMatch(type -> fileName.toLowerCase().startsWith(type)); + } + + private static boolean matchesAnyResource(Path innerFile) { + return !FHIR_TYPE_NAMES.contains(innerFile.getFileName().toString().toLowerCase()); + } + + @Nonnull + private static Stream listFiles(Path innerPath) { + try { + return Files.list(innerPath); + } catch (IOException e) { + logger.error("Error listing files in path: {}", innerPath, e); + return Stream.empty(); + } + } + // This method checks to see if the contents of a file match the type claimed by the filename - private static boolean contentsMatchClaimedType(File file, FHIRAllTypes claimedFhirType) { - Objects.requireNonNull(file); + private static boolean contentsMatchClaimedType(Path filePath, FHIRAllTypes claimedFhirType) { + Objects.requireNonNull(filePath); Objects.requireNonNull(claimedFhirType); - try { - var contents = Files.asCharSource(file, StandardCharsets.UTF_8).read(); - if (contents == null || contents.isEmpty()) { + try (var linesStream = Files.lines(filePath, StandardCharsets.UTF_8)) { + var contents = linesStream.collect(Collectors.joining()); + if (contents.isEmpty()) { return false; } - return contents.toUpperCase().contains("\"RESOURCETYPE\": \"%s\"".formatted(claimedFhirType.name())); + var filename = filePath.getFileName().toString(); + var fileNameWithoutExtension = filename.substring(0, filename.lastIndexOf(".")); + // Check that the contents contain the claimed type, and that the id is not the same as the filename + // NOTE: This does not work for XML files. + return contents.toUpperCase().contains("\"RESOURCETYPE\": \"%s\"".formatted(claimedFhirType.name())) + && !contents.toUpperCase() + .contains("\"ID\": \"%s\"".formatted(fileNameWithoutExtension.toUpperCase())); + } catch (IOException e) { return false; } } // Detects the FHIR type claimed by the filename - private static FHIRAllTypes claimedFhirType(File file) { - var filename = file.getName(); + private static FHIRAllTypes claimedFhirType(Path filePath) { + var filename = filePath.getFileName().toString(); if (!filename.contains("-")) { return FHIRAllTypes.NULL; } @@ -183,29 +239,26 @@ private static FHIRAllTypes claimedFhirType(File file) { } @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((typeLayout == null) ? 0 : typeLayout.hashCode()); - result = prime * result + ((categoryLayout == null) ? 0 : categoryLayout.hashCode()); - result = prime * result + ((filenameMode == null) ? 0 : filenameMode.hashCode()); - return result; + public boolean equals(Object other) { + if (other == null || getClass() != other.getClass()) { + return false; + } + IgConventions that = (IgConventions) other; + return typeLayout == that.typeLayout + && filenameMode == that.filenameMode + && categoryLayout == that.categoryLayout + && compartmentLayout == that.compartmentLayout; } @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null) return false; - if (getClass() != obj.getClass()) return false; - IgConventions other = (IgConventions) obj; - if (typeLayout != other.typeLayout) return false; - if (categoryLayout != other.categoryLayout) return false; - return filenameMode == other.filenameMode; + public int hashCode() { + return Objects.hash(typeLayout, categoryLayout, compartmentLayout, filenameMode); } @Override + @Nonnull public String toString() { - return "IGConventions [typeLayout=%s, categoryLayout=%s, filenameMode=%s]" - .formatted(typeLayout, categoryLayout, filenameMode); + return "IGConventions [typeLayout=%s, categoryLayout=%s compartmentLayout=%s, filenameMode=%s]" + .formatted(typeLayout, categoryLayout, compartmentLayout, filenameMode); } } diff --git a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepository.java b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepository.java index 288e3d949f..b6bd66e85c 100644 --- a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepository.java +++ b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepository.java @@ -41,6 +41,7 @@ import org.opencds.cqf.fhir.utility.repository.Repositories; import org.opencds.cqf.fhir.utility.repository.ig.EncodingBehavior.PreserveEncoding; import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.CategoryLayout; +import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.CompartmentLayout; import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.FhirTypeLayout; import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.FilenameMode; import org.opencds.cqf.fhir.utility.repository.operations.IRepositoryOperationProvider; @@ -90,6 +91,7 @@ * */ public class IgRepository implements IRepository { + private final FhirContext fhirContext; private final Path root; private final IgConventions conventions; @@ -119,18 +121,18 @@ public class IgRepository implements IRepository { .put(EncodingEnum.RDF, "rdf") .build(); + // This header to used so that the user can pass current compartment context + // to the repository. Basically, this will effect how the repository will do reads/writes + // The expected format for this header is: ResourceType/Id (e.g. Patient/123) + public static final String FHIR_COMPARTMENT_HEADER = "X-FHIR-Compartment"; + private static IParser parserForEncoding(FhirContext fhirContext, EncodingEnum encodingEnum) { - switch (encodingEnum) { - case JSON: - return fhirContext.newJsonParser(); - case XML: - return fhirContext.newXmlParser(); - case RDF: - return fhirContext.newRDFParser(); - case NDJSON: - default: - throw new IllegalArgumentException("NDJSON is not supported"); - } + return switch (encodingEnum) { + case JSON -> fhirContext.newJsonParser(); + case XML -> fhirContext.newXmlParser(); + case RDF -> fhirContext.newRDFParser(); + default -> throw new IllegalArgumentException("NDJSON is not supported"); + }; } /** @@ -212,8 +214,9 @@ private boolean isExternalPath(Path path) { * @return The {@code Path} representing the preferred location for the * resource. */ - protected Path preferredPathForResource(Class resourceType, I id) { - var directory = directoryForResource(resourceType); + protected Path preferredPathForResource( + Class resourceType, I id, IgRepositoryCompartment igRepositoryCompartment) { + var directory = directoryForResource(resourceType, igRepositoryCompartment); var fileName = fileNameForResource( resourceType.getSimpleName(), id.getIdPart(), this.encodingBehavior.preferredEncoding()); return directory.resolve(fileName); @@ -226,12 +229,14 @@ protected Path preferredPathForReso * @param The type of the resource identifier. * @param resourceType The class representing the FHIR resource type. * @param id The identifier of the resource. + * @param igRepositoryCompartment The compartment context to use * @return A list of potential paths for the resource. */ - List potentialPathsForResource(Class resourceType, I id) { + protected List potentialPathsForResource( + Class resourceType, I id, IgRepositoryCompartment igRepositoryCompartment) { var potentialDirectories = new ArrayList(); - var directory = directoryForResource(resourceType); + var directory = directoryForResource(resourceType, igRepositoryCompartment); potentialDirectories.add(directory); // Currently, only terminology resources are allowed to be external @@ -280,16 +285,24 @@ protected String fileNameForResource(String resourceType, String resourceId, Enc * * @param The type of the FHIR resource. * @param resourceType The class representing the FHIR resource type. + * @param igRepositoryCompartment The compartment context to use * @return The path representing the directory for the resource category. */ - protected Path directoryForCategory(Class resourceType) { + protected Path directoryForCategory( + Class resourceType, IgRepositoryCompartment igRepositoryCompartment) { if (this.conventions.categoryLayout() == CategoryLayout.FLAT) { return this.root; } var category = ResourceCategory.forType(resourceType.getSimpleName()); var directory = CATEGORY_DIRECTORIES.get(category); - return root.resolve(directory); + var path = root.resolve(directory); + if (category == ResourceCategory.DATA + && this.conventions.compartmentLayout() == CompartmentLayout.DIRECTORY_PER_COMPARTMENT) { + path = path.resolve(pathForCompartment(igRepositoryCompartment)); + } + + return path; } /** @@ -312,10 +325,12 @@ protected Path directoryForCategory(Class resourceT * * @param The type of the FHIR resource. * @param resourceType The class representing the FHIR resource type. + * @param igRepositoryCompartment The compartment context to use * @return The path representing the directory for the resource type. */ - protected Path directoryForResource(Class resourceType) { - var directory = directoryForCategory(resourceType); + protected Path directoryForResource( + Class resourceType, IgRepositoryCompartment igRepositoryCompartment) { + var directory = directoryForCategory(resourceType, igRepositoryCompartment); if (this.conventions.typeLayout() == FhirTypeLayout.FLAT) { return directory; } @@ -440,10 +455,12 @@ private boolean acceptByFileExtensionAndPrefix(Path path, String prefix) { * * @param The resource type. * @param resourceClass The resource class. + * @param igRepositoryCompartment The compartment context to use * @return Map of resource IDs to resources. */ - protected Map readDirectoryForResourceType(Class resourceClass) { - var path = this.directoryForResource(resourceClass); + protected Map readDirectoryForResourceType( + Class resourceClass, IgRepositoryCompartment igRepositoryCompartment) { + var path = this.directoryForResource(resourceClass, igRepositoryCompartment); if (!path.toFile().exists()) { return Collections.emptyMap(); } @@ -519,7 +536,9 @@ public T read( requireNonNull(resourceType, "resourceType cannot be null"); requireNonNull(id, "id cannot be null"); - var paths = this.potentialPathsForResource(resourceType, id); + var compartment = compartmentFrom(headers); + + var paths = this.potentialPathsForResource(resourceType, id, compartment); for (var path : paths) { if (!path.toFile().exists()) { continue; @@ -561,7 +580,9 @@ public MethodOutcome create(T resource, Map MethodOutcome update(T resource, Map MethodOutcome delete( requireNonNull(resourceType, "resourceType cannot be null"); requireNonNull(id, "id cannot be null"); - var paths = this.potentialPathsForResource(resourceType, id); - + var compartment = compartmentFrom(headers); + var paths = this.potentialPathsForResource(resourceType, id, compartment); boolean deleted = false; for (var path : paths) { try { @@ -741,7 +764,9 @@ public B search( BundleBuilder builder = new BundleBuilder(this.fhirContext); builder.setType("searchset"); - var resourceIdMap = readDirectoryForResourceType(resourceType); + var compartment = compartmentFrom(headers); + + var resourceIdMap = readDirectoryForResourceType(resourceType, compartment); if (searchParameters == null || searchParameters.isEmpty()) { resourceIdMap.values().forEach(builder::addCollectionEntry); return (B) builder.getBundle(); @@ -843,4 +868,25 @@ protected R invokeOperation( } return operationProvider.invokeOperation(this, id, resourceType, operationName, parameters); } + + protected IgRepositoryCompartment compartmentFrom(Map headers) { + if (headers == null) { + return new IgRepositoryCompartment(); + } + + var compartmentHeader = headers.get(FHIR_COMPARTMENT_HEADER); + return compartmentHeader == null + ? new IgRepositoryCompartment() + : new IgRepositoryCompartment(compartmentHeader); + } + + // Patient context is a special-case. We don't tack the compartment context on + // the end of the path. We just use the id as the directory. + protected String pathForCompartment(IgRepositoryCompartment igRepositoryCompartment) { + if (igRepositoryCompartment.isEmpty()) { + return ""; + } + + return igRepositoryCompartment.getType() + "/" + igRepositoryCompartment.getId(); + } } diff --git a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepositoryCompartment.java b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepositoryCompartment.java new file mode 100644 index 0000000000..b39440a593 --- /dev/null +++ b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepositoryCompartment.java @@ -0,0 +1,83 @@ +package org.opencds.cqf.fhir.utility.repository.ig; + +import static java.util.Objects.requireNonNull; + +import java.util.Objects; +import java.util.StringJoiner; + +/** + * Class that represents the compartment context for a given request within {@link IgRepository} only. + */ +public class IgRepositoryCompartment { + + private final String type; + private final String id; + + private static String typeOfContext(String context) { + return context.split("/")[0]; + } + + private static String idOfContext(String context) { + return context.split("/")[1]; + } + + // Empty context (i.e. no compartment context) + public IgRepositoryCompartment() { + this.type = null; + this.id = null; + } + + // Context in the format ResourceType/Id + public IgRepositoryCompartment(String context) { + this(typeOfContext(context), idOfContext(context)); + } + + // Context in the format type and id + public IgRepositoryCompartment(String type, String id) { + // Make this lowercase so the path will resolve on Linux (FYI: macOS is case-insensitive) + this.type = requireNonNullOrEmpty("type", type).toLowerCase(); + this.id = requireNonNullOrEmpty("id", id); + } + + public String getType() { + return this.type; + } + + public String getId() { + return this.id; + } + + public boolean isEmpty() { + return this.type == null || this.id == null; + } + + private static String requireNonNullOrEmpty(String name, String value) { + requireNonNull(name, "name cannot be null"); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " cannot be null or empty"); + } + return value; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + IgRepositoryCompartment that = (IgRepositoryCompartment) o; + return Objects.equals(type, that.type) && Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(type, id); + } + + @Override + public String toString() { + return new StringJoiner(", ", IgRepositoryCompartment.class.getSimpleName() + "[", "]") + .add("type='" + type + "'") + .add("id='" + id + "'") + .toString(); + } +} diff --git a/cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventionsTest.java b/cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventionsTest.java index 9261289862..ea9e6e9f6b 100644 --- a/cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventionsTest.java +++ b/cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/repository/ig/IgConventionsTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.io.TempDir; import org.opencds.cqf.fhir.test.Resources; import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.CategoryLayout; +import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.CompartmentLayout; import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.FhirTypeLayout; import org.opencds.cqf.fhir.utility.repository.ig.IgConventions.FilenameMode; @@ -47,6 +48,7 @@ void autoDetectPrefix() { var config = IgConventions.autoDetect(tempDir.resolve("directoryPerType/prefixed")); assertEquals(FilenameMode.TYPE_AND_ID, config.filenameMode()); assertEquals(CategoryLayout.DIRECTORY_PER_CATEGORY, config.categoryLayout()); + assertEquals(CompartmentLayout.FLAT, config.compartmentLayout()); assertEquals(FhirTypeLayout.DIRECTORY_PER_TYPE, config.typeLayout()); } @@ -60,6 +62,7 @@ void autoDetectFlatNoTypeNames() { var config = IgConventions.autoDetect(tempDir.resolve("flatNoTypeNames")); assertEquals(FilenameMode.ID_ONLY, config.filenameMode()); assertEquals(CategoryLayout.FLAT, config.categoryLayout()); + assertEquals(CompartmentLayout.FLAT, config.compartmentLayout()); assertEquals(FhirTypeLayout.FLAT, config.typeLayout()); } @@ -77,4 +80,13 @@ void autoDetectWithEmptyContent() { void autoDetectWithNonFhirFilename() { assertEquals(IgConventions.STANDARD, IgConventions.autoDetect(tempDir.resolve("nonFhirFilename"))); } + + @Test + void autoDetectWitCompartments() { + var config = IgConventions.autoDetect(tempDir.resolve("compartment")); + assertEquals(FilenameMode.ID_ONLY, config.filenameMode()); + assertEquals(CategoryLayout.DIRECTORY_PER_CATEGORY, config.categoryLayout()); + assertEquals(CompartmentLayout.DIRECTORY_PER_COMPARTMENT, config.compartmentLayout()); + assertEquals(FhirTypeLayout.DIRECTORY_PER_TYPE, config.typeLayout()); + } } diff --git a/cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepositoryIgRepositoryCompartmentTest.java b/cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepositoryIgRepositoryCompartmentTest.java new file mode 100644 index 0000000000..d776447250 --- /dev/null +++ b/cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/repository/ig/IgRepositoryIgRepositoryCompartmentTest.java @@ -0,0 +1,254 @@ +package org.opencds.cqf.fhir.utility.repository.ig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Encounter; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Library; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.ValueSet; +import org.junit.jupiter.api.BeforeAll; +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.io.TempDir; +import org.opencds.cqf.fhir.test.Resources; +import org.opencds.cqf.fhir.utility.Ids; +import org.opencds.cqf.fhir.utility.search.Searches; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class IgRepositoryIgRepositoryCompartmentTest { + + private static IRepository repository; + + @TempDir + static Path tempDir; + + @BeforeAll + static void setup() throws URISyntaxException, IOException, ClassNotFoundException { + // This copies the sample IG to a temporary directory so that + // we can test against an actual filesystem + Resources.copyFromJar("/sampleIgs/compartment", tempDir); + repository = new IgRepository(FhirContext.forR4Cached(), tempDir); + } + + @Test + void readLibrary() { + var id = Ids.newId(Library.class, "123"); + var lib = repository.read(Library.class, id); + assertNotNull(lib); + assertEquals(id.getIdPart(), lib.getIdElement().getIdPart()); + } + + @Test + void readLibraryNotExists() { + var id = Ids.newId(Library.class, "DoesNotExist"); + assertThrows(ResourceNotFoundException.class, () -> repository.read(Library.class, id)); + } + + @Test + void searchLibrary() { + var libs = repository.search(Bundle.class, Library.class, Searches.ALL); + + assertNotNull(libs); + assertEquals(2, libs.getEntry().size()); + } + + @Test + void searchLibraryNotExists() { + var libs = repository.search(Bundle.class, Library.class, Searches.byUrl("not-exists")); + assertNotNull(libs); + assertEquals(0, libs.getEntry().size()); + } + + @Test + void readPatientNoCompartment() { + var id = Ids.newId(Patient.class, "123"); + assertThrows(ResourceNotFoundException.class, () -> repository.read(Patient.class, id)); + } + + @Test + void readPatient() { + var id = Ids.newId(Patient.class, "123"); + var p = repository.read(Patient.class, id, Map.of(IgRepository.FHIR_COMPARTMENT_HEADER, "Patient/123")); + + assertNotNull(p); + assertEquals(id.getIdPart(), p.getIdElement().getIdPart()); + } + + @Test + void searchEncounterNoCompartment() { + var encounters = repository.search(Bundle.class, Encounter.class, Searches.ALL); + assertNotNull(encounters); + assertEquals(0, encounters.getEntry().size()); + } + + @Test + void searchEncounter() { + var encounters = repository.search( + Bundle.class, + Encounter.class, + Searches.ALL, + Map.of(IgRepository.FHIR_COMPARTMENT_HEADER, "Patient/123")); + assertNotNull(encounters); + assertEquals(1, encounters.getEntry().size()); + } + + @Test + void readValueSetNoCompartment() { + var id = Ids.newId(ValueSet.class, "456"); + var vs = repository.read(ValueSet.class, id); + + assertNotNull(vs); + assertEquals(vs.getIdPart(), vs.getIdElement().getIdPart()); + } + + // Terminology resources are not in compartments + @Test + void readValueSet() { + var id = Ids.newId(ValueSet.class, "456"); + var vs = repository.read(ValueSet.class, id, Map.of(IgRepository.FHIR_COMPARTMENT_HEADER, "Patient/123")); + + assertNotNull(vs); + assertEquals(vs.getIdPart(), vs.getIdElement().getIdPart()); + } + + @Test + void searchValueSet() { + var sets = repository.search(Bundle.class, ValueSet.class, Searches.byUrl("example.com/ValueSet/456")); + assertNotNull(sets); + assertEquals(1, sets.getEntry().size()); + } + + @Test + void createAndDeleteLibrary() { + var lib = new Library(); + lib.setId("new-library"); + var o = repository.create(lib); + var created = repository.read(Library.class, o.getId()); + assertNotNull(created); + + var loc = tempDir.resolve("resources/library/new-library.json"); + assertTrue(Files.exists(loc)); + + repository.delete(Library.class, created.getIdElement()); + assertFalse(Files.exists(loc)); + } + + @Test + void createAndDeletePatient() { + var p = new Patient(); + p.setId("new-patient"); + var header = Map.of(IgRepository.FHIR_COMPARTMENT_HEADER, "Patient/new-patient"); + var o = repository.create(p, header); + var created = repository.read(Patient.class, o.getId(), header); + assertNotNull(created); + + var loc = tempDir.resolve("tests/patient/new-patient/patient/new-patient.json"); + assertTrue(Files.exists(loc)); + + repository.delete(Patient.class, created.getIdElement(), header); + assertFalse(Files.exists(loc)); + } + + @Test + void createAndDeleteValueSet() { + var v = new ValueSet(); + v.setId("new-valueset"); + var o = repository.create(v); + var created = repository.read(ValueSet.class, o.getId()); + assertNotNull(created); + + var loc = tempDir.resolve("vocabulary/valueset/new-valueset.json"); + assertTrue(Files.exists(loc)); + + repository.delete(ValueSet.class, created.getIdElement()); + assertFalse(Files.exists(loc)); + } + + @Test + void updatePatient() { + var id = Ids.newId(Patient.class, "123"); + var p = repository.read(Patient.class, id, Map.of(IgRepository.FHIR_COMPARTMENT_HEADER, "Patient/123")); + assertFalse(p.hasActive()); + + p.setActive(true); + repository.update(p); + + var updated = repository.read(Patient.class, id, Map.of(IgRepository.FHIR_COMPARTMENT_HEADER, "Patient/123")); + assertTrue(updated.hasActive()); + assertTrue(updated.getActive()); + } + + @Test + void deleteNonExistentPatient() { + var id = Ids.newId(Patient.class, "DoesNotExist"); + assertThrows(ResourceNotFoundException.class, () -> repository.delete(Patient.class, id)); + } + + @Test + void searchNonExistentType() { + var results = repository.search(Bundle.class, Encounter.class, Searches.ALL); + assertNotNull(results); + assertEquals(0, results.getEntry().size()); + } + + @Test + void searchById() { + var bundle = repository.search(Bundle.class, Library.class, Searches.byId("123")); + assertNotNull(bundle); + assertEquals(1, bundle.getEntry().size()); + } + + @Test + void searchByIdNotFound() { + var bundle = repository.search(Bundle.class, Library.class, Searches.byId("DoesNotExist")); + assertNotNull(bundle); + assertEquals(0, bundle.getEntry().size()); + } + + @Test + @Order(1) // Do this test first because it puts the filesystem (temporarily) in an invalid state + void resourceMissingWhenCacheCleared() throws IOException { + var id = new IdType("Library", "ToDelete"); + var lib = new Library().setIdElement(id); + var path = tempDir.resolve("resources/library/ToDelete.json"); + + repository.create(lib); + assertTrue(path.toFile().exists()); + + // Read back, should exist + lib = repository.read(Library.class, id); + assertNotNull(lib); + + // Overwrite the file on disk. + Files.writeString(path, ""); + + // Read from cache, repo doesn't know the content is gone. + lib = repository.read(Library.class, id); + assertNotNull(lib); + assertEquals("ToDelete", lib.getIdElement().getIdPart()); + + ((IgRepository) repository).clearCache(); + + // Try to read again, should be gone because it's not in the cache and the content is gone. + assertThrows(ResourceNotFoundException.class, () -> repository.read(Library.class, id)); + + // Clean up so that we don't affect other tests + path.toFile().delete(); + } +} diff --git a/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/resources/library/123.json b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/resources/library/123.json new file mode 100644 index 0000000000..b026b65f2c --- /dev/null +++ b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/resources/library/123.json @@ -0,0 +1,4 @@ +{ + "resourceType": "Library", + "id": "123" +} \ No newline at end of file diff --git a/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/resources/library/456.json b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/resources/library/456.json new file mode 100644 index 0000000000..7e88b65458 --- /dev/null +++ b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/resources/library/456.json @@ -0,0 +1,4 @@ +{ + "resourceType": "Library", + "id": "456" +} \ No newline at end of file diff --git a/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/123/encounter/ABC.json b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/123/encounter/ABC.json new file mode 100644 index 0000000000..881455a85b --- /dev/null +++ b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/123/encounter/ABC.json @@ -0,0 +1,7 @@ +{ + "resourceType": "Encounter", + "id": "ABC", + "subject": { + "reference": "Patient/123" + } +} \ No newline at end of file diff --git a/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/123/patient/123.json b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/123/patient/123.json new file mode 100644 index 0000000000..7445fac010 --- /dev/null +++ b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/123/patient/123.json @@ -0,0 +1,4 @@ +{ + "resourceType": "Patient", + "id": "123" +} diff --git a/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/456/patient/456.json b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/456/patient/456.json new file mode 100644 index 0000000000..4f8e71f552 --- /dev/null +++ b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/tests/patient/456/patient/456.json @@ -0,0 +1,4 @@ +{ + "resourceType": "Patient", + "id": "456" +} diff --git a/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/vocabulary/valueset/456.json b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/vocabulary/valueset/456.json new file mode 100644 index 0000000000..3ad4292acd --- /dev/null +++ b/cqf-fhir-utility/src/test/resources/sampleIgs/compartment/vocabulary/valueset/456.json @@ -0,0 +1,5 @@ +{ + "resourceType": "ValueSet", + "id": "456", + "url": "example.com/ValueSet/456" +} \ No newline at end of file