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 7 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 @@ -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 All @@ -67,8 +70,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
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.Questionnaire
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse
Expand Down Expand Up @@ -585,23 +586,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}

private 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 @@ -740,18 +724,26 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
QuestionnaireResponseItemValidator.validate(
questionnaireItem,
questionnaireResponseItem.answer,
this@QuestionnaireViewModel.getApplication(),
)
context = this@QuestionnaireViewModel.getApplication(),
LZRS marked this conversation as resolved.
Show resolved Hide resolved
) { _, expression ->
expressionEvaluator.evaluateExpressionValue(
questionnaireItem,
questionnaireResponseItem,
expression,
)
}
} 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 @@ -778,6 +770,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 All @@ -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 when type is of [PrimitiveType] or [Quantity], otherwise null
*/
private fun getValueString(type: Type): String? =
jingtang10 marked this conversation as resolved.
Show resolved Hide resolved
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 All @@ -124,16 +123,10 @@ internal fun Coding.toCodeType(): CodeType {
return CodeType(code)
}

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
@@ -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 All @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.fhirpath
import com.google.android.fhir.datacapture.extensions.calculatedExpression
import com.google.android.fhir.datacapture.extensions.findVariableExpression
import com.google.android.fhir.datacapture.extensions.flattened
import com.google.android.fhir.datacapture.extensions.isFhirPath
import com.google.android.fhir.datacapture.extensions.isReferencedBy
import com.google.android.fhir.datacapture.extensions.variableExpressions
import org.hl7.fhir.exceptions.FHIRException
Expand Down Expand Up @@ -135,6 +136,24 @@ internal class ExpressionEvaluator(
)
}

/** Returns the evaluation result of an expression as a [Type] value */
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 @@ -17,8 +17,11 @@
package com.google.android.fhir.datacapture.validation

import android.content.Context
import org.hl7.fhir.r4.model.Expression
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

/**
* Validates [QuestionnaireResponse.QuestionnaireResponseItemComponent] against a particular
Expand All @@ -39,6 +42,7 @@ internal interface AnswerConstraintValidator {
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
context: Context,
evaluateExtensionCqfCalculatedValue: (Extension, 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,13 @@
package com.google.android.fhir.datacapture.validation

import android.content.Context
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.Extension
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 +39,40 @@ 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,
(
/*extensionValue*/
Type,
/*answer*/
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
) -> Boolean,
val messageGenerator: (Type, Context) -> String,
) : AnswerConstraintValidator {
override fun validate(
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
context: Context,
evaluateExtensionCqfCalculatedValue: (Extension, 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 extensionValueType =
extension.value.let {
it.cqfCalculatedValueExpression?.let { expression ->
evaluateExtensionCqfCalculatedValue(extension, expression)
}
?: it
}

// Only checks constraint if both extension and answer have a value
if (
extensionValueType.hasValue() &&
answer.value.hasValue() &&
predicate(extensionValueType, answer)
) {
return AnswerConstraintValidator.Result(
false,
messageGenerator(extensionValueType, context),
)
}
}
return AnswerConstraintValidator.Result(true, null)
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 All @@ -25,24 +25,24 @@ package com.google.android.fhir.datacapture.validation
*/
import android.content.Context
import com.google.android.fhir.datacapture.R
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.IntegerType
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type

internal object MaxDecimalPlacesValidator :
AnswerExtensionConstraintValidator(
url = MAX_DECIMAL_URL,
predicate = {
extension: Extension,
extensionValue: Type,
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
->
val maxDecimalPlaces = (extension.value as? IntegerType)?.value
val maxDecimalPlaces = (extensionValue as? IntegerType)?.value
answer.hasValueDecimalType() &&
maxDecimalPlaces != null &&
answer.valueDecimalType.valueAsString.substringAfter(".").length > maxDecimalPlaces
},
messageGenerator = { extension: Extension, context: Context ->
context.getString(R.string.max_decimal_validation_error_msg, extension.value.primitiveValue())
messageGenerator = { extensionValue: Type, context: Context ->
context.getString(R.string.max_decimal_validation_error_msg, extensionValue.primitiveValue())
},
)

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 @@ -18,8 +18,11 @@ package com.google.android.fhir.datacapture.validation

import android.content.Context
import com.google.android.fhir.datacapture.extensions.asStringValue
import org.hl7.fhir.r4.model.Expression
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

/**
* A validator to check if the answer exceeds the maximum number of permitted characters.
Expand All @@ -32,6 +35,7 @@ internal object MaxLengthValidator : AnswerConstraintValidator {
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
context: Context,
evaluateExtensionCqfCalculatedValue: (Extension, Expression) -> Type?,
): AnswerConstraintValidator.Result {
if (
questionnaireItem.hasMaxLength() &&
Expand Down
Loading
Loading