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 e8e7876358..b8e6c5f021 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -2530,6 +2530,92 @@ class DatabaseImplTest { assertThat(result.map { it.resource.logicalId }).containsExactly("100").inOrder() } + @Test + fun search_patient_with_given_disjoint_and_has_diabetes() { + runBlocking { + val jane = + Patient().apply { + id = "jane-001" + addName( + HumanName().apply { + addGiven("Jane") + family = "Doe" + }, + ) + } + val john = + Patient().apply { + id = "john-001" + addName( + HumanName().apply { + addGiven("John") + family = "Doe" + }, + ) + } + val jade = + Patient().apply { + id = "jade-001" + addName( + HumanName().apply { + addGiven("Jade") + family = "Doe" + }, + ) + } + + val diabetes1 = + Condition().apply { + subject = Reference("Patient/${jane.logicalId}") + code = CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")) + } + val diabetes2 = + Condition().apply { + subject = Reference("Patient/${john.logicalId}") + code = CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")) + } + val diabetes3 = + Condition().apply { + subject = Reference("Patient/${jade.logicalId}") + code = CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")) + } + database.insert(jane, jade, john, diabetes1, diabetes2, diabetes3) + + val result = + database.search( + Search(ResourceType.Patient) + .apply { + has(Condition.SUBJECT) { + filter( + Condition.CODE, + { value = of(Coding("http://snomed.info/sct", "44054006", "Diabetes")) }, + ) + } + + filter( + Patient.GIVEN, + { + value = "John" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + + filter( + Patient.GIVEN, + { + value = "Jane" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + operation = Operation.OR + } + .getQuery(), + ) + + assertThat(result.map { it.resource.logicalId }).containsExactly("jane-001", "john-001") + } + } + @Test fun search_patient_return_single_patient_who_has_diabetic_careplan() = runBlocking { val patient = @@ -2723,78 +2809,79 @@ class DatabaseImplTest { } @Test - fun test_search_multiple_param_disjunction_covid_immunization_records() = runBlocking { - val resources = - listOf( - Immunization().apply { - id = "immunization-1" - vaccineCode = - CodeableConcept( - Coding( - "http://id.who.int/icd11/mms", - "XM1NL1", - "COVID-19 vaccine, inactivated virus", - ), - ) - status = Immunization.ImmunizationStatus.COMPLETED - }, - Immunization().apply { - id = "immunization-2" - vaccineCode = - CodeableConcept( - Coding( - "http://id.who.int/icd11/mms", - "XM5DF6", - "COVID-19 vaccine, live attenuated virus", - ), - ) - status = Immunization.ImmunizationStatus.COMPLETED - }, - Immunization().apply { - id = "immunization-3" - vaccineCode = - CodeableConcept( - Coding("http://id.who.int/icd11/mms", "XM6AT1", "COVID-19 vaccine, DNA based"), - ) - status = Immunization.ImmunizationStatus.COMPLETED - }, - Immunization().apply { - id = "immunization-4" - vaccineCode = - CodeableConcept( - Coding( - "http://hl7.org/fhir/sid/cvx", - "140", - "Influenza, seasonal, injectable, preservative free", - ), - ) - status = Immunization.ImmunizationStatus.COMPLETED - }, - ) + fun test_search_multiple_param_disjunction_covid_immunization_records() { + runBlocking { + val resources = + listOf( + Immunization().apply { + id = "immunization-1" + vaccineCode = + CodeableConcept( + Coding( + "http://id.who.int/icd11/mms", + "XM1NL1", + "COVID-19 vaccine, inactivated virus", + ), + ) + status = Immunization.ImmunizationStatus.COMPLETED + }, + Immunization().apply { + id = "immunization-2" + vaccineCode = + CodeableConcept( + Coding( + "http://id.who.int/icd11/mms", + "XM5DF6", + "COVID-19 vaccine, live attenuated virus", + ), + ) + status = Immunization.ImmunizationStatus.COMPLETED + }, + Immunization().apply { + id = "immunization-3" + vaccineCode = + CodeableConcept( + Coding("http://id.who.int/icd11/mms", "XM6AT1", "COVID-19 vaccine, DNA based"), + ) + status = Immunization.ImmunizationStatus.COMPLETED + }, + Immunization().apply { + id = "immunization-4" + vaccineCode = + CodeableConcept( + Coding( + "http://hl7.org/fhir/sid/cvx", + "140", + "Influenza, seasonal, injectable, preservative free", + ), + ) + status = Immunization.ImmunizationStatus.COMPLETED + }, + ) - database.insert(*resources.toTypedArray()) + database.insert(*resources.toTypedArray()) - val result = - database.search( - Search(ResourceType.Immunization) - .apply { - filter( - Immunization.VACCINE_CODE, - { value = of(Coding("http://id.who.int/icd11/mms", "XM1NL1", "")) }, - ) + val result = + database.search( + Search(ResourceType.Immunization) + .apply { + filter( + Immunization.VACCINE_CODE, + { value = of(Coding("http://id.who.int/icd11/mms", "XM1NL1", "")) }, + ) - filter( - Immunization.VACCINE_CODE, - { value = of(Coding("http://id.who.int/icd11/mms", "XM5DF6", "")) }, - ) - operation = Operation.OR - } - .getQuery(), - ) + filter( + Immunization.VACCINE_CODE, + { value = of(Coding("http://id.who.int/icd11/mms", "XM5DF6", "")) }, + ) + operation = Operation.OR + } + .getQuery(), + ) - assertThat(result.map { it.resource.vaccineCode.codingFirstRep.code }) - .containsExactly("XM1NL1", "XM5DF6") - .inOrder() + assertThat(result.map { it.resource.vaccineCode.codingFirstRep.code }) + .containsExactly("XM1NL1", "XM5DF6") + } } @Test 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 daa577b68f..39d3c3af96 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -364,17 +364,13 @@ internal fun Search.getQuery( val sortArgs = join.args val filterQuery = getFilterQueries() - val filterQueryStatement = - filterQuery.joinToString(separator = "${operation.logicalOperator} ") { - // spotless:off - """ - a.resourceUuid IN ( - ${it.query} - ) - - """.trimIndent() - // spotless:on + val filterQueryJoinOperator = + when (operation) { + Operation.OR -> "\nUNION\n" + Operation.AND -> "\nINTERSECT\n" } + val filterQueryStatement = + filterQuery.joinToString(separator = filterQueryJoinOperator) { it.query.trimIndent() } val filterQueryArgs = filterQuery.flatMap { it.args } var limitStatement = "" @@ -398,8 +394,14 @@ internal fun Search.getQuery( val filterStatement = listOf(filterQueryStatement, nestedQueryFilterStatement) .filter { it.isNotBlank() } - .joinToString(separator = " AND ") - .ifBlank { "a.resourceType = ?" } + .joinToString(separator = "\nINTERSECT\n") + .takeIf { it.isNotBlank() } + ?.let { """a.resourceUuid IN ( + $it + ) + """ } + ?: "a.resourceType = ?" + val filterArgs = (filterQueryArgs + nestedQueryFilterArgs).ifEmpty { listOf(type.name) } val whereArgs = mutableListOf() 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 1f39a00c32..3f929591f3 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-2024 Google LLC + * Copyright 2022-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -214,15 +214,20 @@ internal fun List.nestedQuery( return if (isEmpty()) { null } else { + val filterJoinOperator = + when (operation) { + Operation.OR -> "\nUNION\n" + Operation.AND -> "\nINTERSECT\n" + } + map { it.nestedQuery(type) } .let { searchQueries -> SearchQuery( query = searchQueries.joinToString( - prefix = "a.resourceUuid IN ", - separator = " ${operation.logicalOperator} a.resourceUuid IN", + separator = " $filterJoinOperator", ) { searchQuery -> - "(\n${searchQuery.query}\n) " + "\n${searchQuery.query}\n" }, args = searchQueries.flatMap { it.args }, ) 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 2ddb427a55..7d989aea80 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * Copyright 2022-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1832,8 +1832,7 @@ class SearchTest { WHERE a.resourceUuid IN ( SELECT resourceUuid FROM StringIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value = ? - ) - AND a.resourceUuid IN ( + INTERSECT SELECT resourceUuid FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceId IN ( @@ -1842,8 +1841,7 @@ class SearchTest { WHERE a.index_name = ? AND a.resourceUuid IN ( SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) - ) - AND a.resourceUuid IN ( + INTERSECT SELECT resourceUuid FROM TokenIndexEntity WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) ) @@ -1873,6 +1871,95 @@ class SearchTest { ) } + @Test + fun search_patient_multiple_given_disjoint_has_condition_diabetes_or_hypertension() { + val query = + Search(ResourceType.Patient) + .apply { + has(Condition.SUBJECT) { + filter( + Condition.CODE, + { value = of(Coding("http://snomed.info/sct", "44054006", "Diabetes")) }, + ) + filter( + Condition.CODE, + { value = of(Coding("http://snomed.info/sct", "827069000", "Hypertension stage 1")) }, + ) + operation = Operation.OR + } + + filter( + Patient.GIVEN, + { + value = "John" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + + filter( + Patient.GIVEN, + { + value = "Jane" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + operation = Operation.OR + } + .getQuery() + + assertThat(query.query) + .isEqualTo( + """ + SELECT a.resourceUuid, a.serializedResource + FROM ResourceEntity a + WHERE a.resourceUuid IN ( + SELECT resourceUuid FROM StringIndexEntity + WHERE resourceType = ? AND index_name = ? AND index_value = ? + UNION + SELECT resourceUuid FROM StringIndexEntity + WHERE resourceType = ? AND index_name = ? AND index_value = ? + INTERSECT + SELECT resourceUuid + FROM ResourceEntity a + WHERE a.resourceType = ? AND a.resourceId IN ( + SELECT substr(a.index_value, 9) + FROM ReferenceIndexEntity a + WHERE a.index_name = ? AND a.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) + UNION + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) + ) + ) + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .isEqualTo( + listOf( + "Patient", + "given", + "John", + "Patient", + "given", + "Jane", + "Patient", + "subject", + "Condition", + "code", + "44054006", + "http://snomed.info/sct", + "Condition", + "code", + "827069000", + "http://snomed.info/sct", + ), + ) + } + @Test fun search_has_patient_has_condition_diabetes_and_hypertension() { val query = @@ -1909,7 +1996,7 @@ class SearchTest { WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) ) ) - ) AND a.resourceUuid IN( + INTERSECT SELECT resourceUuid FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceId IN ( @@ -2058,8 +2145,7 @@ class SearchTest { WHERE a.resourceUuid IN ( SELECT resourceUuid FROM StringIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value = ? - ) - OR a.resourceUuid IN ( + UNION SELECT resourceUuid FROM StringIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value = ? ) @@ -2089,8 +2175,7 @@ class SearchTest { WHERE a.resourceUuid IN ( SELECT resourceUuid FROM StringIndexEntity WHERE resourceType = ? AND index_name = ? AND index_value LIKE ? || '%' COLLATE NOCASE - ) - AND a.resourceUuid IN ( + INTERSECT SELECT resourceUuid FROM StringIndexEntity WHERE resourceType = ? AND index_name = ? AND (index_value LIKE ? || '%' COLLATE NOCASE OR index_value LIKE ? || '%' COLLATE NOCASE) )