From 553e31052a7c6e46f0fc02eb3ca9fee02180fb1f Mon Sep 17 00:00:00 2001 From: jingtang10 Date: Sun, 7 Feb 2021 18:06:19 +0000 Subject: [PATCH 1/2] Prototype definition-based extraction --- .../datacapture/mapping/ResourceMapper.kt | 180 ++++++++++++++++++ .../datacapture/mapping/ResourceMapperTest.kt | 126 ++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt create mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt new file mode 100644 index 0000000000..a640426a31 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2020 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://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.mapping + +import com.google.fhir.r4.core.Boolean +import com.google.fhir.r4.core.Date +import com.google.fhir.r4.core.DateTime +import com.google.fhir.r4.core.Decimal +import com.google.fhir.r4.core.Integer +import com.google.fhir.r4.core.Questionnaire +import com.google.fhir.r4.core.QuestionnaireItemTypeCode +import com.google.fhir.r4.core.QuestionnaireResponse +import com.google.fhir.r4.core.Time +import com.google.fhir.r4.core.Url +import com.google.fhir.shaded.protobuf.Message + +/** + * Maps [QuestionnaireResponse]s to FHIR resources and vice versa. + * + * The process of converting [QuestionnaireResponse]s to other FHIR resources is called + * [extraction](http://build.fhir.org/ig/HL7/sdc/extraction.html). The reverse process of converting + * existing FHIR resources to [QuestionnaireResponse]s to be used to pre-fill the UI is called + * [population](http://build.fhir.org/ig/HL7/sdc/populate.html). + * + * [Definition-based extraction](http://build.fhir.org/ig/HL7/sdc/extraction.html#definition-based-extraction) + * and [expression-based population](http://build.fhir.org/ig/HL7/sdc/populate.html#expression-based-population) + * are used because these approaches are generic enough to work with any FHIR resource types, and at + * the same time relatively easy to implement. + * + * WARNING: This is not production-ready. + */ +internal object ResourceMapper { + + /** + * Extract a FHIR resource from the `questionnaire` and `questionnaireResponse`. + * + * This method assumes there is only one FHIR resource to be extracted from the given + * `questionnaire` and `questionnaireResponse`. + */ + fun extract( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse + ): Message { + val builder = questionnaire.itemContextNameToExpressionMap.values.first().let { + Class.forName("com.google.fhir.r4.core.$it") + .getMethod("newBuilder") + .invoke(null) as Message.Builder + } + return builder + .extractFields(questionnaire.itemList, questionnaireResponse.itemList) + .build() + } +} + +/** + * Extracts values for fields in the builder from the corresponding questions and answers in + * [questionnaireItemList] and [questionnaireResponseItemList]. + */ +private fun Message.Builder.extractFields( + questionnaireItemList: List, + questionnaireResponseItemList: List +): Message.Builder { + val questionnaireItemListIterator = questionnaireItemList.iterator() + val questionnaireResponseItemListIterator = questionnaireResponseItemList.iterator() + while ( + questionnaireItemListIterator.hasNext() && + questionnaireResponseItemListIterator.hasNext() + ) { + val questionnaireItem = questionnaireItemListIterator.next() + val questionnaireResponseItem = questionnaireResponseItemListIterator.next() + this.extractField(questionnaireItem, questionnaireResponseItem) + extractFields(questionnaireItem.itemList, questionnaireResponseItem.itemList) + } + return this +} + +/** + * Extracts value for field in the builder from the corresponding question and answer in + * [questionnaireItem] and [questionnaireResponseItem]. + */ +private fun Message.Builder.extractField( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item +) { + val targetFieldName = questionnaireItem.definitionFieldName + if (targetFieldName.isEmpty()) { + return + } + + questionnaireItem.type.getClass()?.let { + this.javaClass.getMethod("set${targetFieldName.capitalize()}", it).invoke( + this, + questionnaireResponseItem.answerList.single().getValueForType(questionnaireItem.type) + ) + } +} + +/** + * Returns the field name for the [Questionnaire.Item]'s definition. + * + * For example, if the definition URI is + * `"http://hl7.org/fhir/StructureDefinition/Patient#Patient.birthDate"`, this function will return + * `"birthDate"`. + */ +private val Questionnaire.Item.definitionFieldName + get() = this.definition.value.substringAfterLast(".") + +/** + * Returns the [Class] for the answer to the [Questionnaire.Item]. + * + * Used to retrieve the method to invoke to set the field in the extracted FHIR resource. + */ +private fun Questionnaire.Item.TypeCode.getClass(): Class? = when (this.value) { + QuestionnaireItemTypeCode.Value.DATE -> Date::class.java + QuestionnaireItemTypeCode.Value.BOOLEAN -> Boolean::class.java + QuestionnaireItemTypeCode.Value.DECIMAL -> Decimal::class.java + QuestionnaireItemTypeCode.Value.INTEGER -> Integer::class.java + QuestionnaireItemTypeCode.Value.DATE_TIME -> DateTime::class.java + QuestionnaireItemTypeCode.Value.TIME -> Time::class.java + QuestionnaireItemTypeCode.Value.STRING, QuestionnaireItemTypeCode.Value.TEXT -> + com.google.fhir.r4.core.String::class.java + QuestionnaireItemTypeCode.Value.URL -> Url::class.java + else -> null +} + +/** + * Returns the value of the [QuestionnaireResponse.Item.Answer] for the [type]. + * + * Used to retrieve the value to set the field in the extracted FHIR resource. + * + * @throws IllegalArgumentException if [type] is not supported (for example, questions of type + * [QuestionnaireItemTypeCode.Value.GROUP] do not collect any answer). + */ +private fun QuestionnaireResponse.Item.Answer.getValueForType( + type: Questionnaire.Item.TypeCode +): Message = when (val value = type.value) { + QuestionnaireItemTypeCode.Value.DATE -> this.value.date + QuestionnaireItemTypeCode.Value.BOOLEAN -> this.value.boolean + QuestionnaireItemTypeCode.Value.DECIMAL -> this.value.decimal + QuestionnaireItemTypeCode.Value.INTEGER -> this.value.integer + QuestionnaireItemTypeCode.Value.DATE_TIME -> this.value.dateTime + QuestionnaireItemTypeCode.Value.TIME -> this.value.time + QuestionnaireItemTypeCode.Value.STRING, QuestionnaireItemTypeCode.Value.TEXT -> + this.value.stringValue + QuestionnaireItemTypeCode.Value.URL -> this.value.uri + else -> throw IllegalArgumentException("Unsupported value type $value") +} + +/** + * The map from the `name`s to `expression`s in the [item extraction context extension](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-itemExtractionContext.html)s. + */ +private val Questionnaire.itemContextNameToExpressionMap: Map + get() { + return this.extensionList.filter { + it.url.value == ITEM_CONTEXT_EXTENSION_URL + }.map { + val expression = it.value.expression + expression.name.value to expression.expression.value + }.toMap() + } + +/** + * See [Extension: item extraction context](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-itemExtractionContext.html). + */ +private const val ITEM_CONTEXT_EXTENSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemContext" diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt new file mode 100644 index 0000000000..1b20b23b94 --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2020 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://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.mapping + +import android.os.Build +import com.google.common.truth.Truth.assertThat +import com.google.fhir.common.JsonFormat +import com.google.fhir.r4.core.Date +import com.google.fhir.r4.core.Patient +import com.google.fhir.r4.core.Questionnaire +import com.google.fhir.r4.core.QuestionnaireResponse +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class ResourceMapperTest { + @Test + fun extract() { + // https://developer.commure.com/docs/apis/sdc/examples#definition-based-extraction + val questionnaireJson = """ + { + "resourceType": "Questionnaire", + "subjectType": [ + "Patient" + ], + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemContext", + "valueExpression": { + "language": "application/x-fhir-query", + "expression": "Patient", + "name": "patient" + } + } + ], + "item": [ + { + "linkId": "patient-0", + "type": "group", + "item": [ + { + "linkId": "patient-0-birth-date", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.birthDate", + "type": "date" + }, + { + "linkId": "patient-0-active", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.active", + "type": "boolean" + } + ] + } + ] + } + """.trimIndent() + val questionnaireBuilder = Questionnaire.newBuilder() + JsonFormat.getParser().merge(questionnaireJson, questionnaireBuilder) + + val questionnaireResponseJson = """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "patient-0", + "item": [ + { + "linkId": "patient-0-birth-date", + "answer": [ + { + "valueDate": "2021-01-01" + } + ] + }, + { + "linkId": "patient-0-active", + "answer": [ + { + "valueBoolean": true + } + ] + } + ] + } + ] + } + """.trimIndent() + val questionnaireResponseBuilder = QuestionnaireResponse.newBuilder() + JsonFormat.getParser().merge(questionnaireResponseJson, questionnaireResponseBuilder) + + val patient = ResourceMapper.extract( + questionnaireBuilder.build(), + questionnaireResponseBuilder.build() + ) as Patient + + assertThat(patient.birthDate).isEqualTo( + Date.newBuilder() + .setValueUs( + LocalDate.of(2021, 1, 1) + .atStartOfDay(ZoneId.systemDefault()) + .toEpochSecond() * 1000000) + .setTimezone(ZoneId.systemDefault().id) + .setPrecision(Date.Precision.DAY) + .build() + ) + assertThat(patient.active.value).isTrue() + } +} From 5f8c6800a22e6360ea41fb1e1c78a4dbeefa4c55 Mon Sep 17 00:00:00 2001 From: jingtang10 Date: Sun, 14 Feb 2021 13:57:37 +0000 Subject: [PATCH 2/2] Add a warning that nested fields are not handled yet. --- .../google/android/fhir/datacapture/mapping/ResourceMapper.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt index a640426a31..74db047cc7 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -91,6 +91,8 @@ private fun Message.Builder.extractFields( /** * Extracts value for field in the builder from the corresponding question and answer in * [questionnaireItem] and [questionnaireResponseItem]. + * + * NOTE: Nested fields are not handled. See https://github.com/google/android-fhir/issues/240. */ private fun Message.Builder.extractField( questionnaireItem: Questionnaire.Item,