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

Support evaluation of variables, launchContext and other FHIR contexts for cqf-calculatedValue expressions #2326

Merged
merged 16 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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 @@ -30,20 +30,21 @@ import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvalu
import com.google.android.fhir.datacapture.extensions.EntryMode
import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer
import com.google.android.fhir.datacapture.extensions.allItems
import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression
import com.google.android.fhir.datacapture.extensions.cqfExpression
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
import com.google.android.fhir.datacapture.extensions.entryMode
import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension
import com.google.android.fhir.datacapture.extensions.flattened
import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet
import com.google.android.fhir.datacapture.extensions.isCqfCalculatedValue
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 @@ -71,15 +72,12 @@ 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.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 @@ -594,23 +592,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}

private fun resolveExpression(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
expression: Expression,
): Base? {
if (!expression.isFhirPath) {
throw UnsupportedOperationException("${expression.language} not supported yet")
}
return expressionEvaluator
.evaluateExpression(
questionnaireItem,
questionnaireResponseItem,
expression,
)
.singleOrNull()
}

private fun removeDisabledAnswers(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
Expand Down Expand Up @@ -703,27 +684,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
)
}

private fun resolveCqfCalculatedValueExpressions(
LZRS marked this conversation as resolved.
Show resolved Hide resolved
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
) {
questionnaireItem.extension
.filter { it.hasValue() && it.value.isCqfCalculatedValue }
.forEach { extension ->
val currentExtensionValue = extension.value
val currentExtensionValueExtensions = currentExtensionValue.extension
val evaluatedCqfCalculatedValue =
currentExtensionValue.cqfCalculatedValueExpression?.let {
resolveExpression(questionnaireItem, questionnaireResponseItem, it) as? Type
}
// add previous extensions to the evaluated value
evaluatedCqfCalculatedValue?.setExtension(currentExtensionValueExtensions)
if (evaluatedCqfCalculatedValue != null) {
extension.setValue(evaluatedCqfCalculatedValue)
}
}
}

/**
* Returns the list of [QuestionnaireViewItem]s generated for the questionnaire items and
* questionnaire response items.
Expand Down Expand Up @@ -762,9 +722,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat

restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem)

// Evaluate cqf-calculatedValues
resolveCqfCalculatedValueExpressions(questionnaireItem, questionnaireResponseItem)

// Determine the validation result, which will be displayed on the item itself
val validationResult =
if (
Expand All @@ -775,15 +732,22 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
QuestionnaireResponseItemValidator.validate(
questionnaireItem,
questionnaireResponseItem.answer,
context = this@QuestionnaireViewModel.getApplication(),
)
this@QuestionnaireViewModel.getApplication(),
) {
expressionEvaluator.evaluateExpressionValue(
questionnaireItem,
questionnaireResponseItem,
it,
)
}
} else {
NotValidated
}

// Set question text dynamically from CQL expression
questionnaireItem.textElement.cqfExpression?.let { expression ->
resolveExpression(questionnaireItem, questionnaireResponseItem, expression)
expressionEvaluator
.evaluateExpressionValue(questionnaireItem, questionnaireResponseItem, expression)
?.primitiveValue()
?.let { questionnaireResponseItem.text = it }
}
Expand Down Expand Up @@ -814,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 @@ -293,13 +293,17 @@ val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int?
return null
}

/** The inclusive lower bound on the range of allowed answer values. */
internal val Questionnaire.QuestionnaireItemComponent.minValue
get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value?.valueOrCalculateValue()
get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value

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

/** The inclusive upper bound on the range of allowed answer values. */
internal val Questionnaire.QuestionnaireItemComponent.maxValue
get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.valueOrCalculateValue()
get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value

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

// ********************************************************************************************** //
// //
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,11 +18,9 @@ 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
import org.hl7.fhir.exceptions.FHIRException
import org.hl7.fhir.r4.model.Attachment
import org.hl7.fhir.r4.model.BooleanType
import org.hl7.fhir.r4.model.CodeType
Expand All @@ -39,7 +37,6 @@ import org.hl7.fhir.r4.model.Reference
import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.model.Type
import org.hl7.fhir.r4.model.UriType
import timber.log.Timber

/**
* Returns the string representation of a [PrimitiveType].
Expand Down Expand Up @@ -97,11 +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? =
jingtang10 marked this conversation as resolved.
Show resolved Hide resolved
when (type) {
is StringType -> type.getLocalizedText() ?: type.valueAsString
is Quantity -> type.takeIf { it.hasValue() }?.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,30 +131,10 @@ internal fun Quantity.toCoding(): Coding {
return Coding(this.system, this.code, this.unit)
}

fun Type.valueOrCalculateValue(): Type {
return if (getValueString(this) != null) {
this
} else {
this.cqfCalculatedValueExpression?.let { expression ->
try {
fhirPathEngine.evaluate(this, expression.expression).singleOrNull() as? Type
} catch (e: FHIRException) {
Timber.w("Could not evaluate expression with FHIRPathEngine", e)
null
}
}
?: this
}
}

internal val Type.isCqfCalculatedValue
get() = this.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL)
internal fun Type.hasValue(): Boolean = !getValueString(this).isNullOrBlank()

internal val Type.cqfCalculatedValueExpression
get() =
this.takeIf { isCqfCalculatedValue }
?.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL)
?.value as? Expression
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,24 @@ internal class ExpressionEvaluator(
)
}

/** Returns the evaluation result of an expression as a [Type] value */
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
Loading
Loading