diff --git a/buildSrc/src/main/kotlin/SpotlessConfig.kt b/buildSrc/src/main/kotlin/SpotlessConfig.kt index 22e7482c42..6f699f22bc 100644 --- a/buildSrc/src/main/kotlin/SpotlessConfig.kt +++ b/buildSrc/src/main/kotlin/SpotlessConfig.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,6 +36,7 @@ fun Project.configureSpotless() { // It is necessary to tell spotless the top level of a file in order to apply config to it // See: https://github.com/diffplug/spotless/issues/135 ) + toggleOffOn() } kotlinGradle { target("*.gradle.kts") diff --git a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt index a60ad00318..a85da15082 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt @@ -63,7 +63,7 @@ class FhirApplication : Application(), DataCaptureConfig.Provider { dataCaptureConfig = DataCaptureConfig().apply { urlResolver = ReferenceUrlResolver(this@FhirApplication as Context) - xFhirQueryResolver = XFhirQueryResolver { fhirEngine.search(it) } + xFhirQueryResolver = XFhirQueryResolver { fhirEngine.search(it).map { it.resource } } } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsViewModel.kt index 2f695ff4f0..e5d2ae0f6f 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsViewModel.kt @@ -26,8 +26,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.google.android.fhir.FhirEngine -import com.google.android.fhir.get import com.google.android.fhir.logicalId +import com.google.android.fhir.search.revInclude import com.google.android.fhir.search.search import java.text.SimpleDateFormat import java.time.LocalDate @@ -40,6 +40,8 @@ import org.apache.commons.lang3.StringUtils import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.RiskAssessment import org.hl7.fhir.r4.model.codesystems.RiskProbability @@ -59,108 +61,131 @@ class PatientDetailsViewModel( viewModelScope.launch { livePatientData.value = getPatientDetailDataModel() } } - private suspend fun getPatient(): PatientListViewModel.PatientItem { - val patient = fhirEngine.get(patientId) - return patient.toPatientItem(0) - } + private suspend fun getPatientDetailDataModel(): List { + val searchResult = + fhirEngine.search { + filter(Resource.RES_ID, { value = of(patientId) }) - private suspend fun getPatientObservations(): List { - val observations: MutableList = mutableListOf() - fhirEngine - .search { filter(Observation.SUBJECT, { value = "Patient/$patientId" }) } - .take(MAX_RESOURCE_COUNT) - .map { createObservationItem(it, getApplication().resources) } - .let { observations.addAll(it) } - return observations - } + revInclude(RiskAssessment.SUBJECT) + revInclude(Observation.SUBJECT) + revInclude(Condition.SUBJECT) + } + val data = mutableListOf() - private suspend fun getPatientConditions(): List { - val conditions: MutableList = mutableListOf() - fhirEngine - .search { filter(Condition.SUBJECT, { value = "Patient/$patientId" }) } - .take(MAX_RESOURCE_COUNT) - .map { createConditionItem(it, getApplication().resources) } - .let { conditions.addAll(it) } - return conditions - } + searchResult.first().let { + data.addPatientDetailData( + it.resource, + getRiskItem( + it.revIncluded?.get(ResourceType.RiskAssessment to RiskAssessment.SUBJECT.paramName) + as List? + ) + ) - private suspend fun getPatientDetailDataModel(): List { - val data = mutableListOf() - val patient = getPatient() - patient.riskItem = getPatientRiskAssessment() + it.revIncluded?.get(ResourceType.Observation to Observation.SUBJECT.paramName)?.let { + data.addObservationsData(it as List) + } + it.revIncluded?.get(ResourceType.Condition to Condition.SUBJECT.paramName)?.let { + data.addConditionsData(it as List) + } + } + return data + } - val observations = getPatientObservations() - val conditions = getPatientConditions() + private fun getRiskItem(riskAssessments: List?) = + riskAssessments + ?.filter { it.hasOccurrence() } + ?.maxByOrNull { it.occurrenceDateTimeType.value } + .let { + RiskAssessmentItem( + getRiskAssessmentStatusColor(it), + getRiskAssessmentStatus(it), + getLastContactedDate(it), + getPatientDetailsCardColor(it) + ) + } - patient.let { patientItem -> - data.add(PatientDetailOverview(patientItem, firstInGroup = true)) - data.add( - PatientDetailProperty( - PatientProperty(getString(R.string.patient_property_mobile), patientItem.phone) + private fun MutableList.addPatientDetailData( + patient: Patient, + riskAssessment: RiskAssessmentItem + ) { + patient + .toPatientItem(0) + .apply { riskItem = riskAssessment } + .let { patientItem -> + add(PatientDetailOverview(patientItem, firstInGroup = true)) + add( + PatientDetailProperty( + PatientProperty(getString(R.string.patient_property_mobile), patientItem.phone) + ) ) - ) - data.add( - PatientDetailProperty( - PatientProperty(getString(R.string.patient_property_id), patientItem.resourceId) + add( + PatientDetailProperty( + PatientProperty(getString(R.string.patient_property_id), patientItem.resourceId) + ) ) - ) - data.add( - PatientDetailProperty( - PatientProperty( - getString(R.string.patient_property_address), - "${patientItem.city}, ${patientItem.country} " + add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_property_address), + "${patientItem.city}, ${patientItem.country} " + ) ) ) - ) - data.add( - PatientDetailProperty( - PatientProperty( - getString(R.string.patient_property_dob), - patientItem.dob?.localizedString ?: "" + add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_property_dob), + patientItem.dob?.localizedString ?: "" + ) ) ) - ) - data.add( - PatientDetailProperty( - PatientProperty( - getString(R.string.patient_property_gender), - patientItem.gender.replaceFirstChar { - if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() - } - ), - lastInGroup = true + add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_property_gender), + patientItem.gender.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() + } + ), + lastInGroup = true + ) ) - ) - } + } + } + private fun MutableList.addObservationsData(observations: List) { if (observations.isNotEmpty()) { - data.add(PatientDetailHeader(getString(R.string.header_observation))) + add(PatientDetailHeader(getString(R.string.header_observation))) - val observationDataModel = - observations.mapIndexed { index, observationItem -> + observations + .take(MAX_RESOURCE_COUNT) + .map { createObservationItem(it, getApplication().resources) } + .mapIndexed { index, observationItem -> PatientDetailObservation( observationItem, firstInGroup = index == 0, lastInGroup = index == observations.size - 1 ) } - data.addAll(observationDataModel) + .let { addAll(it) } } + } + private fun MutableList.addConditionsData(conditions: List) { if (conditions.isNotEmpty()) { - data.add(PatientDetailHeader(getString(R.string.header_conditions))) - val conditionDataModel = - conditions.mapIndexed { index, conditionItem -> + add(PatientDetailHeader(getString(R.string.header_conditions))) + conditions + .take(MAX_RESOURCE_COUNT) + .map { createConditionItem(it, getApplication().resources) } + .mapIndexed { index, conditionItem -> PatientDetailCondition( conditionItem, firstInGroup = index == 0, lastInGroup = index == conditions.size - 1 ) } - data.addAll(conditionDataModel) + .let { addAll(it) } } - - return data } private val LocalDate.localizedString: String @@ -176,21 +201,6 @@ class PatientDetailsViewModel( private fun getString(resId: Int) = getApplication().resources.getString(resId) - private suspend fun getPatientRiskAssessment(): RiskAssessmentItem { - val riskAssessment = - fhirEngine - .search { filter(RiskAssessment.SUBJECT, { value = "Patient/$patientId" }) } - .filter { it.hasOccurrence() } - .sortedByDescending { it.occurrenceDateTimeType.value } - .firstOrNull() - return RiskAssessmentItem( - getRiskAssessmentStatusColor(riskAssessment), - getRiskAssessmentStatus(riskAssessment), - getLastContactedDate(riskAssessment), - getPatientDetailsCardColor(riskAssessment) - ) - } - private fun getRiskAssessmentStatusColor(riskAssessment: RiskAssessment?): Int { riskAssessment?.let { return when (it.prediction.first().qualitativeRisk.coding.first().code) { diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListViewModel.kt index f982bff058..75568d1124 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListViewModel.kt @@ -24,7 +24,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.google.android.fhir.FhirEngine import com.google.android.fhir.search.Order -import com.google.android.fhir.search.Search import com.google.android.fhir.search.StringFilterModifier import com.google.android.fhir.search.count import com.google.android.fhir.search.search @@ -82,7 +81,6 @@ class PatientListViewModel(application: Application, private val fhirEngine: Fhi } ) } - filterCity(this) } } @@ -99,12 +97,11 @@ class PatientListViewModel(application: Application, private val fhirEngine: Fhi } ) } - filterCity(this) sort(Patient.GIVEN, Order.ASCENDING) count = 100 from = 0 } - .mapIndexed { index, fhirPatient -> fhirPatient.toPatientItem(index + 1) } + .mapIndexed { index, fhirPatient -> fhirPatient.resource.toPatientItem(index + 1) } .let { patients.addAll(it) } val risks = getRiskAssessments() @@ -116,19 +113,15 @@ class PatientListViewModel(application: Application, private val fhirEngine: Fhi return patients } - private fun filterCity(search: Search) { - search.filter(Patient.ADDRESS_CITY, { value = "NAIROBI" }) - } - private suspend fun getRiskAssessments(): Map { return fhirEngine .search {} - .groupBy { it.subject.reference } + .groupBy { it.resource.subject.reference } .mapValues { entry -> entry.value - .filter { it.hasOccurrence() } - .sortedByDescending { it.occurrenceDateTimeType.value } - .firstOrNull() + .filter { it.resource.hasOccurrence() } + .maxByOrNull { it.resource.occurrenceDateTimeType.value } + ?.resource } } 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 62c85008a6..bbec0d44c8 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 @@ -24,6 +24,7 @@ import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.DateProvider import com.google.android.fhir.FhirServices import com.google.android.fhir.LocalChange +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.LocalChangeToken @@ -33,8 +34,11 @@ import com.google.android.fhir.search.Operation import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search import com.google.android.fhir.search.StringFilterModifier +import com.google.android.fhir.search.execute import com.google.android.fhir.search.getQuery import com.google.android.fhir.search.has +import com.google.android.fhir.search.include +import com.google.android.fhir.search.revInclude import com.google.android.fhir.testing.assertJsonArrayEqualsIgnoringOrder import com.google.android.fhir.testing.assertResourceEquals import com.google.android.fhir.testing.readFromFile @@ -54,6 +58,7 @@ import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType 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.HumanName @@ -61,10 +66,13 @@ import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Immunization import org.hl7.fhir.r4.model.Meta 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.Practitioner import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.RiskAssessment import org.hl7.fhir.r4.model.SearchParameter @@ -2482,96 +2490,6 @@ class DatabaseImplTest { assertThat(result.map { it.logicalId }).containsExactly("100").inOrder() } - @Test - fun search_practitioner_has_patient_has_conditions_diabetes_and_hypertension() = runBlocking { - // Running this test with more resources than required to try and hit all the cases - // patient 1 has 2 practitioners & both conditions - // patient 2 has both conditions but no associated practitioner - // patient 3 has 1 practitioner & 1 condition - val diabetesCodeableConcept = - CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")) - val hyperTensionCodeableConcept = - CodeableConcept(Coding("http://snomed.info/sct", "827069000", "Hypertension stage 1")) - val resources = - listOf( - Practitioner().apply { id = "practitioner-001" }, - Practitioner().apply { id = "practitioner-002" }, - Patient().apply { - gender = Enumerations.AdministrativeGender.MALE - id = "patient-001" - this.addGeneralPractitioner(Reference("Practitioner/practitioner-001")) - this.addGeneralPractitioner(Reference("Practitioner/practitioner-002")) - }, - Condition().apply { - subject = Reference("Patient/patient-001") - id = "condition-001" - code = diabetesCodeableConcept - }, - Condition().apply { - subject = Reference("Patient/patient-001") - id = "condition-002" - code = hyperTensionCodeableConcept - }, - Patient().apply { - gender = Enumerations.AdministrativeGender.MALE - id = "patient-002" - }, - Condition().apply { - subject = Reference("Patient/patient-002") - id = "condition-003" - code = hyperTensionCodeableConcept - }, - Condition().apply { - subject = Reference("Patient/patient-002") - id = "condition-004" - code = diabetesCodeableConcept - }, - Practitioner().apply { id = "practitioner-003" }, - Patient().apply { - gender = Enumerations.AdministrativeGender.MALE - id = "patient-003" - this.addGeneralPractitioner(Reference("Practitioner/practitioner-00")) - }, - Condition().apply { - subject = Reference("Patient/patient-003") - id = "condition-005" - code = diabetesCodeableConcept - } - ) - database.insert(*resources.toTypedArray()) - - val result = - database.search( - Search(ResourceType.Practitioner) - .apply { - has(Patient.GENERAL_PRACTITIONER) { - has(Condition.SUBJECT) { - filter( - Condition.CODE, - { value = of(Coding("http://snomed.info/sct", "44054006", "Diabetes")) } - ) - } - } - has(Patient.GENERAL_PRACTITIONER) { - has(Condition.SUBJECT) { - filter( - Condition.CODE, - { - value = - of(Coding("http://snomed.info/sct", "827069000", "Hypertension stage 1")) - } - ) - } - } - } - .getQuery() - ) - - assertThat(result.map { it.logicalId }) - .containsExactly("practitioner-001", "practitioner-002") - .inOrder() - } - @Test fun search_sortDescending_Date(): Unit = runBlocking { database.insert( @@ -3026,6 +2944,544 @@ class DatabaseImplTest { .inOrder() } + @Test + fun search_patient_and_include_practitioners(): Unit = runBlocking { + val patient01 = + Patient().apply { + id = "pa-01" + addName( + HumanName().apply { + addGiven("James") + family = "Gorden" + } + ) + addGeneralPractitioner(Reference("Practitioner/gp-01")) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + } + + val patient02 = + Patient().apply { + id = "pa-02" + addName( + HumanName().apply { + addGiven("James") + family = "Bond" + } + ) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + addGeneralPractitioner(Reference("Practitioner/gp-03")) + } + val patients = listOf(patient01, patient02) + + val gp01 = + Practitioner().apply { + id = "gp-01" + addName( + HumanName().apply { + family = "Practitioner-01" + addGiven("General-01") + } + ) + active = true + } + val gp02 = + Practitioner().apply { + id = "gp-02" + addName( + HumanName().apply { + family = "Practitioner-02" + addGiven("General-02") + } + ) + active = false + } + val gp03 = + Practitioner().apply { + id = "gp-03" + addName( + HumanName().apply { + family = "Practitioner-03" + addGiven("General-03") + } + ) + active = true + } + + val practitioners = listOf(gp01, gp02, gp03) + + database.insertRemote(*(patients + practitioners).toTypedArray()) + + val result = + Search(ResourceType.Patient) + .apply { + filter( + Patient.GIVEN, + { + value = "James" + modifier = StringFilterModifier.MATCHES_EXACTLY + } + ) + + include(Patient.GENERAL_PRACTITIONER) { + filter(Practitioner.ACTIVE, { value = of(true) }) + } + } + .execute(database) + + assertThat(result) + .isEqualTo( + listOf( + SearchResult( + patient01, + included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp01)), + revIncluded = null + ), + SearchResult( + patient02, + included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp03)), + revIncluded = null + ) + ) + ) + } + + @Test + fun search_patient_and_revInclude_conditions(): Unit = runBlocking { + val patient01 = + Patient().apply { + id = "pa-01" + addName( + HumanName().apply { + addGiven("James") + family = "Gorden" + } + ) + addGeneralPractitioner(Reference("Practitioner/gp-01")) + } + + val patient02 = + Patient().apply { + id = "pa-02" + addName( + HumanName().apply { + addGiven("James") + family = "Bond" + } + ) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + } + val patients = listOf(patient01, patient02) + val diabetesCodeableConcept = + CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")) + val hyperTensionCodeableConcept = + CodeableConcept(Coding("http://snomed.info/sct", "827069000", "Hypertension stage 1")) + val migraineCodeableConcept = + CodeableConcept(Coding("http://snomed.info/sct", "37796009", "Migraine")) + + val con1 = + Condition().apply { + id = "con-01" + code = diabetesCodeableConcept + subject = Reference("Patient/pa-01") + } + val con2 = + Condition().apply { + id = "con-02" + code = hyperTensionCodeableConcept + subject = Reference("Patient/pa-01") + } + val con3 = + Condition().apply { + id = "con-03" + code = migraineCodeableConcept + subject = Reference("Patient/pa-02") + } + val conditions = listOf(con1, con2, con3) + + database.insertRemote(*(patients + conditions).toTypedArray()) + + val result = + Search(ResourceType.Patient) + .apply { + filter( + Patient.GIVEN, + { + value = "James" + modifier = StringFilterModifier.MATCHES_EXACTLY + } + ) + revInclude(Condition.SUBJECT) { + filter(Condition.CODE, { value = of(diabetesCodeableConcept) }) + filter(Condition.CODE, { value = of(migraineCodeableConcept) }) + operation = Operation.OR + } + } + .execute(database) + + assertThat(result) + .isEqualTo( + listOf( + SearchResult( + patient01, + included = null, + revIncluded = + mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con1)) + ), + SearchResult( + patient02, + included = null, + revIncluded = + mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con3)) + ) + ) + ) + } + + @Test + fun search_patient_with_reference_resources(): Unit = runBlocking { + val diabetesCodeableConcept = + CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")) + val hyperTensionCodeableConcept = + CodeableConcept(Coding("http://snomed.info/sct", "827069000", "Hypertension stage 1")) + val migraineCodeableConcept = + CodeableConcept(Coding("http://snomed.info/sct", "37796009", "Migraine")) + + val patients = + listOf( + Patient().apply { + id = "pa-01" + addName( + HumanName().apply { + addGiven("James") + family = "Gorden" + } + ) + addGeneralPractitioner(Reference("Practitioner/gp-01")) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + addGeneralPractitioner(Reference("Practitioner/gp-03")) + managingOrganization = Reference("Organization/org-01") + }, + Patient().apply { + id = "pa-02" + addName( + HumanName().apply { + addGiven("James") + family = "Bond" + } + ) + addGeneralPractitioner(Reference("Practitioner/gp-01")) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + addGeneralPractitioner(Reference("Practitioner/gp-03")) + managingOrganization = Reference("Organization/org-02") + }, + Patient().apply { + id = "pa-03" + addName( + HumanName().apply { + addGiven("James") + family = "Doe" + } + ) + addGeneralPractitioner(Reference("Practitioner/gp-01")) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + addGeneralPractitioner(Reference("Practitioner/gp-03")) + managingOrganization = Reference("Organization/org-03") + } + ) + + val practitioners = + listOf( + Practitioner().apply { + id = "gp-01" + addName( + HumanName().apply { + family = "Practitioner-01" + addGiven("General-01") + } + ) + active = true + }, + Practitioner().apply { + id = "gp-02" + addName( + HumanName().apply { + family = "Practitioner-02" + addGiven("General-02") + } + ) + active = true + }, + Practitioner().apply { + id = "gp-03" + addName( + HumanName().apply { + family = "Practitioner-03" + addGiven("General-03") + } + ) + active = false + } + ) + + val organizations = + listOf( + Organization().apply { + id = "org-01" + name = "Organization-01" + active = true + }, + Organization().apply { + id = "org-02" + name = "Organization-02" + active = true + }, + Organization().apply { + id = "org-03" + name = "Organization-03" + active = false + } + ) + + val conditions = + listOf( + Condition().apply { + id = "con-01-pa-01" + code = diabetesCodeableConcept + subject = Reference("Patient/pa-01") + }, + Condition().apply { + id = "con-02-pa-01" + code = hyperTensionCodeableConcept + subject = Reference("Patient/pa-01") + }, + Condition().apply { + id = "con-03-pa-01" + code = migraineCodeableConcept + subject = Reference("Patient/pa-01") + }, + Condition().apply { + id = "con-01-pa-02" + code = diabetesCodeableConcept + subject = Reference("Patient/pa-02") + }, + Condition().apply { + id = "con-02-pa-02" + code = hyperTensionCodeableConcept + subject = Reference("Patient/pa-02") + }, + Condition().apply { + id = "con-03-pa-02" + code = migraineCodeableConcept + subject = Reference("Patient/pa-02") + }, + Condition().apply { + id = "con-01-pa-03" + code = diabetesCodeableConcept + subject = Reference("Patient/pa-03") + }, + Condition().apply { + id = "con-02-pa-03" + code = hyperTensionCodeableConcept + subject = Reference("Patient/pa-03") + }, + Condition().apply { + id = "con-03-pa-03" + code = migraineCodeableConcept + subject = Reference("Patient/pa-03") + }, + ) + + val encounters = + listOf( + Encounter().apply { + id = "en-01-pa-01" + subject = Reference("Patient/pa-01") + period = + Period().apply { + start = DateType(2023, 2, 1).value + end = DateType(2023, 11, 1).value + } + }, + Encounter().apply { + id = "en-02-pa-01" + subject = Reference("Patient/pa-01") + period = + Period().apply { + start = DateType(2023, 2, 1).value + end = DateType(2023, 11, 1).value + } + }, + Encounter().apply { + id = "en-03-pa-01" + subject = Reference("Patient/pa-01") + period = + Period().apply { + start = DateType(2022, 2, 1).value + end = DateType(2022, 11, 1).value + } + }, + Encounter().apply { + id = "en-01-pa-02" + subject = Reference("Patient/pa-02") + period = + Period().apply { + start = DateType(2023, 2, 1).value + end = DateType(2023, 11, 1).value + } + }, + Encounter().apply { + id = "en-02-pa-02" + subject = Reference("Patient/pa-02") + period = + Period().apply { + start = DateType(2023, 2, 1).value + end = DateType(2023, 11, 1).value + } + }, + Encounter().apply { + id = "en-03-pa-02" + subject = Reference("Patient/pa-02") + period = + Period().apply { + start = DateType(2022, 2, 1).value + end = DateType(2022, 11, 1).value + } + }, + Encounter().apply { + id = "en-01-pa-03" + subject = Reference("Patient/pa-03") + period = + Period().apply { + start = DateType(2023, 2, 1).value + end = DateType(2023, 11, 1).value + } + }, + Encounter().apply { + id = "en-02-pa-03" + subject = Reference("Patient/pa-03") + period = + Period().apply { + start = DateType(2023, 2, 1).value + end = DateType(2023, 11, 1).value + } + }, + Encounter().apply { + id = "en-03-pa-03" + subject = Reference("Patient/pa-03") + period = + Period().apply { + start = DateType(2022, 2, 1).value + end = DateType(2022, 11, 1).value + } + } + ) + // 3 Patients. + // Each has 3 conditions, only 2 should match + // Each has 3 encounters, only 2 should match + + val resources: Map = + (patients + practitioners + organizations + conditions + encounters).associateBy { + it.logicalId + } + // Each has 3 GP, only 2 should match + database.insertRemote(*resources.values.toTypedArray()) + + val result = + Search(ResourceType.Patient) + .apply { + filter( + Patient.GIVEN, + { + value = "James" + modifier = StringFilterModifier.MATCHES_EXACTLY + } + ) + + include(Patient.GENERAL_PRACTITIONER) { + filter(Practitioner.ACTIVE, { value = of(true) }) + filter( + Practitioner.FAMILY, + { + value = "Practitioner" + modifier = StringFilterModifier.STARTS_WITH + } + ) + operation = Operation.AND + } + include(Patient.ORGANIZATION) { + filter( + Organization.NAME, + { + value = "Organization" + modifier = StringFilterModifier.STARTS_WITH + } + ) + filter(Practitioner.ACTIVE, { value = of(true) }) + operation = Operation.AND + } + + revInclude(Condition.SUBJECT) { + filter(Condition.CODE, { value = of(diabetesCodeableConcept) }) + filter(Condition.CODE, { value = of(migraineCodeableConcept) }) + operation = Operation.OR + } + revInclude(Encounter.SUBJECT) { + filter( + Encounter.DATE, + { + value = of(DateTimeType("2023-01-01")) + prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS + } + ) + } + } + .execute(database) + + assertThat(result) + .isEqualTo( + listOf( + SearchResult( + resources["pa-01"]!!, + mapOf( + "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), + "organization" to listOf(resources["org-01"]!!), + ), + mapOf( + Pair(ResourceType.Condition, "subject") to + listOf(resources["con-01-pa-01"]!!, resources["con-03-pa-01"]!!), + Pair(ResourceType.Encounter, "subject") to + listOf(resources["en-01-pa-01"]!!, resources["en-02-pa-01"]!!) + ) + ), + SearchResult( + resources["pa-02"]!!, + mapOf( + "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), + "organization" to listOf(resources["org-02"]!!) + ), + mapOf( + Pair(ResourceType.Condition, "subject") to + listOf(resources["con-01-pa-02"]!!, resources["con-03-pa-02"]!!), + Pair(ResourceType.Encounter, "subject") to + listOf(resources["en-01-pa-02"]!!, resources["en-02-pa-02"]!!) + ) + ), + SearchResult( + resources["pa-03"]!!, + mapOf( + "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), + ), + mapOf( + Pair(ResourceType.Condition, "subject") to + listOf(resources["con-01-pa-03"]!!, resources["con-03-pa-03"]!!), + Pair(ResourceType.Encounter, "subject") to + listOf(resources["en-01-pa-03"]!!, resources["en-02-pa-03"]!!) + ) + ) + ) + ) + } + private companion object { const val mockEpochTimeStamp = 1628516301000 const val TEST_PATIENT_1_ID = "test_patient_1" diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt index 69389e4366..f0cc2f72f9 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -46,7 +46,7 @@ interface FhirEngine { /** * Searches the database and returns a list resources according to the [search] specifications. */ - suspend fun search(search: Search): List + suspend fun search(search: Search): List> /** * Synchronizes the [upload] result in the database. [upload] operation may result in multiple @@ -126,3 +126,56 @@ suspend inline fun FhirEngine.get(id: String): R { suspend inline fun FhirEngine.delete(id: String) { delete(getResourceType(R::class.java), id) } + +typealias SearchParamName = String + +/** + * Contains a FHIR resource that satisfies the search criteria in the query together with any + * referenced resources as specified in the query. + */ +data class SearchResult( + /** Matching resource as per the query. */ + val resource: R, + /** Matching referenced resources as per the [Search.include] criteria in the query. */ + val included: Map>?, + /** Matching referenced resources as per the [Search.revInclude] criteria in the query. */ + val revIncluded: Map, List>? +) { + override fun equals(other: Any?) = + other is SearchResult<*> && + equalsShallow(resource, other.resource) && + equalsShallow(included, other.included) && + equalsShallow(revIncluded, other.revIncluded) + + private fun equalsShallow(first: Resource, second: Resource) = + first.resourceType == second.resourceType && first.logicalId == second.logicalId + + private fun equalsShallow(first: List, second: List) = + first.size == second.size && + first.asSequence().zip(second.asSequence()).all { (x, y) -> equalsShallow(x, y) } + + private fun equalsShallow( + first: Map>?, + second: Map>? + ) = + if (first != null && second != null && first.size == second.size) { + first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> + x.key == y.key && equalsShallow(x.value, y.value) + } + } else { + first?.size == second?.size + } + + @JvmName("equalsShallowRevInclude") + private fun equalsShallow( + first: Map, List>?, + second: Map, List>? + ) = + if (first != null && second != null && first.size == second.size) { + first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> + x.key == y.key && equalsShallow(x.value, y.value) + } + } else { + first?.size == second?.size + } +} diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index 3d27639d0a..a3a3fc3386 100644 --- a/engine/src/main/java/com/google/android/fhir/db/Database.kt +++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt @@ -17,6 +17,7 @@ package com.google.android.fhir.db import com.google.android.fhir.LocalChange +import com.google.android.fhir.db.impl.dao.IndexedIdAndResource import com.google.android.fhir.db.impl.dao.LocalChangeToken import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.ResourceEntity @@ -94,6 +95,8 @@ internal interface Database { suspend fun search(query: SearchQuery): List + suspend fun searchReferencedResources(query: SearchQuery): List + suspend fun count(query: SearchQuery): Long /** 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 b2ad702dab..65094691a0 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 @@ -26,6 +26,7 @@ import com.google.android.fhir.DatabaseErrorStrategy import com.google.android.fhir.LocalChange import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABASE_NAME +import com.google.android.fhir.db.impl.dao.IndexedIdAndResource import com.google.android.fhir.db.impl.dao.LocalChangeToken import com.google.android.fhir.db.impl.dao.toLocalChange import com.google.android.fhir.db.impl.entities.ResourceEntity @@ -191,6 +192,20 @@ internal class DatabaseImpl( } } + override suspend fun searchReferencedResources(query: SearchQuery): List { + return db.withTransaction { + resourceDao + .getReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) + .map { + IndexedIdAndResource( + it.matchingIndex, + it.idOfBaseResourceOnWhichThisMatchedInc ?: it.idOfBaseResourceOnWhichThisMatchedRev!!, + iParser.parseResource(it.serializedResource) as Resource + ) + } + } + } + override suspend fun count(query: SearchQuery): Long { return db.withTransaction { resourceDao.countResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) 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 b7cfed6bcf..36acaaf940 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 @@ -17,6 +17,7 @@ package com.google.android.fhir.db.impl.dao import androidx.annotation.VisibleForTesting +import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -170,6 +171,11 @@ internal abstract class ResourceDao { @RawQuery abstract suspend fun getResources(query: SupportSQLiteQuery): List + @RawQuery + abstract suspend fun getReferencedResources( + query: SupportSQLiteQuery + ): List + @RawQuery abstract suspend fun countResources(query: SupportSQLiteQuery): Long suspend fun insertLocalResource(resource: Resource, timeOfChange: Instant) = @@ -343,3 +349,24 @@ internal abstract class ResourceDao { } } } + +/** + * Data class representing the value returned by [getReferencedResources]. The optional fields may + * or may-not contain values based on the search query. + */ +internal data class IndexedIdAndSerializedResource( + @ColumnInfo(name = "index_name") val matchingIndex: String, + @ColumnInfo(name = "index_value") val idOfBaseResourceOnWhichThisMatchedRev: String?, + @ColumnInfo(name = "resourceId") val idOfBaseResourceOnWhichThisMatchedInc: String?, + val serializedResource: String +) + +/** + * Data class representing an included or revIncluded [Resource], index on which the match was done + * and the id of the base [Resource] for which this [Resource] has been included. + */ +internal data class IndexedIdAndResource( + val matchingIndex: String, + val idOfBaseResourceOnWhichThisMatched: String, + val resource: Resource +) 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 e228a4a434..3b9911d66f 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 @@ -20,6 +20,7 @@ import android.content.Context import com.google.android.fhir.DatastoreUtil import com.google.android.fhir.FhirEngine import com.google.android.fhir.LocalChange +import com.google.android.fhir.SearchResult import com.google.android.fhir.db.Database import com.google.android.fhir.db.impl.dao.LocalChangeToken import com.google.android.fhir.logicalId @@ -55,7 +56,7 @@ internal class FhirEngineImpl(private val database: Database, private val contex database.delete(type, id) } - override suspend fun search(search: Search): List { + override suspend fun search(search: Search): List> { return search.execute(database) } 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 new file mode 100644 index 0000000000..d5a02d96ca --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/search/BaseSearch.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2022-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.search + +import ca.uhn.fhir.rest.gclient.DateClientParam +import ca.uhn.fhir.rest.gclient.NumberClientParam +import ca.uhn.fhir.rest.gclient.QuantityClientParam +import ca.uhn.fhir.rest.gclient.ReferenceClientParam +import ca.uhn.fhir.rest.gclient.StringClientParam +import ca.uhn.fhir.rest.gclient.TokenClientParam +import ca.uhn.fhir.rest.gclient.UriClientParam +import com.google.android.fhir.search.filter.DateParamFilterCriterion +import com.google.android.fhir.search.filter.NumberParamFilterCriterion +import com.google.android.fhir.search.filter.QuantityParamFilterCriterion +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 + +@DslMarker @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) annotation class BaseSearchDsl + +/** + * Defines the basic functionality provided by the search including [filter], [sort] and logical + * [operation] on filters. + */ +@BaseSearchDsl +interface BaseSearch { + /** Logical operator between the filters. */ + var operation: Operation + + /** Count of the maximum expected search results. */ + var count: Int? + + /** Index from which the matching search results should be returned. */ + var from: Int? + + fun filter( + stringParameter: StringClientParam, + vararg init: StringParamFilterCriterion.() -> Unit, + operation: Operation = Operation.OR + ) + + fun filter( + referenceParameter: ReferenceClientParam, + vararg init: ReferenceParamFilterCriterion.() -> Unit, + operation: Operation = Operation.OR + ) + + fun filter( + dateParameter: DateClientParam, + vararg init: DateParamFilterCriterion.() -> Unit, + operation: Operation = Operation.OR + ) + + fun filter( + quantityParameter: QuantityClientParam, + vararg init: QuantityParamFilterCriterion.() -> Unit, + operation: Operation = Operation.OR + ) + + fun filter( + tokenParameter: TokenClientParam, + vararg init: TokenParamFilterCriterion.() -> Unit, + operation: Operation = Operation.OR + ) + + fun filter( + numberParameter: NumberClientParam, + vararg init: NumberParamFilterCriterion.() -> Unit, + operation: Operation = Operation.OR + ) + + fun filter( + uriParam: UriClientParam, + vararg init: UriParamFilterCriterion.() -> Unit, + operation: Operation = Operation.OR + ) + + fun sort(parameter: StringClientParam, order: Order) + + fun sort(parameter: NumberClientParam, order: Order) + + 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 c517a06fa7..9c4f071ffe 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 @@ -22,10 +22,12 @@ import ca.uhn.fhir.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.ConverterException import com.google.android.fhir.DateProvider +import com.google.android.fhir.SearchResult import com.google.android.fhir.UcumValue import com.google.android.fhir.UnitConverter import com.google.android.fhir.db.Database import com.google.android.fhir.epochDay +import com.google.android.fhir.logicalId import com.google.android.fhir.ucumUrl import java.math.BigDecimal import java.util.Date @@ -41,8 +43,43 @@ import org.hl7.fhir.r4.model.Resource */ private const val APPROXIMATION_COEFFICIENT = 0.1 -internal suspend fun Search.execute(database: Database): List { - return database.search(getQuery()) +internal suspend fun Search.execute(database: Database): List> { + val baseResources = database.search(getQuery()) + val includedResources = + if (forwardIncludes.isEmpty() || baseResources.isEmpty()) { + null + } else { + database.searchReferencedResources( + getIncludeQuery(includeIds = baseResources.map { it.logicalId }) + ) + } + val revIncludedResources = + if (revIncludes.isEmpty() || baseResources.isEmpty()) { + null + } else { + database.searchReferencedResources( + getRevIncludeQuery(includeIds = baseResources.map { "${it.resourceType}/${it.logicalId}" }) + ) + } + + return baseResources.map { baseResource -> + SearchResult( + baseResource, + included = + includedResources + ?.asSequence() + ?.filter { it.idOfBaseResourceOnWhichThisMatched == baseResource.logicalId } + ?.groupBy({ it.matchingIndex }, { it.resource }), + revIncluded = + revIncludedResources + ?.asSequence() + ?.filter { + it.idOfBaseResourceOnWhichThisMatched == + "${baseResource.fhirType()}/${baseResource.logicalId}" + } + ?.groupBy({ it.resource.resourceType to it.matchingIndex }, { it.resource }) + ) + } } internal suspend fun Search.count(database: Database): Long { @@ -53,6 +90,128 @@ fun Search.getQuery(isCount: Boolean = false): SearchQuery { return getQuery(isCount, null) } +private fun Search.getRevIncludeQuery(includeIds: List): SearchQuery { + var matchQuery = "" + val args = mutableListOf() + args.addAll(includeIds) + + // creating the match and filter query + revIncludes.forEachIndexed { index, (param, search) -> + val resourceToInclude = search.type + args.add(resourceToInclude.name) + args.add(param.paramName) + matchQuery += " ( a.resourceType = ? and a.index_name IN (?) " + + val allFilters = search.getFilterQueries() + + if (allFilters.isNotEmpty()) { + val iterator = allFilters.listIterator() + matchQuery += "AND b.resourceUuid IN (\n" + do { + iterator.next().let { + matchQuery += it.query + args.addAll(it.args) + } + + if (iterator.hasNext()) { + matchQuery += + if (search.operation == Operation.OR) { + "\n UNION \n" + } else { + "\n INTERSECT \n" + } + } + } while (iterator.hasNext()) + matchQuery += "\n)" + } + + matchQuery += " \n)" + + if (index != revIncludes.lastIndex) matchQuery += " OR " + } + + return SearchQuery( + query = + """ + SELECT a.index_name, a.index_value, b.serializedResource + FROM ReferenceIndexEntity a + JOIN ResourceEntity b + ON a.resourceUuid = b.resourceUuid + AND a.index_value IN( ${ CharArray(includeIds.size) { '?' }.joinToString()} ) + ${if (matchQuery.isEmpty()) "" else "AND ($matchQuery) " } + """.trimIndent(), + args = args + ) +} + +private fun Search.getIncludeQuery(includeIds: List): SearchQuery { + var matchQuery = "" + val args = mutableListOf(type.name) + args.addAll(includeIds) + + // creating the match and filter query + forwardIncludes.forEachIndexed { index, (param, search) -> + val resourceToInclude = search.type + args.add(resourceToInclude.name) + args.add(param.paramName) + matchQuery += " ( c.resourceType = ? and b.index_name IN (?) " + + val allFilters = search.getFilterQueries() + + if (allFilters.isNotEmpty()) { + val iterator = allFilters.listIterator() + matchQuery += "AND c.resourceUuid IN (\n" + do { + iterator.next().let { + matchQuery += it.query + args.addAll(it.args) + } + + if (iterator.hasNext()) { + matchQuery += + if (search.operation == Operation.OR) { + "\n UNION \n" + } else { + "\n INTERSECT \n" + } + } + } while (iterator.hasNext()) + matchQuery += "\n)" + } + + matchQuery += " \n)" + + if (index != forwardIncludes.lastIndex) matchQuery += " OR " + } + + return SearchQuery( + query = + // spotless:off + """ + SELECT b.index_name, a.resourceId, c.serializedResource from ResourceEntity a + JOIN ReferenceIndexEntity b + On a.resourceUuid = b.resourceUuid + AND a.resourceType = ? + AND a.resourceId IN ( ${ CharArray(includeIds.size) { '?' }.joinToString()} ) + JOIN ResourceEntity c + ON c.resourceType||"/"||c.resourceId = b.index_value + ${if (matchQuery.isEmpty()) "" else "AND ($matchQuery) " } + """.trimIndent(), + // spotless:on + args = args + ) +} + +private fun Search.getFilterQueries() = + (stringFilterCriteria + + quantityFilterCriteria + + numberFilterCriteria + + referenceFilterCriteria + + dateTimeFilterCriteria + + tokenFilterCriteria + + uriFilterCriteria) + .map { it.query(type) } + internal fun Search.getQuery( isCount: Boolean = false, nestedContext: NestedContext? = null @@ -78,11 +237,12 @@ internal fun Search.getQuery( val tableAlias = 'b' + index sortJoinStatement += - """ + // spotless:off + """ LEFT JOIN ${sortTableName.tableName} $tableAlias ON a.resourceType = $tableAlias.resourceType AND a.resourceUuid = $tableAlias.resourceUuid AND $tableAlias.index_name = ? """ - + // spotless:on sortArgs += sort.paramName } @@ -101,23 +261,17 @@ internal fun Search.getQuery( var filterStatement = "" val filterArgs = mutableListOf() - val filterQuery = - (stringFilterCriteria + - quantityFilterCriteria + - numberFilterCriteria + - referenceFilterCriteria + - dateTimeFilterCriteria + - tokenFilterCriteria + - uriFilterCriteria) - .map { it.query(type) } + val filterQuery = getFilterQueries() filterQuery.forEachIndexed { i, it -> filterStatement += + // spotless:off """ ${if (i == 0) "AND a.resourceUuid IN (" else "a.resourceUuid IN ("} ${it.query} ) ${if (i != filterQuery.lastIndex) "${operation.logicalOperator} " else ""} """.trimIndent() + // spotless:on filterArgs.addAll(it.args) } @@ -140,7 +294,8 @@ internal fun Search.getQuery( val query = when { isCount -> { - """ + // spotless:off + """ SELECT COUNT(*) FROM ResourceEntity a $sortJoinStatement @@ -149,11 +304,13 @@ internal fun Search.getQuery( $sortOrderStatement $limitStatement """ + // spotless:on } nestedContext != null -> { whereArgs.add(nestedContext.param.paramName) val start = "${nestedContext.parentType.name}/".length + 1 - """ + // spotless:off + """ SELECT resourceUuid FROM ResourceEntity a WHERE a.resourceId IN ( @@ -165,9 +322,11 @@ internal fun Search.getQuery( $sortOrderStatement $limitStatement) """ + // spotless:on } else -> - """ + // spotless:off + """ SELECT a.serializedResource FROM ResourceEntity a $sortJoinStatement @@ -176,6 +335,7 @@ internal fun Search.getQuery( $sortOrderStatement $limitStatement """ + // spotless:on } .split("\n") .filter { it.isNotBlank() } diff --git a/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt b/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt index fcf5bd55ce..461aaa6701 100644 --- a/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,7 @@ internal data class NestedContext(val parentType: ResourceType, val param: IPara */ inline fun Search.has( referenceParam: ReferenceClientParam, - init: Search.() -> Unit + init: @BaseSearchDsl BaseSearch.() -> Unit ) { nestedSearches.add( NestedSearch(referenceParam, Search(type = R::class.java.newInstance().resourceType)).apply { @@ -50,6 +50,126 @@ inline fun Search.has( ) } +/** + * Includes additional resources in the search results that reference that reference the resource on + * which [include] is being called. The developers may call [include] multiple times with different + * [ResourceType] to allow search api to return multiple referenced resource types. + * + * e.g. The below example would return all the Patients with given-name as James and their + * associated active [Practitioner] and Organizations. + * + * ``` + * fhirEngine.search { + * filter(Patient.GIVEN, { value = "James" }) + * include(Patient.GENERAL_PRACTITIONER) { + * filter(Practitioner.ACTIVE, { value = of(true) }) + * } + * include(Patient.ORGANIZATION) + * } + * ``` + * **NOTE**: [include] doesn't support order OR count. + */ +inline fun Search.include( + referenceParam: ReferenceClientParam, + init: @BaseSearchDsl BaseSearch.() -> Unit = {} +) { + forwardIncludes.add( + NestedSearch(referenceParam, Search(type = R::class.java.newInstance().resourceType)).apply { + search.init() + } + ) +} + +/** + * Includes additional resources in the search results that reference the resource on which + * [include] is being called. The developers may call [include] multiple times with different + * [ResourceType] to allow search api to return multiple referenced resource types. + * + * e.g. The below example would return all the Patients with given-name as James and their + * associated active [Practitioner] and Organizations. + * + * ``` + * fhirEngine.search { + * filter(Patient.GIVEN, { value = "James" }) + * include(ResourceType.Practitioner, Patient.GENERAL_PRACTITIONER) { + * filter(Practitioner.ACTIVE, { value = of(true) }) + * } + * include(ResourceType.Organization,Patient.ORGANIZATION) + * } + * ``` + * + * **NOTE**: [include] doesn't support order OR count. + */ +fun Search.include( + resourceType: ResourceType, + referenceParam: ReferenceClientParam, + init: @BaseSearchDsl BaseSearch.() -> Unit = {} +) { + forwardIncludes.add( + NestedSearch(referenceParam, Search(type = resourceType)).apply { search.init() } + ) +} + +/** + * Includes additional resources in the search results that reference the resource on which + * [revInclude] is being called. The developers may call [revInclude] multiple times with different + * [ResourceType] to allow search api to return multiple referenced resource types. + * + * e.g. The below example would return all the Patients with given-name as James and their + * associated Encounters and diabetic Conditions. + * + * ``` + * fhirEngine.search { + * filter(Patient.GIVEN, { value = "James" }) + * revInclude( Encounter.PATIENT) + * revInclude( Condition.PATIENT) { + * filter(Condition.CODE, { value = of(diabetesCodeableConcept) }) + * } + * } + * ``` + * + * **NOTE**: [revInclude] doesn't support order OR count. + */ +inline fun Search.revInclude( + referenceParam: ReferenceClientParam, + init: @BaseSearchDsl BaseSearch.() -> Unit = {} +) { + + revIncludes.add( + NestedSearch(referenceParam, Search(type = R::class.java.newInstance().resourceType)).apply { + search.init() + } + ) +} + +/** + * Includes additional resources in the search results that reference the resource on which + * [revInclude] is being called. The developers may call [revInclude] multiple times with different + * [ResourceType] to allow search api to return multiple referenced resource types. + * + * e.g. The below example would return all the Patients with given-name as James and their + * associated Encounters and Conditions. + * + * ``` + * fhirEngine.search { + * filter(Patient.GIVEN, { value = "James" }) + * revInclude(ResourceType.Encounter, Encounter.PATIENT) + * revInclude(ResourceType.Condition, Condition.PATIENT) { + * filter(Condition.CODE, { value = of(diabetesCodeableConcept) }) + * } + * } + * ``` + * + * **NOTE**: [revInclude] doesn't support order OR count. + */ +fun Search.revInclude( + resourceType: ResourceType, + referenceParam: ReferenceClientParam, + init: @BaseSearchDsl BaseSearch.() -> Unit = {} +) { + revIncludes.add(NestedSearch(referenceParam, Search(type = resourceType)).apply { search.init() }) +} + /** * Provide limited support for reverse chaining on [Search] (See * [this](https://www.hl7.org/fhir/search.html#has)). @@ -67,7 +187,7 @@ inline fun Search.has( fun Search.has( resourceType: ResourceType, referenceParam: ReferenceClientParam, - init: Search.() -> Unit + init: @BaseSearchDsl BaseSearch.() -> Unit ) { nestedSearches.add( NestedSearch(referenceParam, Search(type = resourceType)).apply { search.init() } diff --git a/engine/src/main/java/com/google/android/fhir/search/Search.kt b/engine/src/main/java/com/google/android/fhir/search/Search.kt index 26ab0dbd19..50143c5b6f 100644 --- a/engine/src/main/java/com/google/android/fhir/search/Search.kt +++ b/engine/src/main/java/com/google/android/fhir/search/Search.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2023 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,10 +17,14 @@ package com.google.android.fhir.search import com.google.android.fhir.FhirEngine +import com.google.android.fhir.SearchResult import com.google.android.fhir.search.query.XFhirQueryTranslator.translate import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType -suspend inline fun FhirEngine.search(init: Search.() -> Unit): List { +suspend inline fun FhirEngine.search( + init: Search.() -> Unit +): List> { val search = Search(type = R::class.java.newInstance().resourceType) search.init() return this.search(search) @@ -32,6 +36,15 @@ suspend inline fun FhirEngine.count(init: Search.() -> Un return this.count(search) } -suspend fun FhirEngine.search(xFhirQuery: String): List { +suspend fun FhirEngine.search(xFhirQuery: String): List> { return this.search(translate(xFhirQuery)) } + +suspend fun FhirEngine.search( + resourceType: ResourceType, + init: Search.() -> Unit +): List> { + val search = Search(type = resourceType) + search.init() + return this.search(search) +} diff --git a/engine/src/main/java/com/google/android/fhir/search/SearchDsl.kt b/engine/src/main/java/com/google/android/fhir/search/SearchDsl.kt index 9bf11882d2..8b83963332 100644 --- a/engine/src/main/java/com/google/android/fhir/search/SearchDsl.kt +++ b/engine/src/main/java/com/google/android/fhir/search/SearchDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * Copyright 2022-2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,12 +38,14 @@ import com.google.android.fhir.search.filter.TokenParamFilterCriteria import com.google.android.fhir.search.filter.TokenParamFilterCriterion import com.google.android.fhir.search.filter.UriFilterCriteria import com.google.android.fhir.search.filter.UriParamFilterCriterion -import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ResourceType @SearchDslMarker -data class Search(val type: ResourceType, var count: Int? = null, var from: Int? = null) { - internal val p = Patient() +class Search( + val type: ResourceType, + override var count: Int? = null, + override var from: Int? = null +) : BaseSearch { internal val stringFilterCriteria = mutableListOf() internal val dateTimeFilterCriteria = mutableListOf() internal val numberFilterCriteria = mutableListOf() @@ -54,22 +56,25 @@ data class Search(val type: ResourceType, var count: Int? = null, var from: Int? internal var sort: IParam? = null internal var order: Order? = null @PublishedApi internal var nestedSearches = mutableListOf() - var operation = Operation.AND + @PublishedApi internal var revIncludes = mutableListOf() + @PublishedApi internal var forwardIncludes = mutableListOf() - fun filter( + override var operation = Operation.AND + + override fun filter( stringParameter: StringClientParam, vararg init: StringParamFilterCriterion.() -> Unit, - operation: Operation = Operation.OR + operation: Operation ) { val filters = mutableListOf() init.forEach { StringParamFilterCriterion(stringParameter).apply(it).also(filters::add) } stringFilterCriteria.add(StringParamFilterCriteria(stringParameter, filters, operation)) } - fun filter( + override fun filter( referenceParameter: ReferenceClientParam, vararg init: ReferenceParamFilterCriterion.() -> Unit, - operation: Operation = Operation.OR + operation: Operation ) { val filters = mutableListOf() init.forEach { ReferenceParamFilterCriterion(referenceParameter).apply(it).also(filters::add) } @@ -78,67 +83,67 @@ data class Search(val type: ResourceType, var count: Int? = null, var from: Int? ) } - fun filter( + override fun filter( dateParameter: DateClientParam, vararg init: DateParamFilterCriterion.() -> Unit, - operation: Operation = Operation.OR + operation: Operation ) { val filters = mutableListOf() init.forEach { DateParamFilterCriterion(dateParameter).apply(it).also(filters::add) } dateTimeFilterCriteria.add(DateClientParamFilterCriteria(dateParameter, filters, operation)) } - fun filter( + override fun filter( quantityParameter: QuantityClientParam, vararg init: QuantityParamFilterCriterion.() -> Unit, - operation: Operation = Operation.OR + operation: Operation ) { val filters = mutableListOf() init.forEach { QuantityParamFilterCriterion(quantityParameter).apply(it).also(filters::add) } quantityFilterCriteria.add(QuantityParamFilterCriteria(quantityParameter, filters, operation)) } - fun filter( + override fun filter( tokenParameter: TokenClientParam, vararg init: TokenParamFilterCriterion.() -> Unit, - operation: Operation = Operation.OR + operation: Operation ) { val filters = mutableListOf() init.forEach { TokenParamFilterCriterion(tokenParameter).apply(it).also(filters::add) } tokenFilterCriteria.add(TokenParamFilterCriteria(tokenParameter, filters, operation)) } - fun filter( + override fun filter( numberParameter: NumberClientParam, vararg init: NumberParamFilterCriterion.() -> Unit, - operation: Operation = Operation.OR + operation: Operation ) { val filters = mutableListOf() init.forEach { NumberParamFilterCriterion(numberParameter).apply(it).also(filters::add) } numberFilterCriteria.add(NumberParamFilterCriteria(numberParameter, filters, operation)) } - fun filter( + override fun filter( uriParam: UriClientParam, vararg init: UriParamFilterCriterion.() -> Unit, - operation: Operation = Operation.OR + operation: Operation ) { val filters = mutableListOf() init.forEach { UriParamFilterCriterion(uriParam).apply(it).also(filters::add) } uriFilterCriteria.add(UriFilterCriteria(uriParam, filters, operation)) } - fun sort(parameter: StringClientParam, order: Order) { + override fun sort(parameter: StringClientParam, order: Order) { sort = parameter this.order = order } - fun sort(parameter: NumberClientParam, order: Order) { + override fun sort(parameter: NumberClientParam, order: Order) { sort = parameter this.order = order } - fun sort(parameter: DateClientParam, order: Order) { + override fun sort(parameter: DateClientParam, order: Order) { sort = parameter this.order = order } diff --git a/engine/src/main/java/com/google/android/fhir/search/SearchDslMarker.kt b/engine/src/main/java/com/google/android/fhir/search/SearchDslMarker.kt index 114f4f1ccf..67dafed18b 100644 --- a/engine/src/main/java/com/google/android/fhir/search/SearchDslMarker.kt +++ b/engine/src/main/java/com/google/android/fhir/search/SearchDslMarker.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * Copyright 2022-2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,4 +16,6 @@ package com.google.android.fhir.search -@DslMarker internal annotation class SearchDslMarker +@DslMarker +@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) +internal annotation class SearchDslMarker diff --git a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt index 5b5fbc75ea..59e035d41f 100644 --- a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt +++ b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt @@ -22,6 +22,7 @@ import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.FhirEngine import com.google.android.fhir.LocalChange +import com.google.android.fhir.SearchResult import com.google.android.fhir.db.impl.dao.LocalChangeToken import com.google.android.fhir.search.Search import com.google.android.fhir.sync.BundleDownloadRequest @@ -141,7 +142,7 @@ object TestFhirEngineImpl : FhirEngine { override suspend fun delete(type: ResourceType, id: String) {} - override suspend fun search(search: Search): List { + override suspend fun search(search: Search): List> { return emptyList() } diff --git a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt index bae224b5ff..fd050bbcfc 100644 --- a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt @@ -183,7 +183,9 @@ class FhirEngineImplTest { val result = fhirEngine.search("Patient?gender=female") assertThat(result.size).isEqualTo(2) - assertThat(result.all { (it as Patient).gender == Enumerations.AdministrativeGender.FEMALE }) + assertThat( + result.all { (it.resource as Patient).gender == Enumerations.AdministrativeGender.FEMALE } + ) .isTrue() } @@ -198,7 +200,7 @@ class FhirEngineImplTest { fhirEngine.create(*patients.toTypedArray()) - val result = fhirEngine.search("Patient?_sort=-name").map { it as Patient } + val result = fhirEngine.search("Patient?_sort=-name").map { it.resource as Patient } assertThat(result.mapNotNull { it.nameFirstRep.given.firstOrNull()?.value }) .isEqualTo(listOf("C", "B", "A")) @@ -215,7 +217,7 @@ class FhirEngineImplTest { fhirEngine.create(*patients.toTypedArray()) - val result = fhirEngine.search("Patient?_count=1").map { it as Patient } + val result = fhirEngine.search("Patient?_count=1").map { it.resource as Patient } assertThat(result.size).isEqualTo(1) } @@ -261,7 +263,7 @@ class FhirEngineImplTest { fhirEngine.create(*patients.toTypedArray()) - val result = fhirEngine.search("Patient?_tag=Tag1").map { it as Patient } + val result = fhirEngine.search("Patient?_tag=Tag1").map { it.resource as Patient } assertThat(result.size).isEqualTo(1) assertThat(result.all { patient -> patient.meta.tag.all { it.code == "Tag1" } }).isTrue() @@ -295,7 +297,7 @@ class FhirEngineImplTest { .search( "Patient?_profile=http://fhir.org/STU3/StructureDefinition/Example-Patient-Profile-1" ) - .map { it as Patient } + .map { it.resource as Patient } assertThat(result.size).isEqualTo(1) assertThat( @@ -578,7 +580,7 @@ class FhirEngineImplTest { } assertThat(result).isNotEmpty() - assertThat(result.map { it.logicalId }).containsExactly("patient-id-create").inOrder() + assertThat(result.map { it.resource.logicalId }).containsExactly("patient-id-create").inOrder() } @Test @@ -616,7 +618,9 @@ class FhirEngineImplTest { assertThat(DateTimeType(Date.from(localChangeTimestampWhenUpdated)).value) .isAtLeast(DateTimeType(Date.from(localChangeTimestampWhenCreated)).value) assertThat(result).isNotEmpty() - assertThat(result.map { it.logicalId }).containsExactly("patient-id-update").inOrder() + assertThat(result.map { it.resource.logicalId }) + .containsExactly("patient-id-update") + .inOrder() } companion object {