Skip to content

Commit

Permalink
Support evaluation of variables, launchContext and other FHIR context…
Browse files Browse the repository at this point in the history
…s for cqf-calculatedValue expressions (#2326)

* Evaluate cqf-calculatedValue expressions using ExpressionEvaluator

to add support for variable expressions, launchContexts and %resource expressions

* Refactor evaluation of cqf-calculatedValue expressions

* Refactor to not modify extension in questionnaire

When evaluating cqf-calculatedValue expression for validation

* Resolve comments for requested changes

* Suspend validate methods on answerConstraintValidators

* Update documentation
  • Loading branch information
LZRS authored Mar 13, 2024
1 parent 9697a8a commit 6ca5cbc
Show file tree
Hide file tree
Showing 30 changed files with 1,270 additions and 423 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@ import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtensio
import com.google.android.fhir.datacapture.extensions.flattened
import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet
import com.google.android.fhir.datacapture.extensions.isDisplayItem
import com.google.android.fhir.datacapture.extensions.isFhirPath
import com.google.android.fhir.datacapture.extensions.isHelpCode
import com.google.android.fhir.datacapture.extensions.isHidden
import com.google.android.fhir.datacapture.extensions.isPaginated
import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.maxValue
import com.google.android.fhir.datacapture.extensions.maxValueCqfCalculatedValueExpression
import com.google.android.fhir.datacapture.extensions.minValue
import com.google.android.fhir.datacapture.extensions.minValueCqfCalculatedValueExpression
import com.google.android.fhir.datacapture.extensions.packRepeatedGroups
import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts
import com.google.android.fhir.datacapture.extensions.shouldHaveNestedItemsUnderAnswers
Expand Down Expand Up @@ -69,8 +72,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.Element
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 @@ -591,23 +592,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}

private suspend fun resolveCqfExpression(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
element: Element,
): List<Base> {
val cqfExpression = element.cqfExpression ?: return emptyList()

if (!cqfExpression.isFhirPath) {
throw UnsupportedOperationException("${cqfExpression.language} not supported yet")
}
return expressionEvaluator.evaluateExpression(
questionnaireItem,
questionnaireResponseItem,
cqfExpression,
)
}

private fun removeDisabledAnswers(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
Expand Down Expand Up @@ -749,17 +733,25 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
questionnaireItem,
questionnaireResponseItem.answer,
this@QuestionnaireViewModel.getApplication(),
)
) {
expressionEvaluator.evaluateExpressionValue(
questionnaireItem,
questionnaireResponseItem,
it,
)
}
} else {
NotValidated
}

// Set question text dynamically from CQL expression
questionnaireResponseItem.apply {
resolveCqfExpression(questionnaireItem, this, questionnaireItem.textElement)
.firstOrNull()
?.let { text = it.primitiveValue() }
questionnaireItem.textElement.cqfExpression?.let { expression ->
expressionEvaluator
.evaluateExpressionValue(questionnaireItem, questionnaireResponseItem, expression)
?.primitiveValue()
?.let { questionnaireResponseItem.text = it }
}

val (enabledQuestionnaireAnswerOptions, disabledQuestionnaireResponseAnswers) =
answerOptionsEvaluator.evaluate(
questionnaireItem,
Expand All @@ -786,6 +778,24 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
validationResult = validationResult,
answersChangedCallback = answersChangedCallback,
enabledAnswerOptions = enabledQuestionnaireAnswerOptions,
minAnswerValue =
questionnaireItem.minValueCqfCalculatedValueExpression?.let {
expressionEvaluator.evaluateExpressionValue(
questionnaireItem,
questionnaireResponseItem,
it,
)
}
?: questionnaireItem.minValue,
maxAnswerValue =
questionnaireItem.maxValueCqfCalculatedValueExpression?.let {
expressionEvaluator.evaluateExpressionValue(
questionnaireItem,
questionnaireResponseItem,
it,
)
}
?: questionnaireItem.maxValue,
draftAnswer = draftAnswerMap[questionnaireResponseItem],
enabledDisplayItems =
questionnaireItem.item.filter {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import ca.uhn.fhir.util.UrlUtil
import com.google.android.fhir.datacapture.DataCapture
import com.google.android.fhir.datacapture.QuestionnaireViewHolderType
import com.google.android.fhir.datacapture.fhirpath.evaluateToDisplay
import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL
import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL
import com.google.android.fhir.getLocalizedText
import java.math.BigDecimal
import java.time.LocalDate
Expand Down Expand Up @@ -82,9 +84,6 @@ internal const val EXTENSION_CHOICE_ORIENTATION_URL =
internal const val EXTENSION_CHOICE_COLUMN_URL: String =
"http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn"

internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String =
"http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue"

internal const val EXTENSION_DISPLAY_CATEGORY_URL =
"http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory"

Expand Down Expand Up @@ -294,6 +293,18 @@ val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int?
return null
}

internal val Questionnaire.QuestionnaireItemComponent.minValue
get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value

internal val Questionnaire.QuestionnaireItemComponent.minValueCqfCalculatedValueExpression
get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression

internal val Questionnaire.QuestionnaireItemComponent.maxValue
get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value

internal val Questionnaire.QuestionnaireItemComponent.maxValueCqfCalculatedValueExpression
get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression

// ********************************************************************************************** //
// //
// Additional display utilities: display item control, localized text spanned, //
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 Google LLC
* Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -94,7 +94,7 @@ private fun validateLaunchContextExtension(launchExtension: Extension) {
false
}

if (nameCoding.system != EXTENSION_LAUNCH_CONTEXT || !isValidResourceType) {
if (nameCoding.system != CODE_SYSTEM_LAUNCH_CONTEXT || !isValidResourceType) {
error(
"The extension:name and/or extension:type do not follow the format specified in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT",
)
Expand Down Expand Up @@ -139,7 +139,8 @@ internal const val EXTENSION_ENTRY_MODE_URL: String =
internal const val EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT =
"http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext"

internal const val EXTENSION_LAUNCH_CONTEXT = "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext"
internal const val CODE_SYSTEM_LAUNCH_CONTEXT =
"http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext"

val Questionnaire.entryMode: EntryMode?
get() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package com.google.android.fhir.datacapture.extensions

import android.content.Context
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.fhirpath.evaluateToBase
import com.google.android.fhir.datacapture.views.factories.localDate
import com.google.android.fhir.datacapture.views.factories.localTime
import com.google.android.fhir.getLocalizedText
Expand Down Expand Up @@ -95,13 +94,13 @@ private fun getDisplayString(type: Type, context: Context): String? =
else -> (type as? PrimitiveType<*>)?.valueAsString
}

/**
* Returns the string representation for [PrimitiveType] or [Quantity], otherwise defaults to null
*/
private fun getValueString(type: Type): String? =
when (type) {
is DateType,
is DateTimeType,
is StringType, -> type.asStringValue()
is Quantity -> type.value.toString()
else -> (type as? PrimitiveType<*>)?.valueAsString
is Quantity -> type.value?.toString()
else -> (type as? PrimitiveType<*>)?.asStringValue()
}

/** Converts StringType to toUriType. */
Expand Down Expand Up @@ -132,16 +131,10 @@ internal fun Quantity.toCoding(): Coding {
return Coding(this.system, this.code, this.unit)
}

fun Type.valueOrCalculateValue(): Type {
return if (this.hasExtension()) {
this.extension
.firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL }
?.let { extension ->
val expression = (extension.value as Expression).expression
evaluateToBase(this, expression).singleOrNull()?.let { it as Type }
}
?: this
} else {
this
}
}
internal fun Type.hasValue(): Boolean = !getValueString(this).isNullOrBlank()

internal val Type.cqfCalculatedValueExpression
get() = this.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL)?.value as? Expression

internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String =
"http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue"
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,27 @@ internal class ExpressionEvaluator(
)
}

