Skip to content

Commit

Permalink
Evaluate cqf-calculatedValue expressions using ExpressionEvaluator
Browse files Browse the repository at this point in the history
to add support for variable expressions, launchContexts and %resource expressions
  • Loading branch information
LZRS committed Nov 8, 2023
1 parent c26f654 commit 859a1be
Show file tree
Hide file tree
Showing 18 changed files with 1,170 additions and 235 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import ca.uhn.fhir.context.FhirVersionEnum
import ca.uhn.fhir.parser.IParser
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvaluator
import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL
import com.google.android.fhir.datacapture.extensions.EntryMode
import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer
import com.google.android.fhir.datacapture.extensions.allItems
Expand Down Expand Up @@ -68,12 +69,14 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.withIndex
import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.Element
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.Type
import timber.log.Timber

internal class QuestionnaireViewModel(application: Application, state: SavedStateHandle) :
Expand Down Expand Up @@ -579,6 +582,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
)
}

private fun resolveCqfCalculatedValueExpression(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
expression: Expression,
): Type? {
if (!expression.isFhirPath) {
throw UnsupportedOperationException("${expression.language} not supported yet")
}

return expressionEvaluator
.evaluateExpression(questionnaireItem, questionnaireResponseItem, expression)
.singleOrNull()
?.let { it as Type }
}

private fun removeDisabledAnswers(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
Expand Down Expand Up @@ -707,6 +725,23 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat

restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem)

// Evaluate cqf-calculatedValues
questionnaireItem.extension
.filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) }
.forEach { extension ->
val expression =
extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value as Expression
resolveCqfCalculatedValueExpression(
questionnaireItem,
questionnaireResponseItem,
expression,
)
?.let {
it.apply { setExtension(extension.value.extension) }
extension.setValue(it)
}
}

// Determine the validation result, which will be displayed on the item itself
val validationResult =
if (
Expand All @@ -717,7 +752,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
QuestionnaireResponseItemValidator.validate(
questionnaireItem,
questionnaireResponseItem.answer,
this@QuestionnaireViewModel.getApplication(),
context = this@QuestionnaireViewModel.getApplication(),
)
} else {
NotValidated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,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
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,8 @@ private fun getDisplayString(type: Type, context: Context): String? =

private fun getValueString(type: Type): String? =
when (type) {
is DateType,
is DateTimeType,
is StringType, -> type.asStringValue()
is Quantity -> type.value.toString()
is StringType -> type.getLocalizedText() ?: type.valueAsString
is Quantity -> type.takeIf { it.hasValue() }?.value?.toString()
else -> (type as? PrimitiveType<*>)?.valueAsString
}

Expand All @@ -125,15 +123,19 @@ internal fun Coding.toCodeType(): CodeType {
}

fun Type.valueOrCalculateValue(): Type {
return if (this.hasExtension()) {
this.extension
.firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL }
return if (getValueString(this) != null) {
this
} else {
this.takeIf { hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) }
?.extension
?.firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL }
?.let { extension ->
val expression = (extension.value as Expression).expression
fhirPathEngine.evaluate(this, expression).singleOrNull()?.let { it as Type }
}
?: this
} else {
this
}
}

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 @@ -35,7 +35,10 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse
internal open class AnswerExtensionConstraintValidator(
val url: String,
val predicate:
(Extension, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) -> Boolean,
(
Extension,
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
) -> Boolean,
val messageGenerator: (Extension, Context) -> String,
) : AnswerConstraintValidator {
override fun validate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.getValueAsString
import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type

internal const val MAX_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/maxValue"

Expand All @@ -44,11 +42,4 @@ internal object MaxValueValidator :
extension.value?.valueOrCalculateValue()?.getValueAsString(context),
)
},
) {

fun getMaxValue(questionnaireItemComponent: Questionnaire.QuestionnaireItemComponent): Type? {
return questionnaireItemComponent.extension
.firstOrNull { it.url == MAX_VALUE_EXTENSION_URL }
?.let { it.value?.valueOrCalculateValue() }
}
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.getValueAsString
import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type

internal const val MIN_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/minValue"

Expand All @@ -44,13 +42,4 @@ internal object MinValueValidator :
extension.value?.valueOrCalculateValue()?.getValueAsString(context),
)
},
) {

internal fun getMinValue(
questionnaireItemComponent: Questionnaire.QuestionnaireItemComponent,
): Type? {
return questionnaireItemComponent.extension
.firstOrNull { it.url == MIN_VALUE_EXTENSION_URL }
?.let { it.value?.valueOrCalculateValue() }
}
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ package com.google.android.fhir.datacapture.validation

import android.content.Context
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL
import com.google.android.fhir.datacapture.extensions.isFhirPath
import com.google.android.fhir.datacapture.extensions.packRepeatedGroups
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
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

Expand Down Expand Up @@ -69,6 +75,13 @@ object QuestionnaireResponseValidator {
}

val linkIdToValidationResultMap = mutableMapOf<String, MutableList<ValidationResult>>()
val expressionEvaluator =
ExpressionEvaluator(
questionnaire,
questionnaireResponse,
questionnaireItemParentMap,
launchContextMap,
)

validateQuestionnaireResponseItems(
questionnaire.item,
Expand All @@ -80,17 +93,33 @@ object QuestionnaireResponseValidator {
questionnaireItemParentMap,
launchContextMap,
),
expressionEvaluator,
linkIdToValidationResultMap,
)

return linkIdToValidationResultMap
}

