Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support to search using multiple resource types #697

Merged
merged 13 commits into from
Sep 2, 2021
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,28 @@ 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.getQuery
import com.google.android.fhir.search.has
import com.google.android.fhir.sync.DataSource
import com.google.common.truth.Truth.assertThat
import java.math.BigDecimal
import kotlinx.coroutines.runBlocking
import org.hl7.fhir.r4.model.Address
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.CarePlan
import org.hl7.fhir.r4.model.CodeableConcept
import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.Condition
import org.hl7.fhir.r4.model.DateTimeType
import org.hl7.fhir.r4.model.DecimalType
import org.hl7.fhir.r4.model.Enumerations
import org.hl7.fhir.r4.model.HumanName
import org.hl7.fhir.r4.model.Immunization
import org.hl7.fhir.r4.model.Observation
import org.hl7.fhir.r4.model.OperationOutcome
import org.hl7.fhir.r4.model.Patient
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
Expand Down Expand Up @@ -1735,6 +1744,196 @@ class DatabaseImplTest {
assertThat(result.filter { it.id == patient.id }).hasSize(1)
}

@Test
fun search_patient_has_taken_influenza_vaccine_in_India() = runBlocking {
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
val patient =
Patient().apply {
gender = Enumerations.AdministrativeGender.MALE
id = "100"
addAddress(Address().apply { country = "IN" })
}
val immunization =
Immunization().apply {
this.patient = Reference("Patient/${patient.logicalId}")
vaccineCode =
CodeableConcept(
Coding(
"http://hl7.org/fhir/sid/cvx",
"140",
"Influenza, seasonal, injectable, preservative free"
)
)
status = Immunization.ImmunizationStatus.COMPLETED
}
database.insert(patient, TEST_PATIENT_1, immunization)
val result =
database.search<Patient>(
Search(ResourceType.Patient)
.apply {
has<Immunization>(Immunization.PATIENT) {
filter(
Immunization.VACCINE_CODE,
Coding(
"http://hl7.org/fhir/sid/cvx",
"140",
"Influenza, seasonal, injectable, preservative free"
)
)

// Follow Immunization.ImmunizationStatus
filter(
Immunization.STATUS,
Coding("http://hl7.org/fhir/event-status", "completed", "Body Weight")
)
}

filter(Patient.ADDRESS_COUNTRY) {
modifier = StringFilterModifier.MATCHES_EXACTLY
value = "IN"
}
}
.getQuery()
)
assertThat(result.map { it.logicalId }).containsExactly("100").inOrder()
}

@Test
fun search_patient_return_single_patient_who_has_diabetic_careplan() = runBlocking {
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
val patient =
Patient().apply {
gender = Enumerations.AdministrativeGender.MALE
id = "100"
}
// This careplan has 2 patient references. One as subject and other as a performer.
// The search should only find the subject Patient.
val carePlan =
CarePlan().apply {
subject = Reference("Patient/${patient.logicalId}")
activityFirstRep.detail.performer.add(Reference("Patient/${TEST_PATIENT_1.logicalId}"))
category =
listOf(
CodeableConcept(
Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan")
)
)
}
database.insert(patient, TEST_PATIENT_1, carePlan)
val result =
database.search<Patient>(
Search(ResourceType.Patient)
.apply {
has<CarePlan>(CarePlan.SUBJECT) {
filter(
CarePlan.CATEGORY,
Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan")
)
}
}
.getQuery()
)
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 = mutableListOf<Resource>()
Practitioner().apply { id = "practitioner-001" }.also { resources.add(it) }
Practitioner().apply { id = "practitioner-002" }.also { resources.add(it) }
Patient()
.apply {
gender = Enumerations.AdministrativeGender.MALE
id = "patient-001"
this.addGeneralPractitioner(Reference("Practitioner/practitioner-001"))
this.addGeneralPractitioner(Reference("Practitioner/practitioner-002"))
}
.also { resources.add(it) }
Condition()
.apply {
subject = Reference("Patient/patient-001")
id = "condition-001"
code = diabetesCodeableConcept
}
.also { resources.add(it) }
Condition()
.apply {
subject = Reference("Patient/patient-001")
id = "condition-002"
code = hyperTensionCodeableConcept
}
.also { resources.add(it) }

Patient()
.apply {
gender = Enumerations.AdministrativeGender.MALE
id = "patient-002"
}
.also { resources.add(it) }
Condition()
.apply {
subject = Reference("Patient/patient-002")
id = "condition-003"
code = hyperTensionCodeableConcept
}
.also { resources.add(it) }
Condition()
.apply {
subject = Reference("Patient/patient-002")
id = "condition-004"
code = diabetesCodeableConcept
}
.also { resources.add(it) }

Practitioner().apply { id = "practitioner-003" }.also { resources.add(it) }
Patient()
.apply {
gender = Enumerations.AdministrativeGender.MALE
id = "patient-003"
this.addGeneralPractitioner(Reference("Practitioner/practitioner-00"))
}
.also { resources.add(it) }
Condition()
.apply {
subject = Reference("Patient/patient-003")
id = "condition-005"
code = diabetesCodeableConcept
}
.also { resources.add(it) }
database.insert(*resources.toTypedArray())

val result =
database.search<Practitioner>(
Search(ResourceType.Practitioner)
.apply {
has<Patient>(Patient.GENERAL_PRACTITIONER) {
has<Condition>(Condition.SUBJECT) {
filter(Condition.CODE, Coding("http://snomed.info/sct", "44054006", "Diabetes"))
}
}
has<Patient>(Patient.GENERAL_PRACTITIONER) {
has<Condition>(Condition.SUBJECT) {
filter(
Condition.CODE,
Coding("http://snomed.info/sct", "827069000", "Hypertension stage 1")
)
}
}
}
.getQuery()
)

assertThat(result.map { it.logicalId })
.containsExactly("practitioner-001", "practitioner-002")
.inOrder()
}

