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 @@ -32,15 +32,22 @@ 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.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 +1742,122 @@ class DatabaseImplTest {
assertThat(result.filter { it.id == patient.id }).hasSize(1)
}

@Test
fun search_patient_has_diabetes() = runBlocking {
database.insert(TEST_PATIENT_1)
database.insert(TEST_PATIENT_2)
val condition =
Condition().apply {
subject = Reference("Patient/$TEST_PATIENT_1_ID")
code = CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes"))
}
database.insert(condition)
val result =
database.search<Patient>(
Search(ResourceType.Patient)
.apply {
has<Condition>(Condition.SUBJECT) {
filter(Condition.CODE, Coding("http://snomed.info/sct", "44054006", "Diabetes"))
}
}
.getQuery()
)

assertThat(result).hasSize(1)
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
}

@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).hasSize(1)
}

@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).hasSize(1)

assertThat(result[0].logicalId).isEqualTo("100")
}

private companion object {
const val TEST_PATIENT_1_ID = "test_patient_1"
val TEST_PATIENT_1 = Patient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.google.android.fhir.search

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
Expand All @@ -40,9 +41,17 @@ internal suspend fun Search.count(database: Database): Long {
}

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

private fun Search.getQuery(
isCount: Boolean = false,
nestedContext: NestedContext? = null
): SearchQuery {
var sortJoinStatement = ""
var sortOrderStatement = ""
val sortArgs = mutableListOf<Any>()
val outerTableAlias = if (nestedContext == null) "a" else "c"
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
sort?.let { sort ->
val sortTableName =
when (sort) {
Expand All @@ -53,7 +62,7 @@ fun Search.getQuery(isCount: Boolean = false): SearchQuery {
sortJoinStatement =
"""
LEFT JOIN $sortTableName b
ON a.resourceType = b.resourceType AND a.resourceId = b.resourceId AND b.index_name = ?
ON $outerTableAlias.resourceType = b.resourceType AND $outerTableAlias.resourceId = b.resourceId AND b.index_name = ?
""".trimIndent()
sortOrderStatement = """
ORDER BY b.index_value ${order.sqlString}
Expand All @@ -75,7 +84,7 @@ fun Search.getQuery(isCount: Boolean = false): SearchQuery {
if (filterQuery != null) {
filterStatement =
"""
AND a.resourceId IN (
AND $outerTableAlias.resourceId IN (
${filterQuery.query}
)
""".trimIndent()
Expand All @@ -93,20 +102,62 @@ fun Search.getQuery(isCount: Boolean = false): SearchQuery {
}
}

val nestedArgs = mutableListOf<Any>()
nestedSearch?.let {
val nestedQuery = it.search.getQuery(nestedContext = NestedContext(type, it.param))
filterStatement +=
"""
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
${(if (filterStatement.isEmpty()) "" else "\n")}
AND $outerTableAlias.resourceId IN (
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
${nestedQuery.query}
)
""".trimIndent()
nestedArgs.addAll(nestedQuery.args)
}

val select =
"SELECT " +
when {
isCount -> "COUNT(*)"
nestedContext != null -> {
val start = "${nestedContext.parentType.name}/".length + 1
"substr($outerTableAlias.index_value, $start) "
}
else -> "a.serializedResource"
}

val from =
"FROM " +
when {
nestedContext != null -> "ReferenceIndexEntity $outerTableAlias"
else -> "ResourceEntity $outerTableAlias"
}

val whereArgs = mutableListOf<Any>()
val where =
"WHERE $outerTableAlias.resourceType = ? " +
when {
nestedContext != null -> {
whereArgs.add(nestedContext.param.paramName)
"AND $outerTableAlias.index_name = ?"
}
else -> ""
}
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved

val query =
"""
SELECT ${ if (isCount) "COUNT(*)" else "a.serializedResource" }
FROM ResourceEntity a
$select
$from
$sortJoinStatement
WHERE a.resourceType = ?
$where
$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 + nestedArgs + limitArgs)
}

fun StringFilter.query(type: ResourceType): SearchQuery {
Expand Down Expand Up @@ -425,3 +476,6 @@ private val DateTimeType.rangeEpochMillis
private data class ConditionParam<T>(val condition: String, val params: List<T>) {
constructor(condition: String, vararg params: T) : this(condition, params.asList())
}

/** Keeps the parent context for a nested query loop. */
private data class NestedContext(val parentType: ResourceType, val param: IParam)
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import org.hl7.fhir.r4.model.ContactPoint
import org.hl7.fhir.r4.model.DateTimeType
import org.hl7.fhir.r4.model.DateType
import org.hl7.fhir.r4.model.Identifier
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
import org.hl7.fhir.r4.model.UriType

Expand All @@ -46,6 +47,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 nestedSearch: NestedQuery? = null
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved

fun filter(stringParameter: StringClientParam, init: StringFilter.() -> Unit) {
val filter = StringFilter(stringParameter)
Expand Down Expand Up @@ -126,6 +128,28 @@ data class Search(val type: ResourceType, var count: Int? = null, var from: Int?
sort = parameter
this.order = order
}

/**
* Provides limited support for the reverse chaining on [Search]
* [https://www.hl7.org/fhir/search.html#has] Typical example would be `Search all Patient that
* have Condition - Diabetes` Condition.subject is set to a reference to the Patient when
* Condition object is created. So, the search code will look like:
* ```
* FhirEngine.search<Patient> {
* has<Condition>(Condition.SUBJECT) {
* filter(Condition.CODE, Coding("http://snomed.info/sct", "44054006", "Diabetes"))
* }
* }
* ```
*/
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
inline fun <reified R : Resource> has(
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
referenceParam: ReferenceClientParam,
init: Search.() -> Unit
) {
nestedSearch =
NestedQuery(referenceParam, Search(type = R::class.java.newInstance().resourceType))
nestedSearch!!.search.init()
}
}

@SearchDslMarker
Expand Down Expand Up @@ -181,3 +205,5 @@ enum class StringFilterModifier {
MATCHES_EXACTLY,
CONTAINS
}

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