diff --git a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt index 03034afb..130bcf3d 100644 --- a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt +++ b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Main.kt @@ -6,6 +6,7 @@ import ca.uhn.fhir.parser.IParser import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.prompt +import org.apache.commons.codec.Resources import com.google.gson.GsonBuilder import org.apache.commons.io.FileUtils import org.apache.poi.ss.usermodel.CellType @@ -22,6 +23,7 @@ import org.hl7.fhir.utilities.npm.ToolsVersion import java.io.File import java.io.FileInputStream import java.nio.charset.Charset +import java.util.* fun main(args: Array) { Application().main(args) @@ -48,31 +50,10 @@ REMAINING TASKS */ class Application : CliktCommand() { - val xlsFileName: String by option(help = "XLS filepath").prompt("Kindly enter the XLS filename") - // val xlsfile: String by option(help = "XLS filepath").prompt("Kindly enter the XLS filepath") - val questionnaireFileName: String by option(help = "Questionnaire filename").prompt("Kindly enter the questionnaire filename") - //val questionnairefile : String by option(help = "Questionnaire filepath").prompt("Kindly enter the questionnaire filepath") + val xlsfile: String by option(help = "XLS filepath").prompt("Kindly enter the XLS filepath") + val questionnairefile : String by option(help = "Questionnaire filepath").prompt("Kindly enter the questionnaire filepath") override fun run() { - var xlsfile= "" - var questionnairefile = "" - - val xlsUrl = Application::class.java.getResource("/$xlsFileName") - val questionnaireUrl = Application::class.java.getResource("/$questionnaireFileName") - - // Check if the resource exists - if (xlsUrl != null && questionnaireUrl != null) { - // Xls file path extraction - val xlsfilePath = File(xlsUrl.toURI()) - xlsfile = xlsfilePath.absolutePath - - // questionnaire file path extraction - val questionnaireFilePath = File(questionnaireUrl.toURI()) - questionnairefile = questionnaireFilePath.absolutePath - } else { - println("Resource not found: $xlsFileName") - println("Resource not found: $questionnaireFileName") - } /* @@ -121,6 +102,7 @@ class Application : CliktCommand() { val xlsFile = FileInputStream(xlsfile) val xlWb = WorkbookFactory.create(xlsFile) + // TODO: Check that all the Resource(s) ub the Resource column are the correct name and type eg. RiskFlag in the previous XLSX was not valid // TODO: Check that all the path's and other entries in the excel sheet are valid // TODO: Add instructions for adding embedded classes like `RiskAssessment$RiskAssessmentPredictionComponent` to the TransformSupportServices @@ -151,110 +133,128 @@ class Application : CliktCommand() { TODO: Fix Groups calling sequence so that Groups that depend on other resources to be generated need to be called first We can also throw an exception if to figure out cyclic dependency. Good candidate for Floyd's tortoise and/or topological sorting 😁. Cool!!!! */ + val questionnaireResponseItemIds = questionnaireResponse.item.map { it.id } + if(questionnaireId != null && questionnaireResponseItemIds.isNotEmpty()){ - val sb = StringBuilder() - val structureMapHeader = """ - map "http://hl7.org/fhir/StructureMap/$questionnaireId" = '${questionnaireId?.clean()}' + val sb = StringBuilder() + val structureMapHeader = """ + map "http://hl7.org/fhir/StructureMap/$questionnaireId" = '${questionnaireId.clean()}' uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireReponse" as source uses "http://hl7.org/fhir/StructureDefinition/Bundle" as target """.trimIndent() - val structureMapBody = """ - group ${questionnaireId?.clean()}(source src : QuestionnaireResponse, target bundle: Bundle) { + val structureMapBody = """ + group ${questionnaireId.clean()}(source src : QuestionnaireResponse, target bundle: Bundle) { src -> bundle.id = uuid() "rule_c"; src -> bundle.type = 'collection' "rule_b"; src -> bundle.entry as entry then """.trimIndent() - /* + /* - Create a mapping of COLUMN_NAMES to COLUMN indexes + Create a mapping of COLUMN_NAMES to COLUMN indexes - */ - //val mapColumns + */ + //val mapColumns - val lineNos = 1 - var firstResource = true - val extractionResources = hashMapOf() - val resourceConversionInstructions = hashMapOf>() + val lineNos = 1 + var firstResource = true + val extractionResources = hashMapOf() + val resourceConversionInstructions = hashMapOf>() - // Group the rules according to the resource - val fieldMappingsSheet = xlWb.getSheet("Field Mappings") - fieldMappingsSheet.forEachIndexed { index, row -> - if (index == 0) return@forEachIndexed + // Group the rules according to the resource + val fieldMappingsSheet = xlWb.getSheet("Field Mappings") + fieldMappingsSheet.forEachIndexed { index, row -> + if (index == 0) return@forEachIndexed - if (row.isEmpty()) { - return@forEachIndexed - } + if (row.isEmpty()) { + return@forEachIndexed + } - val instruction = row.getInstruction() - if (instruction.resource.isNotEmpty()) { - resourceConversionInstructions.computeIfAbsent(instruction.searchKey(), { key -> mutableListOf() }) - .add(instruction) - } - } - //val resource = ?: Class.forName("org.hl7.fhir.r4.model.$resourceName").newInstance() as Resource + val instruction = row.getInstruction() + val xlsId = instruction.responseFieldId + val comparedResponseAndXlsId = questionnaireResponseItemIds.contains(xlsId) + if (instruction.resource.isNotEmpty() && comparedResponseAndXlsId) { + resourceConversionInstructions.computeIfAbsent(instruction.searchKey(), { key -> mutableListOf() }) + .add(instruction) + } + } + //val resource = ?: Class.forName("org.hl7.fhir.r4.model.$resourceName").newInstance() as Resource - // Perform the extraction for the row - /*generateStructureMapLine(structureMapBody, row, resource, extractionResources) - extractionResources[resourceName + resourceIndex] = resource*/ + // Perform the extraction for the row + /*generateStructureMapLine(structureMapBody, row, resource, extractionResources) - sb.append(structureMapHeader) - sb.appendNewLine().appendNewLine().appendNewLine() - sb.append(structureMapBody) + extractionResources[resourceName + resourceIndex] = resource*/ - // Fix the questions path - val questionsPath = getQuestionsPath(questionnaire) + sb.append(structureMapHeader) + sb.appendNewLine().appendNewLine().appendNewLine() + sb.append(structureMapBody) - // TODO: Generate the links to the group names here - var index = 0 - var len = resourceConversionInstructions.size - resourceConversionInstructions.forEach { entry -> - val resourceName = entry.key.capitalize() - if (index++ != 0) sb.append(", ") - sb.append("Extract$resourceName(src, bundle)") - } - sb.append(""" "rule_a";""".trimMargin()) - sb.appendNewLine() - sb.append("}") + // Fix the questions path + val questionsPath = getQuestionsPath(questionnaire) - // Add the embedded instructions - val groupNames = mutableListOf() + // TODO: Generate the links to the group names here + var index = 0 + var len = resourceConversionInstructions.size + var resourceName = "" + resourceConversionInstructions.forEach { entry -> + resourceName = entry.key.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + if (index++ != 0) sb.append(",") + if(resourceName.isNotEmpty()) sb.append("Extract$resourceName(src, bundle)") + } + sb.append(""" "rule_a";""".trimMargin()) + sb.appendNewLine() + sb.append("}") - sb.appendNewLine().appendNewLine().appendNewLine() + // Add the embedded instructions + val groupNames = mutableListOf() - resourceConversionInstructions.forEach { - Group(it, sb, questionsPath) - .generateGroup(questionnaireResponse) - } + sb.appendNewLine().appendNewLine().appendNewLine() - val structureMapString = sb.toString() - try { - val simpleWorkerContext = SimpleWorkerContext().apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true + resourceConversionInstructions.forEach { + Group(it, sb, questionsPath) + .generateGroup(questionnaireResponse) } - val transformSupportServices = TransformSupportServices(simpleWorkerContext) - val scu = org.hl7.fhir.r4.utils.StructureMapUtilities(simpleWorkerContext, transformSupportServices) - val structureMap = scu.parse(structureMapString, questionnaireId!!.clean()) - // DataFormatException | FHIRLexerException - - val bundle = Bundle() - scu.transform(contextR4, questionnaireResponse, structureMap, bundle) + val structureMapString = sb.toString() + try { + val simpleWorkerContext = SimpleWorkerContext().apply { + setExpansionProfile(Parameters()) + isCanRunWithoutTerminology = true + } + val transformSupportServices = TransformSupportServices(simpleWorkerContext) + val scu = org.hl7.fhir.r4.utils.StructureMapUtilities(simpleWorkerContext, transformSupportServices) + val structureMap = scu.parse(structureMapString, questionnaireId.clean()) + // DataFormatException | FHIRLexerException + + try{ + val bundle = Bundle() + scu.transform(contextR4, questionnaireResponse, structureMap, bundle) + val jsonParser = FhirContext.forR4().newJsonParser() + + println(jsonParser.encodeResourceToString(bundle)) + } catch (e:Exception){ + e.printStackTrace() + } + + } catch (ex: Exception) { + println("The generated StructureMap has a formatting error") + ex.printStackTrace() + } - val jsonParser = FhirContext.forR4().newJsonParser() + var finalStructureMap = sb.toString() + finalStructureMap = finalStructureMap.addIdentation() + println(finalStructureMap) - println(jsonParser.encodeResourceToString(bundle)) - } catch (ex: Exception) { - System.out.println("The generated StructureMap has a formatting error") - ex.printStackTrace() + // TODO: Generate JSON version + // TODO: Provide both as new files + writeStructureMapOutput(sb.toString().addIdentation()) } - writeStructureMapOutput(sb.toString().addIdentation()) + } fun Row.getInstruction() : Instruction { diff --git a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt index 7326bd98..7c61858c 100644 --- a/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt +++ b/sm-gen/src/main/kotlin/org.smartregister.fhir.structuremaptool/Utils.kt @@ -14,10 +14,10 @@ import org.hl7.fhir.r4.utils.FHIRPathEngine import java.lang.reflect.Field import java.lang.reflect.ParameterizedType -fun getQuestionsPath(questionnaire: Questionnaire) : HashMap { +fun getQuestionsPath(questionnaire: Questionnaire): HashMap { val questionsMap = hashMapOf() - questionnaire.item.forEach {itemComponent -> + questionnaire.item.forEach { itemComponent -> getQuestionNames("", itemComponent, questionsMap) } return questionsMap @@ -33,7 +33,11 @@ fun getQuestionNames(parentName: String, item: QuestionnaireItemComponent, quest } -class Group (entry : Map.Entry>, val stringBuilder : StringBuilder, val questionsPath : HashMap) { +class Group( + entry: Map.Entry>, + val stringBuilder: StringBuilder, + val questionsPath: HashMap +) { var lineCounter = 0 var groupName = entry.key @@ -44,8 +48,10 @@ class Group (entry : Map.Entry>, val stringBuil val resourceName = instructions[0].resource stringBuilder.appendNewLine() - stringBuilder.append("group Extract$groupName(source src : QuestionniareResponse, target bundle: Bundle) {").appendNewLine() - stringBuilder.append("src -> bundle.entry as entry, entry.resource = create('$resourceName') as entity1 then {").appendNewLine() + stringBuilder.append("group Extract$groupName(source src : QuestionniareResponse, target bundle: Bundle) {") + .appendNewLine() + stringBuilder.append("src -> bundle.entry as entry, entry.resource = create('$resourceName') as entity1 then {") + .appendNewLine() // TODO: Remove below and replace with Nest.buildStructureMap /*instructions.forEachIndexed { index, instruction -> @@ -84,11 +90,11 @@ class Group (entry : Map.Entry>, val stringBuil stringBuilder.append(""" "${groupName}_${lineCounter++}"; """) } - fun Instruction.getPropertyPath() : String { + fun Instruction.getPropertyPath(): String { return questionsPath.getOrDefault(responseFieldId, "") } - fun Instruction.getAnswerExpression(questionnaireResponse: QuestionnaireResponse) : String { + fun Instruction.getAnswerExpression(questionnaireResponse: QuestionnaireResponse): String { //1. If the answer is static/literal, just return it here // TODO: We should infer the resource element and add the correct conversion or code to assign this correctly @@ -162,6 +168,7 @@ class Group (entry : Map.Entry>, val stringBuil inner class Nest { var instruction: Instruction? = null + // We can change this to a linked list val nests = ArrayList() lateinit var name: String @@ -215,9 +222,9 @@ class Group (entry : Map.Entry>, val stringBuil } else { fullPath = partName } - resourceName = inferType("${this@Nest.resourceName}.$fullPath") ?:"" + resourceName = inferType("${this@Nest.resourceName}.$fullPath") ?: "" - if ((parts[0].isEmpty() && parts.size > 2) || (parts[0].isNotEmpty() && parts.size > 1) ) { + if ((parts[0].isEmpty() && parts.size > 2) || (parts[0].isNotEmpty() && parts.size > 1)) { val nextInstruction = Instruction().apply { copyFrom(instruction) var newFieldPath = "" @@ -240,7 +247,7 @@ class Group (entry : Map.Entry>, val stringBuil name = remainingPath fullPath = instruction.fieldPath this@apply.instruction = instruction - resourceName = inferType("${this@Nest.resourceName}.$fullPath") ?:"" + resourceName = inferType("${this@Nest.resourceName}.$fullPath") ?: "" }) } } @@ -255,14 +262,17 @@ class Group (entry : Map.Entry>, val stringBuil val propertyType = inferType(instruction!!.fullPropertyPath()) val answerType = answerExpression.getAnswerType(questionnaireResponse) - if (propertyType != "Type" && answerType != propertyType && propertyType?.canHandleConversion(answerType?:"")?.not() == true && answerExpression.startsWith("evaluate")) { - System.out.println("Failed type matching --> ${instruction!!.fullPropertyPath()} of type $answerType != $propertyType") + if (propertyType != "Type" && answerType != propertyType && propertyType?.canHandleConversion( + answerType ?: "" + )?.not() == true && answerExpression.startsWith("evaluate") + ) { + println("Failed type matching --> ${instruction!!.fullPropertyPath()} of type $answerType != $propertyType") /*val possibleTypes = listOf<>() if ()*/ stringBuilder.append("src -> entity$currLevel.${instruction!!.fieldPath} = ") - stringBuilder.append("create('${propertyType?.getFhirType()}') as randomVal, randomVal.value = ") + stringBuilder.append("create('${propertyType.getFhirType()}') as randomVal, randomVal.value = ") stringBuilder.append(answerExpression) addRuleNo() stringBuilder.appendNewLine() @@ -309,25 +319,34 @@ class Group (entry : Map.Entry>, val stringBuil } } -fun generateStructureMapLine(structureMapBody: StringBuilder, row: Row, resource: Resource, extractionResources: HashMap) { + +fun generateStructureMapLine( + structureMapBody: StringBuilder, + row: Row, + resource: Resource, + extractionResources: HashMap +) { row.forEachIndexed { index, cell -> - val cellValue =cell.stringCellValue + val cellValue = cell.stringCellValue val fieldPath = row.getCell(4).stringCellValue val targetDataType = determineFhirDataType(cellValue) structureMapBody.append("src -> entity.${fieldPath}=") - when(targetDataType){ + when (targetDataType) { "string" -> { structureMapBody.append("create('string').value ='$cellValue'") } + "integer" -> { structureMapBody.append("create('integer').value = $cellValue") } + "boolean" -> { val booleanValue = if (cellValue.equals("true", ignoreCase = true)) "true" else "false" structureMapBody.append("create('boolean').value = $booleanValue") } + else -> { structureMapBody.append("create('unsupportedDataType').value = '$cellValue'") } @@ -335,7 +354,8 @@ fun generateStructureMapLine(structureMapBody: StringBuilder, row: Row, resource structureMapBody.appendNewLine() } } -fun determineFhirDataType(cellValue: String):String{ + +fun determineFhirDataType(cellValue: String): String { val cleanedValue = cellValue.trim().toLowerCase() when { @@ -350,7 +370,7 @@ fun determineFhirDataType(cellValue: String):String{ } } -fun StringBuilder.appendNewLine() : StringBuilder { +fun StringBuilder.appendNewLine(): StringBuilder { append(System.lineSeparator()) return this } @@ -376,7 +396,7 @@ private fun Class<*>.getFieldOrNull(name: String): Field? { } } -private fun String.isCoding(questionnaireResponse: QuestionnaireResponse) : Boolean { +private fun String.isCoding(questionnaireResponse: QuestionnaireResponse): Boolean { val answerType = getType(questionnaireResponse) return if (answerType != null) { answerType == "org.hl7.fhir.r4.model.Coding" @@ -385,7 +405,7 @@ private fun String.isCoding(questionnaireResponse: QuestionnaireResponse) : Bool } } -private fun String.getType(questionnaireResponse: QuestionnaireResponse) : String? { +private fun String.getType(questionnaireResponse: QuestionnaireResponse): String? { val answer = fhirPathEngine.evaluate(questionnaireResponse, this) return answer.firstOrNull()?.javaClass?.name @@ -399,13 +419,12 @@ internal val fhirPathEngine: FHIRPathEngine = } } -private fun String.isEnumeration(instruction: Instruction) : Boolean { +private fun String.isEnumeration(instruction: Instruction): Boolean { return inferType(instruction.fullPropertyPath())?.contains("Enumeration") ?: false } - -fun String.getAnswerType(questionnaireResponse: QuestionnaireResponse) : String? { +fun String.getAnswerType(questionnaireResponse: QuestionnaireResponse): String? { return if (isEvaluateExpression()) { val fhirPath = substring(indexOf(",") + 1, length - 1) @@ -418,26 +437,31 @@ fun String.getAnswerType(questionnaireResponse: QuestionnaireResponse) : String? } // TODO: Confirm and fix this -fun String.isEvaluateExpression() : Boolean = startsWith("evaluate(") +fun String.isEvaluateExpression(): Boolean = startsWith("evaluate(") /** * Infer's the type and return the short class name eg `HumanName` for org.fhir.hl7.r4.model.Patient * when given the path `Patient.name` */ -fun inferType(propertyPath: String) : String? { +fun inferType(propertyPath: String): String? { // TODO: Handle possible errors // TODO: Handle inferring nested types - + val contextR4 = FhirContext.forR4() + val fhirResources = contextR4.resourceTypes val parts = propertyPath.split(".") val parentResourceClassName = parts[0] + lateinit var parentClass: Class<*> - val parentClass = Class.forName("org.hl7.fhir.r4.model.$parentResourceClassName") - - return inferType(parentClass, parts, 1) + if (fhirResources.contains(parentResourceClassName)) { + parentClass = Class.forName("org.hl7.fhir.r4.model.$parentResourceClassName") + return inferType(parentClass, parts, 1) + } else { + return null + } } -fun inferType(parentClass: Class<*>?, parts: List, index: Int) : String? { +fun inferType(parentClass: Class<*>?, parts: List, index: Int): String? { val resourcePropertyName = parts[index] val propertyField = parentClass?.getFieldOrNull(resourcePropertyName) @@ -457,20 +481,25 @@ fun inferType(parentClass: Class<*>?, parts: List, index: Int) : String? ?.replace("org.hl7.fhir.r4.model.", "") } -fun String.isMultipleTypes() : Boolean = this == "Type" +fun String.isMultipleTypes(): Boolean = this == "Type" // TODO: Finish this. Use the annotation @Chid.type -fun String.getPossibleTypes() : List { +fun String.getPossibleTypes(): List { return listOf() } - -fun String.canHandleConversion(sourceType: String) : Boolean { +fun String.canHandleConversion(sourceType: String): Boolean { val propertyClass = Class.forName("org.hl7.fhir.r4.model.$this") - val targetType2 = if (sourceType == "StringType") String::class.java else Class.forName("org.hl7.fhir.r4.model.$sourceType") + val targetType2 = + if (sourceType == "StringType") String::class.java else Class.forName("org.hl7.fhir.r4.model.$sourceType") - val possibleConversions = listOf("BooleanType" to "StringType", "DateType" to "StringType", "DecimalType" to "IntegerType", "AdministrativeGender" to "CodeType") + val possibleConversions = listOf( + "BooleanType" to "StringType", + "DateType" to "StringType", + "DecimalType" to "IntegerType", + "AdministrativeGender" to "CodeType" + ) possibleConversions.forEach { if (this.contains(it.first) && sourceType == it.second) { @@ -487,14 +516,14 @@ fun String.canHandleConversion(sourceType: String) : Boolean { return true } -fun String.getParentResource() : String? { +fun String.getParentResource(): String? { return substring(0, lastIndexOf('.')) } -fun String.getResourceProperty() : String? { +fun String.getResourceProperty(): String? { return substring(lastIndexOf('.') + 1) } -fun String.getFhirType() : String = replace("Type", "") +fun String.getFhirType(): String = replace("Type", "") .lowercase() \ No newline at end of file