-
Notifications
You must be signed in to change notification settings - Fork 9
Generate enum classes #10
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
base: main
Are you sure you want to change the base?
Changes from all commits
acb26cd
c3d4f4f
3e93a3a
32e5c7f
e5ef44c
78eec34
22f9d1f
94a0337
4738d83
8df843a
3d52009
e803d2e
921df63
428e133
3ac0125
0f4bc97
641f441
fd7790e
0a2d310
9f6e905
9a72b99
7453d2f
ae1e472
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -161,6 +161,8 @@ single sealed interface is generated with a subclass for each type. | |
|----------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------| | ||
| StructureDefinition JSON file (e.g. `StructureDefinition-Patient.json`) | Kotlin .kt file (e.g. `Patient.kt`) | | ||
| StructureDefinition (e.g. `Patient`) | Kotlin class (e.g. `class Patient`) | | ||
| ValueSet JSON file (e.g. `ValueSet-resource-types.json`) | Kotlin .kt file (e.g. `ResourceType`) | | ||
| ValueSet (e.g. `ResourceType`) | Kotlin class (e.g. `enum class ResourceType`) | | ||
| BackboneElement (e.g. `Patient.contact`) | Nested Kotlin class (e.g. `class Contact` nested under `Patient`) | | ||
| Choice of data types (e.g. `Patient.deceased[x]`) | Sealed interface (e.g. `sealed interface Deceased` nested under `Patient` with subtypes `Boolean` and `DateTime`) | | ||
|
||
|
@@ -195,6 +197,29 @@ when (val multipleBirth = patient.multipleBirth) { | |
The generated classes reflect the inheritance hierarchy defined by FHIR. For example, `Patient` | ||
inherits from `DomainResource`, which inherits from `Resource`. | ||
|
||
The constants in the generated Kotlin enum classes are derived from the code property of concepts found in FHIR `CodeSystem` | ||
and `ValueSet` resources. To comply with Kotlin’s enum naming conventions—which require names to start with a letter | ||
and avoid special characters—each code is transformed using a set of formatting rules. This includes handling numeric codes, | ||
special characters, and FHIR URLs. After all transformations, the final name is converted to PascalCase to match Kotlin style guidelines. | ||
|
||
| Rule # | Description | Example Input | Example Output | | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This table is great! One thing - is it possible to add a column that includes some examples of codes that are being converted under these rules. For example, I imagine a lot of the comparison ones (e.g. >=, <, etc.) in this table are actually from some specific value set / code system? |
||
|--------|----------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|-------------------| | ||
| 1 | Codes made up entirely of digits are prefixed with an underscore | `1111` | `_1111` | | ||
| 2 | Dashes (`-`) and dots (`.`) are replaced with underscores (`_`) | `4.0.1` | `_4_0_1` | | ||
| 3 | All other unsupported special characters are removed (if not explicitly handled below) | `some@code!name` | `Somecodename` | | ||
| 4 | Specific special characters are replaced with readable keywords | `>=` | `GreaterOrEquals` | | ||
| | | `<` | `LessThan` | | ||
| | | `!=` or `<>` | `NotEquals` | | ||
| | | `=` | `Equals` | | ||
| | | `*` | `Multiply` | | ||
| | | `+` | `Plus` | | ||
| | | `-` | `Minus` | | ||
| | | `/` | `Divide` | | ||
| | | `%` | `Percent` | | ||
| 5 | For codes that are full URLs, extract the last segment after the final slash or dot | `http://hl7.org/fhir/some-system/DateTime` or `http://hl7.org/fhir.system.DateTime` | `DateTime` | | ||
| 6 | The final formatted string is converted to PascalCase | `some_codename` | `SomeCodename` | | ||
|
||
|
||
### Mapping FHIR JSON representation to Kotlin | ||
|
||
The [Kotlin serialization](https://github.com/Kotlin/kotlinx.serialization) library is used for JSON | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,270 @@ | ||
/* | ||
* Copyright 2025 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.fhir.codegen | ||
|
||
import com.google.fhir.codegen.schema.CodeSystem | ||
import com.google.fhir.codegen.schema.Concept | ||
import com.google.fhir.codegen.schema.ValueSet | ||
import com.google.fhir.codegen.schema.isValueSystemSupported | ||
import com.google.fhir.codegen.schema.sanitizeKDoc | ||
import com.google.fhir.codegen.schema.toPascalCase | ||
import com.squareup.kotlinpoet.ClassName | ||
import com.squareup.kotlinpoet.FunSpec | ||
import com.squareup.kotlinpoet.KModifier | ||
import com.squareup.kotlinpoet.PropertySpec | ||
import com.squareup.kotlinpoet.TypeSpec | ||
import com.squareup.kotlinpoet.asTypeName | ||
|
||
/** | ||
* Generates a [TypeSpec] for [Enum] class representation of FHIR data. The `Enum` class is | ||
* generated using information derived from the [ValueSet] and [CodeSystem] terminology resources. | ||
* Each generated enum class will contain the methods for retrieving the code, system, display and | ||
* definition of each `Enum` class constant. | ||
*/ | ||
object EnumTypeSpecGenerator { | ||
ellykits marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
fun generate( | ||
enumClassName: String, | ||
valueSet: ValueSet, | ||
codeSystemMap: Map<String, CodeSystem>, | ||
): TypeSpec? { | ||
val fhirEnum = generateEnum(valueSet, codeSystemMap) | ||
if (fhirEnum == null || fhirEnum.constants.isEmpty()) return null | ||
val typeSpec = | ||
TypeSpec.enumBuilder(enumClassName) | ||
.apply { | ||
fhirEnum.description?.sanitizeKDoc()?.let { addKdoc(it) } | ||
primaryConstructor( | ||
FunSpec.constructorBuilder().addParameter("code", String::class).build() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: should the enum values just include system, display, and definition fields? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For now, this is implemented similarly to the HAPI structures in Java. They include the most useful information that can be obtained from the enum class. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmmm what I mean here is that your enum classes only include code e.g.:
But what about including other values, so that you don't have to generate those values in the functions:
which I find to be quite cumbersome There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I see. We can include the system, its always-present display is not. |
||
) | ||
|
||
fhirEnum.constants.forEach { | ||
addEnumConstant( | ||
it.name, | ||
TypeSpec.anonymousClassBuilder() | ||
.apply { | ||
if (!it.definition.isNullOrBlank()) addKdoc("%L", it.definition.sanitizeKDoc()) | ||
addSuperclassConstructorParameter("%S", it.code) | ||
} | ||
.build(), | ||
) | ||
.addProperty( | ||
PropertySpec.builder("code", String::class, KModifier.PRIVATE) | ||
.initializer("code") | ||
.build() | ||
) | ||
} | ||
addFunction( | ||
FunSpec.builder("toString") | ||
.addModifiers(KModifier.OVERRIDE) | ||
.addStatement("return code") | ||
.returns(String::class) | ||
.build() | ||
) | ||
.addFunction( | ||
FunSpec.builder("getCode") | ||
.addModifiers(KModifier.PUBLIC) | ||
.returns(String::class) | ||
.addStatement("return code") | ||
.build() | ||
) | ||
|
||
addFunction(createPropertyAccessorFunction("getSystem", fhirEnum.constants) { it.system }) | ||
|
||
addFunction( | ||
createPropertyAccessorFunction("getDisplay", fhirEnum.constants) { it.display } | ||
) | ||
addFunction( | ||
createPropertyAccessorFunction("getDefinition", fhirEnum.constants) { it.definition } | ||
) | ||
|
||
addType( | ||
TypeSpec.companionObjectBuilder() | ||
.addFunction( | ||
FunSpec.builder("fromCode") | ||
.addParameter("code", String::class) | ||
.returns(ClassName.bestGuess(enumClassName)) | ||
.beginControlFlow("return when (code)") | ||
.apply { | ||
fhirEnum.constants.forEach { addStatement("%S -> %L", it.code, it.name) } | ||
addStatement( | ||
"else -> throw IllegalArgumentException(\"Unknown code \$code for enum %L\")", | ||
enumClassName, | ||
) | ||
} | ||
.endControlFlow() | ||
.build() | ||
) | ||
.build() | ||
) | ||
} | ||
.build() | ||
return typeSpec | ||
} | ||
|
||
private fun createPropertyAccessorFunction( | ||
functionName: String, | ||
constants: List<FhirEnumConstant>, | ||
propertySelector: (FhirEnumConstant) -> String?, | ||
): FunSpec { | ||
return FunSpec.builder(functionName) | ||
.addModifiers(KModifier.PUBLIC) | ||
.returns(String::class.asTypeName().copy(nullable = true)) | ||
.apply { | ||
val constantsWithValue = constants.filter { propertySelector(it) != null } | ||
if (constantsWithValue.isEmpty()) { | ||
addStatement("return null") | ||
} else { | ||
beginControlFlow("return when (this)") | ||
constantsWithValue.forEach { constant -> | ||
propertySelector(constant)?.let { addStatement("%L -> %S", constant.name, it) } | ||
} | ||
addStatement("else -> null") | ||
endControlFlow() | ||
} | ||
} | ||
.build() | ||
} | ||
|
||
/** | ||
* Instantiate a [FhirEnum] to facilitate the generation of Kotlin enum classes. The enum | ||
* constants are derived from concepts defined in a `ValueSet`, a `CodeSystem`, or both. When both | ||
* are provided, the constants are generated by intersecting the concepts from the `ValueSet` and | ||
* the `CodeSystem`. If only one is provided, the constants are based solely on that source. Any | ||
* nested concepts are flattened, transformed, and included in the resulting enum constants. | ||
* | ||
* NOTE: This excludes all systems that equals "http://unitsofmeasure.org" or start with "urn", | ||
* e.g. urn:ietf:bcp:13, urn:ietf:bcp:47,urn:iso:std:iso:4217 typically used for MIMETypes, | ||
* Currency Code etc. | ||
*/ | ||
fun generateEnum(valueSet: ValueSet, codeSystemMap: Map<String, CodeSystem>): FhirEnum? { | ||
return valueSet.compose | ||
?.include | ||
?.filter { it.isValueSystemSupported() } | ||
?.let { | ||
val enumConstants = mutableListOf<FhirEnumConstant>() | ||
for (include in it) { | ||
val system = include.system!! | ||
val codeSystem = codeSystemMap[system] | ||
|
||
generateEnumConstants( | ||
system = system, | ||
codeSystemConcepts = codeSystem?.concept, | ||
valueSetConcepts = include.concept, | ||
enumConstants = enumConstants, | ||
) | ||
} | ||
return FhirEnum(valueSet.description, enumConstants) | ||
} | ||
} | ||
|
||
private fun generateEnumConstants( | ||
system: String, | ||
codeSystemConcepts: List<Concept>?, | ||
valueSetConcepts: List<Concept>?, | ||
enumConstants: MutableList<FhirEnumConstant>, | ||
) { | ||
if (!codeSystemConcepts.isNullOrEmpty() && !valueSetConcepts.isNullOrEmpty()) { | ||
val expectedCodesSet = valueSetConcepts.mapTo(hashSetOf()) { it.code } | ||
generateNestedEnumConstants(valueSetConcepts, system, enumConstants, expectedCodesSet) | ||
} else if (!valueSetConcepts.isNullOrEmpty() && codeSystemConcepts.isNullOrEmpty()) { | ||
generateNestedEnumConstants(valueSetConcepts, system, enumConstants, null) | ||
} else if (!codeSystemConcepts.isNullOrEmpty() && valueSetConcepts.isNullOrEmpty()) { | ||
generateNestedEnumConstants(codeSystemConcepts, system, enumConstants, null) | ||
} | ||
} | ||
|
||
private fun generateNestedEnumConstants( | ||
concepts: List<Concept>, | ||
system: String, | ||
enumConstants: MutableList<FhirEnumConstant>, | ||
expectedCodesSet: Set<String>?, | ||
) { | ||
val arrayDeque: ArrayDeque<Concept> = ArrayDeque(concepts) | ||
|
||
while (arrayDeque.isNotEmpty()) { | ||
val currenConcept = arrayDeque.removeFirst() | ||
val name = currenConcept.code.formatEnumConstantName() | ||
val include = expectedCodesSet?.contains(currenConcept.code) != false | ||
if (name.isNotBlank() && include) { | ||
val fhirEnumConstant = | ||
FhirEnumConstant( | ||
code = currenConcept.code, | ||
system = system, | ||
name = name, | ||
display = currenConcept.display, | ||
definition = currenConcept.definition, | ||
) | ||
enumConstants.add(fhirEnumConstant) | ||
} | ||
if (!currenConcept.concept.isNullOrEmpty() && include) { | ||
arrayDeque.addAll(currenConcept.concept) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Transforms a FHIR code into a valid Kotlin enum constant name. | ||
* | ||
* This function applies a series of transformations to ensure the resulting name follows Kotlin | ||
* naming conventions and is a valid identifier. The transformations are applied in the following | ||
* order: | ||
* 1. For URLs (strings starting with "http"), extract only the last segment after the final dot | ||
* Example: "http://hl7.org/fhirpath/System.DateTime" → "DateTime" | ||
* 2. For special characters (>, <, >=, etc.), map them to descriptive names Example: ">" → | ||
* "GreaterThan", "<" → "LessThan" | ||
* 3. Replace all remaining non-alphanumeric characters with underscores Example: "some-value.123" | ||
* → "some_value_123" | ||
* 4. If the string starts with a digit, prefix it with an underscore to make it a valid | ||
* identifier Example: "123test" → "_123test" | ||
* 5. Apply PascalCase to each segment between underscores while preserving the underscores | ||
* Example: "test_value" → "Test_Value" | ||
* | ||
* @return A valid Kotlin enum constant name | ||
*/ | ||
private fun String.formatEnumConstantName(): String { | ||
|
||
if (startsWith("http")) return substringAfterLast(".") | ||
|
||
when (this) { | ||
">" -> return "GreaterThan" | ||
"<" -> return "LessThan" | ||
">=" -> return "GreaterOrEquals" | ||
"<=" -> return "LessOrEquals" | ||
"<>", | ||
"!=" -> return "NotEquals" | ||
"=" -> return "Equals" | ||
"*" -> return "Multiply" | ||
"+" -> return "Plus" | ||
"-" -> return "Minus" | ||
"/" -> return "Divide" | ||
"%" -> return "Percent" | ||
} | ||
|
||
val withUnderscores = this.replace(Regex("[^a-zA-Z0-9]"), "_") | ||
|
||
val prefixed = | ||
if (withUnderscores.isNotEmpty() && withUnderscores[0].isDigit()) { | ||
"_$withUnderscores" | ||
} else { | ||
withUnderscores | ||
} | ||
|
||
val parts = prefixed.split("_") | ||
return parts.joinToString("_") { part -> if (part.isEmpty()) "" else part.toPascalCase() } | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe create a separate table - since this whole table is about structure definitions, backbone elements and choice of data types are also structure definitions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
actually i think this shoudl totally be a separate section:
Mapping FHIR value sets to Kotlin