Skip to content
Open
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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,6 @@ documented in the table below.
| `LowBoundaryDateTimeMillisecond1` | Specification/Test | | | Diverges from FHIRPath specification. See [Discussion](https://chat.fhir.org/#narrow/channel/179266-fhirpath/topic/lowBoundary.20and.20highBoundary.20with.20incomplete.20date.20time/with/611113639). |
| `HighBoundaryDateTimeMillisecond1` | Specification/Test | | As above. | As above. |
| `HighBoundaryDateTimeMillisecond3` | Specification/Test | | As above. | As above. |
| `Comparable*` | Implementation | | | Function `comparable` is not implemented. |
| `Precision*` | Implementation | | | Function `precision` is not implemented. |
| `testIndex` | Implementation | | | `$index` is not implemented. |
| `testPeriodInvariantOld` | Implementation | | | Function `hasValue` is not implemented. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ internal fun Collection<Any>.invoke(
"lowBoundary" -> this.lowBoundary(params, fhirPathTypeResolver)
"highBoundary" -> this.highBoundary(params, fhirPathTypeResolver)
"precision" -> this.precision(fhirPathTypeResolver)
"comparable" -> this.comparable(params, fhirPathTypeResolver)

// Defined as a boolean logic operator in the specification, but the grammar handles this as a
// function invocation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import dev.ohs.fhir.fhirpath.coerceToType
import dev.ohs.fhir.fhirpath.createSecondBigDecimal
import dev.ohs.fhir.fhirpath.decimalPlaces
import dev.ohs.fhir.fhirpath.toBigDecimalPreservingScale
import dev.ohs.fhir.fhirpath.toEqualCanonicalized
import dev.ohs.fhir.fhirpath.toFhirPathType
import dev.ohs.fhir.fhirpath.toPlainStringWithMinDecimalPlaces
import dev.ohs.fhir.fhirpath.types.FhirPathDate
Expand Down Expand Up @@ -458,3 +459,31 @@ private fun computeDecimalHighBoundary(value: BigDecimal, precision: Int?): Coll
.toBigDecimalPreservingScale()
)
}

/**
* Returns whether the two singleton quantities have comparable units, i.e. whether their units
* canonicalize to the same UCUM base unit (e.g. `cm` and `[in_i]` are both lengths, so they are
* comparable; `cm` and `s` are not). A quantity with an unknown unit is only comparable to a
* quantity with the same unit.
*
* The comparison uses equal semantics ([toEqualCanonicalized]), not equivalence: per the
* specification, returning true "indicates that a result from equality or comparison functions will
* succeed, and not return empty" (https://build.fhir.org/ig/HL7/FHIRPath/#fn-comparable). For
* example, a calendar `year` is equivalent (`~`) to `1 'a'` but not comparable to it, since `1 year
* = 1 'a'` is empty (https://hl7.org/fhirpath/N1/#time-valued-quantities).
*/
internal fun Collection<Any>.comparable(
params: List<Any>,
fhirPathTypeResolver: FhirPathTypeResolver,
): Collection<Boolean> {
check(size <= 1) { "comparable() cannot be called on a collection with more than 1 item" }
val left =
singleOrNull()?.toFhirPathType(fhirPathTypeResolver) as? FhirPathQuantity ?: return emptyList()
val right =
params.singleOrNull()?.toFhirPathType(fhirPathTypeResolver) as? FhirPathQuantity
?: return emptyList()

val leftUnit = left.toEqualCanonicalized().unit ?: return emptyList()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should it return emptyList() or listOf(false)?

val rightUnit = right.toEqualCanonicalized().unit ?: return emptyList()
return listOf(leftUnit == rightUnit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Open Health Stack Foundation
*
* 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 dev.ohs.fhir.fhirpath

import dev.ohs.fhir.model.r4.Resource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.serialization.json.Json

private val fhirPathEngine = FhirPathEngine.forR4()

private val observation: Resource =
Json { ignoreUnknownKeys = true }
.decodeFromString(
"""{"resourceType": "Observation", "status": "final", "code": {"text": "weight"},
"valueQuantity": {"value": 80, "unit": "kg", "system": "http://unitsofmeasure.org",
"code": "kg"}}"""
)

class ComparableTest {

@Test
fun `mass units are comparable`() {
assertEquals(
listOf(true),
fhirPathEngine.evaluateExpression("(1 'kg').comparable(1 '[lb_av]')", null).toList(),
)
}

@Test
fun `identical unknown units are comparable`() {
assertEquals(
listOf(true),
fhirPathEngine.evaluateExpression("(1 '[s]').comparable(1 '[s]')", null).toList(),
)
}

@Test
fun `navigated quantity element works as input`() {
assertEquals(
listOf(true),
fhirPathEngine.evaluateExpression("value.comparable(1 'g')", observation).toList(),
)
assertEquals(
listOf(false),
fhirPathEngine.evaluateExpression("value.comparable(1 's')", observation).toList(),
)
}

@Test
fun `calendar year is not comparable to the UCUM year`() {
// Matches the comparison behavior in the published spec: `1 year > 1 'a'` is empty because
// calendar durations above seconds are not comparable to definite durations
// (https://hl7.org/fhirpath/N1/#time-valued-quantities).
assertEquals(
listOf(false),
fhirPathEngine.evaluateExpression("(1 year).comparable(1 'a')", null).toList(),
)
}

@Test
fun `empty input returns empty`() {
assertEquals(
emptyList(),
fhirPathEngine.evaluateExpression("{}.comparable(1 'kg')", null).toList(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ val skippedTestGroupToReasonMap =
"testEscapeUnescape" to "Unimplemented",
"testVariables" to "Unimplemented",
"testConformsTo" to "Unimplemented",
"Comparable" to "Unimplemented",
"Precision" to "Unimplemented",
)

Expand Down
Loading