private fun ExpressionEvaluator.resolveCqfCalculatedValue(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
expression: Expression,
): Type? {
if (!expression.isFhirPath) {
throw UnsupportedOperationException("${expression.language} not supported yet")
}

return evaluateExpression(questionnaireItem, questionnaireResponseItem, expression)
.singleOrNull()
?.let { it as Type }
}

private fun validateQuestionnaireResponseItems(
questionnaireItemList: List<Questionnaire.QuestionnaireItemComponent>,
questionnaireResponseItemList: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>,
context: Context,
enablementEvaluator: EnablementEvaluator,
expressionEvaluator: ExpressionEvaluator,
linkIdToValidationResultMap: MutableMap<String, MutableList<ValidationResult>>,
): Map<String, List<ValidationResult>> {
val questionnaireItemListIterator = questionnaireItemList.iterator()
Expand All @@ -113,11 +142,31 @@ object QuestionnaireResponseValidator {
)

if (enabled) {
// Evaluate cqf-calculatedValues
questionnaireItem.extension
.filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) }
.forEach { extension ->
val expression =
extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value
as Expression
expressionEvaluator
.resolveCqfCalculatedValue(
questionnaireItem,
questionnaireResponseItem,
expression,
)
?.let {
it.apply { setExtension(extension.value.extension) }
extension.setValue(it)
}
}

validateQuestionnaireResponseItem(
questionnaireItem,
questionnaireResponseItem,
context,
enablementEvaluator,
expressionEvaluator,
linkIdToValidationResultMap,
)
}
Expand All @@ -130,6 +179,7 @@ object QuestionnaireResponseValidator {
questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent,
context: Context,
enablementEvaluator: EnablementEvaluator,
expressionEvaluator: ExpressionEvaluator,
linkIdToValidationResultMap: MutableMap<String, MutableList<ValidationResult>>,
): Map<String, List<ValidationResult>> {
when (checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }) {
Expand All @@ -143,6 +193,7 @@ object QuestionnaireResponseValidator {
questionnaireResponseItem.item,
context,
enablementEvaluator,
expressionEvaluator,
linkIdToValidationResultMap,
)
else -> {
Expand All @@ -156,6 +207,7 @@ object QuestionnaireResponseValidator {
it.item,
context,
enablementEvaluator,
expressionEvaluator,
linkIdToValidationResultMap,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.displayString
import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.toSpanned
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.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.Valid
import com.google.android.fhir.datacapture.validation.ValidationResult
Expand Down Expand Up @@ -52,12 +54,9 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse
* @param validationResult the [ValidationResult] of the answer(s) against the `questionnaireItem`
* @param answersChangedCallback the callback to notify the view model that the answers have been
* changed for the [QuestionnaireResponse.QuestionnaireResponseItemComponent]
* @param resolveAnswerValueSet the callback to resolve the answer value set and return the answer
* @param resolveAnswerExpression the callback to resolve answer options when answer-expression
* extension exists options
* @param enabledAnswerOptions the enabled answer options in [questionnaireItem]]
* @param draftAnswer the draft input that cannot be stored in the [QuestionnaireResponse].
* @param enabledDisplayItems the enabled display items in the given [questionnaireItem]
* @param showOptionalText the optional text is being added to the end of the question text
* @param questionViewTextConfiguration configuration to show asterisk, required and optional text
* in the header view.
*/
Expand Down Expand Up @@ -94,6 +93,10 @@ data class QuestionnaireViewItem(
val answers: List<QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent> =
questionnaireResponseItem.answer.map { it.copy() }

val minValue by lazy { questionnaireItem.getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value }

val maxValue by lazy { questionnaireItem.getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value }

/** Updates the answers. This will override any existing answers and removes the draft answer. */
fun setAnswer(
vararg questionnaireResponseItemAnswerComponent:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage
import com.google.android.fhir.datacapture.extensions.parseDate
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.MaxValueValidator.getMaxValue
import com.google.android.fhir.datacapture.validation.MinValueValidator.getMinValue
import com.google.android.fhir.datacapture.validation.ValidationResult
import com.google.android.fhir.datacapture.views.HeaderView
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
Expand Down Expand Up @@ -163,8 +161,8 @@ internal object DatePickerViewHolderFactory :
}

private fun getCalenderConstraint(): CalendarConstraints {
val min = (getMinValue(questionnaireViewItem.questionnaireItem) as? DateType)?.value?.time
val max = (getMaxValue(questionnaireViewItem.questionnaireItem) as? DateType)?.value?.time
val min = (questionnaireViewItem.minValue as? DateType)?.value?.time
val max = (questionnaireViewItem.maxValue as? DateType)?.value?.time

if (min != null && max != null && min > max) {
throw IllegalArgumentException("minValue cannot be greater than maxValue")
Expand Down
Loading

0 comments on commit 859a1be

Please sign in to comment.