Skip to content

Commit

Permalink
Constraint extension support (local and error case only) (#2199)
Browse files Browse the repository at this point in the history
* Create a base interface for validator

* Extend base interface to other validator

* Make validate suspend for QuestionnaireResponseItemConstraintValidator

* Add constraint-extension urls and severity types

* Create ConstraintExtensionValidator

* Put QR and expressionEvaluator in the validator constructor

* Change parameter from answers to responseItem

* Move the high order func for cqfCalculated value to the validator itself

* Add constraint sample to catalog

* Update tests

* Update TODO comment

* Use FHIRPathUtil convertToBoolean

* spotlessApply

* Rename ConstraintExtensionValidator to ConstraintItemExtensionValidator

* Fix test
  • Loading branch information
FikriMilano committed Mar 27, 2024
1 parent a5db185 commit b157fd4
Show file tree
Hide file tree
Showing 18 changed files with 444 additions and 146 deletions.
86 changes: 86 additions & 0 deletions catalog/src/main/assets/behavior_questionnaire_constraint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"resourceType": "Questionnaire",
"item": [
{
"linkId": "1",
"text": "Password",
"type": "string",
"item": [
{
"extension": [
{
"url": "http:https://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory",
"valueCodeableConcept": {
"coding": [
{
"system": "http:https://hl7.org/fhir/questionnaire-display-category",
"code": "instructions"
}
]
}
}
],
"linkId": "1.1",
"text": "Fill the password first",
"type": "display"
}
]
},
{
"extension": [
{
"url": "http:https://hl7.org/fhir/StructureDefinition/questionnaire-constraint",
"extension": [
{
"url": "key",
"valueId": "constraint-1"
},
{
"url": "requirements",
"valueString": "Confirm password field must have the same value as password field"
},
{
"url": "severity",
"valueCode": "error"
},
{
"url": "expression",
"valueString": "%context.answer.value = %resource.descendants().where(linkId='1').answer.value"
},
{
"url": "human",
"valueString": "Password does not match"
},
{
"url": "location",
"valueString": "1"
}
]
}
],
"linkId": "2",
"text": "Confirm password",
"type": "string",
"item": [
{
"extension": [
{
"url": "http:https://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory",
"valueCodeableConcept": {
"coding": [
{
"system": "http:https://hl7.org/fhir/questionnaire-display-category",
"code": "instructions"
}
]
}
}
],
"linkId": "2.1",
"text": "Show error message if confirm password does not match with password",
"type": "display"
}
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ class BehaviorListViewModel(application: Application) : AndroidViewModel(applica
R.string.behavior_name_dynamic_question_text,
"behavior_dynamic_question_text.json",
),
QUESTIONNAIRE_CONSTRAINT(
R.drawable.ic_rule,
R.string.behavior_name_questionnaire_constraint,
"behavior_questionnaire_constraint.json",
),
}

fun isBehavior(context: Context, title: String) =
Expand Down
12 changes: 12 additions & 0 deletions catalog/src/main/res/drawable/ic_rule.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<vector
xmlns:android="http:https://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960"
>
<path
android:fillColor="#1A73E8"
android:pathData="M576,800L534,758L645,647L534,536L576,494L687,605L798,494L840,536L729,647L840,758L798,800L687,689L576,800ZM659,426L517,284L559,242L659,341L838,162L880,205L659,426ZM80,670L80,610L440,610L440,670L80,670ZM80,350L80,290L440,290L440,350L80,350Z"
/>
</vector>
1 change: 1 addition & 0 deletions catalog/src/main/res/layout/behavior_list_fragment.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/component_horizontal_margin"
android:layout_marginBottom="@dimen/bottom_navigation_view_height"
/>

</FrameLayout>
3 changes: 3 additions & 0 deletions catalog/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
<string
name="behavior_name_dynamic_question_text"
>Dynamic question text</string>
<string
name="behavior_name_questionnaire_constraint"
>Questionnaire constraint</string>
<string name="component_name_initial_value">Initial Value</string>
<string
name="behavior_name_calculated_expression_info"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
externalValueSetResolver,
)

private val questionnaireResponseItemValidator: QuestionnaireResponseItemValidator =
QuestionnaireResponseItemValidator(expressionEvaluator)

/**
* Adds empty [QuestionnaireResponseItemComponent]s to `responseItems` so that each
* [QuestionnaireItemComponent] in `questionnaireItems` has at least one corresponding
Expand Down Expand Up @@ -729,17 +732,11 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
forceValidation ||
isInReviewModeFlow.value
) {
QuestionnaireResponseItemValidator.validate(
questionnaireResponseItemValidator.validate(
questionnaireItem,
questionnaireResponseItem.answer,
questionnaireResponseItem,
this@QuestionnaireViewModel.getApplication(),
) {
expressionEvaluator.evaluateExpressionValue(
questionnaireItem,
questionnaireResponseItem,
it,
)
}
)
} else {
NotValidated
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import org.hl7.fhir.r4.model.DecimalType
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.IntegerType
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.Reference
import org.hl7.fhir.r4.model.Resource
Expand Down Expand Up @@ -113,6 +114,27 @@ internal const val EXTENSION_MAX_SIZE = "http:https://hl7.org/fhir/StructureDefinition

internal const val EXTENSION_MIME_TYPE = "http:https://hl7.org/fhir/StructureDefinition/mimeType"

/**
* Extension for questionnaire and its items, representing a rule that must be satisfied before
* [QuestionnaireResponse] can be considered valid.
*
* See https://hl7.org/fhir/extensions/StructureDefinition-questionnaire-constraint.html.
*/
internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_URL =
"http:https://hl7.org/fhir/StructureDefinition/questionnaire-constraint"

internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_KEY = "key"

internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_REQUIREMENTS = "requirements"

internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_SEVERITY = "severity"

internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_EXPRESSION = "expression"

internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_HUMAN = "human"

internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_LOCATION = "location"

/**
* Extension for questionnaire items of integer and decimal types including a single unit to be
* displayed.
Expand Down Expand Up @@ -775,6 +797,14 @@ internal fun Questionnaire.QuestionnaireItemComponent.extractAnswerOptions(
}.map { Questionnaire.QuestionnaireItemAnswerOptionComponent(it) }
}

/** See http:https://hl7.org/fhir/constraint-severity */
enum class ConstraintSeverityTypes(
val code: String,
) {
ERROR("error"),
WARNING("warning"),
}

// ********************************************************************************************** //
// //
// Utilities: zip with questionnaire response item list, nested items, create response items, //
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type

/**
* Validates [QuestionnaireResponse.QuestionnaireResponseItemComponent] against a particular
* Validates [QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent] against a particular
* constraint.
*/
internal interface AnswerConstraintValidator {
internal interface AnswerConstraintValidator : ConstraintValidator {
/**
* Validates whether the [answer] satisfies any constraints of the [questionnaireItem] according
* to the [Structured Data Capture Implementation Guide]
Expand All @@ -42,11 +42,5 @@ internal interface AnswerConstraintValidator {
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
context: Context,
expressionEvaluator: suspend (Expression) -> Type?,
): Result

/**
* The validation result containing whether the answer is valid and any error message if it is not
* valid.
*/
data class Result(val isValid: Boolean, val errorMessage: String?)
): ConstraintValidator.Result
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ internal open class AnswerExtensionConstraintValidator(
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent,
context: Context,
expressionEvaluator: suspend (Expression) -> Type?,
): AnswerConstraintValidator.Result {
): ConstraintValidator.Result {
if (questionnaireItem.hasExtension(url)) {
val extension = questionnaireItem.getExtensionByUrl(url)
val extensionValue =
Expand All @@ -61,12 +61,12 @@ internal open class AnswerExtensionConstraintValidator(
if (
extensionValue.hasValue() && answer.value.hasValue() && predicate(extensionValue, answer)
) {
return AnswerConstraintValidator.Result(
return ConstraintValidator.Result(
false,
messageGenerator(extensionValue, context),
)
}
}
return AnswerConstraintValidator.Result(true, null)
return ConstraintValidator.Result(true, null)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.fhir.datacapture.validation

import android.content.Context
import com.google.android.fhir.datacapture.extensions.ConstraintSeverityTypes
import com.google.android.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_EXPRESSION
import com.google.android.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_HUMAN
import com.google.android.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_SEVERITY
import com.google.android.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_URL
import com.google.android.fhir.datacapture.extensions.asStringValue
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator
import com.google.android.fhir.datacapture.fhirpath.convertToBoolean
import org.hl7.fhir.r4.model.CodeType
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent

/**
* TODO: Add constraint support for global case, create a separate validator,
* https://github.com/google/android-fhir/issues/2479
*/
internal class ConstraintItemExtensionValidator(
private val expressionEvaluator: ExpressionEvaluator,
) : QuestionnaireResponseItemConstraintValidator {
override suspend fun validate(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
context: Context,
): List<ConstraintValidator.Result> {
return questionnaireItem.extension
.filter { extension ->
/**
* TODO: Add constraint support for warning case, update the [ConstraintValidator.Result]
* data class to also include warning state,
* https://github.com/google/android-fhir/issues/2480
*/
extension.url == EXTENSION_QUESTIONNAIRE_CONSTRAINT_URL &&
(extension.getExtensionByUrl(EXTENSION_QUESTIONNAIRE_CONSTRAINT_SEVERITY).value
as CodeType)
.valueAsString == ConstraintSeverityTypes.ERROR.code
}
.map { extension ->
val expression =
Expression().apply {
language = "text/fhirpath"
expression =
extension
.getExtensionByUrl(EXTENSION_QUESTIONNAIRE_CONSTRAINT_EXPRESSION)
.value
.asStringValue()
}
val isValid =
expressionEvaluator
.evaluateExpression(
questionnaireItem,
questionnaireResponseItem,
expression,
)
.let { convertToBoolean(it) }
if (isValid) {
ConstraintValidator.Result(true, null)
} else {
val errorMessage =
extension
.getExtensionByUrl(EXTENSION_QUESTIONNAIRE_CONSTRAINT_HUMAN)
.value
.asStringValue()
ConstraintValidator.Result(false, errorMessage)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.fhir.datacapture.validation

/** Validator base interface. */
internal interface ConstraintValidator {

/**
* The validation result containing whether the response item is valid and any error message if it
* is not valid.
*/
data class Result(val isValid: Boolean, val errorMessage: String?)
}
Loading

0 comments on commit b157fd4

Please sign in to comment.