Skip to content

Commit

Permalink
Refactor to not modify extension in questionnaire
Browse files Browse the repository at this point in the history
When evaluating cqf-calculatedValue expression for validation
  • Loading branch information
LZRS committed Jan 11, 2024
1 parent 080e4b3 commit a49d803
Show file tree
Hide file tree
Showing 29 changed files with 648 additions and 466 deletions.
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 @@ -30,19 +30,20 @@ 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.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 @@ -68,15 +69,12 @@ 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.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 @@ -574,23 +572,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 @@ -681,27 +662,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
)
}

private fun resolveCqfCalculatedValueExpressions(
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 @@ -740,9 +700,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 @@ -754,14 +711,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
questionnaireItem,
questionnaireResponseItem.answer,
context = this@QuestionnaireViewModel.getApplication(),
)
) { _, expression ->
expressionEvaluator.evaluateExpressionValue(
questionnaireItem,
questionnaireResponseItem,
expression,
)
}
} 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 @@ -789,6 +753,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
@@ -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 @@ -292,13 +292,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 All @@ -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 when type is of [PrimitiveType] or [Quantity], otherwise null
*/
private fun getValueString(type: Type): String? =
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 All @@ -124,30 +123,10 @@ internal fun Coding.toCodeType(): CodeType {
return CodeType(code)
}

fun Type.valueOrCalculateValue(): Type {
return if (getValueString(this) != null) {
this
} else {
this.cqfCalculatedValueExpression?.let { expression ->
try {
evaluateToBase(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
@@ -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 @@ -36,20 +40,39 @@ internal open class AnswerExtensionConstraintValidator(
val url: String,
val predicate:
(
Extension,
/*extensionValue*/
Type,
/*answer*/
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
) -> Boolean,
val messageGenerator: (Extension, Context) -> String,
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
Loading

0 comments on commit a49d803

Please sign in to comment.