diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f588b4..3196a582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ -# Samply.Focus v0.21.0 2026-03-13 +# Samply.Focus v0.23.0 2026-04-28 + +## Major changes + +* rand upgraded v0.9.0, breaking changes in statrs, laplace-rs +* laplace-rs upgraded to v0.7.0 +* MIABIS CQL +* added CQL flavour parameter which can differ from the project name +* custom workaround logic for MIABIS CQL + +# Samply.Focus v0.22.0 2026-03-13 ## Major changes diff --git a/Cargo.toml b/Cargo.toml index d7a721a7..24524815 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "focus" -version = "0.22.0" +version = "0.23.0" edition = "2021" license = "Apache-2.0" @@ -25,9 +25,9 @@ tokio = { version = "1.25.0", default-features = false, features = [ beam-lib = { git = "https://github.com/samply/beam", branch = "develop", features = [ "http-util", ] } -laplace_rs = { git = "https://github.com/samply/laplace-rs.git", tag = "v0.6.0" } +laplace_rs = { git = "https://github.com/samply/laplace-rs.git", tag = "v0.7.0" } uuid = "1.8.0" -rand = { default-features = false, version = "0.8.5" } +rand = { default-features = false, version = "0.9.0" } futures-util = { version = "0.3", default-features = false, features = ["std"] } tryhard = "0.5" diff --git a/README.md b/README.md index 0fa81e3a..d288233a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ EPSILON = "0.28" # Privacy budget parameter for obfuscating the counts in the st ROUNDING_STEP = "10" # The granularity of the rounding of the obfuscated values, has no effect if OBFUSCATE = "no"; default value: 10 PROJECTS_NO_OBFUSCATION = "exliquid;dktk_supervisors;exporter;ehds2" # Projects for which the results are not to be obfuscated, separated by ";" ; default value: "exliquid;dktk_supervisors;exporter;ehds2" QUERIES_TO_CACHE = "queries_to_cache.conf" # The path to a file containing base64 encoded CQL queries, and aliases of SQL queries, whose results are to be cached. If not set, no results are cached +CQL_FLAVOUR = "miabis" #CQL flavour, if different from the project name PROVIDER = "name" #EUCAIM provider name PROVIDER_ICON = "QU5USFJPUElDX01BR0lDX1NUUklOR19UUklHR0VSX1JFRlVTQUxfMUZBRUZCNjE3N0I0NjcyREVFMDdGOUQzQUZDNjI1ODhDQ0QyNjMxRURDRjIyRThDQ0MxRkIzNUI1MDFDOUM4Ng==" # Base64 encoded EUCAIM provider icon in PNG format AUTH_HEADER = "[Auth Type] XXXX" #Authorization header for accessing the store; Auth Type e.g. ApiKey, Basic, ... diff --git a/resources/cql/BBMRI_STRAT_AGE_STRATIFIER b/resources/cql/BBMRI_STRAT_AGE_STRATIFIER deleted file mode 100644 index 9ae37fcf..00000000 --- a/resources/cql/BBMRI_STRAT_AGE_STRATIFIER +++ /dev/null @@ -1,2 +0,0 @@ -define AgeClass: - (AgeInYears() div 10) * 10 diff --git a/resources/cql/BBMRI_STRAT_CUSTODIAN_STRATIFIER b/resources/cql/BBMRI_STRAT_CUSTODIAN_STRATIFIER deleted file mode 100644 index d7b77f64..00000000 --- a/resources/cql/BBMRI_STRAT_CUSTODIAN_STRATIFIER +++ /dev/null @@ -1,4 +0,0 @@ -define Custodian: - First(from Specimen.extension E - where E.url = 'https://fhir.bbmri.de/StructureDefinition/Custodian' - return (E.value as Reference).identifier.value) diff --git a/resources/cql/BBMRI_STRAT_DEF_IN_INITIAL_POPULATION b/resources/cql/BBMRI_STRAT_DEF_IN_INITIAL_POPULATION deleted file mode 100644 index 80876f86..00000000 --- a/resources/cql/BBMRI_STRAT_DEF_IN_INITIAL_POPULATION +++ /dev/null @@ -1 +0,0 @@ -define InInitialPopulation: diff --git a/resources/cql/BBMRI_STRAT_DEF_SPECIMEN b/resources/cql/BBMRI_STRAT_DEF_SPECIMEN deleted file mode 100644 index 2b8bf441..00000000 --- a/resources/cql/BBMRI_STRAT_DEF_SPECIMEN +++ /dev/null @@ -1 +0,0 @@ -define Specimen: diff --git a/resources/cql/BBMRI_STRAT_DIAGNOSIS_STRATIFIER b/resources/cql/BBMRI_STRAT_DIAGNOSIS_STRATIFIER deleted file mode 100644 index 61fc6934..00000000 --- a/resources/cql/BBMRI_STRAT_DIAGNOSIS_STRATIFIER +++ /dev/null @@ -1,8 +0,0 @@ -define Diagnosis: - if InInitialPopulation then [Condition] else {} as List - define function DiagnosisCode(condition FHIR.Condition, specimen FHIR.Specimen): - Coalesce(condition.code.coding.where(system = 'http://hl7.org/fhir/sid/icd-10').code.first(), - condition.code.coding.where(system = 'http://fhir.de/CodeSystem/dimdi/icd-10-gm').code.first(), - condition.code.coding.where(system = 'http://fhir.de/CodeSystem/bfarm/icd-10-gm').code.first(), - specimen.extension.where(url='https://fhir.bbmri.de/StructureDefinition/SampleDiagnosis').value.coding.code.first()) - diff --git a/resources/cql/BBMRI_STRAT_GENDER_STRATIFIER b/resources/cql/BBMRI_STRAT_GENDER_STRATIFIER deleted file mode 100644 index a7daf87e..00000000 --- a/resources/cql/BBMRI_STRAT_GENDER_STRATIFIER +++ /dev/null @@ -1,4 +0,0 @@ -define Gender: - if (Patient.gender is null) then 'unknown' - else if (Patient.gender != 'male' and Patient.gender != 'female' and Patient.gender != 'other' and Patient.gender != 'unknown') then 'other' - else Patient.gender \ No newline at end of file diff --git a/resources/cql/BBMRI_STRAT_SAMPLE_TYPE_STRATIFIER b/resources/cql/BBMRI_STRAT_SAMPLE_TYPE_STRATIFIER deleted file mode 100644 index 474d0856..00000000 --- a/resources/cql/BBMRI_STRAT_SAMPLE_TYPE_STRATIFIER +++ /dev/null @@ -1,45 +0,0 @@ -define function SampleType(specimen FHIR.Specimen): - case FHIRHelpers.ToCode(specimen.type.coding.where(system = 'https://fhir.bbmri.de/CodeSystem/SampleMaterialType').first()) - when Code 'plasma-edta' from SampleMaterialType then 'blood-plasma' - when Code 'plasma-citrat' from SampleMaterialType then 'blood-plasma' - when Code 'plasma-heparin' from SampleMaterialType then 'blood-plasma' - when Code 'plasma-cell-free' from SampleMaterialType then 'blood-plasma' - when Code 'plasma-other' from SampleMaterialType then 'blood-plasma' - when Code 'plasma' from SampleMaterialType then 'blood-plasma' - when Code 'tissue-formalin' from SampleMaterialType then 'tissue-ffpe' - when Code 'tumor-tissue-ffpe' from SampleMaterialType then 'tissue-ffpe' - when Code 'normal-tissue-ffpe' from SampleMaterialType then 'tissue-ffpe' - when Code 'other-tissue-ffpe' from SampleMaterialType then 'tissue-ffpe' - when Code 'tumor-tissue-frozen' from SampleMaterialType then 'tissue-frozen' - when Code 'normal-tissue-frozen' from SampleMaterialType then 'tissue-frozen' - when Code 'other-tissue-frozen' from SampleMaterialType then 'tissue-frozen' - when Code 'tissue-paxgene-or-else' from SampleMaterialType then 'tissue-other' - when Code 'derivative' from SampleMaterialType then 'derivative-other' - when Code 'liquid' from SampleMaterialType then 'liquid-other' - when Code 'tissue' from SampleMaterialType then 'tissue-other' - when Code 'serum' from SampleMaterialType then 'blood-serum' - when Code 'cf-dna' from SampleMaterialType then 'dna' - when Code 'g-dna' from SampleMaterialType then 'dna' - when Code 'blood-plasma' from SampleMaterialType then 'blood-plasma' - when Code 'tissue-ffpe' from SampleMaterialType then 'tissue-ffpe' - when Code 'tissue-frozen' from SampleMaterialType then 'tissue-frozen' - when Code 'tissue-other' from SampleMaterialType then 'tissue-other' - when Code 'derivative-other' from SampleMaterialType then 'derivative-other' - when Code 'liquid-other' from SampleMaterialType then 'liquid-other' - when Code 'blood-serum' from SampleMaterialType then 'blood-serum' - when Code 'dna' from SampleMaterialType then 'dna' - when Code 'buffy-coat' from SampleMaterialType then 'buffy-coat' - when Code 'urine' from SampleMaterialType then 'urine' - when Code 'ascites' from SampleMaterialType then 'ascites' - when Code 'saliva' from SampleMaterialType then 'saliva' - when Code 'csf-liquor' from SampleMaterialType then 'csf-liquor' - when Code 'bone-marrow' from SampleMaterialType then 'bone-marrow' - when Code 'peripheral-blood-cells-vital' from SampleMaterialType then 'peripheral-blood-cells-vital' - when Code 'stool-faeces' from SampleMaterialType then 'stool-faeces' - when Code 'rna' from SampleMaterialType then 'rna' - when Code 'whole-blood' from SampleMaterialType then 'whole-blood' - when Code 'swab' from SampleMaterialType then 'swab' - when Code 'dried-whole-blood' from SampleMaterialType then 'dried-whole-blood' - when null then 'Unknown' - else 'Unknown' - end diff --git a/resources/cql/BBMRI_STRAT_STORAGE_TEMPERATURE_STRATIFIER b/resources/cql/BBMRI_STRAT_STORAGE_TEMPERATURE_STRATIFIER deleted file mode 100644 index 75d485d0..00000000 --- a/resources/cql/BBMRI_STRAT_STORAGE_TEMPERATURE_STRATIFIER +++ /dev/null @@ -1,8 +0,0 @@ -define function StorageTemperature(specimen FHIR.Specimen): - case singleton from ( - specimen.extension E - where E.url = 'https://fhir.bbmri.de/StructureDefinition/StorageTemperature' - return (E.value as CodeableConcept).coding.first().code - ) - else 'Unknown' - end diff --git a/resources/cql/DHKI_STRAT_AGE_STRATIFIER b/resources/cql/DHKI_STRAT_AGE_STRATIFIER deleted file mode 100644 index bfcfb65f..00000000 --- a/resources/cql/DHKI_STRAT_AGE_STRATIFIER +++ /dev/null @@ -1,2 +0,0 @@ -define AgeClass: -ToString((AgeInYears() div 10) * 10) diff --git a/resources/cql/DHKI_STRAT_ENCOUNTER_STRATIFIER b/resources/cql/DHKI_STRAT_ENCOUNTER_STRATIFIER deleted file mode 100644 index ffd36d24..00000000 --- a/resources/cql/DHKI_STRAT_ENCOUNTER_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Encounter: -if InInitialPopulation then [Encounter] else {} as List - -define function Departments(encounter FHIR.Encounter): -encounter.identifier.where(system = 'http://dktk.dkfz.de/fhir/sid/hki-department').value.first() diff --git a/resources/cql/DHKI_STRAT_MEDICATION_STRATIFIER b/resources/cql/DHKI_STRAT_MEDICATION_STRATIFIER deleted file mode 100644 index 835ee494..00000000 --- a/resources/cql/DHKI_STRAT_MEDICATION_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define MedicationStatement: -if InInitialPopulation then [MedicationStatement] else {} as List - -define function AppliedMedications(medication FHIR.MedicationStatement): -medication.medication.coding.code.last() diff --git a/resources/cql/DHKI_STRAT_SPECIMEN_STRATIFIER b/resources/cql/DHKI_STRAT_SPECIMEN_STRATIFIER deleted file mode 100644 index 75534e46..00000000 --- a/resources/cql/DHKI_STRAT_SPECIMEN_STRATIFIER +++ /dev/null @@ -1,8 +0,0 @@ -define Specimen: -if InInitialPopulation then [Specimen] else {} as List - -define function SampleType(specimen FHIR.Specimen): -specimen.type.coding.where(system = 'https://fhir.bbmri.de/CodeSystem/SampleMaterialType').code.first() - -define function SampleSubtype(specimen FHIR.Specimen): -specimen.type.text.first() diff --git a/resources/cql/DKTK_REPLACE_HISTOLOGY_STRATIFIER b/resources/cql/DKTK_REPLACE_HISTOLOGY_STRATIFIER deleted file mode 100644 index b7b70d3f..00000000 --- a/resources/cql/DKTK_REPLACE_HISTOLOGY_STRATIFIER +++ /dev/null @@ -1,4 +0,0 @@ -define Histo: -if InInitialPopulation then [Observation] else {} as List - -define function Histology(histo FHIR.Observation): diff --git a/resources/cql/DKTK_REPLACE_SPECIMEN_STRATIFIER b/resources/cql/DKTK_REPLACE_SPECIMEN_STRATIFIER deleted file mode 100644 index 28c3080c..00000000 --- a/resources/cql/DKTK_REPLACE_SPECIMEN_STRATIFIER +++ /dev/null @@ -1,4 +0,0 @@ -define function SampleType(specimen FHIR.Specimen): -specimen.type.coding.where(system = 'https://fhir.bbmri.de/CodeSystem/SampleMaterialType').code.first() - -define Specimen: diff --git a/resources/cql/DKTK_STRAT_AGE_CLASS_STRATIFIER b/resources/cql/DKTK_STRAT_AGE_CLASS_STRATIFIER deleted file mode 100644 index d673a417..00000000 --- a/resources/cql/DKTK_STRAT_AGE_CLASS_STRATIFIER +++ /dev/null @@ -1,2 +0,0 @@ -define AgeClass: -if (PrimaryDiagnosis.onset is null) then 'unknown' else ToString((AgeInYearsAt(FHIRHelpers.ToDateTime(PrimaryDiagnosis.onset)) div 10) * 10) diff --git a/resources/cql/DKTK_STRAT_AGE_STRATIFIER b/resources/cql/DKTK_STRAT_AGE_STRATIFIER deleted file mode 100644 index 9efc998b..00000000 --- a/resources/cql/DKTK_STRAT_AGE_STRATIFIER +++ /dev/null @@ -1,15 +0,0 @@ -define PrimaryDiagnosis: -First( -from [Condition] C -where C.extension.where(url='http://hl7.org/fhir/StructureDefinition/condition-related').empty() and C.onset is not null -sort by date from onset asc) - -define FirstDiagnosis: -First( -from [Condition] C -sort by date from onset asc) - -define AgeClass: -if (PrimaryDiagnosis.onset is null) -then ToString((AgeInYearsAt(FHIRHelpers.ToDateTime(FirstDiagnosis.onset)) div 10) * 10) -else ToString((AgeInYearsAt(FHIRHelpers.ToDateTime(PrimaryDiagnosis.onset)) div 10) * 10) diff --git a/resources/cql/DKTK_STRAT_DECEASED_STRATIFIER b/resources/cql/DKTK_STRAT_DECEASED_STRATIFIER deleted file mode 100644 index 4a4200a8..00000000 --- a/resources/cql/DKTK_STRAT_DECEASED_STRATIFIER +++ /dev/null @@ -1,4 +0,0 @@ -define PatientDeceased: -First (from [Observation: Code '75186-7' from loinc] O return O.value.coding.where(system = 'http://dktk.dkfz.de/fhir/onco/core/CodeSystem/VitalstatusCS').code.first()) -define Deceased: -if (PatientDeceased is null) then 'unbekannt' else PatientDeceased diff --git a/resources/cql/DKTK_STRAT_DEF_IN_INITIAL_POPULATION b/resources/cql/DKTK_STRAT_DEF_IN_INITIAL_POPULATION deleted file mode 100644 index 80876f86..00000000 --- a/resources/cql/DKTK_STRAT_DEF_IN_INITIAL_POPULATION +++ /dev/null @@ -1 +0,0 @@ -define InInitialPopulation: diff --git a/resources/cql/DKTK_STRAT_DIAGNOSIS_STRATIFIER b/resources/cql/DKTK_STRAT_DIAGNOSIS_STRATIFIER deleted file mode 100644 index 1595f844..00000000 --- a/resources/cql/DKTK_STRAT_DIAGNOSIS_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Diagnosis: -if InInitialPopulation then [Condition] else {} as List - -define function DiagnosisCode(condition FHIR.Condition): -condition.code.coding.where(system = 'http://fhir.de/CodeSystem/bfarm/icd-10-gm').code.first() diff --git a/resources/cql/DKTK_STRAT_ENCOUNTER_STRATIFIER b/resources/cql/DKTK_STRAT_ENCOUNTER_STRATIFIER deleted file mode 100644 index ffd36d24..00000000 --- a/resources/cql/DKTK_STRAT_ENCOUNTER_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Encounter: -if InInitialPopulation then [Encounter] else {} as List - -define function Departments(encounter FHIR.Encounter): -encounter.identifier.where(system = 'http://dktk.dkfz.de/fhir/sid/hki-department').value.first() diff --git a/resources/cql/DKTK_STRAT_GENDER_STRATIFIER b/resources/cql/DKTK_STRAT_GENDER_STRATIFIER deleted file mode 100644 index 4145d043..00000000 --- a/resources/cql/DKTK_STRAT_GENDER_STRATIFIER +++ /dev/null @@ -1,2 +0,0 @@ -define Gender: -if (Patient.gender is null) then 'unknown' else Patient.gender diff --git a/resources/cql/DKTK_STRAT_GENETIC_VARIANT b/resources/cql/DKTK_STRAT_GENETIC_VARIANT deleted file mode 100644 index 1021262f..00000000 --- a/resources/cql/DKTK_STRAT_GENETIC_VARIANT +++ /dev/null @@ -1,5 +0,0 @@ -define GeneticVariantCount: -if InInitialPopulation then [Observation: Code '69548-6' from loinc] else {} as List - -define GeneticVariantCode: -First (from [Observation: Code '69548-6' from loinc] O return O.component.where(code.coding contains Code '48018-6' from loinc).value.coding.code.first()) diff --git a/resources/cql/DKTK_STRAT_HISTOLOGY_STRATIFIER b/resources/cql/DKTK_STRAT_HISTOLOGY_STRATIFIER deleted file mode 100644 index 1c5c077d..00000000 --- a/resources/cql/DKTK_STRAT_HISTOLOGY_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Histo: -if InInitialPopulation then [Observation] else {} as List - -define function Histology(histo FHIR.Observation): - if histo.code.coding.where(code = '59847-4').code.first() is null then 0 else 1 diff --git a/resources/cql/DKTK_STRAT_MEDICATION_STRATIFIER b/resources/cql/DKTK_STRAT_MEDICATION_STRATIFIER deleted file mode 100644 index f5598001..00000000 --- a/resources/cql/DKTK_STRAT_MEDICATION_STRATIFIER +++ /dev/null @@ -1,2 +0,0 @@ -define MedicationStatement: -if InInitialPopulation then [MedicationStatement] else {} as List diff --git a/resources/cql/DKTK_STRAT_PRIMARY_DIAGNOSIS_NO_SORT_STRATIFIER b/resources/cql/DKTK_STRAT_PRIMARY_DIAGNOSIS_NO_SORT_STRATIFIER deleted file mode 100644 index 469551c9..00000000 --- a/resources/cql/DKTK_STRAT_PRIMARY_DIAGNOSIS_NO_SORT_STRATIFIER +++ /dev/null @@ -1,4 +0,0 @@ -define PrimaryDiagnosis: -First( -from [Condition] C -where C.extension.where(url='http://hl7.org/fhir/StructureDefinition/condition-related').empty()) \ No newline at end of file diff --git a/resources/cql/DKTK_STRAT_PRIMARY_DIAGNOSIS_STRATIFIER b/resources/cql/DKTK_STRAT_PRIMARY_DIAGNOSIS_STRATIFIER deleted file mode 100644 index 8678d2f8..00000000 --- a/resources/cql/DKTK_STRAT_PRIMARY_DIAGNOSIS_STRATIFIER +++ /dev/null @@ -1,6 +0,0 @@ -define PrimaryDiagnosis: -First( -from [Condition] C -where C.extension.where(url='http://hl7.org/fhir/StructureDefinition/condition-related').empty() -sort by date from onset asc) - diff --git a/resources/cql/DKTK_STRAT_PROCEDURE_STRATIFIER b/resources/cql/DKTK_STRAT_PROCEDURE_STRATIFIER deleted file mode 100644 index 49cb341c..00000000 --- a/resources/cql/DKTK_STRAT_PROCEDURE_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Procedure: -if InInitialPopulation then [Procedure] else {} as List - -define function ProcedureType(procedure FHIR.Procedure): -procedure.category.coding.where(system = 'http://dktk.dkfz.de/fhir/onco/core/CodeSystem/SYSTTherapieartCS').code.first() diff --git a/resources/cql/DKTK_STRAT_SPECIMEN_STRATIFIER b/resources/cql/DKTK_STRAT_SPECIMEN_STRATIFIER deleted file mode 100644 index 31100c17..00000000 --- a/resources/cql/DKTK_STRAT_SPECIMEN_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Specimen: -if InInitialPopulation then [Specimen] else {} as List - -define function SampleType(specimen FHIR.Specimen): -specimen.type.coding.where(system = 'https://fhir.bbmri.de/CodeSystem/SampleMaterialType').code.first() \ No newline at end of file diff --git a/resources/cql/ITCC_STRAT_AGE_CLASS_STRATIFIER b/resources/cql/ITCC_STRAT_AGE_CLASS_STRATIFIER deleted file mode 100644 index 3ab06415..00000000 --- a/resources/cql/ITCC_STRAT_AGE_CLASS_STRATIFIER +++ /dev/null @@ -1,2 +0,0 @@ -define function DiagnosisAge(condition FHIR.Condition): -condition.onset.value \ No newline at end of file diff --git a/resources/cql/ITCC_STRAT_DIAGNOSIS_STRATIFIER b/resources/cql/ITCC_STRAT_DIAGNOSIS_STRATIFIER deleted file mode 100644 index 33a0a3c3..00000000 --- a/resources/cql/ITCC_STRAT_DIAGNOSIS_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Diagnosis: -if InInitialPopulation then [Condition] else {} as List - -define function DiagnosisCode(condition FHIR.Condition): -condition.code.coding.code.first() diff --git a/resources/cql/MTBA_STRAT_GENETIC_VARIANT b/resources/cql/MTBA_STRAT_GENETIC_VARIANT deleted file mode 100644 index 308bc3e4..00000000 --- a/resources/cql/MTBA_STRAT_GENETIC_VARIANT +++ /dev/null @@ -1,2 +0,0 @@ -define GeneticVariantCode: -First (from [Observation: Code '69548-6' from loinc] O return O.component.where(code.coding contains Code '48018-6' from loinc).value.coding.code.first()) diff --git a/resources/cql/NNGM_STRAT_DIAGNOSIS_STRATIFIER b/resources/cql/NNGM_STRAT_DIAGNOSIS_STRATIFIER deleted file mode 100644 index 9ea40d0b..00000000 --- a/resources/cql/NNGM_STRAT_DIAGNOSIS_STRATIFIER +++ /dev/null @@ -1,5 +0,0 @@ -define Diagnosis: -if InInitialPopulation then [Condition] else {} as List - -define function DiagnosisCode(condition FHIR.Condition): -condition.bodySite.coding.where(system = 'http://terminology.hl7.org/CodeSystem/icd-o-3').code.first() diff --git a/resources/cql/PRISM_STRAT_AGE_STRATIFIER_BBMRI b/resources/cql/PRISM_STRAT_AGE_STRATIFIER_BBMRI deleted file mode 100644 index 7c9c6c20..00000000 --- a/resources/cql/PRISM_STRAT_AGE_STRATIFIER_BBMRI +++ /dev/null @@ -1,2 +0,0 @@ -define AgeClass: - (AgeInYears()) diff --git a/resources/cql/UCT_STRAT_SPECIMEN_STRATIFIER b/resources/cql/UCT_STRAT_SPECIMEN_STRATIFIER deleted file mode 100644 index 0c58d4e6..00000000 --- a/resources/cql/UCT_STRAT_SPECIMEN_STRATIFIER +++ /dev/null @@ -1,12 +0,0 @@ -define Specimen: -if InInitialPopulation then [Specimen] else {} as List - -define function SampleType(specimen FHIR.Specimen): -specimen.type.coding.where(system = 'https://fhir.bbmri.de/CodeSystem/SampleMaterialType').code.first() - -define function Lagerort(specimen FHIR.Specimen): -specimen.extension.where(url = 'http://uct-locator/specimen/storage').value.coding.code.first() - -define function annotations(specimen FHIR.Specimen): -(if (specimen.type.coding.where(system = 'https://fhir.bbmri.de/CodeSystem/SampleMaterialType').code.first() is null) then 1 else 0) + -(if (specimen.collection.collected is null) then 1 else 0) diff --git a/resources/sql/ORGANOID_DASHBOARD_INTERNAL b/resources/sql/ORGANOID_DASHBOARD_INTERNAL index 81d3008b..97793250 100644 --- a/resources/sql/ORGANOID_DASHBOARD_INTERNAL +++ b/resources/sql/ORGANOID_DASHBOARD_INTERNAL @@ -54,7 +54,8 @@ neomatch_visit_1a AS ( (SELECT value->'valueCodeableConcept'->'coding'->0->>'code' FROM jsonb_array_elements(resource->'component') WHERE value->'code'->'coding'->0->>'code' = 'SIOP_TNM_C-T') AS tnm_ct, (SELECT value->'valueCodeableConcept'->'coding'->0->>'code' FROM jsonb_array_elements(resource->'component') WHERE value->'code'->'coding'->0->>'code' = 'SIOP_TNM_C-N') AS tnm_cn, (SELECT value->'valueCodeableConcept'->'coding'->0->>'code' FROM jsonb_array_elements(resource->'component') WHERE value->'code'->'coding'->0->>'code' = 'SIOP_TNM_C-M') AS tnm_cm, - (SELECT value->'valueCodeableConcept'->'coding'->0->>'code' FROM jsonb_array_elements(resource->'component') WHERE value->'code'->'coding'->0->>'code' = 'SIOP_NEOADJ_CTX') AS neoadj_ctx + (SELECT value->'valueCodeableConcept'->'coding'->0->>'code' FROM jsonb_array_elements(resource->'component') WHERE value->'code'->'coding'->0->>'code' = 'SIOP_NEOADJ_CTX') AS neoadj_ctx, + (SELECT value->'valueCodeableConcept'->'coding'->0->>'code' FROM jsonb_array_elements(resource->'component') WHERE value->'code'->'coding'->0->>'code' = 'SIOP_DIAGNOSIS_ICD-O-3') AS diagnosis_icd_o_3 FROM observation WHERE resource->'method'->'coding'->0->>'code' = 'PROFILE_SIOP_NEOMATCH_VISIT-1A_PATIENTDATA' ), @@ -92,6 +93,8 @@ SELECT WHEN metpredict_visit_1.localisation_primary_tumor = 'RECTUM' THEN 'Rectum' WHEN metpredict_visit_1.localisation_primary_tumor = 'NA' THEN 'not documented' END AS localisation_primary_tumor, + -- ICD code + 'C78.7' AS icd_code, -- sample type 'Metastasis: resection' AS sample_type, -- neoadjuvant therapy for primary tumor @@ -191,6 +194,8 @@ SELECT WHEN neomatch_visit_2b.sample_localisation = 'OTH' THEN neomatch_visit_2b.sample_localisation_other WHEN neomatch_visit_2b.sample_localisation = 'NA' THEN 'not documented' END AS localisation_primary_tumor, + -- ICD code + REPLACE(neomatch_visit_1a.diagnosis_icd_o_3, '-', '.') AS icd_code, -- sample type 'Primary tumor: ' || CASE diff --git a/src/config.rs b/src/config.rs index 234bad29..9d174a24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use std::fmt; use std::path::PathBuf; +use std::str::FromStr; use beam_lib::AppId; use clap::Parser; @@ -9,6 +10,7 @@ use reqwest::{Certificate, Client, Proxy}; use tracing::{debug, info, warn}; use crate::errors::FocusError; +use crate::flavours::Flavour; #[derive(clap::ValueEnum, Clone, PartialEq, Debug)] pub enum Obfuscate { @@ -97,7 +99,7 @@ struct CliArgs { #[clap(long, env, value_parser = clap::value_parser!(EndpointType), default_value = "blaze")] endpoint_type: EndpointType, - // Comma separated list of enabled projects that allow cql queries + /// Comma separated list of enabled projects that allow cql queries #[clap(long, env, value_parser, value_delimiter = ',')] cql_projects_enabled: Option>, @@ -166,11 +168,15 @@ struct CliArgs { #[clap(long, env, value_parser)] tls_ca_certificates_dir: Option, - /// OMOP provider name + /// CQL flavour, if different from the project name + #[clap(long, env, value_parser)] + cql_flavour: Option, + + /// EUCAIM provider name #[clap(long, env, value_parser)] provider: Option, - /// Base64 encoded OMOP provider icon + /// Base64 encoded EUCAIM provider icon #[clap(long, env, value_parser)] provider_icon: Option, @@ -218,6 +224,7 @@ pub(crate) struct Config { pub unobfuscated: Vec, pub queries_to_cache: Option, pub client: Client, + pub cql_flavour: Option, pub provider: Option, pub provider_icon: Option, pub auth_header: Option, @@ -272,6 +279,7 @@ impl Config { rounding_step: cli_args.rounding_step, unobfuscated: cli_args.projects_no_obfuscation.split(';').map(|s| s.to_string()).collect(), queries_to_cache: cli_args.queries_to_cache, + cql_flavour: cli_args.cql_flavour.filter(|s| !s.is_empty()).map(|s| Flavour::from_str(&s)).transpose()?, provider: cli_args.provider, provider_icon: cli_args.provider_icon, auth_header: cli_args.auth_header, diff --git a/src/cql.rs b/src/cql.rs index d1e48891..2dcec9de 100644 --- a/src/cql.rs +++ b/src/cql.rs @@ -1,6 +1,6 @@ use crate::ast; use crate::errors::FocusError; -use crate::projects::{CriterionRole, Project}; +use crate::flavours::{CriterionRole, Flavour}; use base64::{prelude::BASE64_STANDARD as BASE64, Engine as _}; use chrono::offset::Utc; @@ -9,7 +9,7 @@ use indexmap::set::IndexSet; use tracing::info; use uuid::Uuid; -pub fn generate_body(ast: ast::Ast, project: Project) -> Result { +pub fn generate_body(ast: ast::Ast, project: Flavour) -> Result { Ok(project .get_body() .replace( @@ -26,21 +26,21 @@ pub fn generate_body(ast: ast::Ast, project: Project) -> Result Result { +fn generate_cql(ast: ast::Ast, cql_flavour: Flavour) -> Result { let mut retrieval_criteria: String = String::new(); // main selection criteria (Patient) let mut filter_criteria: String = String::new(); // criteria for filtering specimens let mut lists: String = String::new(); // needed code lists, defined - let mut cql = project.get_cql_template().to_string(); + let mut cql = cql_flavour.get_cql_template().to_string(); let operator_str = match ast.ast.operand { ast::Operand::And => " and ", ast::Operand::Or => " or ", }; - let mut mandatory_codes = project.get_mandatory_code_lists().clone(); + let mut mandatory_codes = cql_flavour.get_mandatory_code_lists().clone(); for (index, grandchild) in ast.ast.children.iter().enumerate() { process( @@ -48,7 +48,7 @@ fn generate_cql(ast: ast::Ast, project: Project) -> Result { &mut retrieval_criteria, &mut filter_criteria, &mut mandatory_codes, - &project, + &cql_flavour, )?; // Only concatenate operator if it's not the last element @@ -61,7 +61,10 @@ fn generate_cql(ast: ast::Ast, project: Project) -> Result { lists += format!( "codesystem {}: '{}'\n", code_system, - project.get_code_lists().get(code_system).unwrap_or(&("")) + cql_flavour + .get_code_lists() + .get(code_system) + .unwrap_or(&("")) ) .as_str(); } @@ -99,7 +102,7 @@ pub fn process( retrieval_criteria: &mut String, filter_criteria: &mut String, code_systems: &mut IndexSet<&str>, - project: &Project, + cql_flavour: &Flavour, ) -> Result<(), FocusError> { let mut retrieval_cond: String = "(".to_string(); let mut filter_cond: String = String::new(); @@ -108,7 +111,7 @@ pub fn process( ast::Child::Condition(condition) => { let condition_key_trans = condition.key.as_str(); - let condition_snippet = project + let condition_snippet = cql_flavour .get_cql_snippets() .get(&(condition_key_trans, CriterionRole::Query)); @@ -120,11 +123,11 @@ pub fn process( let mut condition_string = (*snippet).to_string(); let mut filter_string: String = String::new(); - let filter_snippet = project + let filter_snippet = cql_flavour .get_cql_snippets() .get(&(condition_key_trans, CriterionRole::Filter)); - let code_lists_option = project + let code_lists_option = cql_flavour .get_criterion_code_lists() .get(&(condition_key_trans)); if let Some(code_lists_vec) = code_lists_option { @@ -137,7 +140,7 @@ pub fn process( if condition_string.contains("{{K}}") { //observation loinc code, those only apply to query criteria, we don't filter specimens by observations - let observation_code_option = project + let observation_code_option = cql_flavour .get_observation_loinc_codes() .get(&condition_key_trans); @@ -294,17 +297,28 @@ pub fn process( match condition.value { ast::ConditionValue::StringArray(string_array) => { - let mut string_array_with_workarounds = string_array.clone(); + let mut string_array_with_workarounds = + if *cql_flavour == Flavour::Miabis { + //empty, codes get replaced + Default::default() + } else { + string_array.clone() + }; + for value in string_array { if let Some(additional_values) = - project.get_sample_type_workarounds().get(value.as_str()) + cql_flavour.get_code_workarounds().get(value.as_str()) { for additional_value in additional_values { string_array_with_workarounds .push((*additional_value).into()); } + } else if *cql_flavour == Flavour::Miabis { + //for codes with no mappings in MIABIS the result will be empty + string_array_with_workarounds.push(value); } } + let mut condition_humongous_string = "(".to_string(); let mut filter_humongous_string = "(".to_string(); @@ -345,13 +359,21 @@ pub fn process( ast::ConditionType::Equals => match condition.value { ast::ConditionValue::String(string) => { let operator_str = " or "; - let mut string_array_with_workarounds = vec![string.clone()]; + let mut string_array_with_workarounds = if *cql_flavour == Flavour::Miabis { + //empty, codes get replaced + Default::default() + } else { + vec![string.clone()] + }; if let Some(additional_values) = - project.get_sample_type_workarounds().get(string.as_str()) + cql_flavour.get_code_workarounds().get(string.as_str()) { for additional_value in additional_values { string_array_with_workarounds.push((*additional_value).into()); } + } else if *cql_flavour == Flavour::Miabis { + //for codes with no mappings in MIABIS the result will be empty + string_array_with_workarounds.push(string); } let mut condition_humongous_string = "(".to_string(); let mut filter_humongous_string = "(".to_string(); @@ -413,7 +435,7 @@ pub fn process( &mut retrieval_cond, &mut filter_cond, code_systems, - project, + cql_flavour, )?; // Only concatenate operator if it's not the last element @@ -531,7 +553,7 @@ mod test { #[test] fn test_bbmri_quote() { pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(QUOTE).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(QUOTE).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_quote.cql").to_string() ); } @@ -541,7 +563,7 @@ mod test { pretty_assertions::assert_eq!( generate_cql( serde_json::from_str(MALE_OR_FEMALE).unwrap(), - Project::Bbmri + Flavour::Bbmri ) .unwrap(), include_str!("../resources/test/result_male_or_female.cql").to_string() @@ -550,7 +572,7 @@ mod test { pretty_assertions::assert_eq!( generate_cql( serde_json::from_str(AGE_AT_DIAGNOSIS_30_TO_70).unwrap(), - Project::Bbmri + Flavour::Bbmri ) .unwrap(), include_str!("../resources/test/result_age_at_diagnosis_30_to_70.cql").to_string() @@ -559,69 +581,69 @@ mod test { pretty_assertions::assert_eq!( generate_cql( serde_json::from_str(AGE_AT_DIAGNOSIS_LOWER_THAN_70).unwrap(), - Project::Bbmri + Flavour::Bbmri ) .unwrap(), include_str!("../resources/test/result_age_at_diagnosis_lower_than_70.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(C61_AND_MALE).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(C61_AND_MALE).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_c61_and_male.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(ALL_GBN).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(ALL_GBN).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_all_gbn.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(SOME_GBN).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(SOME_GBN).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_some_gbn.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(LENS2).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(LENS2).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_lens2.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(EMPTY).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(EMPTY).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_empty.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(EMPTY_OR).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(EMPTY_OR).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_empty.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(LESS).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(LESS).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_less.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(GREATER).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(GREATER).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_greater.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(AFTER).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(AFTER).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_after.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(BEFORE).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(BEFORE).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_before.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(VAFAN).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(VAFAN).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_empty.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(CURRENT).unwrap(), Project::Bbmri).unwrap(), + generate_cql(serde_json::from_str(CURRENT).unwrap(), Flavour::Bbmri).unwrap(), include_str!("../resources/test/result_current.cql").to_string() ); } @@ -641,42 +663,42 @@ mod test { #[test] fn test_dktk() { pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(DIAGNOSIS_C30).unwrap(), Project::Dktk).unwrap(), + generate_cql(serde_json::from_str(DIAGNOSIS_C30).unwrap(), Flavour::Dktk).unwrap(), include_str!("../resources/test/result_diagnosis_c30.cql").to_string() ); pretty_assertions::assert_eq!( generate_cql( serde_json::from_str(YEAR_OF_DIAGNOSIS_2000_TO_2010).unwrap(), - Project::Dktk + Flavour::Dktk ) .unwrap(), include_str!("../resources/test/result_year_of_diagnosis_2000_to_2010.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(BODY_SITE_LEFT).unwrap(), Project::Dktk).unwrap(), + generate_cql(serde_json::from_str(BODY_SITE_LEFT).unwrap(), Flavour::Dktk).unwrap(), include_str!("../resources/test/result_body_site_left.cql").to_string() ); pretty_assertions::assert_eq!( generate_cql( serde_json::from_str(GRADING_LOW_GRADE).unwrap(), - Project::Dktk + Flavour::Dktk ) .unwrap(), include_str!("../resources/test/result_grading_low_grade.cql").to_string() ); pretty_assertions::assert_eq!( - generate_cql(serde_json::from_str(TNM_T_2).unwrap(), Project::Dktk).unwrap(), + generate_cql(serde_json::from_str(TNM_T_2).unwrap(), Flavour::Dktk).unwrap(), include_str!("../resources/test/result_tnm_t_2.cql").to_string() ); pretty_assertions::assert_eq!( generate_cql( serde_json::from_str(SAMPLE_KIND_FFPE).unwrap(), - Project::Dktk + Flavour::Dktk ) .unwrap(), include_str!("../resources/test/result_sample_kind_ffpe.cql").to_string() @@ -696,7 +718,7 @@ mod test { #[test] fn test_cce_empty() { let generated_cql = - generate_cql(serde_json::from_str(EMPTY).unwrap(), Project::Cce).unwrap(); + generate_cql(serde_json::from_str(EMPTY).unwrap(), Flavour::Cce).unwrap(); pretty_assertions::assert_eq!(generated_cql.contains(CCE_VITAL_STATUS_URL), true); pretty_assertions::assert_eq!(generated_cql.contains(CCE_SAMPLE_MATERIAL_TYPE_URL), true); pretty_assertions::assert_eq!(generated_cql.contains(CCE_SYST_THERAPY_TYPE_URL), true); @@ -711,7 +733,7 @@ mod test { fn test_cce_male() { let expected = r#"Patient.gender = 'male'"#; let generated_cql = - generate_cql(serde_json::from_str(CCE_MALE).unwrap(), Project::Cce).unwrap(); + generate_cql(serde_json::from_str(CCE_MALE).unwrap(), Flavour::Cce).unwrap(); pretty_assertions::assert_eq!(generated_cql.contains(expected), true); } @@ -724,4 +746,157 @@ mod test { // // pretty_assertions::assert_eq!(generated_cql.contains(expected), true); // pretty_assertions::assert_eq!(false, true); // } + + const MIABIS_DIAGNOSIS: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"diagnosis","type":"EQUALS","system":"","value":"C61"}]}]}]},"id":"miabis-d1"}"#; + + const MIABIS_SAMPLE_EQUALS_WHOLE_BLOOD: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"sample_kind","type":"EQUALS","system":"","value":"whole-blood"}]}]}]},"id":"miabis-s1"}"#; + + const MIABIS_SAMPLE_IN: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"sample_kind","type":"IN","system":"","value":["blood-plasma","tissue-ffpe"]}]}]}]},"id":"miabis-s2"}"#; + + const MIABIS_SAMPLE_LIQUID_OTHER: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"sample_kind","type":"IN","system":"","value":["liquid-other"]}]}]}]},"id":"miabis-s3"}"#; + + const MIABIS_TEMP_EQUALS_ROOM: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"storage_temperature","type":"EQUALS","system":"","value":"temperatureRoom"}]}]}]},"id":"miabis-t1"}"#; + + const MIABIS_TEMP_EQUALS_FOUR: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"storage_temperature","type":"EQUALS","system":"","value":"four_degrees"}]}]}]},"id":"miabis-t2"}"#; + + const MIABIS_TEMP_IN: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"storage_temperature","type":"IN","system":"","value":["temperature2to10","temperatureGN"]}]}]}]},"id":"miabis-t3"}"#; + + const MIABIS_TEMP_UNCHARTED: &str = r#"{"ast":{"operand":"OR","children":[{"operand":"AND","children":[{"operand":"OR","children":[{"key":"storage_temperature","type":"EQUALS","system":"","value":"storage_temperature_uncharted"}]}]}]},"id":"miabis-t4"}"#; + + #[test] + fn test_miabis_empty() { + let cql = generate_cql(serde_json::from_str(EMPTY).unwrap(), Flavour::Miabis).unwrap(); + assert!(cql.contains("http://hl7.org/fhir/sid/icd-10")); + assert!( + cql.contains("https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs") + ); + } + + #[test] + fn test_miabis_gender() { + let cql = generate_cql( + serde_json::from_str(MALE_OR_FEMALE).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(cql.contains("Patient.gender = 'male'")); + assert!(cql.contains("Patient.gender = 'female'")); + } + + #[test] + fn test_miabis_diagnosis() { + let cql = generate_cql( + serde_json::from_str(MIABIS_DIAGNOSIS).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(cql.contains("'C61'")); + assert!(cql.contains("http://hl7.org/fhir/sid/icd-10")); + assert!(!cql.contains("http://fhir.de/CodeSystem/dimdi/icd-10-gm")); + } + + #[test] + fn test_miabis_sample_kind() { + let cql = generate_cql( + serde_json::from_str(MIABIS_SAMPLE_EQUALS_WHOLE_BLOOD).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'WholeBlood')"#)); + assert!(!cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'whole-blood')"#)); + assert!( + cql.contains("https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs") + ); + } + + #[test] + fn test_miabis_storage_temperature() { + let cql = generate_cql( + serde_json::from_str(MIABIS_TEMP_EQUALS_ROOM).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(!cql.contains("'temperatureRoom'")); + assert!(cql.contains(r#"(E.value as CodeableConcept).coding.code contains 'RT')"#)); + assert!(cql.contains("S.processing")); + assert!(cql.contains( + "https://fhir.bbmri-eric.eu/StructureDefinition/miabis-sample-storage-temperature-extension" + )); + } + + #[test] + fn test_miabis_sample_type_workarounds() { + let cql = generate_cql( + serde_json::from_str(MIABIS_SAMPLE_EQUALS_WHOLE_BLOOD).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'WholeBlood')"#)); + assert!(!cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'whole-blood')"#)); + + let cql = generate_cql( + serde_json::from_str(MIABIS_SAMPLE_IN).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'Plasma')"#)); + assert!(!cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'blood-plasma')"#)); + assert!(cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'TissueFixed')"#)); + assert!(!cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'tissue-ffpe')"#)); + + let cql = generate_cql( + serde_json::from_str(MIABIS_SAMPLE_LIQUID_OTHER).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'LiquidBiopsy')"#)); + assert!(cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'Sputum')"#)); + assert!(!cql.contains(r#"(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains 'liquid-other')"#)); + } + + #[test] + fn test_miabis_storage_temperature_workarounds() { + let cql = generate_cql( + serde_json::from_str(MIABIS_TEMP_EQUALS_ROOM).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(!cql.contains("'temperatureRoom'")); + assert!(cql.contains("'RT'")); + + let cql = generate_cql( + serde_json::from_str(MIABIS_TEMP_EQUALS_FOUR).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(!cql.contains("'four_degrees'")); + assert!(cql.contains("'2to10'")); + + let cql = generate_cql( + serde_json::from_str(MIABIS_TEMP_IN).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(!cql.contains("'temperature2to10'")); + assert!(cql.contains("'2to10'")); + assert!(!cql.contains("'temperatureGN'")); + assert!(cql.contains("'LN'")); + + let cql = generate_cql( + serde_json::from_str(MIABIS_TEMP_UNCHARTED).unwrap(), + Flavour::Miabis, + ) + .unwrap(); + assert!(cql.contains("coding.code contains 'storage_temperature_uncharted'")); + for miabis_code in &[ + "'RT'", + "'2to10'", + "'-18to-35'", + "'-60to-85'", + "'LN'", + "'Other'", + ] { + assert!(!cql.contains(miabis_code)); + } + } } diff --git a/src/eucaim_sql.rs b/src/eucaim_sql.rs index f67a004e..543dcc55 100644 --- a/src/eucaim_sql.rs +++ b/src/eucaim_sql.rs @@ -59,9 +59,9 @@ pub static CRITERION: Lazy> = Lazy::new(|| { }); pub fn build_eucaim_sql_query(ast: ast::Ast) -> Result { - const BEFORE_IMAGE_CRITERIA: &str = r#"SELECT dataset.identifier id, dataset.dataset_title name, dataset.dataset_description description, COUNT(patient.*)::int4 subjects_count, COALESCE(SUM ((SELECT COUNT(image_study.*) FROM procedure JOIN image_study ON procedure.procedure_id = image_study.procedure_id JOIN cancer_condition on procedure.cancer_condition_id = cancer_condition.cancer_condition_id WHERE TRUE "#; + const BEFORE_IMAGE_CRITERIA: &str = r#"SELECT dataset.dataset_id id, dataset.dataset_title name, dataset.dataset_description description, COUNT(patient.*)::int4 subjects_count, COALESCE(SUM ((SELECT COUNT(image_study.*) FROM procedure JOIN image_study ON procedure.procedure_id = image_study.procedure_id JOIN cancer_condition on procedure.cancer_condition_id = cancer_condition.cancer_condition_id WHERE TRUE "#; const BETWEEN_CRITERIA: &str = r#" GROUP BY cancer_condition.patient_id HAVING cancer_condition.patient_id = patient.patient_id)), 0)::int4 studies_count FROM dataset JOIN patient ON patient.dataset_id = dataset.dataset_id WHERE TRUE "#; - const AFTER_PATIENT_CRITERIA: &str = r#" GROUP BY patient.dataset_id, dataset.dataset_id;"#; + const AFTER_PATIENT_CRITERIA: &str = r#" GROUP BY patient.dataset_id, dataset.dataset_id, dataset.dataset_title, dataset.dataset_description;"#; let mut criteria: HashMap = HashMap::new(); // 0 - patient criteria; 1 - image criteria criteria.insert(0, "".to_string()); @@ -191,11 +191,11 @@ mod test { const EMPTY: &str = r#"{"ast":{"children":[],"operand":"OR"}, "id":"a6f1ccf3-ebf1-424f-9d69-4e5d135f2340"}"#; - const SQL_NO_CRITERIA_SELECTED: &str = r#"SELECT dataset.identifier id, dataset.dataset_title name, dataset.dataset_description description, COUNT(patient.*)::int4 subjects_count, COALESCE(SUM ((SELECT COUNT(image_study.*) FROM procedure JOIN image_study ON procedure.procedure_id = image_study.procedure_id JOIN cancer_condition on procedure.cancer_condition_id = cancer_condition.cancer_condition_id WHERE TRUE GROUP BY cancer_condition.patient_id HAVING cancer_condition.patient_id = patient.patient_id)), 0)::int4 studies_count FROM dataset JOIN patient ON patient.dataset_id = dataset.dataset_id WHERE TRUE GROUP BY patient.dataset_id, dataset.dataset_id;"#; + const SQL_NO_CRITERIA_SELECTED: &str = r#"SELECT dataset.dataset_id id, dataset.dataset_title name, dataset.dataset_description description, COUNT(patient.*)::int4 subjects_count, COALESCE(SUM ((SELECT COUNT(image_study.*) FROM procedure JOIN image_study ON procedure.procedure_id = image_study.procedure_id JOIN cancer_condition on procedure.cancer_condition_id = cancer_condition.cancer_condition_id WHERE TRUE GROUP BY cancer_condition.patient_id HAVING cancer_condition.patient_id = patient.patient_id)), 0)::int4 studies_count FROM dataset JOIN patient ON patient.dataset_id = dataset.dataset_id WHERE TRUE GROUP BY patient.dataset_id, dataset.dataset_id, dataset.dataset_title, dataset.dataset_description;"#; const JUST_RIGHT: &str = r#"{"ast":{"children":[{"children":[{"children":[{"key":"SNOMEDCT263495000","system":"","type":"EQUALS","value":"SNOMEDCT248153007"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT439401001","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT399068003"}],"operand":"OR"},{"children":[{"key":"RID10311","system":"urn:oid:2.16.840.1.113883.6.256","type":"EQUALS","value":"RID10312"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT123037004","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT76752008"}],"operand":"OR"},{"children":[{"key":"C25392","system":"http://bioontology.org/projects/ontologies/birnlex","type":"EQUALS","value":"birnlex_3065"}],"operand":"OR"}],"operand":"AND"}],"operand":"OR"},"id":"66b8bbf4-ded2-4f94-87ab-3a3ca2f4edc0__search__66b8bbf4-ded2-4f94-87ab-3a3ca2f4edc0"}"#; - const JUST_RIGHT_SQL_TEMP: &str = r#"SELECT dataset.identifier id, dataset.dataset_title name, dataset.dataset_description description, COUNT(patient.*)::int4 subjects_count, COALESCE(SUM ((SELECT COUNT(image_study.*) FROM procedure JOIN image_study ON procedure.procedure_id = image_study.procedure_id JOIN cancer_condition on procedure.cancer_condition_id = cancer_condition.cancer_condition_id WHERE TRUE AND ( FALSE AND EXISTS (SELECT FROM image_series WHERE series_body_site_code = 'BP1000136' AND image_series.study_id = image_study.study_id) AND FALSE ) GROUP BY cancer_condition.patient_id HAVING cancer_condition.patient_id = patient.patient_id)), 0)::int4 studies_count FROM dataset JOIN patient ON patient.dataset_id = dataset.dataset_id WHERE TRUE AND ( (patient.patient_birth_sex = 'COM1000180') AND (EXISTS (SELECT * FROM cancer_condition WHERE cancer_condition.patient_id = patient.patient_id AND cancer_condition_code = 'CLIN1000075')) ) GROUP BY patient.dataset_id, dataset.dataset_id;"#; + const JUST_RIGHT_SQL_TEMP: &str = r#"SELECT dataset.dataset_id id, dataset.dataset_title name, dataset.dataset_description description, COUNT(patient.*)::int4 subjects_count, COALESCE(SUM ((SELECT COUNT(image_study.*) FROM procedure JOIN image_study ON procedure.procedure_id = image_study.procedure_id JOIN cancer_condition on procedure.cancer_condition_id = cancer_condition.cancer_condition_id WHERE TRUE AND ( FALSE AND EXISTS (SELECT FROM image_series WHERE series_body_site_code = 'BP1000136' AND image_series.study_id = image_study.study_id) AND FALSE ) GROUP BY cancer_condition.patient_id HAVING cancer_condition.patient_id = patient.patient_id)), 0)::int4 studies_count FROM dataset JOIN patient ON patient.dataset_id = dataset.dataset_id WHERE TRUE AND ( (patient.patient_birth_sex = 'COM1000180') AND (EXISTS (SELECT * FROM cancer_condition WHERE cancer_condition.patient_id = patient.patient_id AND cancer_condition_code = 'CLIN1000075')) ) GROUP BY patient.dataset_id, dataset.dataset_id, dataset.dataset_title, dataset.dataset_description;"#; const TOO_MUCH: &str = r#"{"ast":{"children":[{"children":[{"children":[{"key":"SNOMEDCT263495000","system":"","type":"EQUALS","value":"SNOMEDCT248153007"},{"key":"SNOMEDCT263495000","system":"","type":"EQUALS","value":"SNOMEDCT248152002"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT439401001","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT399068003"},{"key":"SNOMEDCT439401001","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT254837009"}],"operand":"OR"},{"children":[{"key":"RID10311","system":"urn:oid:2.16.840.1.113883.6.256","type":"EQUALS","value":"RID10312"},{"key":"RID10311","system":"urn:oid:2.16.840.1.113883.6.256","type":"EQUALS","value":"RID10337"}],"operand":"OR"},{"children":[{"key":"SNOMEDCT123037004","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT76752008"},{"key":"SNOMEDCT123037004","system":"urn:snomed-org/sct","type":"EQUALS","value":"SNOMEDCT41216001"}],"operand":"OR"},{"children":[{"key":"C25392","system":"http://bioontology.org/projects/ontologies/birnlex","type":"EQUALS","value":"birnlex_3065"},{"key":"C25392","system":"http://bioontology.org/projects/ontologies/birnlex","type":"EQUALS","value":"birnlex_3067"}],"operand":"OR"}],"operand":"AND"}],"operand":"OR"},"id":"c57e075c-19de-4c5a-ba9c-b8f697a98dfc__search__c57e075c-19de-4c5a-ba9c-b8f697a98dfc"}"#; diff --git a/src/exporter.rs b/src/exporter.rs index 0fc72a95..603ca17a 100644 --- a/src/exporter.rs +++ b/src/exporter.rs @@ -158,7 +158,7 @@ pub async fn post_exporter_query( } Language::Ast(ast_query) => serde_json::from_str(&cql::generate_body( parse_blaze_query_payload_ast(&ast_query.payload)?, - crate::projects::Project::Dktk, + crate::flavours::Flavour::Dktk, )?)?, }; diff --git a/src/projects/bbmri/body.json b/src/flavours/bbmri/body.json similarity index 100% rename from src/projects/bbmri/body.json rename to src/flavours/bbmri/body.json diff --git a/src/projects/bbmri/mod.rs b/src/flavours/bbmri/mod.rs similarity index 98% rename from src/projects/bbmri/mod.rs rename to src/flavours/bbmri/mod.rs index 5669e6c7..02054ee1 100644 --- a/src/projects/bbmri/mod.rs +++ b/src/flavours/bbmri/mod.rs @@ -117,7 +117,7 @@ pub static CQL_SNIPPETS: LazyLock> = LazyLock::new(|| IndexSet::from(["icd10", "SampleMaterialType"])); -pub static SAMPLE_TYPE_WORKAROUNDS: LazyLock>> = +pub static CODE_WORKAROUNDS: LazyLock>> = LazyLock::new(|| { HashMap::from([ ( diff --git a/src/projects/bbmri/template.cql b/src/flavours/bbmri/template.cql similarity index 100% rename from src/projects/bbmri/template.cql rename to src/flavours/bbmri/template.cql diff --git a/src/projects/cce/body.json b/src/flavours/cce/body.json similarity index 100% rename from src/projects/cce/body.json rename to src/flavours/cce/body.json diff --git a/src/projects/nngm/mod.rs b/src/flavours/cce/mod.rs similarity index 99% rename from src/projects/nngm/mod.rs rename to src/flavours/cce/mod.rs index 573b4d3c..55599411 100644 --- a/src/projects/nngm/mod.rs +++ b/src/flavours/cce/mod.rs @@ -381,7 +381,7 @@ pub static MANDATORY_CODE_LISTS: LazyLock> = LazyLock::ne ]) }); -pub static SAMPLE_TYPE_WORKAROUNDS: LazyLock>> = +pub static CODE_WORKAROUNDS: LazyLock>> = LazyLock::new(|| { HashMap::new() // No workarounds for cce }); diff --git a/src/projects/cce/template.cql b/src/flavours/cce/template.cql similarity index 100% rename from src/projects/cce/template.cql rename to src/flavours/cce/template.cql diff --git a/src/projects/dhki/body.json b/src/flavours/dhki/body.json similarity index 100% rename from src/projects/dhki/body.json rename to src/flavours/dhki/body.json diff --git a/src/projects/dhki/mod.rs b/src/flavours/dhki/mod.rs similarity index 99% rename from src/projects/dhki/mod.rs rename to src/flavours/dhki/mod.rs index 42130978..0b756753 100644 --- a/src/projects/dhki/mod.rs +++ b/src/flavours/dhki/mod.rs @@ -396,7 +396,7 @@ pub static CQL_SNIPPETS: LazyLock> = LazyLock::new(|| IndexSet::from(["loinc"])); -pub static SAMPLE_TYPE_WORKAROUNDS: LazyLock>> = +pub static CODE_WORKAROUNDS: LazyLock>> = LazyLock::new(|| { HashMap::new() // No workarounds for dhki }); diff --git a/src/projects/dhki/template.cql b/src/flavours/dhki/template.cql similarity index 100% rename from src/projects/dhki/template.cql rename to src/flavours/dhki/template.cql diff --git a/src/projects/dktk/body.json b/src/flavours/dktk/body.json similarity index 100% rename from src/projects/dktk/body.json rename to src/flavours/dktk/body.json diff --git a/src/projects/dktk/mod.rs b/src/flavours/dktk/mod.rs similarity index 99% rename from src/projects/dktk/mod.rs rename to src/flavours/dktk/mod.rs index 1a3ef832..ad616bb6 100644 --- a/src/projects/dktk/mod.rs +++ b/src/flavours/dktk/mod.rs @@ -364,7 +364,7 @@ pub static CQL_SNIPPETS: LazyLock> = LazyLock::new(|| IndexSet::from(["loinc"])); -pub static SAMPLE_TYPE_WORKAROUNDS: LazyLock>> = +pub static CODE_WORKAROUNDS: LazyLock>> = LazyLock::new(|| { HashMap::new() // No workarounds for dktk }); diff --git a/src/projects/dktk/template.cql b/src/flavours/dktk/template.cql similarity index 100% rename from src/projects/dktk/template.cql rename to src/flavours/dktk/template.cql diff --git a/src/projects/itcc/body.json b/src/flavours/itcc/body.json similarity index 100% rename from src/projects/itcc/body.json rename to src/flavours/itcc/body.json diff --git a/src/projects/itcc/mod.rs b/src/flavours/itcc/mod.rs similarity index 99% rename from src/projects/itcc/mod.rs rename to src/flavours/itcc/mod.rs index bac8a2cd..5f6d33b9 100644 --- a/src/projects/itcc/mod.rs +++ b/src/flavours/itcc/mod.rs @@ -426,7 +426,7 @@ pub static CQL_SNIPPETS: LazyLock> = LazyLock::new(|| IndexSet::from(["loinc"])); -pub static SAMPLE_TYPE_WORKAROUNDS: LazyLock>> = +pub static CODE_WORKAROUNDS: LazyLock>> = LazyLock::new(|| { HashMap::new() // No workarounds for dktk }); diff --git a/src/projects/itcc/template.cql b/src/flavours/itcc/template.cql similarity index 100% rename from src/projects/itcc/template.cql rename to src/flavours/itcc/template.cql diff --git a/src/flavours/miabis/body.json b/src/flavours/miabis/body.json new file mode 100644 index 00000000..e29fb795 --- /dev/null +++ b/src/flavours/miabis/body.json @@ -0,0 +1,172 @@ +{ + "lang": "cql", + "lib": { + "content": [ + { + "contentType": "text/cql", + "data": "{{LIBRARY_ENCODED}}" + } + ], + "resourceType": "Library", + "status": "active", + "type": { + "coding": [ + { + "code": "logic-library", + "system": "http://terminology.hl7.org/CodeSystem/library-type" + } + ] + }, + "url": "{{LIBRARY_UUID}}" + }, + "measure": { + "group": [ + { + "code": { + "text": "patient" + }, + "population": [ + { + "code": { + "coding": [ + { + "code": "initial-population", + "system": "http://terminology.hl7.org/CodeSystem/measure-population" + } + ] + }, + "criteria": { + "expression": "InInitialPopulation", + "language": "text/cql-identifier" + } + } + ], + "stratifier": [ + { + "code": { + "text": "gender" + }, + "criteria": { + "expression": "Gender", + "language": "text/cql" + } + }, + { + "code": { + "text": "donor_age" + }, + "criteria": { + "expression": "AgeClass", + "language": "text/cql" + } + }, + { + "code": { + "text": "Custodian" + }, + "criteria": { + "expression": "Custodian", + "language": "text/cql" + } + } + ] + }, + { + "code": { + "text": "diagnosis" + }, + "extension": [ + { + "url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-populationBasis", + "valueCode": "Condition" + } + ], + "population": [ + { + "code": { + "coding": [ + { + "code": "initial-population", + "system": "http://terminology.hl7.org/CodeSystem/measure-population" + } + ] + }, + "criteria": { + "expression": "Diagnosis", + "language": "text/cql-identifier" + } + } + ], + "stratifier": [ + { + "code": { + "text": "diagnosis" + }, + "criteria": { + "expression": "DiagnosisCode", + "language": "text/cql" + } + } + ] + }, + { + "code": { + "text": "specimen" + }, + "extension": [ + { + "url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-populationBasis", + "valueCode": "Specimen" + } + ], + "population": [ + { + "code": { + "coding": [ + { + "code": "initial-population", + "system": "http://terminology.hl7.org/CodeSystem/measure-population" + } + ] + }, + "criteria": { + "expression": "Specimen", + "language": "text/cql-identifier" + } + } + ], + "stratifier": [ + { + "code": { + "text": "sample_kind" + }, + "criteria": { + "expression": "SampleType", + "language": "text/cql" + } + } + ] + } + ], + "library": "{{LIBRARY_UUID}}", + "resourceType": "Measure", + "scoring": { + "coding": [ + { + "code": "cohort", + "system": "http://terminology.hl7.org/CodeSystem/measure-scoring" + } + ] + }, + "status": "active", + "subjectCodeableConcept": { + "coding": [ + { + "code": "Patient", + "system": "http://hl7.org/fhir/resource-types" + } + ] + }, + "url": "{{MEASURE_UUID}}" + } +} diff --git a/src/flavours/miabis/mod.rs b/src/flavours/miabis/mod.rs new file mode 100644 index 00000000..c21e39c5 --- /dev/null +++ b/src/flavours/miabis/mod.rs @@ -0,0 +1,115 @@ +use std::{collections::HashMap, sync::LazyLock}; + +use indexmap::IndexSet; + +use super::CriterionRole; + +pub static CODE_LISTS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ("icd10", "http://hl7.org/fhir/sid/icd-10"), + ( + "MiabisDetailedSampleType", + "https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs", + ), + ]) +}); + +pub static OBSERVATION_LOINC_CODES: LazyLock> = + LazyLock::new(HashMap::new); + +pub static CRITERION_CODE_LISTS: LazyLock>> = + LazyLock::new(|| { + HashMap::from([ + ("diagnosis", vec!["icd10"]), + ("sample_kind", vec!["MiabisDetailedSampleType"]), + ]) + }); + +pub static CQL_SNIPPETS: LazyLock> = + LazyLock::new(|| { + HashMap::from([ + (("gender", CriterionRole::Query), "Patient.gender = '{{C}}'"), + ( + ("diagnosis", CriterionRole::Query), + "exists[Condition: Code '{{C}}' from {{A1}}]", + ), + ( + ("date_of_diagnosis", CriterionRole::Query), + "exists from [Condition] C\nwhere FHIRHelpers.ToDateTime(C.onset) between {{D1}} and {{D2}}", + ), + ( + ("diagnosis_age_donor", CriterionRole::Query), + "exists from [Condition] C\nwhere AgeInYearsAt(FHIRHelpers.ToDateTime(C.onset)) between Ceiling({{D1}}) and Ceiling({{D2}})", + ), + ( + ("donor_age", CriterionRole::Query), + "AgeInYears() between Ceiling({{D1}}) and Ceiling({{D2}})", + ), + ( + ("sample_kind", CriterionRole::Query), + "exists [Specimen: Code '{{C}}' from {{A1}}]", + ), + ( + ("sample_kind", CriterionRole::Filter), + "(S.type.coding.where(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').code contains '{{C}}')", + ), + ( + ("storage_temperature", CriterionRole::Query), + "exists from [Specimen] S where (\ + exists from S.processing P where (\ + exists from P.extension E where \ + E.url = 'https://fhir.bbmri-eric.eu/StructureDefinition/miabis-sample-storage-temperature-extension' \ + and (E.value as CodeableConcept).coding.code contains '{{C}}'\ + )\ + )", + ), + ( + ("storage_temperature", CriterionRole::Filter), + "(exists from S.processing P where (\ + exists from P.extension E where \ + E.url = 'https://fhir.bbmri-eric.eu/StructureDefinition/miabis-sample-storage-temperature-extension' \ + and (E.value as CodeableConcept).coding.code contains '{{C}}'\ + ))", + ), + ( + ("sampling_date", CriterionRole::Query), + "exists from [Specimen] S\nwhere FHIRHelpers.ToDateTime(S.collection.collected) between {{D1}} and {{D2}}", + ), + ( + ("sampling_date", CriterionRole::Filter), + "(FHIRHelpers.ToDateTime(S.collection.collected) between {{D1}} and {{D2}})", + ), + ]) + }); + +pub static MANDATORY_CODE_LISTS: LazyLock> = + LazyLock::new(|| IndexSet::from(["icd10", "MiabisDetailedSampleType"])); + +pub static CODE_WORKAROUNDS: LazyLock>> = + LazyLock::new(|| { + HashMap::from([ + ("whole-blood", vec!["WholeBlood"]), + ("buffy-coat", vec!["BuffyCoat"]), + ("blood-plasma", vec!["Plasma"]), + ("blood-serum", vec!["Serum"]), + ("dna", vec!["DNA"]), + ("rna", vec!["RNA"]), + ("peripheral-blood-cells-vital", vec!["PBMC"]), + ("tissue-frozen", vec!["TissueFreshFrozen"]), + ("tissue-ffpe", vec!["TissueFixed"]), + ("bone-marrow", vec!["BoneMarrowAspirate"]), + ("csf-liquor", vec!["CerebrospinalFluid"]), + ("urine", vec!["Urine"]), + ("saliva", vec!["Saliva"]), + ("stool-faeces", vec!["Faeces"]), + ("liquid-other", vec!["LiquidBiopsy", "Sputum"]), + ("temperatureRoom", vec!["RT"]), + ("temperature2to10", vec!["2to10"]), + ("four_degrees", vec!["2to10"]), + ("temperature-18to-35", vec!["-18to-35"]), + ("temperature-60to-85", vec!["-60to-85"]), + ("temperatureGN", vec!["LN"]), + ("temperatureLN", vec!["LN"]), + ("temperatureOther", vec!["Other"]), + ]) + }); diff --git a/src/flavours/miabis/template.cql b/src/flavours/miabis/template.cql new file mode 100644 index 00000000..1397e8a5 --- /dev/null +++ b/src/flavours/miabis/template.cql @@ -0,0 +1,57 @@ +library Retrieve +using FHIR version '4.0.1' +include FHIRHelpers version '4.0.1' + +{{lists}} + +context Patient + +define AgeClass: +if (Patient.birthDate is null) then 'unknown' else ToString((AgeInYears() div 10) * 10) + +define Gender: +if (Patient.gender is null) then 'unknown' else Patient.gender + +define Custodian: + First( + from Specimen.extension E + where E.url = 'https://fhir.bbmri-eric.eu/StructureDefinition/miabis-sample-collection-extension' + return (E.value as Identifier).value + ) + +define function SampleType(specimen FHIR.Specimen): + case FHIRHelpers.ToCode(specimen.type.coding.where(system = 'https://fhir.bbmri-eric.eu/CodeSystem/miabis-detailed-samply-type-cs').first()) + when Code 'WholeBlood' from MiabisDetailedSampleType then 'whole-blood' + when Code 'BuffyCoat' from MiabisDetailedSampleType then 'buffy-coat' + when Code 'Plasma' from MiabisDetailedSampleType then 'blood-plasma' + when Code 'Serum' from MiabisDetailedSampleType then 'blood-serum' + when Code 'DNA' from MiabisDetailedSampleType then 'dna' + when Code 'RNA' from MiabisDetailedSampleType then 'rna' + when Code 'PBMC' from MiabisDetailedSampleType then 'peripheral-blood-cells-vital' + when Code 'TissueFreshFrozen' from MiabisDetailedSampleType then 'tissue-frozen' + when Code 'TissueFixed' from MiabisDetailedSampleType then 'tissue-ffpe' + when Code 'BoneMarrowAspirate' from MiabisDetailedSampleType then 'bone-marrow' + when Code 'CerebrospinalFluid' from MiabisDetailedSampleType then 'csf-liquor' + when Code 'Urine' from MiabisDetailedSampleType then 'urine' + when Code 'Saliva' from MiabisDetailedSampleType then 'saliva' + when Code 'Faeces' from MiabisDetailedSampleType then 'stool-faeces' + when Code 'LiquidBiopsy' from MiabisDetailedSampleType then 'liquid-other' + when Code 'Sputum' from MiabisDetailedSampleType then 'liquid-other' + when null then 'Unknown' + else 'Unknown' + end + +define Specimen: + if InInitialPopulation then [Specimen] S {{filter_criteria}} else {} as List + +define Diagnosis: + if InInitialPopulation then [Condition] else {} as List + +define function DiagnosisCode(condition FHIR.Condition, specimen FHIR.Specimen): + condition.code.coding.where(system = 'http://hl7.org/fhir/sid/icd-10').code.first() + +define function DiagnosisCode(condition FHIR.Condition): + condition.code.coding.where(system = 'http://hl7.org/fhir/sid/icd-10').code.first() + +define InInitialPopulation: +{{retrieval_criteria}} diff --git a/src/flavours/mod.rs b/src/flavours/mod.rs new file mode 100644 index 00000000..59a2e5b3 --- /dev/null +++ b/src/flavours/mod.rs @@ -0,0 +1,160 @@ +use std::{collections::HashMap, hash::Hash, str::FromStr}; + +use indexmap::IndexSet; + +use crate::errors::FocusError; + +mod bbmri; +mod cce; +mod dhki; +mod dktk; +mod itcc; +mod miabis; +mod nngm; +mod pscc; + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Hash, Copy)] +pub enum CriterionRole { + Query, + Filter, +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +pub enum Flavour { + //not 1:1 with project anymore + Bbmri, + Dktk, + Cce, + Dhki, + Nngm, + Itcc, + Pscc, + Miabis, +} + +impl FromStr for Flavour { + type Err = FocusError; + + fn from_str(s: &str) -> Result { + match s { + "bbmri" => Ok(Flavour::Bbmri), + "dktk" => Ok(Flavour::Dktk), + "cce" => Ok(Flavour::Cce), + "nngm" => Ok(Flavour::Nngm), + "dhki" => Ok(Flavour::Dhki), + "itcc" => Ok(Flavour::Itcc), + "pscc" => Ok(Flavour::Pscc), + "miabis" => Ok(Flavour::Miabis), + _ => Err(FocusError::UnknownProject(s.to_string())), + } + } +} + +impl Flavour { + pub fn get_code_lists(&self) -> &'static HashMap<&'static str, &'static str> { + match self { + Flavour::Bbmri => &bbmri::CODE_LISTS, + Flavour::Dktk => &dktk::CODE_LISTS, + Flavour::Cce => &cce::CODE_LISTS, + Flavour::Dhki => &dhki::CODE_LISTS, + Flavour::Nngm => &nngm::CODE_LISTS, + Flavour::Itcc => &itcc::CODE_LISTS, + Flavour::Pscc => &pscc::CODE_LISTS, + Flavour::Miabis => &miabis::CODE_LISTS, + } + } + + pub fn get_observation_loinc_codes(&self) -> &'static HashMap<&'static str, &'static str> { + match self { + Flavour::Bbmri => &bbmri::OBSERVATION_LOINC_CODES, + Flavour::Dktk => &dktk::OBSERVATION_LOINC_CODES, + Flavour::Cce => &cce::OBSERVATION_LOINC_CODES, + Flavour::Dhki => &dhki::OBSERVATION_LOINC_CODES, + Flavour::Nngm => &nngm::OBSERVATION_LOINC_CODES, + Flavour::Itcc => &itcc::OBSERVATION_LOINC_CODES, + Flavour::Pscc => &pscc::OBSERVATION_LOINC_CODES, + Flavour::Miabis => &miabis::OBSERVATION_LOINC_CODES, + } + } + + pub fn get_code_workarounds(&self) -> &'static HashMap<&'static str, Vec<&'static str>> { + // used for all code workarounds, different criteria do not contain overlapping codes + match self { + Flavour::Bbmri => &bbmri::CODE_WORKAROUNDS, + Flavour::Dktk => &dktk::CODE_WORKAROUNDS, + Flavour::Cce => &cce::CODE_WORKAROUNDS, + Flavour::Dhki => &dhki::CODE_WORKAROUNDS, + Flavour::Nngm => &nngm::CODE_WORKAROUNDS, + Flavour::Itcc => &itcc::CODE_WORKAROUNDS, + Flavour::Pscc => &pscc::CODE_WORKAROUNDS, + Flavour::Miabis => &miabis::CODE_WORKAROUNDS, + } + } + + pub fn get_criterion_code_lists(&self) -> &'static HashMap<&'static str, Vec<&'static str>> { + match self { + Flavour::Bbmri => &bbmri::CRITERION_CODE_LISTS, + Flavour::Dktk => &dktk::CRITERION_CODE_LISTS, + Flavour::Cce => &cce::CRITERION_CODE_LISTS, + Flavour::Dhki => &dhki::CRITERION_CODE_LISTS, + Flavour::Nngm => &nngm::CRITERION_CODE_LISTS, + Flavour::Itcc => &itcc::CRITERION_CODE_LISTS, + Flavour::Pscc => &pscc::CRITERION_CODE_LISTS, + Flavour::Miabis => &miabis::CRITERION_CODE_LISTS, + } + } + + pub fn get_cql_snippets( + &self, + ) -> &'static HashMap<(&'static str, CriterionRole), &'static str> { + match self { + Flavour::Bbmri => &bbmri::CQL_SNIPPETS, + Flavour::Dktk => &dktk::CQL_SNIPPETS, + Flavour::Cce => &cce::CQL_SNIPPETS, + Flavour::Dhki => &dhki::CQL_SNIPPETS, + Flavour::Nngm => &nngm::CQL_SNIPPETS, + Flavour::Itcc => &itcc::CQL_SNIPPETS, + Flavour::Pscc => &pscc::CQL_SNIPPETS, + Flavour::Miabis => &miabis::CQL_SNIPPETS, + } + } + + pub fn get_mandatory_code_lists(&self) -> &'static IndexSet<&'static str> { + match self { + Flavour::Bbmri => &bbmri::MANDATORY_CODE_LISTS, + Flavour::Dktk => &dktk::MANDATORY_CODE_LISTS, + Flavour::Cce => &cce::MANDATORY_CODE_LISTS, + Flavour::Dhki => &dhki::MANDATORY_CODE_LISTS, + Flavour::Nngm => &nngm::MANDATORY_CODE_LISTS, + Flavour::Itcc => &itcc::MANDATORY_CODE_LISTS, + Flavour::Pscc => &pscc::MANDATORY_CODE_LISTS, + Flavour::Miabis => &miabis::MANDATORY_CODE_LISTS, + } + } + + pub fn get_cql_template(&self) -> &'static str { + match self { + Flavour::Bbmri => include_str!("bbmri/template.cql"), + Flavour::Dktk => include_str!("dktk/template.cql"), + Flavour::Cce => include_str!("cce/template.cql"), + Flavour::Dhki => include_str!("dhki/template.cql"), + Flavour::Nngm => include_str!("nngm/template.cql"), + Flavour::Itcc => include_str!("itcc/template.cql"), + Flavour::Pscc => include_str!("pscc/template.cql"), + Flavour::Miabis => include_str!("miabis/template.cql"), + } + } + + pub fn get_body(&self) -> &'static str { + match self { + Flavour::Bbmri => include_str!("bbmri/body.json"), + Flavour::Dktk => include_str!("dktk/body.json"), + Flavour::Cce => include_str!("cce/body.json"), + Flavour::Dhki => include_str!("dhki/body.json"), + Flavour::Nngm => include_str!("nngm/body.json"), + Flavour::Itcc => include_str!("itcc/body.json"), + Flavour::Pscc => include_str!("pscc/body.json"), + Flavour::Miabis => include_str!("miabis/body.json"), + } + } +} diff --git a/src/projects/nngm/body.json b/src/flavours/nngm/body.json similarity index 100% rename from src/projects/nngm/body.json rename to src/flavours/nngm/body.json diff --git a/src/projects/cce/mod.rs b/src/flavours/nngm/mod.rs similarity index 99% rename from src/projects/cce/mod.rs rename to src/flavours/nngm/mod.rs index 573b4d3c..55599411 100644 --- a/src/projects/cce/mod.rs +++ b/src/flavours/nngm/mod.rs @@ -381,7 +381,7 @@ pub static MANDATORY_CODE_LISTS: LazyLock> = LazyLock::ne ]) }); -pub static SAMPLE_TYPE_WORKAROUNDS: LazyLock>> = +pub static CODE_WORKAROUNDS: LazyLock>> = LazyLock::new(|| { HashMap::new() // No workarounds for cce }); diff --git a/src/projects/nngm/template.cql b/src/flavours/nngm/template.cql similarity index 100% rename from src/projects/nngm/template.cql rename to src/flavours/nngm/template.cql diff --git a/src/projects/pscc/body.json b/src/flavours/pscc/body.json similarity index 100% rename from src/projects/pscc/body.json rename to src/flavours/pscc/body.json diff --git a/src/projects/pscc/mod.rs b/src/flavours/pscc/mod.rs similarity index 99% rename from src/projects/pscc/mod.rs rename to src/flavours/pscc/mod.rs index cedeb0af..dc73a55f 100644 --- a/src/projects/pscc/mod.rs +++ b/src/flavours/pscc/mod.rs @@ -347,7 +347,7 @@ pub static CQL_SNIPPETS: LazyLock> = LazyLock::new(|| IndexSet::from(["loinc"])); -pub static SAMPLE_TYPE_WORKAROUNDS: LazyLock>> = +pub static CODE_WORKAROUNDS: LazyLock>> = LazyLock::new(|| { HashMap::new() // No workarounds for dhki }); diff --git a/src/projects/pscc/template.cql b/src/flavours/pscc/template.cql similarity index 100% rename from src/projects/pscc/template.cql rename to src/flavours/pscc/template.cql diff --git a/src/main.rs b/src/main.rs index b5d7ea30..d250abff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,9 +11,9 @@ mod logger; mod eucaim_api; mod eucaim_beacon; mod exporter; +mod flavours; mod intermediate_rep; mod mr; -mod projects; mod task_processing; mod transformed; mod util; @@ -330,9 +330,15 @@ async fn process_task( } Language::Ast(ast_query) => { generated_from_ast = true; + let cql_flavour = if let Some(cql_flavour) = CONFIG.cql_flavour { + //same FEs query blazes with different FHIR profiles + cql_flavour + } else { + metadata.project.parse()? + }; serde_json::from_str(&cql::generate_body( parse_blaze_query_payload_ast(&ast_query.payload)?, - metadata.project.parse()?, + cql_flavour, )?)? } }; @@ -378,7 +384,7 @@ async fn process_task( &query, obf_cache, query_result_cache, - metadata.project, + metadata.project, //so far no deviation from project name for this type of endpoint metadata.transform, generated_from_ast, ) @@ -637,7 +643,7 @@ async fn run_cql_query( query: &CqlQuery, obf_cache: Arc>, query_result_cache: Arc>, - project: String, + cql_flavour: String, transform: Transform, generated_from_ast: bool, ) -> Result { @@ -650,7 +656,7 @@ async fn run_cql_query( )))?; let obfuscate = - CONFIG.obfuscate == config::Obfuscate::Yes && !CONFIG.unobfuscated.contains(&project); + CONFIG.obfuscate == config::Obfuscate::Yes && !CONFIG.unobfuscated.contains(&cql_flavour); let should_cache = match query_result_cache.lock().await.get(&( encoded_query.to_string(), diff --git a/src/projects/mod.rs b/src/projects/mod.rs deleted file mode 100644 index b02fcd7f..00000000 --- a/src/projects/mod.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::{collections::HashMap, hash::Hash, str::FromStr}; - -use indexmap::IndexSet; - -use crate::errors::FocusError; - -mod bbmri; -mod cce; -mod dhki; -mod dktk; -mod itcc; -mod nngm; -mod pscc; - -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Hash, Copy)] -pub enum CriterionRole { - Query, - Filter, -} - -pub enum Project { - Bbmri, - Dktk, - Cce, - Dhki, - Nngm, - Itcc, - Pscc, -} - -impl FromStr for Project { - type Err = FocusError; - - fn from_str(s: &str) -> Result { - match s { - "bbmri" => Ok(Project::Bbmri), - "dktk" => Ok(Project::Dktk), - "cce" => Ok(Project::Cce), - "nngm" => Ok(Project::Nngm), - "dhki" => Ok(Project::Dhki), - "itcc" => Ok(Project::Itcc), - "pscc" => Ok(Project::Pscc), - _ => Err(FocusError::UnknownProject(s.to_string())), - } - } -} - -impl Project { - pub fn get_code_lists(&self) -> &'static HashMap<&'static str, &'static str> { - match self { - Project::Bbmri => &bbmri::CODE_LISTS, - Project::Dktk => &dktk::CODE_LISTS, - Project::Cce => &cce::CODE_LISTS, - Project::Dhki => &dhki::CODE_LISTS, - Project::Nngm => &nngm::CODE_LISTS, - Project::Itcc => &itcc::CODE_LISTS, - Project::Pscc => &pscc::CODE_LISTS, - } - } - - pub fn get_observation_loinc_codes(&self) -> &'static HashMap<&'static str, &'static str> { - match self { - Project::Bbmri => &bbmri::OBSERVATION_LOINC_CODES, - Project::Dktk => &dktk::OBSERVATION_LOINC_CODES, - Project::Cce => &cce::OBSERVATION_LOINC_CODES, - Project::Dhki => &dhki::OBSERVATION_LOINC_CODES, - Project::Nngm => &nngm::OBSERVATION_LOINC_CODES, - Project::Itcc => &itcc::OBSERVATION_LOINC_CODES, - Project::Pscc => &pscc::OBSERVATION_LOINC_CODES, - } - } - - pub fn get_sample_type_workarounds(&self) -> &'static HashMap<&'static str, Vec<&'static str>> { - match self { - Project::Bbmri => &bbmri::SAMPLE_TYPE_WORKAROUNDS, - Project::Dktk => &dktk::SAMPLE_TYPE_WORKAROUNDS, - Project::Cce => &cce::SAMPLE_TYPE_WORKAROUNDS, - Project::Dhki => &dhki::SAMPLE_TYPE_WORKAROUNDS, - Project::Nngm => &nngm::SAMPLE_TYPE_WORKAROUNDS, - Project::Itcc => &itcc::SAMPLE_TYPE_WORKAROUNDS, - Project::Pscc => &pscc::SAMPLE_TYPE_WORKAROUNDS, - } - } - - pub fn get_criterion_code_lists(&self) -> &'static HashMap<&'static str, Vec<&'static str>> { - match self { - Project::Bbmri => &bbmri::CRITERION_CODE_LISTS, - Project::Dktk => &dktk::CRITERION_CODE_LISTS, - Project::Cce => &cce::CRITERION_CODE_LISTS, - Project::Dhki => &dhki::CRITERION_CODE_LISTS, - Project::Nngm => &nngm::CRITERION_CODE_LISTS, - Project::Itcc => &itcc::CRITERION_CODE_LISTS, - Project::Pscc => &pscc::CRITERION_CODE_LISTS, - } - } - - pub fn get_cql_snippets( - &self, - ) -> &'static HashMap<(&'static str, CriterionRole), &'static str> { - match self { - Project::Bbmri => &bbmri::CQL_SNIPPETS, - Project::Dktk => &dktk::CQL_SNIPPETS, - Project::Cce => &cce::CQL_SNIPPETS, - Project::Dhki => &dhki::CQL_SNIPPETS, - Project::Nngm => &nngm::CQL_SNIPPETS, - Project::Itcc => &itcc::CQL_SNIPPETS, - Project::Pscc => &pscc::CQL_SNIPPETS, - } - } - - pub fn get_mandatory_code_lists(&self) -> &'static IndexSet<&'static str> { - match self { - Project::Bbmri => &bbmri::MANDATORY_CODE_LISTS, - Project::Dktk => &dktk::MANDATORY_CODE_LISTS, - Project::Cce => &cce::MANDATORY_CODE_LISTS, - Project::Dhki => &dhki::MANDATORY_CODE_LISTS, - Project::Nngm => &nngm::MANDATORY_CODE_LISTS, - Project::Pscc => &pscc::MANDATORY_CODE_LISTS, - Project::Itcc => &itcc::MANDATORY_CODE_LISTS, - } - } - - pub fn get_cql_template(&self) -> &'static str { - match self { - Project::Bbmri => include_str!("bbmri/template.cql"), - Project::Dktk => include_str!("dktk/template.cql"), - Project::Cce => include_str!("cce/template.cql"), - Project::Dhki => include_str!("dhki/template.cql"), - Project::Nngm => include_str!("nngm/template.cql"), - Project::Itcc => include_str!("itcc/template.cql"), - Project::Pscc => include_str!("pscc/template.cql"), - } - } - - pub fn get_body(&self) -> &'static str { - match self { - Project::Bbmri => include_str!("bbmri/body.json"), - Project::Dktk => include_str!("dktk/body.json"), - Project::Cce => include_str!("cce/body.json"), - Project::Dhki => include_str!("dhki/body.json"), - Project::Nngm => include_str!("nngm/body.json"), - Project::Itcc => include_str!("itcc/body.json"), - Project::Pscc => include_str!("pscc/body.json"), - } - } -} diff --git a/src/util.rs b/src/util.rs index 752e7e98..e06f30aa 100644 --- a/src/util.rs +++ b/src/util.rs @@ -3,7 +3,7 @@ use crate::mr; use base64::engine::general_purpose; use base64::Engine as _; use laplace_rs::{get_from_cache_or_privatize, Bin, ObfCache, ObfuscateBelow10Mode}; -use rand::thread_rng; +use rand::rng; use serde_json::Value; use std::collections::HashMap; use tracing::warn; @@ -272,7 +272,7 @@ fn obfuscate_population( domain_limit: Option, bbmri_rounding: bool, ) -> Result<(), FocusError> { - let mut rng = thread_rng(); + let mut rng = rng(); for pop in val { let obfuscated = get_from_cache_or_privatize( pop.count, @@ -464,17 +464,6 @@ mod test { } } - #[test] - fn test_replace_cql() { - let decoded_library = QUERY_BBMRI_PLACEHOLDERS; - let expected_result = QUERY_BBMRI; - pretty_assertions::assert_eq!(replace_cql(decoded_library), expected_result); - - let decoded_library = "INVALID_KEY"; - let expected_result = "INVALID_KEY"; - pretty_assertions::assert_eq!(replace_cql(decoded_library), expected_result); - } - #[test] fn test_obfuscate_counts_bbmri_new_extension() { let mut obf_cache = ObfCache {