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

Suspend functions to evaluate fhirpath expressions #2678

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading