Skip to content

Commit

Permalink
Use Dispatchers.Default to evaluate fhirpath expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
LZRS committed Sep 26, 2024
1 parent d114245 commit eb9fbec
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 198 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ internal data class ChoiceColumn(val path: String, val label: String?, val forDi
* resources [Resource], identifiers [Identifier] or codes [Coding]
* @return list of answer options [Questionnaire.QuestionnaireItemAnswerOptionComponent]
*/
internal fun QuestionnaireItemComponent.extractAnswerOptions(
internal suspend fun QuestionnaireItemComponent.extractAnswerOptions(
dataList: List<Base>,
): List<Questionnaire.QuestionnaireItemAnswerOptionComponent> {
return when (this.type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ import com.google.android.fhir.datacapture.extensions.isFhirPath
import com.google.android.fhir.datacapture.extensions.isReferencedBy
import com.google.android.fhir.datacapture.extensions.isXFhirQuery
import com.google.android.fhir.datacapture.extensions.variableExpressions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import org.hl7.fhir.exceptions.FHIRException
import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.ExpressionNode
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse
Expand Down Expand Up @@ -348,7 +353,7 @@ internal class ExpressionEvaluator(
* Creates an x-fhir-query string for evaluation. For this, it evaluates both variables and
* fhir-paths in the expression.
*/
internal fun createXFhirQueryFromExpression(
internal suspend fun createXFhirQueryFromExpression(
expression: Expression,
variablesMap: Map<String, Base?> = emptyMap(),
): String {
Expand All @@ -357,16 +362,17 @@ internal class ExpressionEvaluator(
variablesMap
.filterKeys { expression.expression.contains("{{%$it}}") }
.map { Pair("{{%${it.key}}}", it.value?.primitiveValue() ?: "") }
.asFlow()

val fhirPathsEvaluatedPairs =
questionnaireLaunchContextMap
?.toMutableMap()
.takeIf { !it.isNullOrEmpty() }
?.also { it.put(questionnaireFhirPathSupplement, questionnaire) }
?.let { evaluateXFhirEnhancement(expression, it) }
?: emptySequence()
?: emptyFlow()

return (variablesEvaluatedPairs + fhirPathsEvaluatedPairs).fold(expression.expression) {
return merge(variablesEvaluatedPairs, fhirPathsEvaluatedPairs).fold(expression.expression) {
acc: String,
pair: Pair<String, String>,
->
Expand All @@ -383,21 +389,17 @@ internal class ExpressionEvaluator(
* Practitioner?active=true&{{Practitioner.name.family}}
* @param launchContextMap the launch context to evaluate the expression against
*/
private fun evaluateXFhirEnhancement(
private suspend fun evaluateXFhirEnhancement(
expression: Expression,
launchContextMap: Map<String, Resource>,
): Sequence<Pair<String, String>> =
): Flow<Pair<String, String>> =
xFhirQueryEnhancementRegex
.findAll(expression.expression)
.asFlow()
.map { it.groupValues }
.map { (fhirPathWithParentheses, fhirPath) ->
val expressionNode = extractExpressionNode(fhirPath)
val evaluatedResult =
evaluateToString(
expression = expressionNode,
data = launchContextMap[extractResourceType(expressionNode)],
contextMap = launchContextMap,
)
evaluateToString(contextMap = launchContextMap, fhirPathString = fhirPath)

// If the result of evaluating the FHIRPath expressions is an invalid query, it returns
// null. As per the spec:
Expand Down Expand Up @@ -531,15 +533,5 @@ internal class ExpressionEvaluator(
}
}

/**
* Extract [ResourceType] string representation from constant or name property of given
* [ExpressionNode].
*/
private fun extractResourceType(expressionNode: ExpressionNode): String? {
// TODO(omarismail94): See if FHIRPathEngine.check() can be used to distinguish invalid
// expression vs an expression that is valid, but does not return one resource only.
return expressionNode.constant?.primitiveValue()?.substring(1) ?: expressionNode.name?.lowercase()
}

/** Pair of a [Questionnaire.QuestionnaireItemComponent] with its evaluated answers */
internal typealias ItemToAnswersPair = Pair<QuestionnaireItemComponent, List<Type>>
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ package com.google.android.fhir.datacapture.fhirpath

import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext
import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.ExpressionNode
Expand All @@ -26,32 +29,52 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComp
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.utils.FHIRPathEngine

private val fhirPathEngine: FHIRPathEngine =
private val fhirPathEngine: FHIRPathEngine by lazy {
with(FhirContext.forCached(FhirVersionEnum.R4)) {
FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)).apply {
hostServices = FHIRPathEngineHostServices
}
}
}

internal var fhirPathEngineDefaultDispatcher: CoroutineContext = Dispatchers.Default

/**
* Evaluates the expressions over list of resources [Resource] and joins to space separated string
*/
internal fun evaluateToDisplay(expressions: List<String>, data: Resource) =
expressions.joinToString(" ") { fhirPathEngine.evaluateToString(data, it) }
internal suspend fun evaluateToDisplay(expressions: List<String>, data: Resource) =
withContext(fhirPathEngineDefaultDispatcher) {
expressions.joinToString(" ") { fhirPathEngine.evaluateToString(data, it) }
}

/** Evaluates the expression over resource [Resource] and returns string value */
internal fun evaluateToString(
internal suspend fun evaluateToString(
expression: ExpressionNode,
data: Resource?,
contextMap: Map<String, Base?>,
) =
fhirPathEngine.evaluateToString(
/* appInfo = */ contextMap,
/* focusResource = */ null,
/* rootResource = */ null,
/* base = */ data,
/* node = */ expression,
withContext(fhirPathEngineDefaultDispatcher) {
fhirPathEngine.evaluateToString(
/* appInfo = */ contextMap,
/* focusResource = */ null,
/* rootResource = */ null,
/* base = */ data,
/* node = */ expression,
)
}

/** Evaluates FhirPath expression string over a contextMap and returns string value */
internal suspend fun evaluateToString(
contextMap: Map<String, Resource>,
fhirPathString: String,
): String {
val expressionNode = extractExpressionNode(fhirPathString)
return evaluateToString(
expression = expressionNode,
data = contextMap[extractResourceType(expressionNode)],
contextMap = contextMap,
)
}

/**
* Evaluates the expression and returns the boolean result. The resources [QuestionnaireResponse]
Expand All @@ -60,20 +83,22 @@ internal fun evaluateToString(
*
* %resource = [QuestionnaireResponse], %context = [QuestionnaireResponseItemComponent]
*/
internal fun evaluateToBoolean(
internal suspend fun evaluateToBoolean(
questionnaireResponse: QuestionnaireResponse,
questionnaireResponseItemComponent: QuestionnaireResponseItemComponent,
expression: String,
contextMap: Map<String, Base?> = mapOf(),
): Boolean {
val expressionNode = fhirPathEngine.parse(expression)
return fhirPathEngine.evaluateToBoolean(
contextMap,
questionnaireResponse,
null,
questionnaireResponseItemComponent,
expressionNode,
)
return withContext(fhirPathEngineDefaultDispatcher) {
val expressionNode = fhirPathEngine.parse(expression)
fhirPathEngine.evaluateToBoolean(
contextMap,
questionnaireResponse,
null,
questionnaireResponseItemComponent,
expressionNode,
)
}
}

/**
Expand All @@ -84,31 +109,47 @@ internal fun evaluateToBoolean(
*
* %resource = [QuestionnaireResponse], %context = [QuestionnaireResponseItemComponent]
*/
internal fun evaluateToBase(
internal suspend fun evaluateToBase(
questionnaireResponse: QuestionnaireResponse?,
questionnaireResponseItem: QuestionnaireResponseItemComponent?,
expression: String,
contextMap: Map<String, Base?> = mapOf(),
): List<Base> {
return fhirPathEngine.evaluate(
/* appContext = */ contextMap,
/* focusResource = */ questionnaireResponse,
/* rootResource = */ null,
/* base = */ questionnaireResponseItem,
/* path = */ expression,
)
return withContext(fhirPathEngineDefaultDispatcher) {
fhirPathEngine.evaluate(
/* appContext = */ contextMap,
/* focusResource = */ questionnaireResponse,
/* rootResource = */ null,
/* base = */ questionnaireResponseItem,
/* path = */ expression,
)
}
}

/** Evaluates the given expression and returns list of [Base] */
internal fun evaluateToBase(base: Base, expression: String): List<Base> {
return fhirPathEngine.evaluate(
/* base = */ base,
/* path = */ expression,
)
internal suspend fun evaluateToBase(base: Base, expression: String): List<Base> {
return withContext(fhirPathEngineDefaultDispatcher) {
fhirPathEngine.evaluate(
/* base = */ base,
/* path = */ expression,
)
}
}

/** Evaluates the given list of [Base] elements and returns boolean result */
internal fun convertToBoolean(items: List<Base>) = fhirPathEngine.convertToBoolean(items)
internal suspend fun convertToBoolean(items: List<Base>) =
withContext(fhirPathEngineDefaultDispatcher) { fhirPathEngine.convertToBoolean(items) }

/** Parse the given expression into [ExpressionNode] */
internal fun extractExpressionNode(fhirPath: String) = fhirPathEngine.parse(fhirPath)
internal suspend fun extractExpressionNode(fhirPath: String) =
withContext(fhirPathEngineDefaultDispatcher) { fhirPathEngine.parse(fhirPath) }

/**
* Extract [ResourceType] string representation from constant or name property of given
* [ExpressionNode].
*/
private fun extractResourceType(expressionNode: ExpressionNode): String? {
// TODO(omarismail94): See if FHIRPathEngine.check() can be used to distinguish invalid
// expression vs an expression that is valid, but does not return one resource only.
return expressionNode.constant?.primitiveValue()?.substring(1) ?: expressionNode.name?.lowercase()
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import com.google.android.fhir.datacapture.extensions.createNestedQuestionnaireR
import com.google.android.fhir.datacapture.extensions.entryMode
import com.google.android.fhir.datacapture.extensions.logicalId
import com.google.android.fhir.datacapture.extensions.maxValue
import com.google.android.fhir.datacapture.fhirpath.fhirPathEngineDefaultDispatcher
import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL
Expand Down Expand Up @@ -157,6 +158,7 @@ class QuestionnaireViewModelTest {
"Few tests require a custom application class that implements DataCaptureConfig.Provider"
}
ReflectionHelpers.setStaticField(DataCapture::class.java, "configuration", null)
fhirPathEngineDefaultDispatcher = mainDispatcherRule.testDispatcher
}

// ==================================================================== //
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.google.common.truth.Truth.assertThat
import java.math.BigDecimal
import java.util.Locale
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Attachment
import org.hl7.fhir.r4.model.BooleanType
import org.hl7.fhir.r4.model.CodeType
Expand Down Expand Up @@ -2220,7 +2221,7 @@ class MoreQuestionnaireItemComponentsTest {
}

@Test
fun `extractAnswerOptions should return answer options for coding`() {
fun `extractAnswerOptions should return answer options for coding`() = runTest {
val questionItem =
Questionnaire()
.addItem(
Expand Down Expand Up @@ -2253,7 +2254,7 @@ class MoreQuestionnaireItemComponentsTest {
}

@Test
fun `extractAnswerOptions should return answer options for resources`() {
fun `extractAnswerOptions should return answer options for resources`() = runTest {
val questionItem =
Questionnaire()
.addItem(
Expand Down Expand Up @@ -2302,34 +2303,35 @@ class MoreQuestionnaireItemComponentsTest {
}

@Test
fun `extractAnswerOptions should throw IllegalArgumentException when item type is not reference and data type is resource`() {
val questionItem =
Questionnaire()
.addItem(
Questionnaire.QuestionnaireItemComponent().apply {
linkId = "full-name"
type = Questionnaire.QuestionnaireItemType.CHOICE
extension =
listOf(
Extension(EXTENSION_CHOICE_COLUMN_URL).apply {
addExtension(Extension("path", StringType("name.given")))
addExtension(Extension("label", StringType("GIVEN")))
addExtension(Extension("forDisplay", BooleanType(true)))
},
)
},
)

assertThrows(IllegalArgumentException::class.java) {
questionItem.itemFirstRep.extractAnswerOptions(listOf(Patient()))
}
.run {
assertThat(this.message)
.isEqualTo(
"$EXTENSION_CHOICE_COLUMN_URL not applicable for 'choice'. Only type reference is allowed with resource.",
fun `extractAnswerOptions should throw IllegalArgumentException when item type is not reference and data type is resource`() =
runTest {
val questionItem =
Questionnaire()
.addItem(
Questionnaire.QuestionnaireItemComponent().apply {
linkId = "full-name"
type = Questionnaire.QuestionnaireItemType.CHOICE
extension =
listOf(
Extension(EXTENSION_CHOICE_COLUMN_URL).apply {
addExtension(Extension("path", StringType("name.given")))
addExtension(Extension("label", StringType("GIVEN")))
addExtension(Extension("forDisplay", BooleanType(true)))
},
)
},
)
}
}

assertThrows(IllegalArgumentException::class.java) {
runBlocking { questionItem.itemFirstRep.extractAnswerOptions(listOf(Patient())) }
}
.run {
assertThat(this.message)
.isEqualTo(
"$EXTENSION_CHOICE_COLUMN_URL not applicable for 'choice'. Only type reference is allowed with resource.",
)
}
}

@Test
fun `sliderStepValue should return the integer value in the sliderStepValue extension`() {
Expand Down
Loading

0 comments on commit eb9fbec

Please sign in to comment.