diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 8703801c12..83590d9f68 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -19,14 +19,6 @@ import org.gradle.api.artifacts.DependencyConstraint import org.gradle.kotlin.dsl.exclude object Dependencies { - object Cql { - const val evaluator = "org.opencds.cqf.fhir:cqf-fhir-cr:${Versions.Cql.clinicalReasoning}" - const val evaluatorFhirJackson = - "org.opencds.cqf.fhir:cqf-fhir-jackson:${Versions.Cql.clinicalReasoning}" - const val evaluatorFhirUtilities = - "org.opencds.cqf.fhir:cqf-fhir-utility:${Versions.Cql.clinicalReasoning}" - } - object HapiFhir { const val fhirBaseModule = "ca.uhn.hapi.fhir:hapi-fhir-base" const val fhirClientModule = "ca.uhn.hapi.fhir:hapi-fhir-client" @@ -139,11 +131,6 @@ object Dependencies { const val xmlUnit = "org.xmlunit:xmlunit-core:${Versions.xmlUnit}" object Versions { - - object Cql { - const val clinicalReasoning = "3.0.0-PRE9-SNAPSHOT" - } - const val androidFhirCommon = "0.1.0-alpha05" const val androidFhirEngine = "0.1.0-beta05" const val androidFhirKnowledge = "0.1.0-alpha03" @@ -195,16 +182,17 @@ object Dependencies { } fun Configuration.removeIncompatibleDependencies() { - exclude(module = "xpp3") - exclude(module = "xpp3_min") - exclude(module = "xmlpull") + exclude(module = "hapi-fhir-caching-caffeine") exclude(module = "javax.json") exclude(module = "jcl-over-slf4j") - exclude(group = "org.apache.httpcomponents") - exclude(group = "org.antlr", module = "antlr4") - exclude(group = "org.eclipse.persistence", module = "org.eclipse.persistence.moxy") - exclude(module = "hapi-fhir-caching-caffeine") + exclude(module = "xmlpull") + exclude(module = "xpp3") + exclude(module = "xpp3_min") + exclude(group = "ch.qos.logback", module = "logback-classic") exclude(group = "com.github.ben-manes.caffeine", module = "caffeine") + exclude(group = "org.eclipse.persistence", module = "org.eclipse.persistence.moxy") + exclude(group = "org.antlr", module = "antlr4") + exclude(group = "org.apache.httpcomponents") } fun hapiFhirConstraints(): Map Unit> { diff --git a/buildSrc/src/main/kotlin/LicenseeConfig.kt b/buildSrc/src/main/kotlin/LicenseeConfig.kt index 0beaf453c8..da4a603348 100644 --- a/buildSrc/src/main/kotlin/LicenseeConfig.kt +++ b/buildSrc/src/main/kotlin/LicenseeConfig.kt @@ -72,17 +72,24 @@ fun Project.configureLicensee() { } // Jakarta XML Binding API - allowDependency("jakarta.xml.bind", "jakarta.xml.bind-api", "2.3.3") { + allowDependency("jakarta.xml.bind", "jakarta.xml.bind-api", "4.0.1") { because("BSD 3-clause.") } // Jakarta Activation API 2.1 Specification - allowDependency("jakarta.activation", "jakarta.activation-api", "1.2.2") { + allowDependency("jakarta.activation", "jakarta.activation-api", "2.1.2") { because( "Licensed under Eclipse Distribution License 1.0. http://www.eclipse.org/org/documents/edl-v10.php", ) } + // Jakarta Annotation API 2.1 Specification + allowDependency("jakarta.annotation", "jakarta.annotation-api", "2.1.1") { + because( + "Licensed under EPL 2.0", + ) + } + // Javax Annotation API allowDependency("javax.annotation", "javax.annotation-api", "1.3.2") { because("Dual-licensed under CDDL 1.1 and GPL v2 with classpath exception.") @@ -108,7 +115,7 @@ fun Project.configureLicensee() { because("BSD 3-clause. http://www.antlr.org/license.html") } // ANTLR 4 - allowDependency("org.antlr", "antlr4-runtime", "4.10.1") { + allowDependency("org.antlr", "antlr4-runtime", "4.13.1") { because("BSD 3-clause. http://www.antlr.org/license.html") } @@ -195,6 +202,12 @@ fun Project.configureLicensee() { allowDependency("com.ibm.icu", "icu4j", "72.1") { because("BSD, part MIT and Apache 2.0. https://github.com/unicode-org/icu/blob/main/LICENSE") } + + // Logback + allowDependency("ch.qos.logback", "logback-classic", "1.4.14") { because("LGPL") } + + // Logback + allowDependency("ch.qos.logback", "logback-core", "1.4.14") { because("LGPL") } } } diff --git a/catalog/src/main/assets/behavior_calculated_expression.json b/catalog/src/main/assets/behavior_calculated_expression.json index 1e0f1bb17d..c41b6b0f69 100644 --- a/catalog/src/main/assets/behavior_calculated_expression.json +++ b/catalog/src/main/assets/behavior_calculated_expression.json @@ -2,48 +2,20 @@ "resourceType": "Questionnaire", "item": [ { - "linkId": "a-birthdate", - "text": "Birth Date (select age to auto calculate if not known)", - "type": "date", - "extension": [ - { - "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression", - "valueExpression": { - "language": "text/fhirpath", - "expression": "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" - } - } - ] + "linkId": "birthdate", + "text": "Date of birth", + "type": "date" }, { - "linkId": "a-age-years", + "linkId": "age", "text": "Age", - "type": "quantity", + "type": "integer", "extension": [ { - "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption", - "valueCoding": { - "system": "http://unitsofmeasure.org", - "code": "years", - "display": "years" - } - }, - { - "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption", - "valueCoding": { - "system": "http://unitsofmeasure.org", - "code": "months", - "display": "months" - } - } - ], - "initial": [ - { - "valueQuantity": { - "value": 1, - "unit": "months", - "system": "http://unitsofmeasure.org", - "code": "months" + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='birthdate' and answer.empty().not()).select(today().toString().substring(0,4).toInteger() - answer.value.toString().substring(0,4).toInteger() + iif(today().toString().substring(5,2) & today().toString().substring(8,2) > answer.value.toString().substring(5,2) & answer.value.toString().substring(8,2), 1, 0) - 1)" } } ] diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index 989e4480fe..7e09047feb 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -58,7 +58,7 @@ Initial Value Input age to automatically calculate birthdate until birthdate is updated manually. + >Input date of birth to automatically calculate age until age is updated manually. Calculated Expression diff --git a/docs/contrib/git.md b/docs/contrib/git.md index 729fa78fb3..cabd02a501 100644 --- a/docs/contrib/git.md +++ b/docs/contrib/git.md @@ -4,6 +4,27 @@ This page documents [Git](https://git-scm.com) tips for contributors to this pro This project generally follows [these guidelines](https://github.com/google/fhir-data-pipes/blob/master/doc/review_process.md) from our `fhir-data-pipes` sister project. +## Overview + +We use the following workflow: + +1. Contributors can open "draft" PRs for any Work In Progress (WIP) which does not require review yet. + It's OK if the checks do not pass yet. + +1. When a PR is ready for code review, the contributor marks the PR as "Ready for review". For a smooth review, the PR must pass all the checks. + At this point, the PR should only have 1 single commit; please "squash" (or use `--amend`) your "local history", + before pushing to a branch to open a PR (or when your WIP Draft PR is ready for review). The commit message + of this initial commit should explain what this PR is all about. + +1. Maintainers, or other Contributors, will now review the PR. They may add comments requesting changes. + +1. When contributors update PRs to make changes requested by reviewers, those should be added as + additional new single commits per round of review, + typically with a generic message such as _"Incorporated review feedback."_ + Do NOT squash (or amend) these review updates into the original commit. + +1. Maintainers (with write acces) _squash_ all commits of PRs into a single commit when merging. + ## Usage ### Edit on GitHub Web UI diff --git a/docs/contrib/index.md b/docs/contrib/index.md index 2dde9bbe0c..400188ab19 100644 --- a/docs/contrib/index.md +++ b/docs/contrib/index.md @@ -324,3 +324,18 @@ This section defines the process one goes through when making changes to any of * Update your/any dependent PR (PR using the library) with the new _Artifact ID_ and make/trigger the CI **NB:** For a specific example on working with FHIR SDK's Common Library during development, see [Common Library](#common-library). + +# Database migration + +If you are making changes to the database schema (in the `engine` or the `knowledge` module), you +need to consider how applications with Android FHIR SDK dependencies can upgrade to the new schema +without losing or corrupting existing data already on device. This can be done with [Room database +migration](https://developer.android.com/training/data-storage/room/migrating-db-versions). + +> [!TIP] +> A new JSON schema file will be generated under the `schemas` folder in the module when you +update the database version. If you are having trouble with this, make sure you run the gradle +> command with `--rerun-tasks`: +> ``` +> ./gradlew ::build --rerun-tasks +> ``` diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 62ec25224e..d18f5f60ed 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -14,6 +14,9 @@ publishArtifact(Releases.Engine) createJacocoTestReportTask() +// Generate database schema in the schemas folder +ksp { arg("room.schemaLocation", "$projectDir/schemas") } + val generateSearchParamsTask = project.tasks.register("generateSearchParamsTask", GenerateSearchParamsTask::class) { srcOutputDir.set(project.layout.buildDirectory.dir("gen/main")) @@ -39,12 +42,6 @@ android { // need to specify this to prevent junit runner from going deep into our dependencies testInstrumentationRunnerArguments["package"] = "com.google.android.fhir" consumerProguardFile("proguard-rules.pro") - - javaCompileOptions { - annotationProcessorOptions { - compilerArgumentProviders(RoomSchemaArgProvider(File(projectDir, "schemas"))) - } - } } sourceSets { diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt index 705b3c8a58..ee0aede8c9 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt @@ -29,6 +29,7 @@ import com.google.android.fhir.SearchParamName import com.google.android.fhir.SearchResult import com.google.android.fhir.db.Database import com.google.android.fhir.db.ResourceNotFoundException +import com.google.android.fhir.db.impl.dao.LocalChangeDao import com.google.android.fhir.logicalId import com.google.android.fhir.search.LOCAL_LAST_UPDATED_PARAM import com.google.android.fhir.search.Operation @@ -67,6 +68,7 @@ import org.hl7.fhir.r4.model.DecimalType import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Immunization @@ -75,6 +77,8 @@ import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Organization import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Period +import org.hl7.fhir.r4.model.Person +import org.hl7.fhir.r4.model.Person.PersonLinkComponent import org.hl7.fhir.r4.model.Practitioner import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Reference @@ -83,6 +87,7 @@ import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.RiskAssessment import org.hl7.fhir.r4.model.SearchParameter import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.Task import org.json.JSONArray import org.json.JSONObject import org.junit.After @@ -3680,6 +3685,124 @@ class DatabaseImplTest { .inOrder() } + @Test + fun search_patient_and_revinclude_person_should_map_common_person_to_all_matching_patients() = + runBlocking { + val person1 = + Person().apply { + id = "person-1" + addName( + HumanName().apply { + family = "Person" + addGiven("First") + }, + ) + addLink(PersonLinkComponent(Reference("Patient/pa-01"))) + addLink(PersonLinkComponent(Reference("Patient/pa-02"))) + } + + val person2 = + Person().apply { + id = "person-2" + addName( + HumanName().apply { + family = "Person" + addGiven("Second") + }, + ) + addLink(PersonLinkComponent(Reference("Patient/pa-02"))) + addLink(PersonLinkComponent(Reference("Patient/pa-03"))) + } + + val person3 = + Person().apply { + id = "person-3" + addName( + HumanName().apply { + family = "Person" + addGiven("Third") + }, + ) + addLink(PersonLinkComponent(Reference("Patient/pa-01"))) + addLink(PersonLinkComponent(Reference("Patient/pa-03"))) + } + + val patient01 = + Patient().apply { + id = "pa-01" + addName( + HumanName().apply { + addGiven("James") + family = "Gorden" + }, + ) + } + + val patient02 = + Patient().apply { + id = "pa-02" + addName( + HumanName().apply { + addGiven("James") + family = "Bond" + }, + ) + } + + val patient03 = + Patient().apply { + id = "pa-03" + addName( + HumanName().apply { + addGiven("Jamie") + family = "Bond" + }, + ) + } + + database.insert(person1, person2, person3, patient01, patient02, patient03) + + val result = + Search(ResourceType.Patient) + .apply { + filter( + Patient.GIVEN, + { + value = "Jam" + modifier = StringFilterModifier.STARTS_WITH + }, + ) + + revInclude(ResourceType.Person, Person.LINK) { sort(Person.NAME, Order.ASCENDING) } + } + .execute(database) + + assertThat(result) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult( + patient01, + included = null, + revIncluded = + mapOf(Pair(ResourceType.Person, Person.LINK.paramName) to listOf(person1, person3)), + ), + SearchResult( + patient02, + included = null, + revIncluded = + mapOf(Pair(ResourceType.Person, Person.LINK.paramName) to listOf(person1, person2)), + ), + SearchResult( + patient03, + included = null, + revIncluded = + mapOf(Pair(ResourceType.Person, Person.LINK.paramName) to listOf(person2, person3)), + ), + ) + .inOrder() + } + @Test fun search_patient_and_revInclude_encounters_sorted_by_date_descending(): Unit = runBlocking { val patient01 = @@ -4086,6 +4209,828 @@ class DatabaseImplTest { assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) } + @Test // https://github.com/google/android-fhir/issues/2512 + fun included_results_sort_ascending_should_have_distinct_resources() = runBlocking { + /** + * This tests that the search query does not return duplicated resources as a result of sorting + * by a field that has multiple index values. For example, searching a group of patients sorted + * by Patient.GIVEN should return a single copy of each patient even if some of them might have + * multiple given names indexed. + * + * Whilst sorting, the resource table and the relevant index table are joined, which could + * result in multiple rows for a single resource if there are multiple index values for the + * particular index used in the sorting criteria. This is prevented by adding the `GROUP BY` + * with `HAVING` clause in the generated SQL query. See `MoreSearch.generateGroupAndOrderQuery` + * for additional info. + */ + val group = + Group().apply { + id = "group" + addMember(Group.GroupMemberComponent(Reference("Patient/p1"))) + addMember(Group.GroupMemberComponent(Reference("Patient/p2"))) + } + val p1 = + Patient().apply { + id = "p1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("3") + addGiven("1") + }, + ) + } + + val p2 = + Patient().apply { + id = "p2" + addName( + HumanName().apply { + family = "Cooper" + addGiven("2") + addGiven("4") + }, + ) + } + database.insert(group, p1, p2) + + val ascendingResult = + Search(ResourceType.Group) + .apply { include(Group.MEMBER) { sort(Patient.GIVEN, Order.ASCENDING) } } + .execute(database) + + assertThat(ascendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .contains(SearchResult(group, mapOf(Group.MEMBER.paramName to listOf(p1, p2)), null)) + } + + @Test // https://github.com/google/android-fhir/issues/2512 + fun included_results_sort_descending_should_have_distinct_resources() = runBlocking { + /** + * This tests that the search query does not return duplicated resources as a result of sorting + * by a field that has multiple index values. For example, searching a group of patients sorted + * by Patient.GIVEN should return a single copy of each patient even if some of them might have + * multiple given names indexed. + * + * Whilst sorting, the resource table and the relevant index table are joined, which could + * result in multiple rows for a single resource if there are multiple index values for the + * particular index used in the sorting criteria. This is prevented by adding the `GROUP BY` + * with `HAVING` clause in the generated SQL query. See `MoreSearch.generateGroupAndOrderQuery` + * for additional info. + */ + val group = + Group().apply { + id = "group" + addMember(Group.GroupMemberComponent(Reference("Patient/p1"))) + addMember(Group.GroupMemberComponent(Reference("Patient/p2"))) + } + val p1 = + Patient().apply { + id = "p1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("3") + addGiven("1") + }, + ) + } + + val p2 = + Patient().apply { + id = "p2" + addName( + HumanName().apply { + family = "Cooper" + addGiven("2") + addGiven("4") + }, + ) + } + database.insert(group, p1, p2) + + val descendingResult = + Search(ResourceType.Group) + .apply { include(Group.MEMBER) { sort(Patient.GIVEN, Order.DESCENDING) } } + .execute(database) + + assertThat(descendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .contains(SearchResult(group, mapOf(Group.MEMBER.paramName to listOf(p2, p1)), null)) + } + + @Test + fun revIncluded_results_sort_ascending_should_have_distinct_resources() = runBlocking { + val practitioner = + Practitioner().apply { + id = "practitioner-1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("James") + }, + ) + } + val p1 = + Patient().apply { + id = "p1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("3") + addGiven("1") + }, + ) + + addGeneralPractitioner(Reference("Practitioner/practitioner-1")) + } + + val p2 = + Patient().apply { + id = "p2" + addName( + HumanName().apply { + family = "Cooper" + addGiven("2") + addGiven("4") + }, + ) + addGeneralPractitioner(Reference("Practitioner/practitioner-1")) + } + + database.insert(practitioner, p1, p2) + val ascendingResult = + Search(ResourceType.Practitioner) + .apply { + revInclude(Patient.GENERAL_PRACTITIONER) { sort(Patient.GIVEN, Order.ASCENDING) } + } + .execute(database) + + assertThat(ascendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .contains( + SearchResult( + practitioner, + null, + mapOf( + Pair(ResourceType.Patient, Patient.GENERAL_PRACTITIONER.paramName) to listOf(p1, p2), + ), + ), + ) + } + + @Test + fun revIncluded_results_sort_descending_should_have_distinct_resources() = runBlocking { + val practitioner = + Practitioner().apply { + id = "practitioner-1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("James") + }, + ) + } + val p1 = + Patient().apply { + id = "p1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("3") + addGiven("1") + }, + ) + + addGeneralPractitioner(Reference("Practitioner/practitioner-1")) + } + + val p2 = + Patient().apply { + id = "p2" + addName( + HumanName().apply { + family = "Cooper" + addGiven("2") + addGiven("4") + }, + ) + addGeneralPractitioner(Reference("Practitioner/practitioner-1")) + } + + database.insert(practitioner, p1, p2) + val descendingResult = + Search(ResourceType.Practitioner) + .apply { + revInclude(Patient.GENERAL_PRACTITIONER) { + sort(Patient.GIVEN, Order.DESCENDING) + } + } + .execute(database) + + assertThat(descendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .contains( + SearchResult( + practitioner, + null, + mapOf( + Pair(ResourceType.Patient, Patient.GENERAL_PRACTITIONER.paramName) to listOf(p2, p1), + ), + ), + ) + } + + @Test + fun included_and_revIncluded_results_should_have_distinct_resources() = runBlocking { + // A person has multiple first names and encounter has multiple location + // Searching a group including Patient and revIncluding encounter with results sorted by + // Patient.GIVEN and Encounter.LOCATION_PERIOD should return single copies of the resources. + + val group = + Group().apply { + id = "group" + addMember(Group.GroupMemberComponent(Reference("Patient/multiple-first-names"))) + } + val patient = + Patient().apply { + id = "multiple-first-names" + + addName( + HumanName().apply { + family = "LastName" + addGiven("FirstName-01") + addGiven("FirstName-02") + addGiven("FirstName-03") + }, + ) + } + + val encounter = + Encounter().apply { + id = "encounter-multiple-locations" + + subject = Reference("Group/group") + + addLocation().apply { + location = Reference("Location/1") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T10:00:00-05:30") + endElement = DateTimeType("2024-03-13T10:30:00-05:30") + } + } + + addLocation().apply { + location = Reference("Location/2") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T11:00:00-05:30") + endElement = DateTimeType("2024-03-13T11:30:00-05:30") + } + } + + addLocation().apply { + location = Reference("Location/3") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T09:00:00-05:30") + endElement = DateTimeType("2024-03-13T09:30:00-05:30") + } + } + } + database.insert(group, patient, encounter) + + val result = + Search(ResourceType.Group) + .apply { + include(Group.MEMBER) { + filter( + Patient.GIVEN, + { + value = "FirstName" + modifier = StringFilterModifier.STARTS_WITH + }, + ) + + sort(Patient.GIVEN, Order.ASCENDING) + } + + revInclude(Encounter.SUBJECT) { + sort(Encounter.LOCATION_PERIOD, Order.ASCENDING) + } + } + .execute(database) + + assertThat(result) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .contains( + SearchResult( + group, + mapOf( + Group.MEMBER.paramName to listOf(patient), + ), + mapOf(Pair(ResourceType.Encounter, Encounter.SUBJECT.paramName) to listOf(encounter)), + ), + ) + } + + @Test + fun sort_ascending_repeated_values_with_string_param_should_return_distinct_values() = + runBlocking { + val p1 = + Patient().apply { + id = "p1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("3") + addGiven("1") + }, + ) + } + + val p2 = + Patient().apply { + id = "p2" + addName( + HumanName().apply { + family = "Cooper" + addGiven("2") + addGiven("4") + }, + ) + } + database.insert(p1, p2) + + val ascendingResult = + Search(ResourceType.Patient) + .apply { + filter( + Patient.FAMILY, + { + value = "Cooper" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + sort(Patient.GIVEN, Order.ASCENDING) + } + .execute(database) + assertThat(ascendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult(p1, null, null), + SearchResult(p2, null, null), + ) + .inOrder() + } + + @Test + fun sort_descending_repeated_values_with_string_param_should_return_distinct_values() = + runBlocking { + val p1 = + Patient().apply { + id = "p1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("3") + addGiven("1") + }, + ) + } + + val p2 = + Patient().apply { + id = "p2" + addName( + HumanName().apply { + family = "Cooper" + addGiven("2") + addGiven("4") + }, + ) + } + database.insert(p1, p2) + + val descendingResult = + Search(ResourceType.Patient) + .apply { + filter( + Patient.FAMILY, + { + value = "Cooper" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + sort(Patient.GIVEN, Order.DESCENDING) + } + .execute(database) + + assertThat(descendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult(p2, null, null), + SearchResult(p1, null, null), + ) + .inOrder() + } + + @Test + fun sort_ascending_repeated_values_with_date_param_should_return_distinct_values() = runBlocking { + val e1 = + Encounter().apply { + id = "encounter-multiple-locations-1" + + subject = Reference("Group/group") + + addLocation().apply { + location = Reference("Location/3") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T09:00:00-05:30") + endElement = DateTimeType("2024-03-13T09:30:00-05:30") + } + } + + addLocation().apply { + location = Reference("Location/2") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T11:00:00-05:30") + endElement = DateTimeType("2024-03-13T11:30:00-05:30") + } + } + } + + val e2 = + Encounter().apply { + id = "encounter-multiple-locations-2" + + subject = Reference("Group/group") + + addLocation().apply { + location = Reference("Location/1") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T10:00:00-05:30") + endElement = DateTimeType("2024-03-13T10:30:00-05:30") + } + } + + addLocation().apply { + location = Reference("Location/2") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T11:30:00-05:30") + endElement = DateTimeType("2024-03-13T12:00:00-05:30") + } + } + } + + database.insert(e1, e2) + + val ascendingResult = + Search(ResourceType.Encounter) + .apply { sort(Encounter.LOCATION_PERIOD, Order.ASCENDING) } + .execute(database) + + assertThat(ascendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult(e1, null, null), + SearchResult(e2, null, null), + ) + .inOrder() + } + + @Test + fun sort_descending_repeated_values_with_date_param_should_return_distinct_values() = + runBlocking { + val e1 = + Encounter().apply { + id = "encounter-multiple-locations-1" + + subject = Reference("Group/group") + + addLocation().apply { + location = Reference("Location/3") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T09:00:00-05:30") + endElement = DateTimeType("2024-03-13T09:30:00-05:30") + } + } + + addLocation().apply { + location = Reference("Location/2") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T11:00:00-05:30") + endElement = DateTimeType("2024-03-13T11:30:00-05:30") + } + } + } + + val e2 = + Encounter().apply { + id = "encounter-multiple-locations-2" + + subject = Reference("Group/group") + + addLocation().apply { + location = Reference("Location/1") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T10:00:00-05:30") + endElement = DateTimeType("2024-03-13T10:30:00-05:30") + } + } + + addLocation().apply { + location = Reference("Location/2") + period = + Period().apply { + startElement = DateTimeType("2024-03-13T11:30:00-05:30") + endElement = DateTimeType("2024-03-13T12:00:00-05:30") + } + } + } + + database.insert(e1, e2) + + val descendingResult = + Search(ResourceType.Encounter) + .apply { sort(Encounter.LOCATION_PERIOD, Order.DESCENDING) } + .execute(database) + + assertThat(descendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult(e2, null, null), + SearchResult(e1, null, null), + ) + .inOrder() + } + + @Test + fun sort_ascending_repeated_values_with_numeric_param_should_return_distinct_values() = + runBlocking { + val r1 = + RiskAssessment().apply { + id = "ris-01" + subject = Reference("Patient/risk-patient") + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.8) + }, + ) + + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.5) + }, + ) + } + + val r2 = + RiskAssessment().apply { + id = "ris-02" + subject = Reference("Patient/risk-patient") + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.6) + }, + ) + + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.9) + }, + ) + } + + val r3 = + RiskAssessment().apply { + id = "ris-03" + subject = Reference("Patient/risk-patient") + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.2) + }, + ) + + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.4) + }, + ) + } + database.insert(r1, r2, r3) + val ascendingResult = + Search(ResourceType.RiskAssessment) + .apply { + filter( + RiskAssessment.PROBABILITY, + { + value = BigDecimal.valueOf(0.4) + prefix = ParamPrefixEnum.GREATERTHAN + }, + ) + sort(RiskAssessment.PROBABILITY, Order.ASCENDING) + } + .execute(database) + + assertThat(ascendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult(r1, null, null), + SearchResult(r2, null, null), + ) + .inOrder() + } + + @Test + fun sort_descending_repeated_values_with_numeric_param_should_return_distinct_values() = + runBlocking { + val r1 = + RiskAssessment().apply { + id = "ris-01" + subject = Reference("Patient/risk-patient") + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.8) + }, + ) + + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.5) + }, + ) + } + + val r2 = + RiskAssessment().apply { + id = "ris-02" + subject = Reference("Patient/risk-patient") + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.6) + }, + ) + + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.9) + }, + ) + } + + val r3 = + RiskAssessment().apply { + id = "ris-03" + subject = Reference("Patient/risk-patient") + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.2) + }, + ) + + addPrediction( + RiskAssessment.RiskAssessmentPredictionComponent().apply { + probability = DecimalType(0.4) + }, + ) + } + database.insert(r1, r2, r3) + + val descendingResult = + Search(ResourceType.RiskAssessment) + .apply { + filter( + RiskAssessment.PROBABILITY, + { + value = BigDecimal.valueOf(0.4) + prefix = ParamPrefixEnum.GREATERTHAN + }, + ) + sort(RiskAssessment.PROBABILITY, Order.DESCENDING) + } + .execute(database) + + assertThat(descendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult(r2, null, null), + SearchResult(r1, null, null), + ) + .inOrder() + } + + @Test + fun sort_resource_with_null_sort_index_value_but_matching_filter_should_be_included() = + runBlocking { + val p1 = + Patient().apply { + id = "p1" + addName( + HumanName().apply { + family = "Cooper" + addGiven("4") + addGiven("1") + }, + ) + this.birthDateElement = DateType(2020, 4, 2) + } + + val p2 = + Patient().apply { + id = "p2" + addName( + HumanName().apply { + family = "Cooper" + addGiven("2") + addGiven("5") + }, + ) + this.birthDateElement = DateType(2010, 4, 2) + } + + val p3 = + Patient().apply { + id = "p3" + addName( + HumanName().apply { + family = "Cooper" + addGiven("3") + addGiven("6") + }, + ) + } + database.insert(p1, p2, p3) + + val ascendingResult = + Search(ResourceType.Patient) + .apply { + filter( + Patient.FAMILY, + { + value = "Cooper" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + sort(Patient.BIRTHDATE, Order.ASCENDING) + } + .execute(database) + assertThat(ascendingResult) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult(p2, null, null), + SearchResult(p1, null, null), + SearchResult(p3, null, null), + ) + .inOrder() + } + + // https://github.com/google/android-fhir/issues/2559 + @Test + fun getLocalChangeResourceReferences_shouldSafelyReturnReferencesAboveSQLiteInOpLimit() = + runBlocking { + val patientsCount = LocalChangeDao.SQLITE_LIMIT_MAX_VARIABLE_NUMBER * 7 + val locallyCreatedPatients = + (1..patientsCount).map { + Patient().apply { + id = "local-patient-id$it" + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("$it")))) + } + } + database.insert(*locallyCreatedPatients.toTypedArray()) + val locallyCreatedPatientTasks = + locallyCreatedPatients.mapIndexed { index, patient -> + Task().apply { + `for` = Reference("Patient/${patient.logicalId}") + id = "local-observation-$index" + } + } + database.insert(*locallyCreatedPatientTasks.toTypedArray()) + val localChangeIds = database.getAllLocalChanges().flatMap { it.token.ids } + val localChangeResourceReferences = database.getLocalChangeResourceReferences(localChangeIds) + assertThat(localChangeResourceReferences.size).isEqualTo(locallyCreatedPatients.size) + } + private companion object { const val mockEpochTimeStamp = 1628516301000 const val TEST_PATIENT_1_ID = "test_patient_1" diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt index cba9a5f211..d99c71d21d 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt @@ -38,6 +38,7 @@ import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Task import org.junit.After import org.junit.Before import org.junit.Test @@ -358,6 +359,39 @@ class LocalChangeDaoTest { .isEqualTo(practitionerReference) } + // https://github.com/google/android-fhir/issues/2559 + @Test + fun updateResourceIdAndReferences_shouldSafelyUpdateLocalChangesReferencesAboveSQLiteInOpLimit() = + runBlocking { + val localPatientId = "local-patient-id" + val patientResourceUuid = UUID.randomUUID() + val localPatient = Patient().apply { id = localPatientId } + val patientCreationTime = Instant.now() + localChangeDao.addInsert(localPatient, patientResourceUuid, patientCreationTime) + + val countAboveLimit = LocalChangeDao.SQLITE_LIMIT_MAX_VARIABLE_NUMBER * 10 + (1..countAboveLimit).forEach { + val taskResourceUuid = UUID.randomUUID() + val task = + Task().apply { + id = "local-task-$it" + `for` = Reference("Patient/$localPatientId") + } + val taskCreationTime = Instant.now() + localChangeDao.addInsert(task, taskResourceUuid, taskCreationTime) + } + + val updatedPatientId = "synced-patient-id" + val updatedLocalPatient = localPatient.copy().apply { id = updatedPatientId } + val updatedReferences = + localChangeDao.updateResourceIdAndReferences( + patientResourceUuid, + oldResource = localPatient, + updatedResource = updatedLocalPatient, + ) + assertThat(updatedReferences.size).isEqualTo(countAboveLimit) + } + @Test fun getReferencesForLocalChanges_should_return_all_changes(): Unit = runBlocking { listOf( diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index b43f64480e..7bf364f4e9 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt @@ -31,6 +31,7 @@ import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.db.ResourceWithUUID import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABASE_NAME import com.google.android.fhir.db.impl.dao.ForwardIncludeSearchResult +import com.google.android.fhir.db.impl.dao.LocalChangeDao.Companion.SQLITE_LIMIT_MAX_VARIABLE_NUMBER import com.google.android.fhir.db.impl.dao.ReverseIncludeSearchResult import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.index.ResourceIndexer @@ -204,10 +205,9 @@ internal class DatabaseImpl( query: SearchQuery, ): List> { return db.withTransaction { - resourceDao - .getResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) - .map { ResourceWithUUID(it.uuid, iParser.parseResource(it.serializedResource) as R) } - .distinctBy { it.uuid } + resourceDao.getResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())).map { + ResourceWithUUID(it.uuid, iParser.parseResource(it.serializedResource) as R) + } } } @@ -408,12 +408,14 @@ internal class DatabaseImpl( override suspend fun getLocalChangeResourceReferences( localChangeIds: List, ): List { - return localChangeDao.getReferencesForLocalChanges(localChangeIds).map { - LocalChangeResourceReference( - it.localChangeId, - it.resourceReferenceValue, - it.resourceReferencePath, - ) + return localChangeIds.chunked(SQLITE_LIMIT_MAX_VARIABLE_NUMBER).flatMap { chunk -> + localChangeDao.getReferencesForLocalChanges(chunk).map { + LocalChangeResourceReference( + it.localChangeId, + it.resourceReferenceValue, + it.resourceReferencePath, + ) + } } } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt index 13f0b9d560..a078f80b65 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt @@ -412,7 +412,10 @@ internal abstract class LocalChangeDao { val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" val referringLocalChangeIds = getLocalChangeReferencesWithValue(oldReferenceValue).map { it.localChangeId }.distinct() - val referringLocalChanges = getLocalChanges(referringLocalChangeIds) + val referringLocalChanges = + referringLocalChangeIds.chunked(SQLITE_LIMIT_MAX_VARIABLE_NUMBER).flatMap { + getLocalChanges(it) + } referringLocalChanges.forEach { existingLocalChangeEntity -> val updatedLocalChangeEntity = @@ -498,6 +501,13 @@ internal abstract class LocalChangeDao { companion object { const val DEFAULT_ID_VALUE = 0L + + /** + * Represents SQLite limit on the size of parameters that can be passed in an IN(..) query See + * https://issuetracker.google.com/issues/192284727 See https://www.sqlite.org/limits.html See + * https://github.com/google/android-fhir/issues/2559 + */ + const val SQLITE_LIMIT_MAX_VARIABLE_NUMBER = 999 } } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt index 84458cf7a1..449277f31d 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt @@ -71,6 +71,7 @@ internal abstract class ResourceDao { it.copy( serializedResource = iParser.encodeResourceToString(resource), lastUpdatedLocal = timeOfLocalChange, + lastUpdatedRemote = resource.meta.lastUpdated?.toInstant() ?: it.lastUpdatedRemote, ) updateChanges(entity, resource) } @@ -129,11 +130,6 @@ internal abstract class ResourceDao { createLocalLastUpdatedIndex(resource.resourceType, InstantType(Date.from(instant))), ) } - entity.lastUpdatedRemote?.let { instant -> - addDateTimeIndex( - createLastUpdatedIndex(resource.resourceType, InstantType(Date.from(instant))), - ) - } } .build() updateIndicesForResource(index, resource.resourceType, entity.resourceUuid) diff --git a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt index 0c0b37c1d5..fe5331bd73 100644 --- a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt @@ -33,60 +33,51 @@ import com.google.android.fhir.sync.upload.ResourceConsolidatorFactory import com.google.android.fhir.sync.upload.SyncUploadProgress import com.google.android.fhir.sync.upload.UploadRequestResult import com.google.android.fhir.sync.upload.UploadStrategy -import java.time.OffsetDateTime +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType /** Implementation of [FhirEngine]. */ internal class FhirEngineImpl(private val database: Database, private val context: Context) : FhirEngine { - override suspend fun create(vararg resource: Resource): List { - return database.insert(*resource) - } + override suspend fun create(vararg resource: Resource) = + withContext(Dispatchers.IO) { database.insert(*resource) } - override suspend fun get(type: ResourceType, id: String): Resource { - return database.select(type, id) - } + override suspend fun get(type: ResourceType, id: String) = + withContext(Dispatchers.IO) { database.select(type, id) } - override suspend fun update(vararg resource: Resource) { - database.update(*resource) - } + override suspend fun update(vararg resource: Resource) = + withContext(Dispatchers.IO) { database.update(*resource) } - override suspend fun delete(type: ResourceType, id: String) { - database.delete(type, id) - } + override suspend fun delete(type: ResourceType, id: String) = + withContext(Dispatchers.IO) { database.delete(type, id) } - override suspend fun search(search: Search): List> { - return search.execute(database) - } + override suspend fun search(search: Search): List> = + withContext(Dispatchers.IO) { search.execute(database) } - override suspend fun count(search: Search): Long { - return search.count(database) - } + override suspend fun count(search: Search) = + withContext(Dispatchers.IO) { search.count(database) } - override suspend fun getLastSyncTimeStamp(): OffsetDateTime? { - return FhirEngineProvider.getFhirDataStore(context).readLastSyncTimestamp() - } + override suspend fun getLastSyncTimeStamp() = + withContext(Dispatchers.IO) { + FhirEngineProvider.getFhirDataStore(context).readLastSyncTimestamp() + } - override suspend fun clearDatabase() { - database.clearDatabase() - } + override suspend fun clearDatabase() = withContext(Dispatchers.IO) { database.clearDatabase() } - override suspend fun getLocalChanges(type: ResourceType, id: String): List { - return database.getLocalChanges(type, id) - } + override suspend fun getLocalChanges(type: ResourceType, id: String) = + withContext(Dispatchers.IO) { database.getLocalChanges(type, id) } - override suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean) { - database.purge(type, setOf(id), forcePurge) - } + override suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean) = + withContext(Dispatchers.IO) { database.purge(type, setOf(id), forcePurge) } - override suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean) { - database.purge(type, ids, forcePurge) - } + override suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean) = + withContext(Dispatchers.IO) { database.purge(type, ids, forcePurge) } override suspend fun syncDownload( conflictResolver: ConflictResolver, diff --git a/engine/src/main/java/com/google/android/fhir/search/BaseSearch.kt b/engine/src/main/java/com/google/android/fhir/search/BaseSearch.kt index 79be44b747..9bfd11a594 100644 --- a/engine/src/main/java/com/google/android/fhir/search/BaseSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/BaseSearch.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,8 @@ import com.google.android.fhir.search.filter.ReferenceParamFilterCriterion import com.google.android.fhir.search.filter.StringParamFilterCriterion import com.google.android.fhir.search.filter.TokenParamFilterCriterion import com.google.android.fhir.search.filter.UriParamFilterCriterion +import org.hl7.fhir.r4.model.HumanName +import org.hl7.fhir.r4.model.Patient @DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) annotation class BaseSearchDsl @@ -90,9 +92,65 @@ interface BaseSearch { operation: Operation = Operation.OR, ) + /** + * When sorting is applied on a field with repeated values (e.g. [Patient.GIVEN] ), the order is + * defined by the `value` of the repeated values in the resource (e.g. [HumanName.given] for + * [Patient]). + * + * If there are two Patients p1 and p2 as follows + * + * ``` + * { + * "resourceType": "Patient", + * "id": "p1", + * "name": [ + * { + * "family": "Cooper", + * "given": [ + * "3", + * "1" + * ] + * } + * ] + * } + * ``` + * + * AND + * + * ``` + * { + * "resourceType": "Patient", + * "id": "p2", + * "name": [ + * { + * "family": "Cooper", + * "given": [ + * "2", + * "4" + * ] + * } + * ] + * } + * ``` + * + * Then sorting the patients in ascending or descending order with their given i.e [Patient.GIVEN] + * depends on the smallest (`1`, `3`) or largest (`2`, `4`) given in the first name respectively . + */ fun sort(parameter: StringClientParam, order: Order) + /** + * When sorting is applied on a field with repeated values, defined by the `value` of the repeated + * values in the resource. + * + * @see sort(parameter: StringClientParam, order: Order) for more details. + */ fun sort(parameter: NumberClientParam, order: Order) + /** + * When sorting is applied on a field with repeated values, defined by the `value` of the repeated + * values in the resource. + * + * @see sort(parameter: StringClientParam, order: Order) for more details. + */ fun sort(parameter: DateClientParam, order: Order) } diff --git a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt index 44c342200a..2d1f4044f5 100644 --- a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt @@ -20,6 +20,7 @@ import android.annotation.SuppressLint import androidx.annotation.VisibleForTesting import androidx.room.util.convertUUIDToByte import ca.uhn.fhir.rest.gclient.DateClientParam +import ca.uhn.fhir.rest.gclient.IParam import ca.uhn.fhir.rest.gclient.NumberClientParam import ca.uhn.fhir.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum @@ -48,6 +49,14 @@ import timber.log.Timber */ private const val APPROXIMATION_COEFFICIENT = 0.1 +/** + * SQLite supports signed and unsigned integers with a maximum length of 8 bytes. The signed + * integers can range from `-9223372036854775808` to `+9223372036854775807`. See + * [Storage Classes and Datatypes](https://www.sqlite.org/datatype3.html) + */ +private const val MIN_VALUE = "-9223372036854775808" +private const val MAX_VALUE = "9223372036854775808" + internal suspend fun Search.execute(database: Database): List> { val baseResources = database.search(getQuery()) val includedResources = @@ -132,11 +141,12 @@ internal fun Search.getRevIncludeQuery(includeIds: List): SearchQuery { return revIncludes .map { - val (join, order) = it.search.getSortOrder(otherTable = "re") + val (join, order) = + it.search.getSortOrder(otherTable = "re", groupByColumn = "rie.index_value") args.addAll(join.args) val filterQuery = generateFilterQuery(it) """ - SELECT rie.index_name, rie.index_value, re.serializedResource + SELECT rie.index_name, rie.index_value, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceUuid = rie.resourceUuid @@ -194,11 +204,12 @@ internal fun Search.getIncludeQuery(includeIds: List): SearchQuery { return forwardIncludes .map { - val (join, order) = it.search.getSortOrder(otherTable = "re") + val (join, order) = + it.search.getSortOrder(otherTable = "re", groupByColumn = "rie.resourceuuid") args.addAll(join.args) val filterQuery = generateFilterQuery(it) """ - SELECT rie.index_name, rie.resourceUuid, re.serializedResource + SELECT rie.index_name, rie.resourceUuid, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceType||"/"||re.resourceId = rie.index_value @@ -221,6 +232,7 @@ internal fun Search.getIncludeQuery(includeIds: List): SearchQuery { private fun Search.getSortOrder( otherTable: String, isReferencedSearch: Boolean = false, + groupByColumn: String = "", ): Pair { var sortJoinStatement = "" var sortOrderStatement = "" @@ -255,22 +267,83 @@ private fun Search.getSortOrder( .joinToString(separator = "\n") sortTableNames.forEach { _ -> args.add(sort.paramName) } - sortTableNames.forEachIndexed { index, sortTableName -> - val tableAlias = 'b' + index - sortOrderStatement += - if (index == 0) { - """ - ORDER BY $tableAlias.${sortTableName.columnName} ${order.sqlString} - """ - .trimIndent() - } else { - ", $tableAlias.${SortTableInfo.DATE_TIME_SORT_TABLE_INFO.columnName} ${order.sqlString}" - } - } + sortOrderStatement += + generateGroupAndOrderQuery(sort, order!!, otherTable, groupByColumn, sortTableNames) } return Pair(SearchQuery(sortJoinStatement, args), sortOrderStatement) } +/** + * Sorting by a field that has multiple indexed values may result in duplicated resources. So, we + * use `GROUP BY` + `HAVING` clause to find distinct values in specified order. + * + * To make the sorting order a bit predictable, we use MIN and MAX functions with `HAVING` to use + * the corresponding values for GROUPING to find the distinct results. + * + * e.g. If there are Two Patients resources with multiple first names P1 ( first names =`3`, `1`) + * and P2 (first names = `2`, `4`), when sorting them in + * + * *ASCENDING order*: MIN function is used so that the smallest names of both the patients are + * considered for Grouping `[P1(`1`), P2(`2`)]`. + * + * *DESCENDING order*: MAX function is used so that the largest names of both the patients are + * considered for Grouping `[P2(`4`), P1(`3`)]`. + * + * For the special case where the index value is NULL, we use the default 0 value and to complete + * the expression, we check that the value is greater than [MIN_VALUE], the minimum value an INTEGER + * type can store in SQLITE. The reason to check against [MIN_VALUE] rather that 0, since string is + * always greater than integer (StringIndexEntity) and Date/DateTimeIndexEntity will always have + * positive integer values, is because the NumberIndexEntity table may contain negative values in + * it. + * + * Without the `>= MIN_VALUE` check, NULL values are not included in the results if the default + * is 0. + * + * The default values provided in GROUP BY stage are not carried forward during the ORDER BY, so we + * provide [MAX_VALUE] and [MIN_VALUE] as default in ORDER BY respectively for ASCENDING and + * DESCENDING to make sure that results with null index values are always at the bottom. + */ +private fun generateGroupAndOrderQuery( + sort: IParam, + order: Order, + otherTable: String, + groupByColumn: String, + sortTableNames: List, +): String { + var sortOrderStatement = "" + val havingColumn = + when (sort) { + is StringClientParam, + is NumberClientParam, -> "IFNULL(b.index_value,0)" + /* The DateClientParam is used for both Date and DateTime values and the value is present exclusively in either one of the DateIndexEntity or DateTimeIndexEntity tables. + To find the MIN or MAX values, we add the values from both the tables. It results in the exact value as the other table would have null and hence default 0 would be added to actual date/datetime value.*/ + is DateClientParam -> "IFNULL(b.index_from,0) + IFNULL(c.index_from,0)" + else -> throw NotImplementedError("Unhandled sort parameter of type ${sort::class}: $sort") + } + + sortOrderStatement += + """ + GROUP BY $otherTable.resourceUuid ${if (groupByColumn.isNotEmpty()) ", $groupByColumn" else ""} + HAVING ${if (order == Order.ASCENDING) "MIN($havingColumn) >= $MIN_VALUE" else "MAX($havingColumn) >= $MIN_VALUE"} + + """ + .trimIndent() + val defaultValue = if (order == Order.ASCENDING) MAX_VALUE else MIN_VALUE + sortTableNames.forEachIndexed { index, sortTableName -> + val tableAlias = 'b' + index + sortOrderStatement += + if (index == 0) { + """ + ORDER BY IFNULL($tableAlias.${sortTableName.columnName}, $defaultValue) ${order.sqlString} + """ + .trimIndent() + } else { + ", IFNULL($tableAlias.${SortTableInfo.DATE_TIME_SORT_TABLE_INFO.columnName}, $defaultValue) ${order.sqlString}" + } + } + return sortOrderStatement +} + private fun Search.getFilterQueries() = (stringFilterCriteria + quantityFilterCriteria + diff --git a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt index d46c110664..938dd64bc6 100644 --- a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt +++ b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt @@ -1719,7 +1719,9 @@ class SearchTest { LEFT JOIN StringIndexEntity b ON a.resourceUuid = b.resourceUuid AND b.index_name = ? WHERE a.resourceType = ? - ORDER BY b.index_value ASC + GROUP BY a.resourceUuid + HAVING MIN(IFNULL(b.index_value,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_value, 9223372036854775808) ASC """ .trimIndent(), ) @@ -1739,7 +1741,9 @@ class SearchTest { LEFT JOIN StringIndexEntity b ON a.resourceUuid = b.resourceUuid AND b.index_name = ? WHERE a.resourceType = ? - ORDER BY b.index_value DESC + GROUP BY a.resourceUuid + HAVING MAX(IFNULL(b.index_value,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_value, -9223372036854775808) DESC """ .trimIndent(), ) @@ -1761,7 +1765,9 @@ class SearchTest { LEFT JOIN NumberIndexEntity b ON a.resourceUuid = b.resourceUuid AND b.index_name = ? WHERE a.resourceType = ? - ORDER BY b.index_value ASC + GROUP BY a.resourceUuid + HAVING MIN(IFNULL(b.index_value,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_value, 9223372036854775808) ASC """ .trimIndent(), ) @@ -1791,7 +1797,9 @@ class SearchTest { SELECT resourceUuid FROM StringIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value LIKE ? || '%' COLLATE NOCASE ) - ORDER BY b.index_value ASC + GROUP BY a.resourceUuid + HAVING MIN(IFNULL(b.index_value,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_value, 9223372036854775808) ASC LIMIT ? OFFSET ? """ .trimIndent(), @@ -2045,8 +2053,10 @@ class SearchTest { LEFT JOIN DateTimeIndexEntity c ON a.resourceUuid = c.resourceUuid AND c.index_name = ? WHERE a.resourceType = ? - ORDER BY b.index_from ASC, c.index_from ASC - """ + GROUP BY a.resourceUuid + HAVING MIN(IFNULL(b.index_from,0) + IFNULL(c.index_from,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_from, 9223372036854775808) ASC, IFNULL(c.index_from, 9223372036854775808) ASC + """ .trimIndent(), ) } @@ -2066,7 +2076,9 @@ class SearchTest { LEFT JOIN DateTimeIndexEntity c ON a.resourceUuid = c.resourceUuid AND c.index_name = ? WHERE a.resourceType = ? - ORDER BY b.index_from DESC, c.index_from DESC + GROUP BY a.resourceUuid + HAVING MAX(IFNULL(b.index_from,0) + IFNULL(c.index_from,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_from, -9223372036854775808) DESC, IFNULL(c.index_from, -9223372036854775808) DESC """ .trimIndent(), ) @@ -2277,7 +2289,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.resourceUuid, re.serializedResource + SELECT rie.index_name, rie.resourceUuid, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceType||"/"||re.resourceId = rie.index_value @@ -2319,7 +2331,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.resourceUuid, re.serializedResource + SELECT rie.index_name, rie.resourceUuid, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceType||"/"||re.resourceId = rie.index_value @@ -2369,7 +2381,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.resourceUuid, re.serializedResource + SELECT rie.index_name, rie.resourceUuid, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceType||"/"||re.resourceId = rie.index_value @@ -2380,7 +2392,9 @@ class SearchTest { SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value = ? ) - ORDER BY b.index_value DESC + GROUP BY re.resourceUuid , rie.resourceuuid + HAVING MAX(IFNULL(b.index_value,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_value, -9223372036854775808) DESC ) """ .trimIndent(), @@ -2428,7 +2442,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.resourceUuid, re.serializedResource + SELECT rie.index_name, rie.resourceUuid, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceType||"/"||re.resourceId = rie.index_value @@ -2439,11 +2453,13 @@ class SearchTest { SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value = ? ) - ORDER BY b.index_value DESC + GROUP BY re.resourceUuid , rie.resourceuuid + HAVING MAX(IFNULL(b.index_value,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_value, -9223372036854775808) DESC ) UNION ALL SELECT * FROM ( - SELECT rie.index_name, rie.resourceUuid, re.serializedResource + SELECT rie.index_name, rie.resourceUuid, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceType||"/"||re.resourceId = rie.index_value @@ -2454,7 +2470,9 @@ class SearchTest { SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value = ? ) - ORDER BY b.index_value DESC + GROUP BY re.resourceUuid , rie.resourceuuid + HAVING MAX(IFNULL(b.index_value,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_value, -9223372036854775808) DESC ) """ .trimIndent(), @@ -2496,7 +2514,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.index_value, re.serializedResource + SELECT rie.index_name, rie.index_value, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceUuid = rie.resourceUuid @@ -2534,7 +2552,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.index_value, re.serializedResource + SELECT rie.index_name, rie.index_value, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceUuid = rie.resourceUuid @@ -2587,7 +2605,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.index_value, re.serializedResource + SELECT rie.index_name, rie.index_value, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceUuid = rie.resourceUuid @@ -2600,7 +2618,9 @@ class SearchTest { SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) ) - ORDER BY b.index_from DESC, c.index_from DESC + GROUP BY re.resourceUuid , rie.index_value + HAVING MAX(IFNULL(b.index_from,0) + IFNULL(c.index_from,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_from, -9223372036854775808) DESC, IFNULL(c.index_from, -9223372036854775808) DESC ) """ .trimIndent(), @@ -2664,7 +2684,7 @@ class SearchTest { .isEqualTo( """ SELECT * FROM ( - SELECT rie.index_name, rie.index_value, re.serializedResource + SELECT rie.index_name, rie.index_value, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceUuid = rie.resourceUuid @@ -2677,11 +2697,13 @@ class SearchTest { SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) ) - ORDER BY b.index_from DESC, c.index_from DESC + GROUP BY re.resourceUuid , rie.index_value + HAVING MAX(IFNULL(b.index_from,0) + IFNULL(c.index_from,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_from, -9223372036854775808) DESC, IFNULL(c.index_from, -9223372036854775808) DESC ) UNION ALL SELECT * FROM ( - SELECT rie.index_name, rie.index_value, re.serializedResource + SELECT rie.index_name, rie.index_value, re.serializedResource FROM ResourceEntity re JOIN ReferenceIndexEntity rie ON re.resourceUuid = rie.resourceUuid @@ -2694,7 +2716,9 @@ class SearchTest { SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) ) - ORDER BY b.index_from DESC, c.index_from DESC + GROUP BY re.resourceUuid , rie.index_value + HAVING MAX(IFNULL(b.index_from,0) + IFNULL(c.index_from,0)) >= -9223372036854775808 + ORDER BY IFNULL(b.index_from, -9223372036854775808) DESC, IFNULL(c.index_from, -9223372036854775808) DESC ) """ .trimIndent(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8dc847960a..9a1d4ad458 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,8 @@ junit = "4.13.2" kotlin-stdlib = "1.9.22" kotlin-test = "1.9.22" kotlinx-coroutines = "1.8.1" +logback-android = "3.0.0" +opencds-cqf-fhir = "3.8.0" truth = "1.1.5" [libraries] @@ -58,13 +60,17 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +junit = { module = "junit:junit", version.ref = "junit" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin-stdlib" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin-test" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-playservices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } -junit = { module = "junit:junit", version.ref = "junit" } +logback-android = { module = "com.github.tony19:logback-android", version.ref = "logback-android" } +opencds-cqf-fhir-cr = { module = "org.opencds.cqf.fhir:cqf-fhir-cr", version.ref = "opencds-cqf-fhir" } +opencds-cqf-fhir-jackson = { module = "org.opencds.cqf.fhir:cqf-fhir-jackson", version.ref = "opencds-cqf-fhir" } +opencds-cqf-fhir-utility = { module = "org.opencds.cqf.fhir:cqf-fhir-utility", version.ref = "opencds-cqf-fhir" } truth = { module = "com.google.truth:truth", version.ref = "truth" } [bundles] diff --git a/knowledge/build.gradle.kts b/knowledge/build.gradle.kts index e4687a987b..0a1f5fed19 100644 --- a/knowledge/build.gradle.kts +++ b/knowledge/build.gradle.kts @@ -14,6 +14,9 @@ publishArtifact(Releases.Knowledge) createJacocoTestReportTask() +// Generate database schema in the schemas folder +ksp { arg("room.schemaLocation", "$projectDir/schemas") } + android { namespace = "com.google.android.fhir.knowledge" compileSdk = Sdk.COMPILE_SDK @@ -25,7 +28,10 @@ android { } sourceSets { - getByName("androidTest").apply { resources.setSrcDirs(listOf("testdata")) } + getByName("androidTest").apply { + resources.setSrcDirs(listOf("testdata")) + assets.srcDirs("$projectDir/schemas") + } getByName("test").apply { resources.setSrcDirs(listOf("testdata")) } } diff --git a/knowledge/schemas/com.google.android.fhir.knowledge.db.KnowledgeDatabase/1.json b/knowledge/schemas/com.google.android.fhir.knowledge.db.KnowledgeDatabase/1.json new file mode 100644 index 0000000000..e8761b3c58 --- /dev/null +++ b/knowledge/schemas/com.google.android.fhir.knowledge.db.KnowledgeDatabase/1.json @@ -0,0 +1,234 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "41dc6411ce57c1eeba3300592f302b07", + "entities": [ + { + "tableName": "ImplementationGuideEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`implementationGuideId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `packageId` TEXT NOT NULL, `version` TEXT, `rootDirectory` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "implementationGuideId", + "columnName": "implementationGuideId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rootDirectory", + "columnName": "rootDirectory", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "implementationGuideId" + ] + }, + "indices": [ + { + "name": "index_ImplementationGuideEntity_implementationGuideId", + "unique": false, + "columnNames": [ + "implementationGuideId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ImplementationGuideEntity_implementationGuideId` ON `${TABLE_NAME}` (`implementationGuideId`)" + }, + { + "name": "index_ImplementationGuideEntity_packageId_url_version", + "unique": true, + "columnNames": [ + "packageId", + "url", + "version" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ImplementationGuideEntity_packageId_url_version` ON `${TABLE_NAME}` (`packageId`, `url`, `version`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ResourceMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resourceMetadataId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `url` TEXT, `name` TEXT, `version` TEXT, `resourceFile` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "resourceMetadataId", + "columnName": "resourceMetadataId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "resourceFile", + "columnName": "resourceFile", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "resourceMetadataId" + ] + }, + "indices": [ + { + "name": "index_ResourceMetadataEntity_resourceMetadataId", + "unique": false, + "columnNames": [ + "resourceMetadataId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ResourceMetadataEntity_resourceMetadataId` ON `${TABLE_NAME}` (`resourceMetadataId`)" + }, + { + "name": "index_ResourceMetadataEntity_url_version_resourceFile", + "unique": true, + "columnNames": [ + "url", + "version", + "resourceFile" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ResourceMetadataEntity_url_version_resourceFile` ON `${TABLE_NAME}` (`url`, `version`, `resourceFile`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ImplementationGuideResourceMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `implementationGuideId` INTEGER, `resourceMetadataId` INTEGER NOT NULL, FOREIGN KEY(`implementationGuideId`) REFERENCES `ImplementationGuideEntity`(`implementationGuideId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`resourceMetadataId`) REFERENCES `ResourceMetadataEntity`(`resourceMetadataId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "implementationGuideId", + "columnName": "implementationGuideId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "resourceMetadataId", + "columnName": "resourceMetadataId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ImplementationGuideResourceMetadataEntity_implementationGuideId", + "unique": false, + "columnNames": [ + "implementationGuideId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ImplementationGuideResourceMetadataEntity_implementationGuideId` ON `${TABLE_NAME}` (`implementationGuideId`)" + }, + { + "name": "index_ImplementationGuideResourceMetadataEntity_resourceMetadataId", + "unique": false, + "columnNames": [ + "resourceMetadataId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ImplementationGuideResourceMetadataEntity_resourceMetadataId` ON `${TABLE_NAME}` (`resourceMetadataId`)" + }, + { + "name": "index_ImplementationGuideResourceMetadataEntity_implementationGuideId_resourceMetadataId", + "unique": true, + "columnNames": [ + "implementationGuideId", + "resourceMetadataId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ImplementationGuideResourceMetadataEntity_implementationGuideId_resourceMetadataId` ON `${TABLE_NAME}` (`implementationGuideId`, `resourceMetadataId`)" + } + ], + "foreignKeys": [ + { + "table": "ImplementationGuideEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "implementationGuideId" + ], + "referencedColumns": [ + "implementationGuideId" + ] + }, + { + "table": "ResourceMetadataEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceMetadataId" + ], + "referencedColumns": [ + "resourceMetadataId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '41dc6411ce57c1eeba3300592f302b07')" + ] + } +} \ No newline at end of file diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/KnowledgeDatabase.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/KnowledgeDatabase.kt index b140c443bd..8e86864d3f 100644 --- a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/KnowledgeDatabase.kt +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/KnowledgeDatabase.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,7 +42,7 @@ import com.google.android.fhir.knowledge.db.entities.ResourceMetadataEntity ImplementationGuideResourceMetadataEntity::class, ], version = 1, - exportSchema = false, + exportSchema = true, ) @TypeConverters(DbTypeConverters::class) internal abstract class KnowledgeDatabase : RoomDatabase() { diff --git a/workflow-testing/build.gradle.kts b/workflow-testing/build.gradle.kts index 682ad0cf33..9e1ba37a2d 100644 --- a/workflow-testing/build.gradle.kts +++ b/workflow-testing/build.gradle.kts @@ -15,9 +15,9 @@ android { configurations { all { removeIncompatibleDependencies() } } dependencies { - compileOnly(Dependencies.Cql.evaluator) - compileOnly(Dependencies.Cql.evaluatorFhirJackson) - compileOnly(Dependencies.Cql.evaluatorFhirUtilities) + compileOnly(libs.opencds.cqf.fhir.cr) + compileOnly(libs.opencds.cqf.fhir.jackson) + compileOnly(libs.opencds.cqf.fhir.utility) compileOnly(project(":engine")) { exclude(module = "truth") } compileOnly(Dependencies.jsonAssert) diff --git a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt index 93085ec0e5..b9621aaee7 100644 --- a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt +++ b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt @@ -120,7 +120,7 @@ object CqlBuilder : Loadable() { version = libVersion status = Enumerations.PublicationStatus.ACTIVE experimental = true - url = "http://localhost/Library/$libName|$libVersion" + url = "http://localhost/Library/$libName" attachmentCql?.let { addContent(it) } attachmentJson?.let { addContent(it) } attachmentXml?.let { addContent(it) } diff --git a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/IGInputStreamStructureRepository.kt b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/IGInputStreamStructureRepository.kt index dfd2a924ae..58596c550c 100644 --- a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/IGInputStreamStructureRepository.kt +++ b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/IGInputStreamStructureRepository.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,6 @@ import ca.uhn.fhir.util.BundleBuilder import com.google.common.collect.ImmutableMap import java.io.File import java.io.FileNotFoundException -import java.util.Locale import java.util.Objects import java.util.function.Consumer import org.hl7.fhir.instance.model.api.IBaseBundle @@ -40,9 +39,7 @@ import org.opencds.cqf.fhir.api.Repository import org.opencds.cqf.fhir.utility.Ids import org.opencds.cqf.fhir.utility.dstu3.AttachmentUtil import org.opencds.cqf.fhir.utility.matcher.ResourceMatcher -import org.opencds.cqf.fhir.utility.repository.IGLayoutMode import org.opencds.cqf.fhir.utility.repository.Repositories -import org.opencds.cqf.fhir.utility.repository.ResourceCategory /** * This class implements the Repository interface on onto a directory structure that matches the @@ -51,7 +48,6 @@ import org.opencds.cqf.fhir.utility.repository.ResourceCategory class IGInputStreamStructureRepository( private val fhirContext: FhirContext, private val root: String? = null, - private val layoutMode: IGLayoutMode = IGLayoutMode.DIRECTORY, private val encodingEnum: EncodingEnum = EncodingEnum.JSON, ) : Loadable(), Repository { private val resourceCache: MutableMap = HashMap() @@ -62,7 +58,7 @@ class IGInputStreamStructureRepository( resourceCache.clear() } - protected fun locationForResource( + private fun locationForResource( resourceType: Class, id: I, ): String { @@ -70,17 +66,12 @@ class IGInputStreamStructureRepository( return directory + "/" + fileNameForLayoutAndEncoding(resourceType.simpleName, id!!.idPart) } - protected fun fileNameForLayoutAndEncoding(resourceType: String, resourceId: String): String { + private fun fileNameForLayoutAndEncoding(resourceType: String, resourceId: String): String { val name = resourceId + fileExtensions[encodingEnum] - return if (layoutMode === IGLayoutMode.DIRECTORY) { - // TODO: case sensitivity!! - resourceType.lowercase(Locale.getDefault()) + "/" + name - } else { - "$resourceType-$name" - } + return "$resourceType-$name" } - protected fun directoryForType(resourceType: Class): String { + private fun directoryForType(resourceType: Class): String { val category = ResourceCategory.forType(resourceType.simpleName) val directory = categoryDirectories[category] @@ -88,16 +79,11 @@ class IGInputStreamStructureRepository( return (if (root!!.endsWith("/")) root else "$root/") + directory } - protected fun directoryForResource(resourceType: Class): String { - val directory = directoryForType(resourceType) - return if (layoutMode === IGLayoutMode.DIRECTORY) { - directory + "/" + resourceType.simpleName.lowercase(Locale.getDefault()) - } else { - directory - } + private fun directoryForResource(resourceType: Class): String { + return directoryForType(resourceType) } - protected fun readLocation( + private fun readLocation( resourceClass: Class?, location: String, ): T { @@ -112,7 +98,7 @@ class IGInputStreamStructureRepository( } as T } - protected fun handleLibrary(resource: T, location: String?): T { + private fun handleLibrary(resource: T, location: String?): T { var resourceOutput = resource if (resourceOutput.fhirType() == "Library") { val cqlLocation: String? @@ -161,7 +147,7 @@ class IGInputStreamStructureRepository( return resourceOutput } - protected fun getCqlContent(rootPath: String?, relativePath: String?): String { + private fun getCqlContent(rootPath: String?, relativePath: String?): String { val p = File(File(rootPath).parent, relativePath).normalize().toString() return try { load(p) @@ -171,18 +157,14 @@ class IGInputStreamStructureRepository( } } - protected fun readLocation(resourceClass: Class): Map { + private fun readLocation(resourceClass: Class): Map { val location = directoryForResource(resourceClass) val resources = HashMap() val inputFiles = listFiles(location) for (file in inputFiles) { - if ( - layoutMode.equals(IGLayoutMode.DIRECTORY) || - (layoutMode.equals(IGLayoutMode.TYPE_PREFIX) && - file.startsWith(resourceClass.simpleName + "-")) - ) { + if (file.startsWith(resourceClass.simpleName + "-")) { try { val r = this.readLocation(resourceClass, "$location/$file") if (r.fhirType() == resourceClass.simpleName) { @@ -282,7 +264,7 @@ class IGInputStreamStructureRepository( ): B { val builder = BundleBuilder(fhirContext) val resourceIdMap = readLocation(resourceType) - if (searchParameters == null || searchParameters.isEmpty()) { + if (searchParameters.isEmpty()) { resourceIdMap.values.forEach( Consumer { theResource: T -> builder.addCollectionEntry( @@ -442,6 +424,34 @@ class IGInputStreamStructureRepository( } companion object { + enum class ResourceCategory { + DATA, + TERMINOLOGY, + CONTENT, + ; + + companion object { + private val TERMINOLOGY_RESOURCES: Set = hashSetOf("ValueSet", "CodeSystem") + private val CONTENT_RESOURCES: Set = + hashSetOf( + "Library", + "Questionnaire", + "Measure", + "PlanDefinition", + "StructureDefinition", + "ActivityDefinition", + ) + + fun forType(resourceType: String): ResourceCategory { + return if (TERMINOLOGY_RESOURCES.contains(resourceType)) { + TERMINOLOGY + } else { + if (CONTENT_RESOURCES.contains(resourceType)) CONTENT else DATA + } + } + } + } + private val categoryDirectories: Map = ImmutableMap.Builder() .put(ResourceCategory.CONTENT, "resources") diff --git a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/PlanDefinition.kt b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/PlanDefinition.kt index 34d48a30ee..6e5651ae66 100644 --- a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/PlanDefinition.kt +++ b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/PlanDefinition.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,6 @@ import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.rest.api.EncodingEnum import java.io.IOException import org.hl7.fhir.instance.model.api.IBaseResource -import org.hl7.fhir.instance.model.api.IPrimitiveType import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.CommunicationRequest @@ -38,8 +37,8 @@ import org.junit.Assert.fail import org.opencds.cqf.fhir.api.Repository import org.opencds.cqf.fhir.cql.EvaluationSettings import org.opencds.cqf.fhir.cql.LibraryEngine -import org.opencds.cqf.fhir.cr.plandefinition.r4.PlanDefinitionProcessor -import org.opencds.cqf.fhir.utility.repository.IGLayoutMode +import org.opencds.cqf.fhir.cr.plandefinition.PlanDefinitionProcessor +import org.opencds.cqf.fhir.utility.monad.Eithers import org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository import org.opencds.cqf.fhir.utility.repository.Repositories import org.skyscreamer.jsonassert.JSONAssert @@ -152,7 +151,6 @@ object PlanDefinition : Loadable() { IGInputStreamStructureRepository( fhirContext, repositoryPath ?: ".", - IGLayoutMode.TYPE_PREFIX, EncodingEnum.JSON, ) if (dataRepository == null && contentRepository == null && terminologyRepository == null) { @@ -200,14 +198,12 @@ object PlanDefinition : Loadable() { return GeneratedBundle( buildProcessor(repository) - .applyR5>( - /* id = */ IdType("PlanDefinition", planDefinitionID), - /* canonical = */ null, - /* planDefinition = */ null, - /* patientId = */ patientID, - /* encounterId = */ encounterID, - /* practitionerId = */ practitionerID, - /* organizationId = */ null, + .applyR5( + /* planDefinition = */ Eithers.forMiddle3(IdType("PlanDefinition", planDefinitionID)), + /* subject = */ patientID, + /* encounter = */ encounterID, + /* practitioner = */ practitionerID, + /* organization = */ null, /* userType = */ null, /* userLanguage = */ null, /* userTaskContext = */ null, @@ -253,10 +249,8 @@ object PlanDefinition : Loadable() { return GeneratedCarePlan( (buildProcessor(repository) - .apply>( - IdType("PlanDefinition", planDefinitionID), - null, - null, + .apply( + /* planDefinition = */ Eithers.forMiddle3(IdType("PlanDefinition", planDefinitionID)), patientID, encounterID, practitionerID, @@ -280,10 +274,8 @@ object PlanDefinition : Loadable() { val repository = overrideRepository ?: buildRepository() return GeneratedPackage( (buildProcessor(repository) - .packagePlanDefinition>( - IdType("PlanDefinition", planDefinitionID), - null, - null, + .packagePlanDefinition( + Eithers.forMiddle3(IdType("PlanDefinition", planDefinitionID)), true, ) as Bundle), null, diff --git a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/TestRepositoryFactory.java b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/TestRepositoryFactory.java index b14ff3ff13..cee62db23d 100644 --- a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/TestRepositoryFactory.java +++ b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/TestRepositoryFactory.java @@ -1,27 +1,15 @@ package com.google.android.fhir.workflow.testing; import org.opencds.cqf.fhir.api.Repository; -import org.opencds.cqf.fhir.utility.repository.IGFileStructureRepository; -import org.opencds.cqf.fhir.utility.repository.IGLayoutMode; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.rest.api.EncodingEnum; public class TestRepositoryFactory { - private TestRepositoryFactory() { - // intentionally empty - } - public static Repository createRepository(FhirContext fhirContext, String path) { - return createRepository(fhirContext, path, IGLayoutMode.TYPE_PREFIX); - } - - public static Repository createRepository( - FhirContext fhirContext, String path, IGLayoutMode layoutMode) { return new IGInputStreamStructureRepository( - fhirContext, - path, - layoutMode, + fhirContext, + path, EncodingEnum.JSON ); } diff --git a/workflow-testing/src/main/resources/plan-definition/anc-visit/anc_visit_careplan.json b/workflow-testing/src/main/resources/plan-definition/anc-visit/anc_visit_careplan.json index efad1f3768..3ebe65432e 100644 --- a/workflow-testing/src/main/resources/plan-definition/anc-visit/anc_visit_careplan.json +++ b/workflow-testing/src/main/resources/plan-definition/anc-visit/anc_visit_careplan.json @@ -79,8 +79,7 @@ "instantiatesCanonical": "ActivityDefinition/careplan-activity", "basedOn": [ { - "reference": "#RequestGroup/AncVisit-PlanDefinition", - "type": "RequestGroup" + "reference": "RequestGroup/AncVisit-PlanDefinition" } ], "status": "ready", diff --git a/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-generate-questionnaire.json b/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-generate-questionnaire.json index 240097cc7c..23b121188f 100644 --- a/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-generate-questionnaire.json +++ b/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-generate-questionnaire.json @@ -11,7 +11,7 @@ "status": "draft", "intent": "proposal", "subject": { - "reference": "OPA-Patient1" + "reference": "Patient/OPA-Patient1" }, "action": [ { diff --git a/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-hello-world-patient-view.json b/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-hello-world-patient-view.json index 545d4419f7..6bf625dff7 100644 --- a/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-hello-world-patient-view.json +++ b/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-hello-world-patient-view.json @@ -11,10 +11,10 @@ "status": "draft", "intent": "proposal", "subject": { - "reference": "helloworld-patient-1" + "reference": "Patient/helloworld-patient-1" }, "encounter": { - "reference": "helloworld-patient-1-encounter-1" + "reference": "Encounter/helloworld-patient-1-encounter-1" }, "action": [ { @@ -40,10 +40,10 @@ "status": "draft", "intent": "proposal", "subject": { - "reference": "helloworld-patient-1" + "reference": "Patient/helloworld-patient-1" }, "encounter": { - "reference": "helloworld-patient-1-encounter-1" + "reference": "Encounter/helloworld-patient-1-encounter-1" }, "activity": [ { diff --git a/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-prepopulate.json b/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-prepopulate.json index d6320cb553..50973ddc5e 100644 --- a/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-prepopulate.json +++ b/workflow-testing/src/main/resources/plan-definition/base-repo/tests/CarePlan-prepopulate.json @@ -11,7 +11,7 @@ "status": "draft", "intent": "proposal", "subject": { - "reference": "OPA-Patient1" + "reference": "Patient/OPA-Patient1" }, "action": [ { @@ -842,7 +842,7 @@ "reference": "Questionnaire/OutpatientPriorAuthorizationRequest-OPA-Patient1" }, "for": { - "reference": "OPA-Patient1" + "reference": "Patient/OPA-Patient1" } } ], @@ -852,7 +852,7 @@ "status": "draft", "intent": "proposal", "subject": { - "reference": "OPA-Patient1" + "reference": "Patient/OPA-Patient1" }, "activity": [ { diff --git a/workflow-testing/src/main/resources/plan-definition/cds-hooks-multiple-actions/cds_hooks_multiple_actions_careplan.json b/workflow-testing/src/main/resources/plan-definition/cds-hooks-multiple-actions/cds_hooks_multiple_actions_careplan.json index 618cf8894a..5c9ca26391 100644 --- a/workflow-testing/src/main/resources/plan-definition/cds-hooks-multiple-actions/cds_hooks_multiple_actions_careplan.json +++ b/workflow-testing/src/main/resources/plan-definition/cds-hooks-multiple-actions/cds_hooks_multiple_actions_careplan.json @@ -11,7 +11,7 @@ "status": "draft", "intent": "proposal", "subject": { - "reference": "patient-CdsHooksMultipleActions" + "reference": "Patient/patient-CdsHooksMultipleActions" }, "action": [ { @@ -51,7 +51,7 @@ "status": "draft", "intent": "proposal", "subject": { - "reference": "patient-CdsHooksMultipleActions" + "reference": "Patient/patient-CdsHooksMultipleActions" }, "activity": [ { diff --git a/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json b/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json index 892663c8e0..131bb2fc7d 100644 --- a/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json +++ b/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json @@ -26,17 +26,9 @@ }, { "resourceType": "Task", "id": "16", - "extension": [ { - "url": "http://hl7.org/fhir/aphl/StructureDefinition/condition", - "valueExpression": { - "language": "text/cql.identifier", - "expression": "Patient is Female" - } - } ], "instantiatesCanonical": "http://example.com/ActivityDefinition/Activity-Example", "basedOn": [ { - "reference": "#RequestGroup/17", - "type": "RequestGroup" + "reference": "RequestGroup/17" } ], "status": "draft", "intent": "proposal", diff --git a/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json b/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json index 117d63447d..8e7b401c7b 100644 --- a/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json +++ b/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json @@ -18,14 +18,14 @@ "id": "medication-action-1", "title": "Administer Medication 1", "resource": { - "reference": "MedicationRequest/16" + "reference": "medication-action-1-16" } } ] }, { "resourceType": "MedicationRequest", - "id": "16", + "id": "medication-action-1-16", "status": "draft", "intent": "order", "medicationCodeableConcept": { diff --git a/workflow/benchmark/build.gradle.kts b/workflow/benchmark/build.gradle.kts index be695d4492..7d1d5a2d26 100644 --- a/workflow/benchmark/build.gradle.kts +++ b/workflow/benchmark/build.gradle.kts @@ -23,6 +23,7 @@ android { "META-INF/ASL2.0", "META-INF/ASL-2.0.txt", "META-INF/DEPENDENCIES", + "META-INF/INDEX.LIST", "META-INF/LGPL-3.0.txt", "META-INF/LICENSE", "META-INF/LICENSE.txt", @@ -55,16 +56,17 @@ afterEvaluate { configureFirebaseTestLabForMicroBenchmark() } configurations { all { removeIncompatibleDependencies() } } dependencies { - androidTestImplementation(Dependencies.Cql.evaluator) - androidTestImplementation(Dependencies.Cql.evaluatorFhirJackson) - androidTestImplementation(Dependencies.Cql.evaluatorFhirUtilities) androidTestImplementation(libs.androidx.benchmark.junit4) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.work.runtime) androidTestImplementation(libs.androidx.work.testing) androidTestImplementation(libs.junit) + androidTestImplementation(libs.logback.android) androidTestImplementation(libs.kotlinx.coroutines.android) + androidTestImplementation(libs.opencds.cqf.fhir.cr) + androidTestImplementation(libs.opencds.cqf.fhir.jackson) + androidTestImplementation(libs.opencds.cqf.fhir.utility) androidTestImplementation(libs.truth) androidTestImplementation(project(":engine")) androidTestImplementation(project(":knowledge")) { diff --git a/workflow/build.gradle.kts b/workflow/build.gradle.kts index 58ac122a95..83e5d49f56 100644 --- a/workflow/build.gradle.kts +++ b/workflow/build.gradle.kts @@ -49,6 +49,7 @@ android { "META-INF/ASL2.0", "META-INF/ASL-2.0.txt", "META-INF/DEPENDENCIES", + "META-INF/INDEX.LIST", "META-INF/LGPL-3.0.txt", "META-INF/LICENSE", "META-INF/LICENSE.txt", @@ -86,14 +87,13 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.work.testing) androidTestImplementation(libs.junit) + androidTestImplementation(libs.logback.android) androidTestImplementation(libs.truth) androidTestImplementation(project(":workflow-testing")) api(Dependencies.HapiFhir.structuresR4) { exclude(module = "junit") } api(Dependencies.HapiFhir.guavaCaching) - implementation(Dependencies.Cql.evaluator) - implementation(Dependencies.Cql.evaluatorFhirJackson) implementation(Dependencies.HapiFhir.guavaCaching) implementation(Dependencies.androidFhirEngine) { exclude(module = "truth") } implementation(Dependencies.androidFhirKnowledge) @@ -103,6 +103,8 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) + implementation(libs.opencds.cqf.fhir.cr) + implementation(libs.opencds.cqf.fhir.jackson) testImplementation(Dependencies.jsonAssert) testImplementation(Dependencies.robolectric) diff --git a/workflow/sampledata/smart-imm/tests/IMMZ-Patient-NoVaxeninfant-f/CarePlan/CarePlan.json b/workflow/sampledata/smart-imm/tests/IMMZ-Patient-NoVaxeninfant-f/CarePlan/CarePlan.json index 414e994094..8996cb92fc 100644 --- a/workflow/sampledata/smart-imm/tests/IMMZ-Patient-NoVaxeninfant-f/CarePlan/CarePlan.json +++ b/workflow/sampledata/smart-imm/tests/IMMZ-Patient-NoVaxeninfant-f/CarePlan/CarePlan.json @@ -57,7 +57,7 @@ "reference": "Patient/IMMZ-Patient-NoVaxeninfant-f" }, "instantiatesCanonical": [ - "http://fhir.org/guides/who/smart-immunization/ActivityDefinition/IMMZD2DTMeaslesMR" + "http://fhir.org/guides/who/smart-immunization/ActivityDefinition/IMMZD2DTMeaslesMR|0.1.0" ], "dispenseRequest": { "validityPeriod": { diff --git a/workflow/src/androidTest/java/com/google/android/fhir/workflow/CqlBuilderAndroidTest.kt b/workflow/src/androidTest/java/com/google/android/fhir/workflow/CqlBuilderAndroidTest.kt index 989f98b2f4..d9cee67881 100644 --- a/workflow/src/androidTest/java/com/google/android/fhir/workflow/CqlBuilderAndroidTest.kt +++ b/workflow/src/androidTest/java/com/google/android/fhir/workflow/CqlBuilderAndroidTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package com.google.android.fhir.workflow import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.fhir.workflow.testing.CqlBuilder +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -29,6 +30,7 @@ class CqlBuilderAndroidTest { * This is part of [#1365](https://github.com/google/android-fhir/issues/1365) */ @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun shouldCompileAndAssembleImmunityCheck() { CqlBuilder.Assert.that("/cql-compiler/ImmunityCheck-1.0.0.cql") .compiles() @@ -43,6 +45,7 @@ class CqlBuilderAndroidTest { * This is part of [#1365](https://github.com/google/android-fhir/issues/1365) */ @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun shouldCompileAndAssembleFhirHelpers() { CqlBuilder.Assert.that("/cql-compiler/FHIRHelpers-4.0.1.cql") .compiles() diff --git a/workflow/src/androidTest/java/com/google/android/fhir/workflow/PlanDefinitionProcessorAndroidTest.kt b/workflow/src/androidTest/java/com/google/android/fhir/workflow/PlanDefinitionProcessorAndroidTest.kt index 9453fed57a..6444581954 100644 --- a/workflow/src/androidTest/java/com/google/android/fhir/workflow/PlanDefinitionProcessorAndroidTest.kt +++ b/workflow/src/androidTest/java/com/google/android/fhir/workflow/PlanDefinitionProcessorAndroidTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -83,6 +83,7 @@ class PlanDefinitionProcessorAndroidTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testANCDT17WithElm() { PlanDefinition.Assert.that( "ANCDT17", @@ -160,6 +161,7 @@ class PlanDefinitionProcessorAndroidTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testQuestionnairePrepopulate() { val planDefinitionID = "prepopulate" val patientID = "OPA-Patient1" @@ -173,6 +175,7 @@ class PlanDefinitionProcessorAndroidTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testQuestionnairePrepopulate_NoLibrary() { val planDefinitionID = "prepopulate-noLibrary" val patientID = "OPA-Patient1" @@ -185,6 +188,7 @@ class PlanDefinitionProcessorAndroidTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testQuestionnaireResponse() { val planDefinitionID = "prepopulate" val patientID = "OPA-Patient1" @@ -199,6 +203,7 @@ class PlanDefinitionProcessorAndroidTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testGenerateQuestionnaire() { val planDefinitionID = "generate-questionnaire" val patientID = "OPA-Patient1" diff --git a/workflow/src/androidTest/java/com/google/android/fhir/workflow/SmartImmunizationAndroidTest.kt b/workflow/src/androidTest/java/com/google/android/fhir/workflow/SmartImmunizationAndroidTest.kt index f144c18b3e..32f88a275a 100644 --- a/workflow/src/androidTest/java/com/google/android/fhir/workflow/SmartImmunizationAndroidTest.kt +++ b/workflow/src/androidTest/java/com/google/android/fhir/workflow/SmartImmunizationAndroidTest.kt @@ -23,14 +23,12 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.get import com.google.android.fhir.knowledge.FhirNpmPackage import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.workflow.testing.FhirEngineProviderTestRule import com.google.common.truth.Truth.assertThat import java.io.File import java.io.InputStream -import java.lang.RuntimeException import java.util.TimeZone import kotlinx.coroutines.runBlocking import org.hl7.fhir.instance.model.api.IBaseResource diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt index 39dc05843d..7ccdc1aea3 100644 --- a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt +++ b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt @@ -40,7 +40,7 @@ import org.opencds.cqf.fhir.cql.LibraryEngine import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions import org.opencds.cqf.fhir.cr.measure.common.MeasureReportType import org.opencds.cqf.fhir.cr.measure.r4.R4MeasureProcessor -import org.opencds.cqf.fhir.cr.plandefinition.r4.PlanDefinitionProcessor +import org.opencds.cqf.fhir.cr.plandefinition.PlanDefinitionProcessor import org.opencds.cqf.fhir.utility.monad.Eithers import org.opencds.cqf.fhir.utility.repository.ProxyRepository @@ -57,19 +57,23 @@ internal constructor( } private var dataRepo = FhirEngineRepository(fhirContext, fhirEngine) - private var contentRepo = KnowledgeRepository(fhirContext, knowledgeManager) - private var terminologyRepo = KnowledgeRepository(fhirContext, knowledgeManager) + private var knowledgeRepo = KnowledgeRepository(fhirContext, knowledgeManager) + + // The knowledge manager is used for both content and terminology. + private val repository = + ProxyRepository( + /* data = */ dataRepo, + /* content = */ knowledgeRepo, + /* terminology = */ knowledgeRepo, + ) - private val repository = ProxyRepository(dataRepo, contentRepo, terminologyRepo) private val evaluationSettings: EvaluationSettings = EvaluationSettings.getDefault() - private val measureEvaluationOptions = MeasureEvaluationOptions().apply { evaluationSettings = this@FhirOperator.evaluationSettings } private val libraryProcessor = LibraryEngine(repository, evaluationSettings) - - private val measureProcessor = R4MeasureProcessor(repository, measureEvaluationOptions) private val planDefinitionProcessor = PlanDefinitionProcessor(repository, evaluationSettings) + private val measureProcessor = R4MeasureProcessor(repository, measureEvaluationOptions) /** * The function evaluates a FHIR library against the database. @@ -120,6 +124,7 @@ internal constructor( subjectId: String? = null, practitioner: String? = null, additionalData: IBaseBundle? = null, + parameters: Parameters? = null, ): MeasureReport { val subject = if (!practitioner.isNullOrBlank()) { @@ -139,6 +144,7 @@ internal constructor( /* reportType = */ reportType, /* subjectIds = */ listOf(subject), /* additionalData = */ additionalData, + /* parameters = */ parameters, ) // add subject reference for non-individual reportTypes @@ -174,13 +180,15 @@ internal constructor( prefetchData: IBaseParameters? = null, ): IBaseResource { return planDefinitionProcessor.apply( - /* id = */ planDefinitionId?.let { IdType("PlanDefinition", it) }, - /* canonical = */ planDefinitionCanonical, - /* planDefinition = */ planDefinition, + /* planDefinition = */ Eithers.for3( + planDefinitionCanonical, + IdType("PlanDefinition", planDefinitionId), + planDefinition, + ), /* subject = */ subject, - /* encounterId = */ encounterId, - /* practitionerId = */ practitionerId, - /* organizationId = */ organizationId, + /* encounter = */ encounterId, + /* practitioner = */ practitionerId, + /* organization = */ organizationId, /* userType = */ userType, /* userLanguage = */ userLanguage, /* userTaskContext = */ userTaskContext, diff --git a/workflow/src/test/java/com/google/android/fhir/workflow/CqlBuilderJavaTest.kt b/workflow/src/test/java/com/google/android/fhir/workflow/CqlBuilderJavaTest.kt index 45e80aba5a..2d8ead4bb5 100644 --- a/workflow/src/test/java/com/google/android/fhir/workflow/CqlBuilderJavaTest.kt +++ b/workflow/src/test/java/com/google/android/fhir/workflow/CqlBuilderJavaTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,11 +17,13 @@ package com.google.android.fhir.workflow import com.google.android.fhir.workflow.testing.CqlBuilder +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) +@Ignore("https://github.com/google/android-fhir/issues/2638") class CqlBuilderJavaTest { /** * Tests the compilation of CQL expressions into ELM by verifying if the compiled JSONs match. diff --git a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt index c35c649e44..287faceef8 100644 --- a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt +++ b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt @@ -209,6 +209,7 @@ class FhirOperatorTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun evaluateGroupPopulationMeasure() = runBlockingOnWorkerThread { loader.loadFile("/group-measure/PatientGroups-1.0.0.cql", ::installToIgManager) loader.loadFile("/group-measure/PatientGroupsMeasure.json", ::installToIgManager) diff --git a/workflow/src/test/java/com/google/android/fhir/workflow/PlanDefinitionProcessorJavaTest.kt b/workflow/src/test/java/com/google/android/fhir/workflow/PlanDefinitionProcessorJavaTest.kt index 209f79f965..c933618038 100644 --- a/workflow/src/test/java/com/google/android/fhir/workflow/PlanDefinitionProcessorJavaTest.kt +++ b/workflow/src/test/java/com/google/android/fhir/workflow/PlanDefinitionProcessorJavaTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ class PlanDefinitionProcessorJavaTest { .isEqualsTo("/plan-definition/anc-visit/anc_visit_careplan.json") @Test - @Ignore("works when the full suite is run but not if this individual test is run") + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testANCDT17() { val repository = TestRepositoryFactory.createRepository( @@ -78,6 +78,7 @@ class PlanDefinitionProcessorJavaTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testANCDT17WithElm() { PlanDefinition.Assert.that( "ANCDT17", @@ -155,6 +156,7 @@ class PlanDefinitionProcessorJavaTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testQuestionnairePrepopulate() { val planDefinitionID = "prepopulate" val patientID = "OPA-Patient1" @@ -168,6 +170,7 @@ class PlanDefinitionProcessorJavaTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testQuestionnairePrepopulate_NoLibrary() { val planDefinitionID = "prepopulate-noLibrary" val patientID = "OPA-Patient1" @@ -180,6 +183,7 @@ class PlanDefinitionProcessorJavaTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testQuestionnaireResponse() { val planDefinitionID = "prepopulate" val patientID = "OPA-Patient1" @@ -194,6 +198,7 @@ class PlanDefinitionProcessorJavaTest { } @Test + @Ignore("https://github.com/google/android-fhir/issues/2638") fun testGenerateQuestionnaire() { val planDefinitionID = "generate-questionnaire" val patientID = "OPA-Patient1"