Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,20 +653,23 @@ consistent formatting using the [`spotless`](https://github.com/diffplug/spotles

### Testing

The library includes comprehensive **serialization round-trip tests** for examples published in the
The library includes comprehensive **serialization round-trip and equality tests** for the example resources published in the
following packages:

- [hl7.fhir.r4.examples](https://simplifier.net/packages/hl7.fhir.r4.examples) (5309 examples)
- [hl7.fhir.r4b.examples](https://simplifier.net/packages/hl7.fhir.r4b.examples) (2840 examples)
- [hl7.fhir.r5.examples](https://simplifier.net/packages/hl7.fhir.r5.examples) (2822 examples)

For each JSON example of a FHIR resource in the packages above, a test is performed with the
following steps:
For each JSON example of a FHIR resource in the referenced packages, two categories of tests are executed:

1. Deserialization: The JSON is deserialized into the corresponding generated Kotlin resource class.
2. Serialization: The Kotlin object is then serialized back into JSON format.
3. Verification: The newly generated JSON is compared, character by character[^7], to the original
JSON to ensure complete fidelity.
1. Serialization round-trip test:
- Deserialization: The JSON is parsed into its corresponding generated Kotlin resource class.
- Serialization: That Kotlin object is then converted back into JSON.
- Verification: The regenerated JSON is compared character by character[^7] with the original to confirm exact fidelity.
2. Equality test:
- First instance: Deserialize the JSON into a Kotlin resource object.
- Second instance: Deserialize the same JSON into a separate Kotlin resource object.
- Verification: The two objects are structurally equal (using `==` operator).

[^7]: There are several exceptions. The FHIR specification allows for some variability in data
representation, which may lead to differences between the original and newly serialized JSON. For
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ class ModelTypeSpecGenerator(private val valueSetMap: Map<String, ValueSet>) {
// with '_'.
if (structureDefinition.name == "Element") {
addModifiers(KModifier.OPEN)
// Implement equals/hashCode for Element to compare properties (like data classes).
addEqualsAndHashCodeFunctions(
structureDefinition.name,
structureDefinition.rootElements,
)
} else {
addModifiers(KModifier.SEALED)
}
Expand All @@ -92,6 +97,12 @@ class ModelTypeSpecGenerator(private val valueSetMap: Map<String, ValueSet>) {
// since they need to be subclassed. E.g. Uri can be extended by Url, and Quantity can
// be extended by Duration.
addModifiers(KModifier.OPEN)
// Implement equals/hashCode for open classes (to perform property-based comparison,
// like data classes).
addEqualsAndHashCodeFunctions(
structureDefinition.name,
structureDefinition.rootElements,
)
} else {
addModifiers(KModifier.DATA)
}
Expand Down Expand Up @@ -195,6 +206,55 @@ class ModelTypeSpecGenerator(private val valueSetMap: Map<String, ValueSet>) {
return typeSpec
}

private fun TypeSpec.Builder.addEqualsAndHashCodeFunctions(
name: String,
elements: List<Element>,
) {
if (elements.isEmpty()) return
val equalsFunSpec =
FunSpec.builder("equals")
.addModifiers(KModifier.OVERRIDE)
.addParameter("other", Any::class.asTypeName().copy(nullable = true))
.returns(Boolean::class)
.addCode(
"""
if (this === other) return true
if (other !is ${name.capitalized()}) return false
"""
.trimIndent()
)
.addCode(
elements.joinToString(separator = "\n", prefix = "\n", postfix = "\n") {
"if( ${it.getElementName()} != other.${it.getElementName()}) return false"
}
)
.addCode("return true")
.build()

this.addFunction(equalsFunSpec)

val hashCodeFunSpec =
FunSpec.builder("hashCode")
.addModifiers(KModifier.OVERRIDE)
.returns(Int::class)
.addCode(
"// Using 31 improves hash distribution and reduces collisions in hash-based collections\n"
)
.addCode("var result = ${elements.first().getElementName()}?.hashCode() ?: 0")
.addCode(
elements.subList(1, elements.size).joinToString(
separator = "\n",
prefix = "\n",
postfix = "\n",
) {
"result = 31 * result + (${it.getElementName()}?.hashCode() ?: 0)"
}
)
.addCode("return result")
.build()
this.addFunction(hashCodeFunSpec)
}

/** Adds a nested class for each BackboneElement in the [StructureDefinition]. */
private fun TypeSpec.Builder.addBackboneElement(
path: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ object FhirDateFileSpecGenerator {
)
addType(
TypeSpec.classBuilder("YearMonth")
.addModifiers(KModifier.DATA)
.addSuperinterface(sealedInterfaceClassName)
.primaryConstructor(
FunSpec.constructorBuilder()
Expand All @@ -84,6 +85,7 @@ object FhirDateFileSpecGenerator {
)
addType(
TypeSpec.classBuilder("Date")
.addModifiers(KModifier.DATA)
.addSuperinterface(sealedInterfaceClassName)
.primaryConstructor(
FunSpec.constructorBuilder().addParameter("date", LocalDate::class).build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ object FhirDateTimeFileSpecGenerator {
)
addType(
TypeSpec.classBuilder("YearMonth")
.addModifiers(KModifier.DATA)
.addSuperinterface(sealedInterfaceClassName)
.primaryConstructor(
FunSpec.constructorBuilder()
Expand All @@ -89,6 +90,7 @@ object FhirDateTimeFileSpecGenerator {
)
addType(
TypeSpec.classBuilder("Date")
.addModifiers(KModifier.DATA)
.addSuperinterface(sealedInterfaceClassName)
.primaryConstructor(
FunSpec.constructorBuilder().addParameter("date", LocalDate::class).build()
Expand All @@ -107,6 +109,7 @@ object FhirDateTimeFileSpecGenerator {
)
addType(
TypeSpec.classBuilder("DateTime")
.addModifiers(KModifier.DATA)
.addSuperinterface(sealedInterfaceClassName)
.primaryConstructor(
FunSpec.constructorBuilder()
Expand Down
6 changes: 5 additions & 1 deletion fhir-model/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ kotlin {
}
}
val jvmMain by getting
val jvmTest by getting
val jvmTest by getting {
dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
}
Comment on lines +138 to +140
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought i commented on this :/ let's remove this in a subsequent PR (probably no need to send a 1 line change)

}
val jsMain by getting
val jsTest by getting
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

package com.google.fhir.model.r4

import kotlin.Any
import kotlin.Boolean
import kotlin.Int
import kotlin.String
import kotlin.Suppress
import kotlin.collections.MutableList
Expand All @@ -44,4 +47,19 @@ public open class Element(
* simplicity for everyone.
*/
public open var extension: MutableList<Extension> = mutableListOf(),
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Element) return false
if (id != other.id) return false
if (extension != other.extension) return false
return true
}

override fun hashCode(): Int {
// Using 31 improves hash distribution and reduces collisions in hash-based collections
var result = id?.hashCode() ?: 0
result = 31 * result + (extension?.hashCode() ?: 0)
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ public sealed interface FhirDate {
override fun toString(): String = value.toString()
}

public class YearMonth(public val year: Int, public val month: Int) : FhirDate {
public data class YearMonth(public val year: Int, public val month: Int) : FhirDate {
override fun toString(): String = "$year-${month.toString().padStart(2,'0')}"
}

public class Date(public val date: LocalDate) : FhirDate {
public data class Date(public val date: LocalDate) : FhirDate {
override fun toString(): String = date.toString()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ public sealed interface FhirDateTime {
override fun toString(): String = value.toString()
}

public class YearMonth(public val year: Int, public val month: Int) : FhirDateTime {
public data class YearMonth(public val year: Int, public val month: Int) : FhirDateTime {
override fun toString(): String = "$year-${month.toString().padStart(2,'0')}"
}

public class Date(public val date: LocalDate) : FhirDateTime {
public data class Date(public val date: LocalDate) : FhirDateTime {
override fun toString(): String = date.toString()
}

public class DateTime(public val dateTime: LocalDateTime, public val utcOffset: UtcOffset) :
public data class DateTime(public val dateTime: LocalDateTime, public val utcOffset: UtcOffset) :
FhirDateTime {
override fun toString(): String =
dateTime.format(LocalDateTime.Formats.ISO) + utcOffset.toString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

package com.google.fhir.model.r4

import kotlin.Any
import kotlin.Boolean
import kotlin.Int
import kotlin.String
import kotlin.Suppress
Expand All @@ -43,6 +45,23 @@ public open class Integer(
/** The actual value */
public open var `value`: Int? = null,
) : Element(id, extension) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Integer) return false
if (id != other.id) return false
if (extension != other.extension) return false
if (value != other.value) return false
return true
}

override fun hashCode(): Int {
// Using 31 improves hash distribution and reduces collisions in hash-based collections
var result = id?.hashCode() ?: 0
result = 31 * result + (extension?.hashCode() ?: 0)
result = 31 * result + (value?.hashCode() ?: 0)
return result
}

public open fun toElement(): Element? {
if (id != null || extension.isNotEmpty()) {
return Element(id, extension)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
package com.google.fhir.model.r4

import com.google.fhir.model.r4.serializers.QuantitySerializer
import kotlin.Any
import kotlin.Boolean
import kotlin.Int
import kotlin.Suppress
import kotlin.collections.MutableList
import kotlinx.serialization.Serializable
Expand Down Expand Up @@ -74,6 +77,31 @@ public open class Quantity(
*/
public open var code: Code? = null,
) : Element() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Quantity) return false
if (id != other.id) return false
if (extension != other.extension) return false
if (value != other.value) return false
if (comparator != other.comparator) return false
if (unit != other.unit) return false
if (system != other.system) return false
if (code != other.code) return false
return true
}

override fun hashCode(): Int {
// Using 31 improves hash distribution and reduces collisions in hash-based collections
var result = id?.hashCode() ?: 0
result = 31 * result + (extension?.hashCode() ?: 0)
result = 31 * result + (value?.hashCode() ?: 0)
result = 31 * result + (comparator?.hashCode() ?: 0)
result = 31 * result + (unit?.hashCode() ?: 0)
result = 31 * result + (system?.hashCode() ?: 0)
result = 31 * result + (code?.hashCode() ?: 0)
return result
}

/** How the Quantity should be understood and represented. */
public enum class QuantityComparator(
private val code: kotlin.String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

package com.google.fhir.model.r4

import kotlin.Any
import kotlin.Boolean
import kotlin.Int
import kotlin.Suppress
import kotlin.collections.MutableList

Expand All @@ -41,6 +44,23 @@ public open class String(
/** The actual value */
public open var `value`: kotlin.String? = null,
) : Element(id, extension) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is String) return false
if (id != other.id) return false
if (extension != other.extension) return false
if (value != other.value) return false
return true
}

override fun hashCode(): Int {
// Using 31 improves hash distribution and reduces collisions in hash-based collections
var result = id?.hashCode() ?: 0
result = 31 * result + (extension?.hashCode() ?: 0)
result = 31 * result + (value?.hashCode() ?: 0)
return result
}

public open fun toElement(): Element? {
if (id != null || extension.isNotEmpty()) {
return Element(id, extension)
Expand Down
20 changes: 20 additions & 0 deletions fhir-model/src/commonMain/kotlin/com/google/fhir/model/r4/Uri.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

package com.google.fhir.model.r4

import kotlin.Any
import kotlin.Boolean
import kotlin.Int
import kotlin.String
import kotlin.Suppress
import kotlin.collections.MutableList
Expand All @@ -44,6 +47,23 @@ public open class Uri(
/** The actual value */
public open var `value`: String? = null,
) : Element(id, extension) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Uri) return false
if (id != other.id) return false
if (extension != other.extension) return false
if (value != other.value) return false
return true
}

override fun hashCode(): Int {
// Using 31 improves hash distribution and reduces collisions in hash-based collections
var result = id?.hashCode() ?: 0
result = 31 * result + (extension?.hashCode() ?: 0)
result = 31 * result + (value?.hashCode() ?: 0)
return result
}

public open fun toElement(): Element? {
if (id != null || extension.isNotEmpty()) {
return Element(id, extension)
Expand Down
Loading
Loading