private companion object {
const val TEST_PATIENT_1_ID = "test_patient_1"
val TEST_PATIENT_1 = Patient()
Expand Down
56 changes: 46 additions & 10 deletions engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ internal suspend fun Search.count(database: Database): Long {
}

fun Search.getQuery(isCount: Boolean = false): SearchQuery {
return getQuery(isCount, null)
}

internal fun Search.getQuery(
isCount: Boolean = false,
nestedContext: NestedContext? = null
): SearchQuery {
var sortJoinStatement = ""
var sortOrderStatement = ""
val sortArgs = mutableListOf<Any>()
Expand Down Expand Up @@ -93,20 +100,49 @@ fun Search.getQuery(isCount: Boolean = false): SearchQuery {
}
}

filterStatement += nestedSearches.nestedQuery(filterStatement, filterArgs, type)
val whereArgs = mutableListOf<Any>()
val query =
"""
SELECT ${ if (isCount) "COUNT(*)" else "a.serializedResource" }
FROM ResourceEntity a
$sortJoinStatement
WHERE a.resourceType = ?
$filterStatement
$sortOrderStatement
$limitStatement
"""
when {
isCount -> {
"""
SELECT COUNT(*)
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
FROM ResourceEntity a
$sortJoinStatement
WHERE a.resourceType = ?
$filterStatement
$sortOrderStatement
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please refactor so that count queries don't include sort order statement and limit statement? throw exception and add a test case as well please.

happy for you to do that in a follow-up immediately after this pr.

$limitStatement
"""
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
}
nestedContext != null -> {
whereArgs.add(nestedContext.param.paramName)
val start = "${nestedContext.parentType.name}/".length + 1
"""
SELECT substr(a.index_value, $start)
FROM ReferenceIndexEntity a
$sortJoinStatement
WHERE a.resourceType = ? AND a.index_name = ?
$filterStatement
$sortOrderStatement
$limitStatement
"""
}
else ->
"""
SELECT a.serializedResource
FROM ResourceEntity a
$sortJoinStatement
WHERE a.resourceType = ?
$filterStatement
$sortOrderStatement
$limitStatement
"""
}
.split("\n")
.filter { it.isNotBlank() }
.joinToString("\n") { it.trim() }
return SearchQuery(query, sortArgs + type.name + filterArgs + limitArgs)
return SearchQuery(query, sortArgs + type.name + whereArgs + filterArgs + limitArgs)
}

fun StringFilter.query(type: ResourceType): SearchQuery {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright 2020 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.IParam
import ca.uhn.fhir.rest.gclient.ReferenceClientParam
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType

/** Lets users perform a nested search using [Search.has] api. */
@PublishedApi internal data class NestedSearch(val param: ReferenceClientParam, val search: Search)

/** Keeps the parent context for a nested query loop. */
internal data class NestedContext(val parentType: ResourceType, val param: IParam)

/**
* Provides limited support for the reverse chaining on [Search]
* [https://www.hl7.org/fhir/search.html#has]. For example: search all Patient that have Condition -
* Diabetes. This search uses the subject field in the Condition resource. Code snippet:
* ```
* FhirEngine.search<Patient> {
* has<Condition>(Condition.SUBJECT) {
* filter(Condition.CODE, Coding("http://snomed.info/sct", "44054006", "Diabetes"))
* }
* }
* ```
*/
inline fun <reified R : Resource> Search.has(
referenceParam: ReferenceClientParam,
init: Search.() -> Unit
) {
nestedSearches.add(
NestedSearch(referenceParam, Search(type = R::class.java.newInstance().resourceType)).apply {
search.init()
}
)
}

/**
* Generates the complete nested query going to several depths depending on the [Search] dsl
* specified by the user .
*/
internal fun List<NestedSearch>.nestedQuery(
filterStatement: String,
filterArgs: MutableList<Any>,
type: ResourceType
): String {
return this.map { it.nestedQuery(type) }.intersect()?.let {
filterArgs.addAll(it.args)
"""
${(if (filterStatement.isEmpty()) "" else "\n")}
AND a.resourceId IN (
${it.query}
)
""".trimIndent()
}
?: ""
}

private fun NestedSearch.nestedQuery(type: ResourceType): SearchQuery {
return search.getQuery(nestedContext = NestedContext(type, param))
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ data class Search(val type: ResourceType, var count: Int? = null, var from: Int?
internal val quantityFilters = mutableListOf<QuantityFilter>()
internal var sort: IParam? = null
internal var order: Order? = null
@PublishedApi internal var nestedSearches = mutableListOf<NestedSearch>()

fun filter(stringParameter: StringClientParam, init: StringFilter.() -> Unit) {
val filter = StringFilter(stringParameter)
Expand Down Expand Up @@ -181,3 +182,5 @@ enum class StringFilterModifier {
MATCHES_EXACTLY,
CONTAINS
}

@PublishedApi internal data class NestedQuery(val param: ReferenceClientParam, val search: Search)
Loading