/**
* Returns single [Type] evaluation value result of an expression, including cqf-expression and
* cqf-calculatedValue expressions
*/
suspend fun evaluateExpressionValue(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent?,
expression: Expression,
): Type? {
if (!expression.isFhirPath) {
throw UnsupportedOperationException("${expression.language} not supported yet")
}
return try {
evaluateExpression(questionnaireItem, questionnaireResponseItem, expression).singleOrNull()
as? Type
} catch (e: Exception) {
Timber.w("Could not evaluate expression ${expression.expression} with FHIRPathEngine", e)
null
}
}

/**
* Returns a list of pair of item and the calculated and evaluated value for all items with
* calculated expression extension, which is dependent on value of updated response
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022-2023 Google LLC
* Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,7 +24,6 @@ import org.hl7.fhir.r4.model.ExpressionNode
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.Type
import org.hl7.fhir.r4.utils.FHIRPathEngine

private val fhirPathEngine: FHIRPathEngine =
Expand Down Expand Up @@ -101,9 +100,9 @@ internal fun evaluateToBase(
}

/** Evaluates the given expression and returns list of [Base] */
internal fun evaluateToBase(type: Type, expression: String): List<Base> {
internal fun evaluateToBase(base: Base, expression: String): List<Base> {
return fhirPathEngine.evaluate(
/* base = */ type,
/* base = */ base,
/* path = */ expression,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022-2023 Google LLC
* Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,8 +17,10 @@
package com.google.android.fhir.datacapture.validation

import android.content.Context
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type

/**
* Validates [QuestionnaireResponse.QuestionnaireResponseItemComponent] against a particular
Expand All @@ -35,10 +37,11 @@ internal interface AnswerConstraintValidator {
*
* [Learn more](https://www.hl7.org/fhir/questionnaireresponse.html#link).
*/
fun validate(
suspend fun validate(
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
context: Context,
expressionEvaluator: suspend (Expression) -> Type?,
): Result

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022-2023 Google LLC
* Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,9 +17,12 @@
package com.google.android.fhir.datacapture.validation

import android.content.Context
import org.hl7.fhir.r4.model.Extension
import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression
import com.google.android.fhir.datacapture.extensions.hasValue
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type

/**
* Validates [QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent] against a constraint
Expand All @@ -35,18 +38,33 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse
internal open class AnswerExtensionConstraintValidator(
val url: String,
val predicate:
(Extension, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) -> Boolean,
val messageGenerator: (Extension, Context) -> String,
(
/*constraintValue*/
Type,
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
) -> Boolean,
val messageGenerator: (Type, Context) -> String,
) : AnswerConstraintValidator {
override fun validate(
override suspend fun validate(
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
context: Context,
expressionEvaluator: suspend (Expression) -> Type?,
): AnswerConstraintValidator.Result {
if (questionnaireItem.hasExtension(url)) {
val extension = questionnaireItem.getExtensionByUrl(url)
if (predicate(extension, answer)) {
return AnswerConstraintValidator.Result(false, messageGenerator(extension, context))
val extensionValue =
extension.value.cqfCalculatedValueExpression?.let { expressionEvaluator(it) }
?: extension.value

// Only checks constraint if both extension and answer have a value
if (
extensionValue.hasValue() && answer.value.hasValue() && predicate(extensionValue, answer)
) {
return AnswerConstraintValidator.Result(
false,
messageGenerator(extensionValue, context),
)
}
}
return AnswerConstraintValidator.Result(true, null)
Expand Down
Loading

0 comments on commit 6ca5cbc

Please sign in to comment.