> scalaSeq = CollectionConverters.asScala(x).toSeq();
+ return column(new UnresolvedTransformTree(
+ expression(value),
+ liftToExpression(extractor)::apply,
+ scalaSeq,
+ maxDepth
+ ));
+ }
+
/**
* Removes all fields starting with '_' (underscore) from struct values.
*
diff --git a/encoders/src/main/java/au/csiro/pathling/encoders/terminology/ucum/Ucum.java b/encoders/src/main/java/au/csiro/pathling/encoders/terminology/ucum/Ucum.java
index a0e30011a0..940a10c716 100644
--- a/encoders/src/main/java/au/csiro/pathling/encoders/terminology/ucum/Ucum.java
+++ b/encoders/src/main/java/au/csiro/pathling/encoders/terminology/ucum/Ucum.java
@@ -26,7 +26,11 @@
import au.csiro.pathling.annotations.UsedByReflection;
import io.github.fhnaumann.funcs.CanonicalizerService;
+import io.github.fhnaumann.funcs.ConverterService;
+import io.github.fhnaumann.funcs.ConverterService.ConversionResult;
import io.github.fhnaumann.funcs.UCUMService;
+import io.github.fhnaumann.model.UCUMExpression.CanonicalTerm;
+import io.github.fhnaumann.util.PreciseDecimal;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.math.BigDecimal;
@@ -42,6 +46,16 @@ public class Ucum {
public static final String NO_UNIT_CODE = "1";
+ /**
+ * A record to hold a canonical value and unit pair.
+ */
+ public record ValueWithUnit(
+ @Nonnull BigDecimal value,
+ @Nonnull String unit
+ ) {
+
+ }
+
private static final UCUMService service;
static {
@@ -57,72 +71,139 @@ public static UCUMService service() {
return service;
}
- @UsedByReflection
+ /**
+ * Gets both the canonical value and code for a given value and UCUM code in a single operation.
+ * This method performs a single canonicalization call and returns both results together, ensuring
+ * consistency and better performance compared to calling getCanonicalValue and getCanonicalCode
+ * separately.
+ *
+ * @param value the value to canonicalize
+ * @param code the UCUM code of the value
+ * @return a ValueWithUnit containing both canonical value and code, or null if canonicalization
+ * fails
+ */
@Nullable
- public static BigDecimal getCanonicalValue(@Nullable final BigDecimal value,
+ public static ValueWithUnit getCanonical(@Nullable final BigDecimal value,
@Nullable final String code) {
if (value == null || code == null) {
return null;
}
try {
- final CanonicalizerService.CanonicalizationResult result = service.canonicalize(code);
+ // We need to delegate the canonicalization to the service including both value and code.
+ // This is because some UCUM conversions use multiplicative factors and some use additive
+ // offsets (e.g., temperature conversions).
+ final CanonicalizerService.CanonicalizationResult result = service.canonicalize(
+ new PreciseDecimal(value.toPlainString()),
+ code
+ );
// Check if the result is a Success instance.
- if (!(result instanceof CanonicalizerService.Success success)) {
+ if (!(result instanceof CanonicalizerService.Success(
+ PreciseDecimal magnitude,
+ CanonicalTerm canonicalTerm
+ ))) {
log.warn("Failed to canonicalise UCUM code '{}': {}", code, result);
return null;
}
- // Get the magnitude (conversion factor) from the success result.
- @Nullable final BigDecimal conversionFactor = success.magnitude().getValue();
- if (conversionFactor == null) {
- log.warn("No conversion factor available for UCUM code '{}'", code);
+ // Get the magnitude of the value in canonical units.
+ if (magnitude == null) {
+ log.warn("No magnitude available for UCUM code '{}'", code);
+ return null;
+ }
+
+ // Get the canonical unit code by printing the canonical term.
+ @Nullable final String canonicalCode = service.print(canonicalTerm);
+ if (canonicalCode == null) {
+ log.warn("No canonical code available for UCUM code '{}'", code);
return null;
}
- // Apply the conversion factor to the value to get the canonical value.
- return value.multiply(conversionFactor);
+ // Handle empty canonical code by converting to NO_UNIT_CODE
+ final String adjustedCode = canonicalCode.isEmpty()
+ ? NO_UNIT_CODE
+ : canonicalCode;
+
+ return new ValueWithUnit(magnitude.getValue(), adjustedCode);
} catch (final Exception e) {
log.warn("Error canonicalising UCUM code '{}': {}", code, e.getMessage());
return null;
}
}
+ /**
+ * Gets the canonical value for a given value and UCUM code.
+ *
+ * @param value the value to canonicalize
+ * @param code the UCUM code of the value
+ * @return the canonical value, or null if canonicalization fails
+ */
+ @UsedByReflection
+ @Nullable
+ public static BigDecimal getCanonicalValue(@Nullable final BigDecimal value,
+ @Nullable final String code) {
+ @Nullable final ValueWithUnit canonical = getCanonical(value, code);
+ return canonical != null
+ ? canonical.value()
+ : null;
+ }
+
+ /**
+ * Gets the canonical UCUM code for a given value and UCUM code.
+ *
+ * @param value the value to canonicalize
+ * @param code the UCUM code of the value
+ * @return the canonical UCUM code, or null if canonicalization fails
+ */
@UsedByReflection
@Nullable
public static String getCanonicalCode(@Nullable final BigDecimal value,
@Nullable final String code) {
- if (value == null || code == null) {
+ @Nullable final ValueWithUnit canonical = getCanonical(value, code);
+ return canonical != null
+ ? canonical.unit()
+ : null;
+ }
+
+ /**
+ * Converts a value from one UCUM unit to another. Supports both multiplicative conversions (e.g.,
+ * mg to kg) and additive conversions (e.g., Celsius to Kelvin).
+ *
+ * @param value the value to convert
+ * @param fromCode the source UCUM code
+ * @param toCode the target UCUM code
+ * @return the converted value, or null if conversion is not possible
+ */
+ @Nullable
+ public static BigDecimal convertValue(@Nullable final BigDecimal value,
+ @Nullable final String fromCode, @Nullable final String toCode) {
+ if (value == null || fromCode == null || toCode == null) {
return null;
}
try {
- final CanonicalizerService.CanonicalizationResult result = service.canonicalize(code);
-
- // Check if the result is a Success instance.
- if (!(result instanceof CanonicalizerService.Success success)) {
- log.warn("Failed to canonicalise UCUM code '{}': {}", code, result);
+ // Use the ucumate library's convert method that handles both multiplicative and additive
+ // conversions directly by taking the value as the first argument
+ final ConversionResult conversionResult = service.convert(
+ new PreciseDecimal(value.toPlainString()),
+ fromCode,
+ toCode
+ );
+
+ if (!(conversionResult instanceof ConverterService.Success(var convertedValue))
+ || convertedValue == null) {
+ log.warn("Failed to convert value {} from '{}' to '{}': {}",
+ value, fromCode, toCode, conversionResult);
return null;
}
- // Get the canonical unit code by printing the canonical term.
- @Nullable final String canonicalCode = service.print(success.canonicalTerm());
-
- // Apply the NO_UNIT_CODE adjustment for empty codes.
- return adjustNoUnitCode(canonicalCode);
+ return convertedValue.getValue();
} catch (final Exception e) {
- log.warn("Error canonicalising UCUM code '{}': {}", code, e.getMessage());
- return null;
- }
- }
-
- @Nullable
- private static String adjustNoUnitCode(@Nullable final String code) {
- if (code == null) {
+ log.warn("Error converting value {} from '{}' to '{}': {}",
+ value, fromCode, toCode, e.getMessage());
return null;
}
- return code.isEmpty() ? NO_UNIT_CODE : code;
}
}
diff --git a/encoders/src/main/scala/au/csiro/pathling/encoders/Expressions.scala b/encoders/src/main/scala/au/csiro/pathling/encoders/Expressions.scala
index b50b15575d..9abf668e4b 100644
--- a/encoders/src/main/scala/au/csiro/pathling/encoders/Expressions.scala
+++ b/encoders/src/main/scala/au/csiro/pathling/encoders/Expressions.scala
@@ -25,16 +25,15 @@
package au.csiro.pathling.encoders
import org.apache.spark.SparkException
-import org.apache.spark.sql.Column
+import org.apache.spark.sql.AnalysisException
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.analysis.TypeCheckResult.{TypeCheckFailure, TypeCheckSuccess}
import org.apache.spark.sql.catalyst.analysis.{TypeCheckResult, UnresolvedException}
-import org.apache.spark.sql.catalyst.expressions.{FoldableUnevaluable, _}
+import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.expressions.codegen.Block._
import org.apache.spark.sql.catalyst.expressions.codegen.{CodeGenerator, CodegenContext, ExprCode, FalseLiteral}
import org.apache.spark.sql.catalyst.trees.TreePattern.{ARRAYS_ZIP, TreePattern}
import org.apache.spark.sql.catalyst.util.{ArrayData, GenericArrayData}
-import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.types._
import scala.language.existentials
@@ -289,8 +288,8 @@ case class AttachExtensions(targetObject: Expression,
* in both Spark 4.0.1 and Spark 4.1.0-rc1 (where FoldableUnevaluable has been removed)
* and which is deployed in Databrics Runtime 17.3 LTS.
*
- * An expression that cannot be evaluated and is not foldable. These expressions
- * on't live past analysis or optimization time (e.g. Star)
+ * An expression that cannot be evaluated and is not foldable. These expressions
+ * don't live past analysis or optimization time (e.g. Star)
* and should not be evaluated during query planning and execution.
*/
trait UnevaluableCopy extends Expression {
@@ -413,6 +412,139 @@ case class UnresolvedUnnest(value: Expression)
}
}
+/**
+ * Returns null when a struct field doesn't exist in the schema, instead of throwing an error.
+ *
+ * This expression is essential for handling optional fields in nested structures where a field
+ * may not be present in all instances of a struct type. When the specified field is missing
+ * from the struct schema, this returns null rather than causing a FIELD_NOT_FOUND analysis error.
+ *
+ * '''Important:''' This only handles fields that don't exist in the schema. If a field
+ * exists but has a null value, that null value is returned normally.
+ *
+ * @param value the expression that may reference a non-existent field
+ */
+case class UnresolvedNullIfMissingField(value: Expression)
+ extends Expression with UnevaluableCopy with NonSQLExpression {
+
+ override def mapChildren(f: Expression => Expression): Expression = {
+ try {
+ val newValue = f(value)
+ if (newValue.resolved) {
+ newValue
+ } else {
+ copy(value = newValue)
+ }
+ } catch {
+ case e: AnalysisException if e.errorClass.contains("FIELD_NOT_FOUND") =>
+ // If field is not found, return null instead of throwing an error
+ Literal(null)
+ }
+ }
+
+ override def dataType: DataType = throw new UnresolvedException("dataType")
+
+ override def nullable: Boolean = throw new UnresolvedException("nullable")
+
+ override lazy val resolved = false
+
+ override def toString: String = s"$value"
+
+ override def children: Seq[Expression] = value :: Nil
+
+ override def withNewChildrenInternal(newChildren: IndexedSeq[Expression]): Expression = {
+ UnresolvedNullIfMissingField(newChildren.head)
+ }
+}
+
+/**
+ * A custom Spark expression for recursive tree traversal with extraction at each level.
+ *
+ * This expression implements a depth-first traversal of nested structures by recursively
+ * applying a sequence of traversal operations and extracting values at each level. When
+ * resolved, it concatenates:
+ * 1. The extracted value from the current node
+ * 2. The results of recursively traversing child nodes
+ *
+ * The expression handles field resolution gracefully - if a field is not found during
+ * traversal (FIELD_NOT_FOUND error), it returns an empty array rather than failing.
+ *
+ * '''Depth Limiting:''' The `level` parameter (maxDepth) controls recursion depth to prevent
+ * infinite loops in self-referential structures. The depth counter only decrements when
+ * traversing to a node of the '''same type''' as its parent (`parentType.contains(newValue.dataType)`).
+ * This allows finite paths through different types to traverse deeper than the level limit while
+ * preventing infinite recursion. For example:
+ * - Traversing Item → Answer → Item (type changes): depth counter does not decrement
+ * - Traversing Item → Item (same type): depth counter decrements, limiting recursion
+ *
+ * '''Requirements:'''
+ * - The `extractor` function must return an array type
+ * - The array type must be consistent across all traversed nodes to avoid type mismatch errors
+ *
+ * @param node the current node expression to traverse
+ * @param extractor a function to extract values from a node (must return array type)
+ * @param traversals a sequence of functions to traverse to child nodes
+ * @param parentType the data type of the parent node, used to determine if depth should decrement
+ * @param level the remaining levels to traverse for same-type recursion; when exhausted, only extraction occurs
+ */
+case class UnresolvedTransformTree(node: Expression,
+ extractor: Expression => Expression,
+ traversals: Seq[Expression => Expression],
+ parentType: Option[DataType],
+ level: Int
+ )
+ extends Expression with UnevaluableCopy with NonSQLExpression {
+
+ def this(node: Expression,
+ extractor: Expression => Expression,
+ traversals: Seq[Expression => Expression],
+ level: Int) = {
+ this(node, extractor, traversals, None, level)
+ }
+
+ override def mapChildren(f: Expression => Expression): Expression = {
+
+ try {
+ val newValue = f(node)
+ if (newValue.resolved) {
+ // if node is resolved we concatenate
+ // the value extracted from the node with next level traversal
+ if (level > 0 || !parentType.contains(newValue.dataType))
+ Concat(
+ Seq(extractor(node)) ++
+ traversals
+ .map(t => UnresolvedTransformTree(t(node), extractor, traversals,
+ Some(newValue.dataType),
+ if (parentType.contains(newValue.dataType)) level - 1 else level
+ ))
+ )
+ else CreateArray(Seq.empty)
+ }
+ else {
+ copy(node = newValue)
+ }
+ } catch {
+ case e: AnalysisException if e.errorClass.contains("FIELD_NOT_FOUND") =>
+ // in case of AnalysisException we just return an empty array
+ CreateArray(Seq.empty)
+ }
+ }
+
+ override def dataType: DataType = throw new UnresolvedException("dataType")
+
+ override def nullable: Boolean = throw new UnresolvedException("nullable")
+
+ override lazy val resolved = false
+
+ override def toString: String = s"$node"
+
+ override def children: Seq[Expression] = node :: Nil
+
+ override def withNewChildrenInternal(newChildren: IndexedSeq[Expression]): Expression = {
+ UnresolvedTransformTree(newChildren.head, extractor, traversals, parentType, level)
+ }
+}
+
// ValueFunctions has been moved to a Java class to access package-private Spark methods
diff --git a/encoders/src/test/java/au/csiro/pathling/encoders/ExpressionsTest.java b/encoders/src/test/java/au/csiro/pathling/encoders/ExpressionsTest.java
index f60292b00b..cca8009bf5 100644
--- a/encoders/src/test/java/au/csiro/pathling/encoders/ExpressionsTest.java
+++ b/encoders/src/test/java/au/csiro/pathling/encoders/ExpressionsTest.java
@@ -26,9 +26,11 @@
import static au.csiro.pathling.encoders.ValueFunctions.ifArray;
import static au.csiro.pathling.encoders.ValueFunctions.ifArray2;
+import static au.csiro.pathling.encoders.ValueFunctions.nullIfMissingField;
import static au.csiro.pathling.encoders.ValueFunctions.unnest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import java.util.Arrays;
import java.util.List;
@@ -477,4 +479,237 @@ void testPruneAnnotationsWithGroupBy() {
assertEquals(expectedResult.schema(), groupedResult.schema());
assertEquals(expectedResult.collectAsList(), groupedResult.collectAsList());
}
+
+ /**
+ * Helper method to create a nested item structure with 3 levels for testing transformTree. The
+ * structure has different types at each level to enable testing of type-based traversal limits.
+ *
+ * @return Dataset with structure: items[level0Type(item[level1Type(item[level2Type])])]
+ */
+ private Dataset createNestedItemDataset() {
+ final Metadata metadata = Metadata.empty();
+
+ // Level 2 (leaf): NO item field - different type from level1
+ final StructType level2Type = DataTypes.createStructType(new StructField[]{
+ new StructField("linkId", DataTypes.StringType, true, metadata),
+ new StructField("text", DataTypes.StringType, true, metadata)
+ });
+
+ // Level 1: HAS item field (array of level2Type) - different type from level0
+ final StructType level1Type = DataTypes.createStructType(new StructField[]{
+ new StructField("linkId", DataTypes.StringType, true, metadata),
+ new StructField("text", DataTypes.StringType, true, metadata),
+ new StructField("item", DataTypes.createArrayType(level2Type), true, metadata)
+ });
+
+ // Level 0: HAS item field (array of level1Type) - root level type
+ final StructType level0Type = DataTypes.createStructType(new StructField[]{
+ new StructField("linkId", DataTypes.StringType, true, metadata),
+ new StructField("text", DataTypes.StringType, true, metadata),
+ new StructField("item", DataTypes.createArrayType(level1Type), true, metadata)
+ });
+
+ // Create test data: 3 levels deep
+ final Row level2Item = RowFactory.create("3", "Level 2");
+ final Row level1Item = RowFactory.create("2", "Level 1", List.of(level2Item));
+ final Row level0Item = RowFactory.create("1", "Level 0", List.of(level1Item));
+
+ final StructType rootSchema = DataTypes.createStructType(new StructField[]{
+ new StructField("id", DataTypes.IntegerType, true, metadata),
+ new StructField("items", DataTypes.createArrayType(level0Type), true, metadata)
+ });
+
+ return spark.createDataFrame(
+ List.of(RowFactory.create(1, List.of(level0Item))),
+ rootSchema
+ );
+ }
+
+ @Test
+ void testTransformTreeFinitePathWithDifferentTypes() {
+ // Finite path: traversing via getField('item') through structurally different types
+ // Each level has a different schema type (level0Type != level1Type != level2Type)
+ // maxDepth should NOT apply because types change at each traversal step
+
+ final Dataset ds = createNestedItemDataset();
+
+ // Traverse with getField('item') - each traversal step changes type
+ // Level 0 (level0Type) -> getField('item') -> Level 1 (level1Type) -> getField('item') -> Level 2 (level2Type)
+ // maxDepth=1 is very strict, but should NOT limit because types change
+ final Dataset result = ds.withColumn("collected",
+ ValueFunctions.transformTree(
+ ds.col("items"),
+ c -> c.getField("linkId"), // Extract linkId at each level
+ List.of(c -> unnest(c.getField("item"))), // Traverse via item field (type changes)
+ 1 // maxDepth=1 (strict limit)
+ )
+ );
+
+ final List results = result.collectAsList();
+ assertEquals(1, results.size());
+
+ final Row row = results.getFirst();
+ final Seq> collected = row.getAs("collected");
+ final List> linkIds = CollectionConverters.asJava(collected);
+
+ // Should collect ALL 3 levels because each level has different type
+ // "1" (level 0), "2" (level 1), "3" (level 2)
+ assertEquals(List.of("1", "2", "3"), linkIds);
+ }
+
+ @Test
+ void testTransformTreeSelfReferentialInfiniteLoop() {
+ // Self-referential: traversing with identity function c -> c
+ // This would create infinite recursion (same type forever)
+ // maxDepth MUST apply to prevent infinite loop
+
+ final Dataset ds = createNestedItemDataset();
+
+ // Traverse with identity function c -> c
+ // This would loop infinitely: items -> items -> items -> items ...
+ // Type never changes (always array), so maxDepth applies
+ // maxDepth=1 should limit to only 2 iterations (levels 0 and 1)
+ final Dataset result = ds.withColumn("collected",
+ ValueFunctions.transformTree(
+ ds.col("items"),
+ c -> c.getField("linkId"), // Extract linkId at each level
+ List.of(c -> c), // Identity function: infinite loop!
+ 2 // maxDepth=1 must prevent infinite recursion
+ )
+ );
+
+ final List results = result.collectAsList();
+ assertEquals(1, results.size());
+
+ final Row row = results.getFirst();
+ final Seq> collected = row.getAs("collected");
+ final List> linkIds = CollectionConverters.asJava(collected);
+
+ // Should collect only 3 levels (0, 1 and 2) because:
+ // - Level 0: items (type = array)
+ // - Level 1: c -> c returns items again (type = array, same type!)
+ // - Level 2: c -> c returns items again (type = array, same type!)
+ // - Level 3: would be items again but maxDepth=3 prevents it
+ // All extracted linkIds should be "1" (same item repeated)
+ assertEquals(List.of("1", "1", "1"), linkIds);
+ }
+
+ @Test
+ void testTransformTreeMultipleTraversalPaths() {
+ // Multiple traversal paths: combines finite path (type-changing) and self-referential (infinite loop)
+ // This demonstrates that different paths can have different depth behaviors simultaneously
+
+ final Dataset ds = createNestedItemDataset();
+
+ // Use TWO traversal paths:
+ // Path 1: c -> unnest(c.getField("item")) - finite path with type changes
+ // Path 2: c -> c - self-referential with same type (infinite loop)
+ final Dataset result = ds.withColumn("collected",
+ ValueFunctions.transformTree(
+ ds.col("items"),
+ c -> c.getField("linkId"), // Extract linkId at each level
+ List.of(
+ c -> unnest(c.getField("item")), // Path 1: finite, type changes
+ c -> c // Path 2: infinite loop, type stays same
+ ),
+ 1 // maxDepth=1
+ )
+ );
+
+ final List results = result.collectAsList();
+ assertEquals(1, results.size());
+
+ final Row row = results.getFirst();
+ final Seq> collected = row.getAs("collected");
+ final List> linkIds = CollectionConverters.asJava(collected);
+
+
+ // Should collect:
+ // From Path 1 (finite, type-changing):
+ // - "1" (level 0), "2" (level 1),
+ // From Path 2 (self-referential, infinite loop, maxDepth=1):
+ // - "1" (level 0), "2" (level 1),
+ // Total collected: "1", "2", "3", "3", "2", "3", "1", "2", "3"
+
+ assertEquals(List.of("1", "2", "3", "3", "2", "3", "1", "2", "3"), linkIds);
+
+ }
+
+ @Test
+ void testNullIfMissingField() {
+ // Comprehensive test for nullIfMissingField covering all scenarios:
+ // 1. Existing top-level fields returning actual values
+ // 2. Existing nested struct fields returning actual values
+ // 3. Missing struct fields returning null
+ // 4. Struct fields with null values vs non-existent fields
+
+ final Metadata metadata = Metadata.empty();
+
+ // Create a struct type for person with name and age fields (no email or salary fields)
+ final StructType personType = DataTypes.createStructType(new StructField[]{
+ new StructField("name", DataTypes.StringType, true, metadata),
+ new StructField("age", DataTypes.IntegerType, true, metadata)
+ });
+
+ final StructType schema = DataTypes.createStructType(new StructField[]{
+ new StructField("id", DataTypes.IntegerType, true, metadata),
+ new StructField("value", DataTypes.IntegerType, true, metadata),
+ new StructField("person", personType, true, metadata)
+ });
+
+ final List data = List.of(
+ RowFactory.create(1, 100, RowFactory.create(null, 25)), // person.name is null
+ RowFactory.create(2, 200, RowFactory.create("Bob", null)), // person.age is null
+ RowFactory.create(3, 300, RowFactory.create("Charlie", 30))
+ );
+
+ final Dataset ds = spark.createDataFrame(data, schema);
+
+ // Apply multiple nullIfMissingField expressions to test all scenarios
+ final Dataset result = ds
+ // Test 1: Existing top-level field
+ .withColumn("test_top_level", nullIfMissingField(ds.col("value")))
+ // Test 2: Existing nested field using dot notation
+ .withColumn("test_nested_exists", nullIfMissingField(ds.col("person.name")))
+ // Test 3: Existing nested field using getField
+ .withColumn("test_struct_field", nullIfMissingField(ds.col("person").getField("age")))
+ // Test 4: Missing struct field (address doesn't exist)
+ .withColumn("test_missing_address",
+ nullIfMissingField(ds.col("person").getField("address")))
+ // Test 5: Missing struct field (email doesn't exist)
+ .withColumn("test_missing_email", nullIfMissingField(ds.col("person").getField("email")))
+ // Test 6: Missing struct field (salary doesn't exist)
+ .withColumn("test_missing_salary", nullIfMissingField(ds.col("person").getField("salary")));
+
+ final List results = result.collectAsList();
+ assertEquals(3, results.size());
+
+ // Row 1: person.name is null, person.age is 25
+ assertEquals(100, (Integer) results.getFirst().getAs("test_top_level"));
+ assertNull(results.getFirst().getAs("test_nested_exists")); // null value from data
+ assertEquals(25, (Integer) results.getFirst().getAs("test_struct_field"));
+ assertNull(results.getFirst().getAs("test_missing_address")); // field doesn't exist
+ assertNull(results.get(0).getAs("test_missing_email"));
+ assertNull(results.get(0).getAs("test_missing_salary"));
+
+ // Row 2: person.name is "Bob", person.age is null
+ assertEquals(200, (Integer) results.get(1).getAs("test_top_level"));
+ assertEquals("Bob", results.get(1).getAs("test_nested_exists"));
+ assertNull(results.get(1).getAs("test_struct_field")); // null value from data
+ assertNull(results.get(1).getAs("test_missing_address")); // field doesn't exist
+ assertNull(results.get(1).getAs("test_missing_email"));
+ assertNull(results.get(1).getAs("test_missing_salary"));
+
+ // Row 3: person.name is "Charlie", person.age is 30
+ assertEquals(300, (Integer) results.get(2).getAs("test_top_level"));
+ assertEquals("Charlie", results.get(2).getAs("test_nested_exists"));
+ assertEquals(30, (Integer) results.get(2).getAs("test_struct_field"));
+ assertNull(results.get(2).getAs("test_missing_address")); // field doesn't exist
+ assertNull(results.get(2).getAs("test_missing_email"));
+ assertNull(results.get(2).getAs("test_missing_salary"));
+
+ // Key distinction: Fields that exist but have null values (row 1 name, row 2 age)
+ // preserve the null from the data, while missing fields (address, email, salary)
+ // return null because they don't exist in the struct schema
+ }
}
diff --git a/encoders/src/test/java/au/csiro/pathling/encoders/FhirEncodersTest.java b/encoders/src/test/java/au/csiro/pathling/encoders/FhirEncodersTest.java
index a3e1d72d7f..c14aaea2c5 100644
--- a/encoders/src/test/java/au/csiro/pathling/encoders/FhirEncodersTest.java
+++ b/encoders/src/test/java/au/csiro/pathling/encoders/FhirEncodersTest.java
@@ -31,6 +31,7 @@
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import au.csiro.pathling.config.EncodingConfiguration;
import ca.uhn.fhir.parser.IParser;
import java.io.IOException;
import java.math.BigDecimal;
@@ -39,6 +40,7 @@
import java.util.Date;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.apache.spark.api.java.JavaRDD;
@@ -53,6 +55,7 @@
import org.hl7.fhir.exceptions.FHIRException;
import org.hl7.fhir.r4.model.Annotation;
import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Condition;
import org.hl7.fhir.r4.model.DateTimeType;
import org.hl7.fhir.r4.model.Encounter;
@@ -684,4 +687,65 @@ void nullEncodingFromJson() {
}
+ @Test
+ void testResourceWithMetaVersionId() {
+ // Setting meta.versionId does NOT affect the id_versioned column.
+ // The id_versioned column is populated from IdType.getValue(), which only includes
+ // version info if the IdType was constructed with a version parameter.
+ final Patient patientWithMetaVersion = new Patient();
+ patientWithMetaVersion.setId("patient-123");
+ patientWithMetaVersion.getMeta().setVersionId("1");
+
+ final Dataset dataset = spark.createDataset(
+ List.of(patientWithMetaVersion), ENCODERS_L0.of(Patient.class));
+
+ final Row row = dataset.select("id", "id_versioned").head();
+
+ // Both columns contain just the logical ID when meta.versionId is used.
+ assertEquals("patient-123", row.getString(0));
+ assertEquals("patient-123", row.getString(1));
+ }
+
+ @Test
+ void testResourceWithVersionedIdType() {
+ // When IdType is constructed with resource type and version, id_versioned includes the
+ // full versioned reference. This is what getResourceKey() returns.
+ final Patient patientWithVersionedIdType = new Patient();
+ patientWithVersionedIdType.setIdElement(new IdType("Patient", "patient-123", "1"));
+
+ final Dataset dataset = spark.createDataset(
+ List.of(patientWithVersionedIdType), ENCODERS_L0.of(Patient.class));
+
+ final Row row = dataset.select("id", "id_versioned").head();
+
+ // The id column contains just the logical ID part.
+ assertEquals("patient-123", row.getString(0));
+
+ // The id_versioned column contains the full versioned reference.
+ assertEquals("Patient/patient-123/_history/1", row.getString(1));
+ }
+
+ @Test
+ void testEncodersConfiguration() {
+ // test the default FhirEncoders configuration
+ assertEquals(
+ EncodingConfiguration.builder().enableExtensions(false).maxNestingLevel(0).openTypes(
+ Set.of()).build(),
+ FhirEncoders.forR4().getOrCreate()
+ .getConfiguration());
+
+ // test a custom FhirEncoders configuration
+ final EncodingConfiguration customConfig = EncodingConfiguration.builder()
+ .enableExtensions(true)
+ .maxNestingLevel(5)
+ .openTypes(Set.of("code", "Identifier", "string"))
+ .build();
+
+ assertEquals(customConfig, FhirEncoders.forR4()
+ .withMaxNestingLevel(customConfig.getMaxNestingLevel())
+ .withExtensionsEnabled(customConfig.isEnableExtensions())
+ .withOpenTypes(customConfig.getOpenTypes())
+ .getOrCreate().getConfiguration());
+ }
+
}
diff --git a/encoders/src/test/java/au/csiro/pathling/encoders/LightweightFhirEncodersTest.java b/encoders/src/test/java/au/csiro/pathling/encoders/LightweightFhirEncodersTest.java
index 4e2ea2d229..a82c2446af 100644
--- a/encoders/src/test/java/au/csiro/pathling/encoders/LightweightFhirEncodersTest.java
+++ b/encoders/src/test/java/au/csiro/pathling/encoders/LightweightFhirEncodersTest.java
@@ -331,10 +331,10 @@ void testQuantityArrayCanonicalization() {
final List quantityArray = propertyRow.getList(propertyRow.fieldIndex("valueQuantity"));
final Row quantity1 = quantityArray.getFirst();
- assertQuantity(quantity1, "0.0010", "m");
+ assertQuantity(quantity1, "0.001", "m");
final Row quantity2 = quantityArray.get(1);
- assertQuantity(quantity2, "0.0020", "m");
+ assertQuantity(quantity2, "0.002", "m");
}
}
diff --git a/encoders/src/test/java/au/csiro/pathling/encoders/UnresolvedExpressionsTest.java b/encoders/src/test/java/au/csiro/pathling/encoders/UnresolvedExpressionsTest.java
new file mode 100644
index 0000000000..37819741d4
--- /dev/null
+++ b/encoders/src/test/java/au/csiro/pathling/encoders/UnresolvedExpressionsTest.java
@@ -0,0 +1,90 @@
+/*
+ * This is a modified version of the Bunsen library, originally published at
+ * https://github.com/cerner/bunsen.
+ *
+ * Bunsen is copyright 2017 Cerner Innovation, Inc., and is licensed under
+ * the Apache License, version 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
+ *
+ * These modifications are copyright 2018-2025 Commonwealth Scientific
+ * and Industrial Research Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.encoders;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import jakarta.annotation.Nonnull;
+import java.util.Arrays;
+import org.apache.spark.sql.catalyst.analysis.UnresolvedException;
+import org.apache.spark.sql.catalyst.expressions.Expression;
+import org.apache.spark.sql.catalyst.expressions.Literal;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.unsafe.types.UTF8String;
+import org.junit.jupiter.api.Test;
+import scala.Function1;
+import scala.collection.immutable.IndexedSeq;
+import scala.jdk.javaapi.CollectionConverters;
+
+class UnresolvedExpressionsTest {
+
+ private static Expression stringLiteral(@Nonnull final String value) {
+ return new Literal(UTF8String.fromString(value), DataTypes.StringType);
+ }
+
+ @SafeVarargs
+ private static IndexedSeq toIndexedSeq(@Nonnull final T... expressions) {
+ return CollectionConverters.asScala(Arrays.asList(expressions)).toIndexedSeq();
+ }
+
+ static void assertUnresolvedExpression(@Nonnull final Expression expression) {
+ assertThrows(UnresolvedException.class, expression::nullable);
+ assertThrows(UnresolvedException.class, expression::dataType);
+ assertFalse(expression.resolved());
+ }
+
+ @Test
+ void testUnresolvedNullIfMissingField() {
+ final UnresolvedNullIfMissingField unresolvedNullIfMissingField = new UnresolvedNullIfMissingField(
+ stringLiteral("data1")
+ );
+ assertUnresolvedExpression(unresolvedNullIfMissingField);
+ assertEquals("data1", unresolvedNullIfMissingField.toString());
+ assertEquals(new UnresolvedNullIfMissingField(stringLiteral("data2")),
+ unresolvedNullIfMissingField.withNewChildrenInternal(toIndexedSeq(stringLiteral("data2")))
+ );
+ }
+
+ @Test
+ void testUnresolvedTransformTree() {
+
+ final Function1 extractor = x -> x;
+ final Function1 traversor = x -> x;
+
+ final UnresolvedTransformTree unresolvedTransformTree = new UnresolvedTransformTree(
+ stringLiteral("data1"), extractor, toIndexedSeq(traversor), 2
+ );
+ assertUnresolvedExpression(unresolvedTransformTree);
+ assertEquals("data1", unresolvedTransformTree.toString());
+
+ assertEquals(
+ new UnresolvedTransformTree(
+ stringLiteral("data2"), extractor, toIndexedSeq(traversor), 2),
+ unresolvedTransformTree.withNewChildrenInternal(toIndexedSeq(stringLiteral("data2")))
+ );
+ }
+}
diff --git a/examples/java/PathlingJavaApp/pom.xml b/examples/java/PathlingJavaApp/pom.xml
index f7ef37cab2..ed6269eee6 100644
--- a/examples/java/PathlingJavaApp/pom.xml
+++ b/examples/java/PathlingJavaApp/pom.xml
@@ -35,7 +35,7 @@
au.csiro.pathling
library-runtime
- 9.0.1-SNAPSHOT
+ 9.1.0-SNAPSHOT
org.apache.spark
diff --git a/fhirpath/pom.xml b/fhirpath/pom.xml
index ba4f5bb37b..43f7f11b10 100644
--- a/fhirpath/pom.xml
+++ b/fhirpath/pom.xml
@@ -24,7 +24,7 @@
au.csiro.pathling
pathling
- 9.0.1-SNAPSHOT
+ 9.1.0-SNAPSHOT
fhirpath
jar
diff --git a/fhirpath/src/license/THIRD-PARTY.properties b/fhirpath/src/license/THIRD-PARTY.properties
index f00db44700..2f0daba615 100644
--- a/fhirpath/src/license/THIRD-PARTY.properties
+++ b/fhirpath/src/license/THIRD-PARTY.properties
@@ -59,6 +59,6 @@
# Please fill the missing licenses for dependencies :
#
#
-#Tue Oct 28 09:07:42 AEST 2025
+#Mon Nov 24 10:19:04 AEST 2025
org.apache.datasketches--datasketches-memory--3.0.2=
oro--oro--2.0.8=
diff --git a/fhirpath/src/main/java/au/csiro/pathling/config/QueryConfiguration.java b/fhirpath/src/main/java/au/csiro/pathling/config/QueryConfiguration.java
index 7c09374cd2..11ee2aebc6 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/config/QueryConfiguration.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/config/QueryConfiguration.java
@@ -17,7 +17,7 @@
package au.csiro.pathling.config;
-import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Min;
import lombok.Builder;
import lombok.Data;
@@ -34,15 +34,15 @@ public class QueryConfiguration {
* Setting this option to {@code true} will enable additional logging relating to the query plan
* used to execute queries.
*/
- @NotNull
@Builder.Default
- private Boolean explainQueries = false;
+ private boolean explainQueries = false;
/**
- * This controls whether the built-in caching within Spark is used for search results. It may be
- * useful to turn this off for large datasets in memory-constrained environments.
+ * Maximum depth for self-referencing structure traversals in repeat operations. Controls how
+ * deeply nested hierarchical data can be flattened during projection.
*/
- @NotNull
+ @Min(0)
@Builder.Default
- private Boolean cacheResults = true;
+ private int maxUnboundTraversalDepth = 10;
+
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/CalendarDurationUnit.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/CalendarDurationUnit.java
deleted file mode 100644
index 95623a9140..0000000000
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/CalendarDurationUnit.java
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
- * Organisation (CSIRO) ABN 41 687 119 230.
- *
- * 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 au.csiro.pathling.fhirpath;
-
-import jakarta.annotation.Nonnull;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-
-/**
- * Enumeration of valid FHIRPath calendar duration units from year to millisecond.
- */
-public enum CalendarDurationUnit {
- YEAR("year", false, "a"),
- MONTH("month", false, "mo"),
- WEEK("week", false, "wk"),
- DAY("day", false, "d"),
- HOUR("hour", false, "h"),
- MINUTE("minute", false, "min"),
- SECOND("second", true, "s"),
- MILLISECOND("millisecond", true, "ms");
-
- @Nonnull
- private final String unit;
-
- /**
- * Indicates whether the unit is a definite duration (i.e., has a fixed length in time). For
- * example seconds, and milliseconds are definite units, while years and
- * months are not because their lengths can vary (e.g., due to leap years or different month
- * lengths).
- */
- private final boolean definite;
-
- /**
- * The UCUM equivalent of the calendar duration unit.
- */
- @Nonnull
- private final String ucumEquivalent;
-
- private static final Map NAME_MAP = new HashMap<>();
-
- static {
- for (CalendarDurationUnit unit : values()) {
- NAME_MAP.put(unit.unit, unit);
- NAME_MAP.put(unit.unit + "s", unit); // plural
- }
- }
-
- CalendarDurationUnit(@Nonnull String name, boolean definite, @Nonnull String ucumEquivalent) {
- this.unit = name;
- this.definite = definite;
- this.ucumEquivalent = ucumEquivalent;
- }
-
- /**
- * Gets the canonical (that is singular) name of the calendar duration unit.
- *
- * @return the name of the unit
- */
- @Nonnull
- public String getUnit() {
- return unit;
- }
-
- /**
- * Indicates whether the unit is a definite duration (i.e., has a fixed length in time).
- * For example seconds, and milliseconds are definite units, while years and
- * months are not because their lengths can vary (e.g., due to leap years or different month
- * lengths).
- * @return true if the unit is definite, false otherwise
- */
- public boolean isDefinite() {
- return definite;
- }
-
- /**
- * Gets the UCUM equivalent of the calendar duration unit.
- *
- * @return the UCUM equivalent string
- */
- @Nonnull
- public String getUcumEquivalent() {
- return ucumEquivalent;
- }
-
- /**
- * Gets the CalendarDurationUnit from its string representation (case-insensitive, singular or
- * plural).
- *
- * @param name the name of the unit (e.g. "year", "years")
- * @return the corresponding CalendarDurationUnit
- * @throws IllegalArgumentException if the name is not valid
- */
- @Nonnull
- public static CalendarDurationUnit fromString(@Nonnull String name) {
- CalendarDurationUnit unit = NAME_MAP.get(name.toLowerCase(Locale.ROOT));
- if (unit == null) {
- throw new IllegalArgumentException("Unknown calendar duration unit: " + name);
- }
- return unit;
- }
-}
-
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathQuantity.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathQuantity.java
index 1d75a51a7a..d59b55bcd3 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathQuantity.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathQuantity.java
@@ -17,114 +17,119 @@
package au.csiro.pathling.fhirpath;
+import static java.util.Objects.isNull;
+import static java.util.Objects.requireNonNull;
+
+import au.csiro.pathling.encoders.terminology.ucum.Ucum;
+import au.csiro.pathling.fhirpath.unit.CalendarDurationUnit;
+import au.csiro.pathling.fhirpath.unit.FhirPathUnit;
+import au.csiro.pathling.fhirpath.unit.UcumUnit;
import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import lombok.AllArgsConstructor;
import lombok.Value;
/**
* Represents a FHIRPath Quantity value.
*/
@Value
-@AllArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public class FhirPathQuantity {
/**
- * The system URI for Fhipath calendar duration units (e.g. year, month, day).
+ * Regex pattern for parsing FHIRPath quantity literals. Unit is optional per FHIRPath spec -
+ * defaults to '1' when omitted.
*/
- public static final String FHIRPATH_CALENDAR_DURATION_SYSTEM_URI = "https://hl7.org/fhirpath/N1/calendar-duration";
+ private static final Pattern QUANTITY_REGEX = Pattern.compile(
+ "(?[+-]?\\d+(?:\\.\\d+)?)\\s*(?:'(?[^']+)'|(?[a-zA-Z]+))?"
+ );
- /**
- * The system URI for UCUM units.
- */
- public static final String UCUM_SYSTEM_URI = "http://unitsofmeasure.org";
+ private FhirPathQuantity(@Nonnull final BigDecimal value, @Nonnull final FhirPathUnit unit,
+ @Nonnull final String unitName) {
+ this.value = value;
+ this.unit = unit;
+ this.unitName = unitName;
+ // validate the consistency between unit and unitName
+ if (!unit.isValidName(unitName)) {
+ throw new IllegalArgumentException(
+ "Unit name " + unitName + " is not valid for unit " + unit);
+ }
+ }
/**
- * Regex pattern for parsing FHIRPath quantity literals.
+ * The numeric value of the quantity.
*/
- private static final Pattern QUANTITY_REGEX = Pattern.compile(
- "(?[+-]?\\d+(?:\\.\\d+)?)\\s*(?:'(?[^']+)'|(?[a-zA-Z]+))"
- );
-
@Nonnull
BigDecimal value;
+
+ /**
+ * The FhirPathUnit representing the unit of measure (UCUM or calendar duration).
+ */
@Nonnull
- String unit;
- @Nonnull
- String system;
- @Nonnull
- String code;
+ FhirPathUnit unit;
/**
- * Check if the quantity is a calendar duration.
- *
- * @return true if the quantity is a calendar duration, false otherwise
+ * The string name of the unit as it appears in the original representation (e.g., "mg", "year",
+ * "years").
*/
- public boolean isCalendarDuration() {
- return FHIRPATH_CALENDAR_DURATION_SYSTEM_URI.equals(system);
- }
+ @Nonnull
+ String unitName;
/**
- * Check if the quantity is a UCUM quantity.
+ * Gets the system URI for this quantity's unit.
*
- * @return true if the quantity is a UCUM quantity, false otherwise
+ * @return the system URI (e.g., {@value UcumUnit#UCUM_SYSTEM_URI} or
+ * {@value CalendarDurationUnit#FHIRPATH_CALENDAR_DURATION_SYSTEM_URI})
*/
-
- public boolean isUCUM() {
- return UCUM_SYSTEM_URI.equals(system);
+ @Nonnull
+ public String getSystem() {
+ return unit.system();
}
/**
- * Factory method for UCUM quantities.
+ * Gets the canonical code for this quantity's unit.
*
- * @param unit the UCUM unit string (e.g. 'mg', 'kg', 'mL')
- * @param value the numeric value
- * @return UCUM quantity
+ * @return the unit code (e.g., "mg", "second")
*/
@Nonnull
- public static FhirPathQuantity ofUCUM(@Nonnull final BigDecimal value,
- @Nonnull final String unit) {
- return new FhirPathQuantity(value, unit, UCUM_SYSTEM_URI, unit);
+ public String getCode() {
+ return unit.code();
}
/**
- * Factory method for calendar duration quantities.
+ * Factory method for UCUM quantities.
*
- * @param unitName the name of the calendar duration unit (e.g. 'year', 'month', 'day')
- * @param unit the CalendarDurationUnit enum value
* @param value the numeric value
- * @return calendar duration quantity
+ * @param unit the UCUM unit string (e.g. 'mg', 'kg', 'mL')
+ * @return UCUM quantity
*/
@Nonnull
- public static FhirPathQuantity ofCalendar(@Nonnull final BigDecimal value,
- @Nonnull final CalendarDurationUnit unit, @Nonnull final String unitName) {
- if (!CalendarDurationUnit.fromString(unitName).equals(unit)) {
- throw new IllegalArgumentException(
- "Unit name " + unitName + " does not match CalendarDurationUnit " + unit);
- }
- return new FhirPathQuantity(value, unitName,
- FHIRPATH_CALENDAR_DURATION_SYSTEM_URI,
- unit.getUnit());
+ public static FhirPathQuantity ofUcum(@Nonnull final BigDecimal value,
+ @Nonnull final String unit) {
+ return new FhirPathQuantity(value, new UcumUnit(unit), unit);
}
/**
* Factory method for calendar duration quantities.
*
- * @param unit the CalendarDurationUnit enum value
* @param value the numeric value
+ * @param calendarDurationUnit the FHIRPath calendar duration unit string (e.g., "year", "month",
+ * "days") singular or plural
* @return calendar duration quantity
*/
@Nonnull
- public static FhirPathQuantity ofCalendar(@Nonnull final BigDecimal value,
- @Nonnull final CalendarDurationUnit unit) {
- return ofCalendar(value, unit, unit.getUnit());
+ public static FhirPathQuantity ofDuration(@Nonnull final BigDecimal value,
+ @Nonnull final String calendarDurationUnit) {
+ return ofUnit(value, CalendarDurationUnit.parseString(calendarDurationUnit),
+ calendarDurationUnit);
}
/**
- * Parses a FHIRPath quantity literal (e.g. 5.4 'mg', 1 year). Only supports UCUM and calendar
- * duration units.
+ * Parses a FHIRPath quantity literal (e.g. 5.4 'mg', 1 year, 42). Only supports UCUM and calendar
+ * duration units. When no unit is specified, defaults to '1' in UCUM system.
*
* @param literal the FHIRPath quantity literal
* @return the parsed FhirPathQuantity
@@ -140,15 +145,153 @@ public static FhirPathQuantity parse(@Nonnull final String literal) {
final BigDecimal value = new BigDecimal(matcher.group("value"));
if (matcher.group("unit") != null) {
// Quoted unit, always UCUM
- return ofUCUM(value, matcher.group("unit"));
+ return ofUcum(value, matcher.group("unit"));
} else if (matcher.group("time") != null) {
- return ofCalendar(value,
- CalendarDurationUnit.fromString(matcher.group("time")),
- matcher.group("time")
- );
+ return ofDuration(value, matcher.group("time"));
} else {
- // one of "unit" or "time" groups should have matched
- throw new IllegalStateException("Unexpected parsing state for literal: " + literal);
+ // No unit specified, default to '1' in UCUM system per FHIRPath spec
+ return ofUnit(value, UcumUnit.ONE);
}
}
+
+ /**
+ * Factory method for creating a quantity from a FhirPathUnit.
+ *
+ * @param value the numeric value
+ * @param unit the FhirPathUnit (UCUM, calendar duration, or custom)
+ * @param unitName the name of the unit
+ * @return quantity with the specified value and unit
+ */
+ @Nonnull
+ public static FhirPathQuantity ofUnit(@Nonnull final BigDecimal value,
+ @Nonnull final FhirPathUnit unit, @Nonnull final String unitName) {
+ return new FhirPathQuantity(value, unit, unitName);
+ }
+
+ /**
+ * Factory method for creating a quantity from a FhirPathUnit.
+ *
+ * @param value the numeric value
+ * @param unit the FhirPathUnit (UCUM, calendar duration, or custom)
+ * @return quantity with the specified value and unit
+ */
+ @Nonnull
+ public static FhirPathQuantity ofUnit(@Nonnull final BigDecimal value,
+ @Nonnull final FhirPathUnit unit) {
+ return ofUnit(value, unit, unit.code());
+ }
+
+ /**
+ * Factory method for creating a quantity from system, code, and optional unit name. This method
+ * is typically used when reconstructing quantities from FHIR Quantity resources.
+ *
+ * @param value the numeric value (nullable)
+ * @param system the system URI (nullable)
+ * @param code the unit code (nullable)
+ * @param unit the optional unit name (nullable, defaults to code if not provided)
+ * @return quantity with the specified value and unit, or null if any required parameter is null
+ */
+ @Nullable
+ public static FhirPathQuantity of(@Nullable final BigDecimal value, @Nullable final String system,
+ @Nullable final String code, @Nullable final String unit) {
+
+ if (isNull(value) || isNull(system) || isNull(code)) {
+ return null;
+ }
+ return switch (requireNonNull(system)) {
+ case UcumUnit.UCUM_SYSTEM_URI -> ofUcum(requireNonNull(value), requireNonNull(code));
+ case CalendarDurationUnit.FHIRPATH_CALENDAR_DURATION_SYSTEM_URI ->
+ new FhirPathQuantity(value, CalendarDurationUnit.parseString(code),
+ unit != null
+ ? unit
+ : code);
+ default -> null;
+ };
+ }
+
+ /**
+ * Converts this quantity to the specified target unit if a conversion is possible.
+ *
+ * Supports:
+ *
+ * UCUM to UCUM conversions (e.g., 'mg' to 'kg')
+ * Calendar duration to calendar duration conversions (only to definite units: second,
+ * millisecond)
+ * Cross-type conversions: calendar duration to UCUM 's' or 'ms', and vice versa
+ *
+ *
+ * @param unitName the target unit string (e.g., "mg", "second", "s")
+ * @param scale the scale to apply to the converted value
+ * @return an Optional containing the converted quantity, or empty if conversion is not possible
+ */
+ @Nonnull
+ public Optional convertToUnit(@Nonnull final String unitName, final int scale) {
+ final FhirPathUnit sourceUnit = getUnit();
+ final FhirPathUnit targetUnit = FhirPathUnit.fromString(unitName);
+ if (targetUnit.equals(sourceUnit)) {
+ return Optional.of(FhirPathQuantity.ofUnit(getValue(), targetUnit, unitName));
+ } else {
+ return FhirPathUnit.convertValue(getValue(), getUnit(), targetUnit)
+ .map(convertedValue ->
+ FhirPathQuantity.ofUnit(
+ convertedValue.setScale(scale, RoundingMode.HALF_UP).stripTrailingZeros()
+ , targetUnit, unitName));
+ }
+ }
+
+ /**
+ * Converts this quantity to the specified target unit if a conversion is possible, using the
+ * default scale.
+ *
+ * @param unitName the target unit string (e.g., "mg", "second", "s")
+ * @return an Optional containing the converted quantity, or empty if conversion is not possible
+ */
+ @Nonnull
+ public Optional convertToUnit(@Nonnull final String unitName) {
+ return convertToUnit(unitName, FhirPathUnit.DEFAULT_PRECISION);
+ }
+
+ /**
+ * Helper method to create a canonical UCUM quantity.
+ *
+ * @param value the numeric value
+ * @param code the UCUM unit code
+ * @return an Optional containing the canonical UCUM quantity, or empty if conversion is not
+ * possible
+ */
+ private static Optional ofUcumCanonical(
+ @Nonnull final BigDecimal value,
+ @Nonnull final String code) {
+ return Optional.ofNullable(Ucum.getCanonical(value, code))
+ .map(result -> ofUcum(result.value(), result.unit()));
+ }
+
+ /**
+ * Converts this quantity to its canonical UCUM representation if possible.
+ *
+ * Uses the UCUM conversion service to determine the canonical form of the quantity. UCUM
+ * quantities are converted to their canonical UCUM units, while definite calendar durations are
+ * converted to their UCUM equivalents (e.g., 's' for seconds).
+ *
+ * @return an Optional containing the canonical UCUM quantity, or empty if conversion is not
+ * possible
+ */
+ public Optional asCanonical() {
+ return switch (unit) {
+ case UcumUnit(var code) -> ofUcumCanonical(value, code);
+ case final CalendarDurationUnit cdUnit when (cdUnit.isDefinite()) ->
+ ofUcumCanonical(value, cdUnit.getUcumEquivalent());
+ default -> Optional.empty();
+ };
+ }
+
+ @Override
+ @Nonnull
+ public String toString() {
+ final String formattedValue = getValue().stripTrailingZeros().toPlainString();
+ return switch (unit) {
+ case final UcumUnit ignore -> formattedValue + " '" + unitName + "'";
+ case final CalendarDurationUnit ignore -> formattedValue + " " + unitName;
+ };
+ }
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathType.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathType.java
index aeec1df8a2..eca9ad3102 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathType.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/FhirPathType.java
@@ -23,6 +23,7 @@
import au.csiro.pathling.fhirpath.collection.DateCollection;
import au.csiro.pathling.fhirpath.collection.DateTimeCollection;
import au.csiro.pathling.fhirpath.collection.DecimalCollection;
+import au.csiro.pathling.fhirpath.collection.EmptyCollection;
import au.csiro.pathling.fhirpath.collection.IntegerCollection;
import au.csiro.pathling.fhirpath.collection.QuantityCollection;
import au.csiro.pathling.fhirpath.collection.StringCollection;
@@ -93,7 +94,12 @@ public enum FhirPathType {
* Quantity FHIRPath type.
*/
QUANTITY("Quantity", QuantityEncoding.dataType(), QuantityCollection.class,
- FHIRDefinedType.QUANTITY);
+ FHIRDefinedType.QUANTITY),
+
+ /**
+ * Nothing FHIRPath type (empty collection).
+ */
+ NOTHING("Nothing", DataTypes.NullType, EmptyCollection.class, FHIRDefinedType.NULL);
@Nonnull
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/Collection.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/Collection.java
index e60df8fba7..115baff391 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/Collection.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/Collection.java
@@ -278,19 +278,22 @@ protected Collection traverseChild(@Nonnull final ChildDefinition childDefinitio
// 1. If the child is a choice, we have special behaviour for traversing to the choice that
// results in a mixed collection.
// 2. If the child is a regular element, we use the standard traversal method.
- if (childDefinition instanceof final ChoiceDefinition choiceChildDefinition) {
- return MixedCollection.buildElement(this, choiceChildDefinition);
- } else if (childDefinition instanceof final ElementDefinition elementChildDefinition) {
- if (elementChildDefinition.isChoiceElement()) {
- log.warn(
- "Traversing a choice element `{}` without using ofType() is not portable and may not work in some FHIRPath implementations. "
- + "Consider using ofType() to specify the type of element you want to traverse.",
- elementChildDefinition.getElementName());
+ switch (childDefinition) {
+ case final ChoiceDefinition choiceChildDefinition -> {
+ return MixedCollection.buildElement(this, choiceChildDefinition);
}
- return traverseElement(elementChildDefinition);
- } else {
- throw new IllegalArgumentException("Unsupported child definition type: " + childDefinition
- .getClass().getSimpleName());
+ case final ElementDefinition elementChildDefinition -> {
+ if (elementChildDefinition.isChoiceElement()) {
+ log.warn(
+ "Traversing a choice element `{}` without using ofType() is not portable and may not work in some FHIRPath implementations. "
+ + "Consider using ofType() to specify the type of element you want to traverse.",
+ elementChildDefinition.getElementName());
+ }
+ return traverseElement(elementChildDefinition);
+ }
+ default ->
+ throw new IllegalArgumentException("Unsupported child definition type: " + childDefinition
+ .getClass().getSimpleName());
}
}
@@ -359,6 +362,26 @@ public Collection copyWith(@Nonnull final ColumnRepresentation newValue) {
return getInstance(newValue, getFhirType(), Optional.empty(), extensionMapColumn);
}
+ /**
+ * Returns a new {@link Collection} with the specified {@link Column}, preserving type and
+ * extension information.
+ *
+ * This is a convenience method that wraps the provided column in a {@link DefaultRepresentation}
+ * and creates a new collection while maintaining the FHIR type and extension mapping from the
+ * original collection. This is particularly useful when transforming column data while preserving
+ * the collection's semantic context.
+ *
+ *
+ * @param newColumn The new {@link Column} to use as the collection's data
+ * @return A new {@link Collection} with the specified {@link Column} but preserving FHIR type
+ * and extension information
+ * @throws CollectionConstructionError if there was a problem constructing the collection
+ */
+ @Nonnull
+ public Collection copyWithColumn(@Nonnull final Column newColumn) {
+ return copyWith(new DefaultRepresentation(newColumn));
+ }
+
/**
* Filters the elements of this collection using the specified lambda.
*
@@ -372,6 +395,36 @@ public Collection filter(
ctx -> ctx.filter(col -> lambda.apply(new DefaultRepresentation(col)).getValue()));
}
+ /**
+ * Checks if this collection is statically known to be empty.
+ *
+ * This method performs a static type check to determine if the collection is an
+ * {@link EmptyCollection}. It does not evaluate the actual data or count elements
+ * at runtime. A collection that is not statically empty may still contain zero
+ * elements when evaluated.
+ *
+ *
+ * @return {@code true} if this collection is an {@link EmptyCollection}, {@code false} otherwise
+ */
+ public boolean isEmpty() {
+ return this instanceof EmptyCollection;
+ }
+
+ /**
+ * Checks if this collection is statically known to be non-empty.
+ *
+ * This method performs a static type check to determine if the collection is not an
+ * {@link EmptyCollection}. It does not evaluate the actual data or count elements
+ * at runtime. A collection that is statically non-empty may still contain zero
+ * elements when evaluated.
+ *
+ *
+ * @return {@code true} if this collection is not an {@link EmptyCollection}, {@code false}
+ * otherwise
+ */
+ public boolean isNotEmpty() {
+ return !isEmpty();
+ }
/**
* Returns a new collection representing the elements of this collection as a singular value.
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/QuantityCollection.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/QuantityCollection.java
index b97e1ed733..d1823fc330 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/QuantityCollection.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/QuantityCollection.java
@@ -33,6 +33,7 @@
import au.csiro.pathling.fhirpath.definition.defaults.DefaultPrimitiveDefinition;
import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
import au.csiro.pathling.sql.misc.QuantityToLiteral;
+import au.csiro.pathling.fhirpath.column.QuantityValue;
import jakarta.annotation.Nonnull;
import java.util.List;
import java.util.Optional;
@@ -109,7 +110,7 @@ public static QuantityCollection build(@Nonnull final ColumnRepresentation colum
*/
@Nonnull
public static QuantityCollection build(@Nonnull final ColumnRepresentation columnRepresentation) {
- return build(columnRepresentation, Optional.empty());
+ return build(columnRepresentation, Optional.of(LITERAL_DEFINITION));
}
/**
@@ -168,4 +169,32 @@ public StringCollection asStringPath() {
public ColumnComparator getComparator() {
return QuantityComparator.getInstance();
}
+
+ /**
+ * Converts this quantity to the specified unit.
+ *
+ * @param targetUnit The target unit as a Collection (should be a StringCollection)
+ * @return QuantityCollection with matching unit, or EmptyCollection if the conversion is not
+ * possible.
+ */
+ @Nonnull
+ public Collection toUnit(@Nonnull final Collection targetUnit) {
+ final Column unitColumn = targetUnit.getColumn().singular().getValue();
+ return map(q -> new DefaultRepresentation(QuantityValue.of(q).toUnit(unitColumn)));
+ }
+
+ /**
+ * Checks if this quantity can be converted to the specified unit. Follows the same rules as
+ * FHIRPath convertibleToXXX functions, returning a BooleanCollection true/false value for
+ * non-empty input collections or an empty boolean collection if the input is empty.
+ *
+ * @param targetUnit The target unit as a Collection (should be a StringCollection)
+ * @return BooleanCollection indicating if conversion is possible
+ */
+ @Nonnull
+ public Collection convertibleToUnit(@Nonnull final Collection targetUnit) {
+ final Column unitColumn = targetUnit.getColumn().singular().getValue();
+ final Column result = QuantityValue.of(getColumn()).convertibleToUnit(unitColumn);
+ return BooleanCollection.build(new DefaultRepresentation(result));
+ }
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/ResourceCollection.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/ResourceCollection.java
index aaad98a088..f100ca8dfc 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/ResourceCollection.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/ResourceCollection.java
@@ -17,6 +17,9 @@
package au.csiro.pathling.fhirpath.collection;
+import static org.apache.spark.sql.functions.concat;
+import static org.apache.spark.sql.functions.lit;
+
import au.csiro.pathling.encoders.ExtensionSupport;
import au.csiro.pathling.fhirpath.FhirPathType;
import au.csiro.pathling.fhirpath.TypeSpecifier;
@@ -133,13 +136,19 @@ public Collection copyWith(@Nonnull final ColumnRepresentation newValue) {
}
/**
- * @return A column that can be used as a key for joining to this resource type
+ * Returns a column that can be used as a key for joining to this resource type. The key is
+ * constructed as "ResourceType/id" (e.g., "Patient/123") to match the format used by FHIR
+ * references. This ensures that {@code getResourceKey()} returns a value compatible with
+ * {@code getReferenceKey()} for joining resources to their references.
+ *
+ * @return A collection containing the resource key
*/
@Nonnull
public Collection getKeyCollection() {
- return StringCollection.build(
- getColumn().traverse("id_versioned", Optional.of(FHIRDefinedType.STRING))
- );
+ final String prefix = resourceDefinition.getResourceCode() + "/";
+ final ColumnRepresentation idColumn = getColumn()
+ .traverse("id", Optional.of(FHIRDefinedType.STRING));
+ return StringCollection.build(idColumn.transform(id -> concat(lit(prefix), id)));
}
/**
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/ColumnRepresentation.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/ColumnRepresentation.java
index 189567439c..09ebaa37d8 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/ColumnRepresentation.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/ColumnRepresentation.java
@@ -108,6 +108,7 @@ public ColumnRepresentation call(@Nonnull final UnaryOperator function)
*
* @return The underlying {@link Column}
*/
+ @Nonnull
public abstract Column getValue();
/**
@@ -116,6 +117,7 @@ public ColumnRepresentation call(@Nonnull final UnaryOperator function)
* @param newValue The new {@link Column} to represent
* @return A new {@link ColumnRepresentation} representing the new column
*/
+ @Nonnull
protected abstract ColumnRepresentation copyOf(@Nonnull final Column newValue);
@@ -252,6 +254,26 @@ public ColumnRepresentation singular(@Nullable final String errorMessage) {
);
}
+ /**
+ * Creates a Column expression that enforces the singularity constraint. This should be called
+ * when a singular value is required but will not be directly used, to ensure the constraint is
+ * checked during evaluation.
+ *
+ * The returned Column evaluates to null if the constraint is satisfied, or raises an error if the
+ * collection has multiple elements.
+ *
+ * @return A Column that enforces singularity and evaluates to null
+ */
+ @Nonnull
+ public Column ensureSingular() {
+ return vectorize(
+ arrayColumn -> when(size(arrayColumn).gt(1),
+ raise_error(lit(DEF_NOT_SINGULAR_ERROR)))
+ .otherwise(lit(null)),
+ singularColumn -> lit(null)
+ ).getValue();
+ }
+
/**
* Converts the current {@link ColumnRepresentation} a plural representation. The values are
* represented as arrays where an empty collection is represented as an empty array. This is
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/DefaultRepresentation.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/DefaultRepresentation.java
index 36cd23ee80..46a67c33c7 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/DefaultRepresentation.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/DefaultRepresentation.java
@@ -62,7 +62,9 @@ public static DefaultRepresentation empty() {
/**
* The column value represented by this object.
*/
+
@Setter(AccessLevel.PROTECTED)
+ @Nonnull
private Column value;
/**
@@ -96,6 +98,7 @@ public static ColumnRepresentation fromBinaryColumn(@Nonnull final Column column
}
@Override
+ @Nonnull
protected DefaultRepresentation copyOf(@Nonnull final Column newValue) {
return new DefaultRepresentation(newValue);
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/QuantityValue.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/QuantityValue.java
new file mode 100644
index 0000000000..7f29fa5f36
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/QuantityValue.java
@@ -0,0 +1,317 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.column;
+
+import static java.util.Objects.nonNull;
+import static java.util.Objects.requireNonNull;
+
+import static org.apache.spark.sql.functions.callUDF;
+import static org.apache.spark.sql.functions.coalesce;
+import static org.apache.spark.sql.functions.lit;
+import static org.apache.spark.sql.functions.when;
+
+import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
+import au.csiro.pathling.fhirpath.unit.CalendarDurationUnit;
+import au.csiro.pathling.fhirpath.unit.UcumUnit;
+import au.csiro.pathling.sql.misc.ConvertQuantityToUnit;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.util.function.BinaryOperator;
+import org.apache.spark.sql.Column;
+
+/**
+ * Utility class for working with Quantity columns in SQL operations.
+ *
+ * Wraps a Quantity struct Column and provides methods for:
+ *
+ * Unit-based conversions (toQuantity, convertsToQuantity)
+ * Quantity comparisons (equals, less than, greater than, etc.)
+ * Accessing different representations (original, normalized, literal)
+ *
+ *
+ * This class centralizes all Quantity-related SQL column operations and provides a unified
+ * abstraction through the {@link ValueWithUnit} record.
+ *
+ * @author Piotr Szul
+ */
+public class QuantityValue {
+
+ @Nonnull
+ private final Column quantityColumn;
+
+ private QuantityValue(@Nonnull final Column quantityColumn) {
+ this.quantityColumn = quantityColumn;
+ }
+
+ /**
+ * Creates an SQLQuantity wrapper for the specified Quantity column.
+ *
+ * @param quantityColumn the Quantity struct column to wrap
+ * @return an SQLQuantity instance
+ */
+ @Nonnull
+ public static QuantityValue of(@Nonnull final Column quantityColumn) {
+ return new QuantityValue(quantityColumn);
+ }
+
+ /**
+ * Creates an SQLQuantity wrapper for the specified ColumnRepresentation singular value.
+ *
+ * @param columnRepresentation the ColumnRepresentation to extract single value from.
+ * @return an SQLQuantity instance
+ */
+ @Nonnull
+ public static QuantityValue of(@Nonnull final ColumnRepresentation columnRepresentation) {
+ return new QuantityValue(
+ columnRepresentation.singular("Singular Quantity collection required").getValue());
+ }
+
+
+ /**
+ * A helper record to hold a value and a unit column.
+ *
+ * Provides methods to extract different representations of a Quantity (normalized, original,
+ * literal) and to compare two {@link ValueWithUnit} instances using a provided comparator,
+ * ensuring that the units are the same.
+ */
+ public record ValueWithUnit(
+ @Nonnull Column value,
+ @Nonnull Column unit,
+ @Nullable Column system
+ ) {
+
+ /**
+ * Extracts the normalized (canonicalized) value and unit from a Quantity column.
+ *
+ * Uses canonicalized_value and canonicalized_code fields, with null system (as canonicalized
+ * quantities are system-independent).
+ *
+ * @param quantity the Quantity column
+ * @return a ValueWithUnit instance containing the normalized value and unit
+ */
+ @Nonnull
+ public static ValueWithUnit normalizedValueOf(@Nonnull final Column quantity) {
+ return new ValueWithUnit(
+ quantity.getField(QuantityEncoding.CANONICALIZED_VALUE_COLUMN),
+ quantity.getField(QuantityEncoding.CANONICALIZED_CODE_COLUMN),
+ null
+ );
+ }
+
+ /**
+ * Extracts the original value and unit from a Quantity column.
+ *
+ * Uses value, code (canonical unit code), and system fields. This representation is used for
+ * decimal-based comparisons.
+ *
+ * @param quantity the Quantity column
+ * @return a ValueWithUnit instance containing the original value and unit
+ */
+ @Nonnull
+ public static ValueWithUnit originalValueOf(@Nonnull final Column quantity) {
+ return new ValueWithUnit(
+ quantity.getField(QuantityEncoding.VALUE_COLUMN),
+ quantity.getField(QuantityEncoding.CODE_COLUMN),
+ quantity.getField(QuantityEncoding.SYSTEM_COLUMN)
+ );
+ }
+
+ /**
+ * Extracts the literal value and unit from a Quantity column.
+ *
+ * Uses value, unit (literal unit string as written, e.g., "days" instead of "day"), and system
+ * fields. This representation is used for exact string matching in unit conversion.
+ *
+ * @param quantity the Quantity column
+ * @return a ValueWithUnit instance containing the literal value and unit
+ */
+ @Nonnull
+ public static ValueWithUnit literalValueOf(@Nonnull final Column quantity) {
+ return new ValueWithUnit(
+ quantity.getField(QuantityEncoding.VALUE_COLUMN),
+ quantity.getField(QuantityEncoding.UNIT_COLUMN),
+ lit(null)
+ );
+ }
+
+ /**
+ * Compares this ValueWithUnit with another using the provided value comparator.
+ *
+ * First checks that units match (and systems match if present), then applies the comparator to
+ * the values. Returns null if units/systems don't match.
+ *
+ * @param other the ValueWithUnit to compare with
+ * @param comparator the binary operator to apply to values (e.g., Column::equalTo)
+ * @return Column expression with comparison result, or null if units don't match
+ */
+ @Nonnull
+ public Column compare(@Nonnull final ValueWithUnit other,
+ @Nonnull final BinaryOperator comparator) {
+ // assert that both (this and other) systems are either non-null or null
+ // that is we compare either two original or two normalized quantities
+ if ((system == null) != (other.system == null)) {
+ throw new IllegalArgumentException("Both quantities must have system or neither.");
+ }
+ return when(
+ nonNull(system)
+ ? requireNonNull(system).equalTo(requireNonNull(other.system))
+ .and(unit.equalTo(other.unit))
+ : unit.equalTo(other.unit),
+ comparator.apply(value, other.value)
+ );
+ }
+ }
+
+ /**
+ * Gets the original (non-normalized) representation of this Quantity.
+ *
+ * Returns a ValueWithUnit with value, code (canonical unit), and system fields.
+ *
+ * @return ValueWithUnit representing the original Quantity
+ */
+ @Nonnull
+ public ValueWithUnit originalValue() {
+ return ValueWithUnit.originalValueOf(quantityColumn);
+ }
+
+ /**
+ * Gets the normalized (canonicalized) representation of this Quantity.
+ *
+ * Returns a ValueWithUnit with canonicalized_value, canonicalized_code, and null system.
+ *
+ * @return ValueWithUnit representing the normalized Quantity
+ */
+ @Nonnull
+ public ValueWithUnit normalizedValue() {
+ return ValueWithUnit.normalizedValueOf(quantityColumn);
+ }
+
+ /**
+ * Gets the full Quantity struct column.
+ *
+ * @return the Quantity column
+ */
+ @Nonnull
+ public Column getColumn() {
+ return quantityColumn;
+ }
+
+ /**
+ * Checks if this quantity uses the UCUM unit system.
+ *
+ * Returns a boolean column that evaluates to true if the quantity's system field equals
+ * {@link UcumUnit#UCUM_SYSTEM_URI}, false otherwise.
+ *
+ * @return Column of BooleanType indicating if this is a UCUM quantity
+ */
+ @Nonnull
+ public Column isUcum() {
+ return quantityColumn.getField(QuantityEncoding.SYSTEM_COLUMN)
+ .equalTo(lit(UcumUnit.UCUM_SYSTEM_URI));
+ }
+
+ /**
+ * Checks if this quantity uses the FHIRPath calendar duration system.
+ *
+ * Returns a boolean column that evaluates to true if the quantity's system field equals
+ * {@link CalendarDurationUnit#FHIRPATH_CALENDAR_DURATION_SYSTEM_URI},
+ * false otherwise.
+ *
+ * @return Column of BooleanType indicating if this is a calendar duration quantity
+ */
+ @Nonnull
+ public Column isCalendarDuration() {
+ return quantityColumn.getField(QuantityEncoding.SYSTEM_COLUMN)
+ .equalTo(lit(CalendarDurationUnit.FHIRPATH_CALENDAR_DURATION_SYSTEM_URI));
+ }
+
+ /**
+ * Converts this quantity to the specified unit if units match or are compatible via UCUM
+ * conversion, or returns null otherwise. It matches the FHIRPath toXXX() conversion semantics
+ * returning null when conversion is not possible.
+ *
+ * First checks if this is a UCUM or calendar duration quantity. For exact unit match with valid
+ * system, returns the quantity as-is (fast path). Otherwise, attempts UCUM unit conversion via
+ * the UDF. Returns the converted quantity if conversion is successful, or null if conversion is
+ * not possible.
+ *
+ * Non-UCUM/non-calendar quantities (e.g., Money with system "urn:iso:std:iso:4217") will always
+ * return null, even if the unit string happens to match the target unit.
+ *
+ * This implements the FHIRPath toQuantity(unit) conversion semantics with full UCUM unit
+ * conversion support for compatible units (e.g., 'kg' to 'g', 'wk' to 'd').
+ *
+ * @param targetUnit the target unit to convert to
+ * @return Column expression that evaluates to the converted quantity or null if conversion fails
+ */
+ @Nonnull
+ public Column toUnit(@Nonnull final Column targetUnit) {
+ final ValueWithUnit literal = ValueWithUnit.literalValueOf(quantityColumn);
+
+ // Try UCUM conversion (will return null for non-UCUM/non-calendar quantities)
+ final Column ucumConverted = callUDF(ConvertQuantityToUnit.FUNCTION_NAME,
+ quantityColumn, targetUnit);
+
+ // Short-circuit: exact match only if unit matches AND system is UCUM or calendar duration
+ // For non-UCUM/non-calendar systems (e.g., Money), fall through to UCUM conversion (returns null)
+ final Column hasValidSystem = isUcum().or(isCalendarDuration());
+ final Column exactMatchWithValidSystem = literal.unit().equalTo(targetUnit)
+ .and(hasValidSystem);
+
+ // Return exact match if available (fast path), otherwise UCUM conversion result (or null)
+ return when(exactMatchWithValidSystem, quantityColumn)
+ .otherwise(coalesce(ucumConverted, lit(null).cast(QuantityEncoding.dataType())));
+ }
+
+ /**
+ * Checks if this quantity can be converted to the specified unit via exact match or UCUM
+ * conversion.
+ *
+ * First checks if this is a UCUM or calendar duration quantity with exact unit match. If not,
+ * checks if UCUM unit conversion is possible via the UDF. Returns true if the unit matches (with
+ * valid system) or conversion is possible, false if not convertible, or null if the quantity row
+ * is null (to propagate empty values in the caller's coalesce logic).
+ *
+ * Non-UCUM/non-calendar quantities (e.g., Money with system "urn:iso:std:iso:4217") will always
+ * return false, even if the unit string happens to match the target unit.
+ *
+ * This implements the FHIRPath convertsToQuantity(unit) validation semantics with full UCUM unit
+ * conversion support.
+ *
+ * @param targetUnit the target unit to check compatibility with
+ * @return Column of BooleanType that evaluates to true if convertible and null for empty rows
+ */
+ @Nonnull
+ public Column convertibleToUnit(@Nonnull final Column targetUnit) {
+ final ValueWithUnit literal = ValueWithUnit.literalValueOf(quantityColumn);
+
+ // Check exact string match with valid system (UCUM or calendar duration)
+ final Column hasValidSystem = isUcum().or(isCalendarDuration());
+ final Column exactMatchWithValidSystem = literal.unit().equalTo(targetUnit)
+ .and(hasValidSystem);
+
+ // Check UCUM convertibility by attempting conversion and checking if result is non-null
+ final Column ucumConverted = callUDF(ConvertQuantityToUnit.FUNCTION_NAME,
+ quantityColumn, targetUnit);
+ final Column ucumConvertible = ucumConverted.isNotNull();
+
+ // Return true if either exact match (with valid system) or UCUM conversion is possible
+ // Return null if quantity is null (for empty propagation)
+ return when(quantityColumn.isNotNull(), exactMatchWithValidSystem.or(ucumConvertible));
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/UnsupportedRepresentation.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/UnsupportedRepresentation.java
index f5deadcf06..77201f5740 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/UnsupportedRepresentation.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/column/UnsupportedRepresentation.java
@@ -43,12 +43,14 @@ public class UnsupportedRepresentation extends ColumnRepresentation {
private final String description;
@Override
+ @Nonnull
public Column getValue() {
throw new UnsupportedFhirPathFeatureError(
"Representation of this path is not supported: " + description);
}
@Override
+ @Nonnull
protected ColumnRepresentation copyOf(@Nonnull final Column newValue) {
return new UnsupportedRepresentation(description);
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/comparison/QuantityComparator.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/comparison/QuantityComparator.java
index 320b37d3e5..e412921665 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/comparison/QuantityComparator.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/comparison/QuantityComparator.java
@@ -17,15 +17,10 @@
package au.csiro.pathling.fhirpath.comparison;
-import static java.util.Objects.nonNull;
-import static java.util.Objects.requireNonNull;
-import static org.apache.spark.sql.functions.when;
-
-import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
+import au.csiro.pathling.fhirpath.column.QuantityValue;
import au.csiro.pathling.sql.types.FlexiDecimal;
import jakarta.annotation.Nonnull;
import java.util.function.BinaryOperator;
-import jakarta.annotation.Nullable;
import org.apache.spark.sql.Column;
import org.apache.spark.sql.functions;
@@ -47,77 +42,16 @@ public static QuantityComparator getInstance() {
private QuantityComparator() {
}
- /**
- * A helper record to hold a value and a unit column.
- *
- *
It provides methods to extract normalized and original values from a Quantity column, and
- * to compare two {@link ValueWithUnit} instances using a provided comparator, ensuring that the
- * units are the same.
- */
- private record ValueWithUnit(
- @Nonnull Column value,
- @Nonnull Column unit,
- @Nullable Column system
- ) {
-
- /**
- * Extracts the normalized (canonicalized) value and unit from a Quantity column.
- *
- * @param quantity the Quantity column
- * @return a ValueWithUnit instance containing the normalized value and unit
- */
- @Nonnull
- static ValueWithUnit normalizedQuantity(@Nonnull Column quantity) {
- return new ValueWithUnit(
- quantity.getField(QuantityEncoding.CANONICALIZED_VALUE_COLUMN),
- quantity.getField(QuantityEncoding.CANONICALIZED_CODE_COLUMN),
- null
- );
- }
-
- /**
- * Extracts the original value and unit from a Quantity column.
- *
- * @param quantity the Quantity column
- * @return a ValueWithUnit instance containing the original value and unit
- */
- @Nonnull
- static ValueWithUnit originalQuantity(@Nonnull Column quantity) {
- return new ValueWithUnit(
- quantity.getField(QuantityEncoding.VALUE_COLUMN),
- quantity.getField(QuantityEncoding.CODE_COLUMN),
- quantity.getField(QuantityEncoding.SYSTEM_COLUMN)
- );
- }
-
-
- @Nonnull
- Column compare(@Nonnull final ValueWithUnit other, BinaryOperator comparator) {
- // assert that both (this and other) systems are either non-null or null
- // that is we compare either two original or two normalized quantities
- if ((system == null) != (other.system == null)) {
- throw new IllegalArgumentException("Both quantities must have system or neither.");
- }
- return when(
- nonNull(system)
- ? requireNonNull(system).equalTo(requireNonNull(other.system))
- .and(unit.equalTo(other.unit))
- : unit.equalTo(other.unit),
- comparator.apply(value, other.value)
- );
- }
- }
-
private static BinaryOperator wrap(
@Nonnull final BinaryOperator decimalComparator,
@Nonnull final BinaryOperator flexComparator
) {
return (left, right) -> functions.coalesce(
- ValueWithUnit.normalizedQuantity(left)
- .compare(ValueWithUnit.normalizedQuantity(right), flexComparator),
- ValueWithUnit.originalQuantity(left)
- .compare(ValueWithUnit.originalQuantity(right), decimalComparator)
+ QuantityValue.of(left).normalizedValue()
+ .compare(QuantityValue.of(right).normalizedValue(), flexComparator),
+ QuantityValue.of(left).originalValue()
+ .compare(QuantityValue.of(right).originalValue(), decimalComparator)
);
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/encoding/QuantityEncoding.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/encoding/QuantityEncoding.java
index 52c3fe8b0a..828e9145fe 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/encoding/QuantityEncoding.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/encoding/QuantityEncoding.java
@@ -21,12 +21,14 @@
import static java.util.stream.Collectors.toUnmodifiableMap;
import static org.apache.spark.sql.functions.lit;
import static org.apache.spark.sql.functions.struct;
+import static org.apache.spark.sql.functions.when;
import au.csiro.pathling.encoders.QuantitySupport;
import au.csiro.pathling.encoders.datatypes.DecimalCustomCoder;
import au.csiro.pathling.encoders.terminology.ucum.Ucum;
-import au.csiro.pathling.fhirpath.CalendarDurationUnit;
import au.csiro.pathling.fhirpath.FhirPathQuantity;
+import au.csiro.pathling.fhirpath.unit.CalendarDurationUnit;
+import au.csiro.pathling.fhirpath.unit.UcumUnit;
import au.csiro.pathling.sql.types.FlexiDecimal;
import au.csiro.pathling.sql.types.FlexiDecimalSupport;
import jakarta.annotation.Nonnull;
@@ -45,20 +47,49 @@
import org.apache.spark.sql.types.MetadataBuilder;
import org.apache.spark.sql.types.StructField;
import org.apache.spark.sql.types.StructType;
-import org.hl7.fhir.r4.model.Quantity;
-import org.hl7.fhir.r4.model.Quantity.QuantityComparator;
/**
- * Object decoders/encoders for {@link Quantity}.
+ * Object encoders/decoders for FHIRPath Quantity values.
+ *
+ * Provides conversion between {@link FhirPathQuantity} objects and Spark SQL Row representation.
*
* @author Piotr Szul
*/
@Value(staticConstructor = "of")
public class QuantityEncoding {
+ /**
+ * The name of the column containing the numeric value of the quantity.
+ */
public static final String VALUE_COLUMN = "value";
+
+ /**
+ * The name of the column containing the code system URI of the quantity unit.
+ */
public static final String SYSTEM_COLUMN = "system";
+
+ /**
+ * The name of the column containing the code representing the quantity unit.
+ */
public static final String CODE_COLUMN = "code";
+
+ /**
+ * The name of the column containing the human-readable unit string.
+ */
+ public static final String UNIT_COLUMN = "unit";
+
+ /**
+ * The name of the column containing the canonicalised value of the quantity.
+ */
+ public static final String CANONICALIZED_VALUE_COLUMN = QuantitySupport
+ .VALUE_CANONICALIZED_FIELD_NAME();
+
+ /**
+ * The name of the column containing the canonicalised code of the quantity unit.
+ */
+ public static final String CANONICALIZED_CODE_COLUMN = QuantitySupport
+ .CODE_CANONICALIZED_FIELD_NAME();
+
@Nonnull
Column id;
@Nonnull
@@ -84,14 +115,9 @@ public class QuantityEncoding {
CalendarDurationUnit.values())
.filter(CalendarDurationUnit::isDefinite)
.collect(
- toUnmodifiableMap(CalendarDurationUnit::getUnit,
+ toUnmodifiableMap(CalendarDurationUnit::code,
CalendarDurationUnit::getUcumEquivalent));
- public static final String CANONICALIZED_VALUE_COLUMN = QuantitySupport
- .VALUE_CANONICALIZED_FIELD_NAME();
- public static final String CANONICALIZED_CODE_COLUMN = QuantitySupport
- .CODE_CANONICALIZED_FIELD_NAME();
-
/**
* Converts this quantity to a struct column.
*
@@ -104,7 +130,7 @@ public Column toStruct() {
value.cast(DecimalCustomCoder.decimalType()).as(VALUE_COLUMN),
value_scale.as("value_scale"),
comparator.as("comparator"),
- unit.as("unit"),
+ unit.as(UNIT_COLUMN),
system.as(SYSTEM_COLUMN),
code.as(CODE_COLUMN),
canonicalizedValue.as(CANONICALIZED_VALUE_COLUMN),
@@ -114,90 +140,32 @@ public Column toStruct() {
}
/**
- * Encodes a Quantity to a Row (spark SQL compatible type)
+ * Decodes a FhirPathQuantity from a Row.
*
- * @param quantity a coding to encode
- * @param includeScale whether the scale of the value should be encoded (or set to null)
- * @return the Row representation of the quantity
+ * @param row the row to decode
+ * @return the resulting FhirPathQuantity
*/
@Nullable
- public static Row encode(@Nullable final Quantity quantity, final boolean includeScale) {
- if (quantity == null) {
+ public static FhirPathQuantity decode(@Nullable final Row row) {
+ if (row == null) {
return null;
}
- final BigDecimal value = quantity.getValue();
- @Nullable final String code = quantity.getCode();
- final BigDecimal canonicalizedValue;
- final String canonicalizedCode;
- if (quantity.getSystem().equals(FhirPathQuantity.UCUM_SYSTEM_URI)) {
- canonicalizedValue = Ucum.getCanonicalValue(value, code);
- canonicalizedCode = Ucum.getCanonicalCode(value, code);
- } else {
- canonicalizedValue = null;
- canonicalizedCode = null;
- }
- final String comparator = Optional.ofNullable(quantity.getComparator())
- .map(QuantityComparator::toCode).orElse(null);
- return RowFactory.create(quantity.getId(),
- quantity.getValue(),
- // We cannot encode the scale of the results of arithmetic operations.
- includeScale
- ? quantity.getValue().scale()
- : null,
- comparator,
- quantity.getUnit(), quantity.getSystem(), quantity.getCode(),
- FlexiDecimal.toValue(canonicalizedValue),
- canonicalizedCode, null /* _fid */);
- }
-
- /**
- * Encodes a Quantity to a Row (spark SQL compatible type)
- *
- * @param quantity a coding to encode
- * @return the Row representation of the quantity
- */
- @Nullable
- public static Row encode(@Nullable final Quantity quantity) {
- return encode(quantity, true);
- }
-
- /**
- * Decodes a Quantity from a Row.
- *
- * @param row the row to decode
- * @return the resulting Quantity
- */
- @Nonnull
- public static Quantity decode(@Nonnull final Row row) {
- final Quantity quantity = new Quantity();
-
- Optional.ofNullable(row.getString(0)).ifPresent(quantity::setId);
-
- // The value gets converted to a BigDecimal, taking into account the scale that has been encoded
- // alongside it.
+ // Extract value with scale handling
@Nullable final Integer scale = !row.isNullAt(2)
? row.getInt(2)
: null;
- final BigDecimal value = Optional.ofNullable(row.getDecimal(1))
+ @Nullable final BigDecimal value = Optional.ofNullable(row.getDecimal(1))
.map(bd -> nonNull(scale) && bd.scale() > scale
? bd.setScale(scale, RoundingMode.HALF_UP)
: bd)
.orElse(null);
- quantity.setValue(value);
-
- // The comparator is encoded as a string code, we need to convert it back to an enum.
- Optional.ofNullable(row.getString(3))
- .map(QuantityComparator::fromCode)
- .ifPresent(quantity::setComparator);
-
- Optional.ofNullable(row.getString(4)).ifPresent(quantity::setUnit);
- Optional.ofNullable(row.getString(5)).ifPresent(quantity::setSystem);
- Optional.ofNullable(row.getString(6)).ifPresent(quantity::setCode);
- return quantity;
+ @Nullable final String unit = row.getString(4);
+ @Nullable final String system = row.getString(5);
+ @Nullable final String code = row.getString(6);
+ return FhirPathQuantity.of(value, system, code, unit);
}
-
/**
* @return A {@link StructType} for a Quantity
*/
@@ -211,7 +179,7 @@ public static StructType dataType() {
metadata);
final StructField comparator = new StructField("comparator", DataTypes.StringType, true,
metadata);
- final StructField unit = new StructField("unit", DataTypes.StringType, true, metadata);
+ final StructField unit = new StructField(UNIT_COLUMN, DataTypes.StringType, true, metadata);
final StructField system = new StructField(SYSTEM_COLUMN, DataTypes.StringType, true, metadata);
final StructField code = new StructField(CODE_COLUMN, DataTypes.StringType, true, metadata);
final StructField canonicalizedValue = new StructField(CANONICALIZED_VALUE_COLUMN,
@@ -259,6 +227,19 @@ private static Column toStruct(
.toStruct();
}
+ /**
+ * Creates a canonical ValueWithUnit from a FhirPathQuantity.
+ *
+ * @param quantity the quantity
+ * @return the canonical ValueWithUnit, or null if canonicalization fails
+ */
+ @Nullable
+ private static Ucum.ValueWithUnit canonicalOf(@Nonnull final FhirPathQuantity quantity) {
+ return quantity.asCanonical()
+ .map(q -> new Ucum.ValueWithUnit(q.getValue(), q.getCode()))
+ .orElse(null);
+ }
+
/**
* Encodes the quantity as a literal column that includes appropriate canonicalization.
*
@@ -268,62 +249,80 @@ private static Column toStruct(
@Nonnull
public static Column encodeLiteral(@Nonnull final FhirPathQuantity quantity) {
final BigDecimal value = quantity.getValue();
- final BigDecimal canonicalizedValue;
- final String canonicalizedCode;
- if (quantity.isUCUM()) {
- // If it is a UCUM Quantity, use the UCUM library to canonicalize the value and code.
- canonicalizedValue = Ucum.getCanonicalValue(value, quantity.getCode());
- canonicalizedCode = Ucum.getCanonicalCode(value, quantity.getCode());
- } else if (quantity.isCalendarDuration() &&
- CALENDAR_DURATION_TO_UCUM.containsKey(quantity.getCode())) {
- // If it is a (supported) calendar duration, get the corresponding UCUM unit and then use the
- // UCUM library to canonicalize the value and code.
- final String resolvedCode = CALENDAR_DURATION_TO_UCUM.get(quantity.getCode());
- canonicalizedValue = Ucum.getCanonicalValue(value, resolvedCode);
- canonicalizedCode = Ucum.getCanonicalCode(value, resolvedCode);
- } else {
- // If it is neither a UCUM Quantity nor a calendar duration, it will not have a canonicalized
- // form available.
- canonicalizedValue = null;
- canonicalizedCode = null;
- }
-
+ @Nullable final Ucum.ValueWithUnit canonical = canonicalOf(quantity);
return toStruct(
lit(null),
lit(value),
lit(value.scale()),
lit(null),
- lit(quantity.getUnit()),
+ lit(quantity.getUnitName()),
lit(quantity.getSystem()),
lit(quantity.getCode()),
- FlexiDecimalSupport.toLiteral(canonicalizedValue),
- lit(canonicalizedCode),
+ FlexiDecimalSupport.toLiteral(canonical != null ? canonical.value() : null),
+ lit(canonical != null ? canonical.unit() : null),
lit(null));
}
/**
- * Encodes a numeric column as a quantity with unit "1" in the UCUM system.
+ * Encodes a numeric column as a quantity with unit "1" in the UCUM system. Returns a fully null
+ * struct when the input is null to ensure FHIRPath empty collection semantics.
*
* @param numericColumn the numeric column to encode
- * @return the column with the representation of the quantity.
+ * @return the column with the representation of the quantity, or fully null struct if input is
+ * null
*/
@Nonnull
public static Column encodeNumeric(@Nonnull final Column numericColumn) {
- return toStruct(
- lit(null),
- // The value is cast to a decimal type to ensure that it has the appropriate precision and
- // scale.
- numericColumn.cast(DecimalCustomCoder.decimalType()),
- // We cannot encode the scale of the results of arithmetic operations.
- lit(null),
- lit(null),
- lit("1"),
- lit(FhirPathQuantity.UCUM_SYSTEM_URI),
- lit("1"),
- // we do not need to normalize this as the unit is always "1"
- // so it will be comparable with other quantities with unit "1"
- lit(null),
- lit(null),
- lit(null));
+ // Cast value to decimal type
+ final Column decimalValue = numericColumn.cast(DecimalCustomCoder.decimalType());
+
+ // Return fully null struct when value is null to maintain FHIRPath empty collection semantics
+ return when(decimalValue.isNotNull(),
+ toStruct(
+ lit(null),
+ decimalValue,
+ // We cannot encode the scale of the results of arithmetic operations.
+ lit(null),
+ lit(null),
+ lit(UcumUnit.ONE.code()),
+ lit(UcumUnit.UCUM_SYSTEM_URI),
+ lit(UcumUnit.ONE.code()),
+ // we do not need to normalize this as the unit is always "1"
+ // so it will be comparable with other quantities with unit "1"
+ lit(null),
+ lit(null),
+ lit(null)))
+ .otherwise(lit(null).cast(dataType()));
+ }
+
+ /**
+ * Encodes a FhirPathQuantity to a Row representation.
+ *
+ * This method converts a FhirPathQuantity into the Row representation used by Spark. It handles
+ * canonicalization for both UCUM units and calendar duration units.
+ *
+ * @param quantity the FhirPathQuantity to encode
+ * @return the Row representation of the quantity
+ */
+ @Nonnull
+ public static Row encode(@Nonnull final FhirPathQuantity quantity) {
+ final BigDecimal value = quantity.getValue();
+ @Nullable final Ucum.ValueWithUnit canonical = canonicalOf(quantity);
+
+ // Create the Quantity Row with all fields:
+ // id, value, value_scale, comparator, unit, system, code,
+ // canonicalized_value, canonicalized_code, _fid
+ return RowFactory.create(
+ null, // id
+ value, // value
+ value.scale(), // value_scale
+ null, // comparator
+ quantity.getUnitName(), // unit
+ quantity.getSystem(), // system
+ quantity.getCode(), // code
+ FlexiDecimal.toValue(canonical != null ? canonical.value() : null), // canonicalized_value (as FlexiDecimal Row)
+ canonical != null ? canonical.unit() : null, // canonicalized_code
+ null // _fid
+ );
}
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ConversionFunctions.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ConversionFunctions.java
new file mode 100644
index 0000000000..34181fe0cd
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ConversionFunctions.java
@@ -0,0 +1,384 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.function.provider;
+
+import static java.util.Objects.requireNonNull;
+
+import au.csiro.pathling.fhirpath.FhirPathType;
+import au.csiro.pathling.fhirpath.annotations.SqlOnFhirConformance;
+import au.csiro.pathling.fhirpath.annotations.SqlOnFhirConformance.Profile;
+import au.csiro.pathling.fhirpath.collection.BooleanCollection;
+import au.csiro.pathling.fhirpath.collection.Collection;
+import au.csiro.pathling.fhirpath.collection.DateCollection;
+import au.csiro.pathling.fhirpath.collection.DateTimeCollection;
+import au.csiro.pathling.fhirpath.collection.DecimalCollection;
+import au.csiro.pathling.fhirpath.collection.IntegerCollection;
+import au.csiro.pathling.fhirpath.collection.QuantityCollection;
+import au.csiro.pathling.fhirpath.collection.StringCollection;
+import au.csiro.pathling.fhirpath.collection.TimeCollection;
+import au.csiro.pathling.fhirpath.function.FhirPathFunction;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import lombok.experimental.UtilityClass;
+import org.apache.spark.sql.functions;
+
+/**
+ * Contains functions for converting values between types.
+ *
+ * This implementation provides 16 FHIRPath conversion functions:
+ *
+ * 8 conversion functions: toBoolean, toInteger, toDecimal, toString, toDate, toDateTime, toTime, toQuantity
+ * 8 validation functions: convertsToBoolean, convertsToInteger, convertsToDecimal, convertsToString, convertsToDate, convertsToDateTime, convertsToTime, convertsToQuantity
+ *
+ *
+ * Note: The toLong() and convertsToLong() functions are not implemented as they are marked
+ * as STU (Standard for Trial Use) in the FHIRPath specification and are not yet finalized.
+ * When these functions are finalized in the FHIRPath specification, they can be added following
+ * the same pattern as the other conversion functions.
+ *
+ * The actual conversion and validation logic is delegated to package-private helper classes:
+ *
+ * {@link ConversionLogic} - handles type conversion orchestration and logic
+ * {@link ValidationLogic} - handles conversion validation orchestration and logic
+ *
+ *
+ * @author Piotr Szul
+ * @see FHIRPath Specification -
+ * Conversion
+ */
+@UtilityClass
+@SuppressWarnings("unused")
+public class ConversionFunctions {
+
+ // ========== PUBLIC API - CONVERSION FUNCTIONS ==========
+
+ /**
+ * Converts the input to a Boolean value. Per FHIRPath specification: - String: 'true', 't',
+ * 'yes', 'y', '1', '1.0' → true (case-insensitive) - String: 'false', 'f', 'no', 'n', '0', '0.0'
+ * → false (case-insensitive) - Integer: 1 → true, 0 → false - Decimal: 1.0 → true, 0.0 → false -
+ * All other inputs → empty
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing the converted value or empty
+ * @see FHIRPath
+ * Specification - toBoolean
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toBoolean(@Nonnull final Collection input) {
+ return ConversionLogic.performConversion(input, FhirPathType.BOOLEAN);
+ }
+
+ /**
+ * Converts the input to an Integer value. Returns the integer value for boolean (true=1,
+ * false=0), integer, or valid integer strings. Returns empty for all other inputs.
+ *
+ * @param input The input collection
+ * @return An {@link IntegerCollection} containing the converted value or empty
+ * @see FHIRPath
+ * Specification - toInteger
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toInteger(@Nonnull final Collection input) {
+ return ConversionLogic.performConversion(input, FhirPathType.INTEGER);
+ }
+
+ /**
+ * Converts the input to a Decimal value. Returns the decimal value for boolean (true=1.0,
+ * false=0.0), integer, decimal, or valid decimal strings. Returns empty for all other inputs.
+ *
+ * @param input The input collection
+ * @return A {@link DecimalCollection} containing the converted value or empty
+ * @see FHIRPath
+ * Specification - toDecimal
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toDecimal(@Nonnull final Collection input) {
+ return ConversionLogic.performConversion(input, FhirPathType.DECIMAL);
+ }
+
+ /**
+ * Converts the input to a String value. All primitive types can be converted to string.
+ *
+ * @param input The input collection
+ * @return A {@link StringCollection} containing the converted value or empty
+ * @see FHIRPath Specification
+ * - toString
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toString(@Nonnull final Collection input) {
+ return ConversionLogic.performConversion(input, FhirPathType.STRING);
+ }
+
+ /**
+ * Converts the input to a Date value. Accepts strings in ISO 8601 date format.
+ *
+ * @param input The input collection
+ * @return A {@link DateCollection} containing the converted value or empty
+ * @see FHIRPath Specification -
+ * toDate
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toDate(@Nonnull final Collection input) {
+ return ConversionLogic.performConversion(input, FhirPathType.DATE);
+ }
+
+ /**
+ * Converts the input to a DateTime value. Accepts strings in ISO 8601 datetime format.
+ *
+ * @param input The input collection
+ * @return A {@link DateTimeCollection} containing the converted value or empty
+ * @see FHIRPath Specification -
+ * toDateTime
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toDateTime(@Nonnull final Collection input) {
+ return ConversionLogic.performConversion(input, FhirPathType.DATETIME);
+ }
+
+ /**
+ * Converts the input to a Time value. Accepts strings in ISO 8601 time format.
+ *
+ * @param input The input collection
+ * @return A {@link TimeCollection} containing the converted value or empty
+ * @see FHIRPath Specification -
+ * toTime
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toTime(@Nonnull final Collection input) {
+ return ConversionLogic.performConversion(input, FhirPathType.TIME);
+ }
+
+ /**
+ * Converts the input to a Quantity value. Per FHIRPath specification:
+ *
+ * Boolean: true → 1.0 '1', false → 0.0 '1'
+ * Integer/Decimal: Convert to Quantity with default unitCode '1'
+ * Quantity: returns as-is
+ * String: Parse as FHIRPath quantity literal (e.g., "10 'mg'", "4 days")
+ * All other inputs → empty
+ *
+ *
+ * The optional {@code unitCode} parameter specifies a target unitCode for conversion. If provided, the
+ * function converts the quantity to the target unitCode using UCUM conversion rules. Returns the
+ * converted quantity if conversion is successful, or empty if units are incompatible or
+ * conversion is not possible.
+ *
+ * UCUM Conversion: Full UCUM unitCode conversion is supported for compatible units (e.g., 'kg'
+ * to 'g', 'wk' to 'd', 'cm' to 'mm'). Incompatible units (e.g., mass to length) return empty.
+ *
+ * Note: Calendar duration conversions (e.g., days to hours, years to months) are supported
+ * via {@code CalendarDurationUnit.conversionFactorTo}. If any specific enhancements remain, see issue #2505.
+ *
+ * @param input The input collection
+ * @param unit Optional target unitCode for conversion (null if not specified)
+ * @return A {@link QuantityCollection} containing the converted value or empty
+ * @see FHIRPath Specification -
+ * toQuantity
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection toQuantity(@Nonnull final Collection input,
+ @Nullable final Collection unit) {
+ // First convert to Quantity using standard conversion
+ final Collection converted = ConversionLogic.performConversion(input, FhirPathType.QUANTITY);
+ // If unitCode provided and result is QuantityCollection, apply unitCode conversion
+ if (unit != null && converted instanceof final QuantityCollection quantityCollection) {
+ return quantityCollection.toUnit(requireNonNull(unit));
+ } else {
+ return converted;
+ }
+ }
+
+ // ========== PUBLIC API - VALIDATION FUNCTIONS ==========
+
+ /**
+ * Checks if the input can be converted to a Boolean value. Per FHIRPath specification: - Boolean:
+ * always convertible - String: 'true', 't', 'yes', 'y', '1', '1.0', 'false', 'f', 'no', 'n', '0',
+ * '0.0' (case-insensitive) - Integer: 0 or 1 - Decimal: 0.0 or 1.0 - Empty collection: returns
+ * empty
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToBoolean
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToBoolean(@Nonnull final Collection input) {
+ return ValidationLogic.performValidation(input, FhirPathType.BOOLEAN);
+ }
+
+ /**
+ * Checks if the input can be converted to an Integer value.
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToInteger
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToInteger(@Nonnull final Collection input) {
+ return ValidationLogic.performValidation(input, FhirPathType.INTEGER);
+ }
+
+ /**
+ * Checks if the input can be converted to a Decimal value.
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToDecimal
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToDecimal(@Nonnull final Collection input) {
+ return ValidationLogic.performValidation(input, FhirPathType.DECIMAL);
+ }
+
+ /**
+ * Checks if the input can be converted to a String value.
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToString
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToString(@Nonnull final Collection input) {
+ return ValidationLogic.performValidation(input, FhirPathType.STRING);
+ }
+
+ /**
+ * Checks if the input can be converted to a Date value.
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToDate
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToDate(@Nonnull final Collection input) {
+ return ValidationLogic.performValidation(input, FhirPathType.DATE);
+ }
+
+ /**
+ * Checks if the input can be converted to a DateTime value.
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToDateTime
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToDateTime(@Nonnull final Collection input) {
+ return ValidationLogic.performValidation(input, FhirPathType.DATETIME);
+ }
+
+ /**
+ * Checks if the input can be converted to a Time value.
+ *
+ * @param input The input collection
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToTime
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToTime(@Nonnull final Collection input) {
+ return ValidationLogic.performValidation(input, FhirPathType.TIME);
+ }
+
+ /**
+ * Checks if the input can be converted to a Quantity value.
+ *
+ * The optional {@code unitCode} parameter specifies a target unitCode for validation. If
+ * provided, the function returns true if the input can be converted to a Quantity AND the
+ * quantity can be converted to the target unitCode (either via exact match or UCUM conversion).
+ * Returns false if units are incompatible or conversion is not possible.
+ *
+ * UCUM Conversion: Full UCUM unitCode conversion checking is supported for compatible
+ * units (e.g., 'kg' to 'g', 'wk' to 'd', 'cm' to 'mm'). Incompatible units (e.g., mass to length)
+ * return false.
+ *
+ * Note: Calendar duration conversions (e.g., days to hours, years to months) are
+ * supported.
+ *
+ * @param input The input collection
+ * @param unit Optional target unitCode for validation (null if not specified)
+ * @return A {@link BooleanCollection} containing {@code true} if convertible, {@code false}
+ * otherwise, or empty for empty input
+ * @see FHIRPath Specification -
+ * convertsToQuantity
+ */
+ @FhirPathFunction
+ @SqlOnFhirConformance(Profile.SHARABLE)
+ @Nonnull
+ public Collection convertsToQuantity(@Nonnull final Collection input,
+ @Nullable final Collection unit) {
+ final Collection canConvertToQuantity = ValidationLogic.performValidation(input,
+ FhirPathType.QUANTITY);
+
+ // Only evaluate if unitCode is provided
+ @Nullable final Collection converted =
+ unit != null
+ ? ConversionLogic.performConversion(input, FhirPathType.QUANTITY)
+ : null;
+
+ if (unit != null && converted instanceof final QuantityCollection quantityCollection) {
+ final Collection canConvertToUnit = quantityCollection.convertibleToUnit(
+ requireNonNull(unit));
+ return canConvertToQuantity.mapColumn(
+ c -> functions.coalesce(canConvertToUnit.getColumnValue(), c));
+ } else {
+ return canConvertToQuantity;
+ }
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ConversionLogic.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ConversionLogic.java
new file mode 100644
index 0000000000..4784a39706
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ConversionLogic.java
@@ -0,0 +1,417 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.function.provider;
+
+import static org.apache.spark.sql.functions.callUDF;
+import static org.apache.spark.sql.functions.coalesce;
+import static org.apache.spark.sql.functions.lit;
+import static org.apache.spark.sql.functions.when;
+
+import au.csiro.pathling.fhirpath.FhirPathType;
+import au.csiro.pathling.fhirpath.collection.BooleanCollection;
+import au.csiro.pathling.fhirpath.collection.Collection;
+import au.csiro.pathling.fhirpath.collection.DateCollection;
+import au.csiro.pathling.fhirpath.collection.DateTimeCollection;
+import au.csiro.pathling.fhirpath.collection.DecimalCollection;
+import au.csiro.pathling.fhirpath.collection.EmptyCollection;
+import au.csiro.pathling.fhirpath.collection.IntegerCollection;
+import au.csiro.pathling.fhirpath.collection.QuantityCollection;
+import au.csiro.pathling.fhirpath.collection.StringCollection;
+import au.csiro.pathling.fhirpath.collection.TimeCollection;
+import au.csiro.pathling.fhirpath.column.DefaultRepresentation;
+import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
+import au.csiro.pathling.sql.misc.DecimalToLiteral;
+import au.csiro.pathling.sql.misc.QuantityToLiteral;
+import au.csiro.pathling.sql.misc.StringToQuantity;
+import jakarta.annotation.Nonnull;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import lombok.experimental.UtilityClass;
+import org.apache.spark.sql.Column;
+import org.apache.spark.sql.types.DataTypes;
+
+/**
+ * Package-private utility class containing type conversion orchestration and logic.
+ *
+ * This class provides the template method for performing type conversions and all type-specific
+ * conversion helper methods used by {@link ConversionFunctions}.
+ *
+ * @author Piotr Szul
+ */
+@UtilityClass
+class ConversionLogic {
+
+ /**
+ * Regex pattern for validating FHIRPath integer strings.
+ *
+ * Matches: Optional sign (+ or -) followed by one or more digits.
+ *
+ * Examples: "123", "+456", "-789", "0"
+ *
+ * Pattern: ^(\+|-)?\d+$
+ */
+ static final String INTEGER_REGEX = "^(\\+|-)?\\d+$";
+
+ /**
+ * Regex pattern for validating FHIR date strings with partial precision support.
+ *
+ * Matches three valid formats:
+ *
+ * YYYY - Year only (e.g., "2023")
+ * YYYY-MM - Year and month (e.g., "2023-06")
+ * YYYY-MM-DD - Full date (e.g., "2023-06-15")
+ *
+ *
+ * Pattern: ^\d{4}(-\d{2}(-\d{2})?)?$
+ */
+ static final String DATE_REGEX = "^\\d{4}(-\\d{2}(-\\d{2})?)?$";
+
+ /**
+ * Regex pattern for validating FHIR dateTime strings with partial precision and timezone
+ * support.
+ *
+ * Matches progressively more precise formats:
+ *
+ * YYYY - Year (e.g., "2023")
+ * YYYY-MM - Year and month (e.g., "2023-06")
+ * YYYY-MM-DD - Date (e.g., "2023-06-15")
+ * YYYY-MM-DDThh - Date with hour (e.g., "2023-06-15T14")
+ * YYYY-MM-DDThh:mm - Date with hour and minute (e.g., "2023-06-15T14:30")
+ * YYYY-MM-DDThh:mm:ss - Date with seconds (e.g., "2023-06-15T14:30:45")
+ * YYYY-MM-DDThh:mm:ss.fff - Date with fractional seconds (e.g., "2023-06-15T14:30:45.123")
+ *
+ * Optional timezone: Z for UTC or ±hh:mm offset (e.g., "2023-06-15T14:30:45+10:00")
+ *
+ * Pattern: ^\d{4}(-\d{2}(-\d{2}(T\d{2}(:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+\-]\d{2}:\d{2})?)?)?)?$
+ */
+ static final String DATETIME_REGEX =
+ "^\\d{4}(-\\d{2}(-\\d{2}(T\\d{2}(:\\d{2}(:\\d{2}(\\.\\d+)?)?)?(Z|[+\\-]\\d{2}:\\d{2})?)?)?)?$";
+
+ /**
+ * Regex pattern for validating FHIR time strings with partial precision support.
+ *
+ * Matches three valid formats:
+ *
+ * hh - Hour only (e.g., "14")
+ * hh:mm - Hour and minute (e.g., "14:30")
+ * hh:mm:ss - Hour, minute, and second (e.g., "14:30:45")
+ * hh:mm:ss.fff - Hour, minute, second with fractional seconds (e.g., "14:30:45.123")
+ *
+ *
+ * Pattern: ^\d{2}(:\d{2}(:\d{2}(\.\d+)?)?)?$
+ */
+ static final String TIME_REGEX = "^\\d{2}(:\\d{2}(:\\d{2}(\\.\\d+)?)?)?$";
+
+ /**
+ * Regex pattern for validating FHIRPath quantity literal strings.
+ *
+ * Matches a numeric value with optional unit specification:
+ *
+ * Numeric part: Optional sign (+ or -), integer or decimal (e.g., "1", "-2.5", "+3.14")
+ * Optional whitespace separator
+ * Optional unit: Either quoted UCUM code (e.g., "'mg'") or calendar duration unit
+ *
+ * Valid calendar duration units (case-insensitive, singular or plural):
+ * year, month, week, day, hour, minute, second, millisecond
+ *
+ * Examples: "5", "1.5 'kg'", "3 weeks", "10.5 mg", "-2.5 'cm'"
+ *
+ * Note: Unit is optional per FHIRPath spec. When absent, unitCode defaults to '1'.
+ *
+ * Pattern: ^[+-]?\d+(?:\.\d+)?\s*(?:'[^']+'|(?i:years?|months?|weeks?|days?|hours?|minutes?|seconds?|milliseconds?))?$
+ */
+ static final String QUANTITY_REGEX =
+ "^[+-]?\\d+(?:\\.\\d+)?\\s*(?:'[^']+'|(?i:years?|months?|weeks?|days?|hours?|minutes?|seconds?|milliseconds?))?$";
+
+ // Registry mapping target types to their conversion functions
+ private static final Map> CONVERSION_REGISTRY =
+ Map.ofEntries(
+ Map.entry(FhirPathType.BOOLEAN, ConversionLogic::convertToBoolean),
+ Map.entry(FhirPathType.INTEGER, ConversionLogic::convertToInteger),
+ Map.entry(FhirPathType.DECIMAL, ConversionLogic::convertToDecimal),
+ Map.entry(FhirPathType.STRING, ConversionLogic::convertToString),
+ Map.entry(FhirPathType.DATE, ConversionLogic::convertToDate),
+ Map.entry(FhirPathType.DATETIME, ConversionLogic::convertToDateTime),
+ Map.entry(FhirPathType.TIME, ConversionLogic::convertToTime),
+ Map.entry(FhirPathType.QUANTITY, ConversionLogic::convertToQuantity)
+ );
+
+ // Registry mapping target types to their collection builders
+ private static final Map> BUILDER_REGISTRY =
+ Map.ofEntries(
+ Map.entry(FhirPathType.BOOLEAN, BooleanCollection::build),
+ Map.entry(FhirPathType.INTEGER, IntegerCollection::build),
+ Map.entry(FhirPathType.DECIMAL, DecimalCollection::build),
+ Map.entry(FhirPathType.STRING, StringCollection::build),
+ Map.entry(FhirPathType.DATE, repr -> DateCollection.build(repr, Optional.empty())),
+ Map.entry(FhirPathType.DATETIME,
+ repr -> DateTimeCollection.build(repr, Optional.empty())),
+ Map.entry(FhirPathType.TIME, repr -> TimeCollection.build(repr, Optional.empty())),
+ Map.entry(FhirPathType.QUANTITY, QuantityCollection::build)
+ );
+
+ /**
+ * Template method for performing type conversions. Handles common orchestration: empty check,
+ * singular check, identity conversion, delegation to conversion logic, and result building.
+ *
+ * The conversion function and collection builder are automatically determined from the target
+ * type using internal registries.
+ *
+ * @param input The input collection to convert
+ * @param targetType The target FHIRPath type
+ * @return The converted collection or EmptyCollection if conversion fails
+ */
+ Collection performConversion(
+ @Nonnull final Collection input,
+ @Nonnull final FhirPathType targetType) {
+
+ if (input instanceof EmptyCollection) {
+ return EmptyCollection.getInstance();
+ }
+
+ // Look up conversion function and builder from registries
+ final BiFunction conversionLogic = CONVERSION_REGISTRY.get(
+ targetType);
+ @SuppressWarnings("unchecked")
+ final Function collectionBuilder =
+ (Function) BUILDER_REGISTRY.get(targetType);
+
+ final Column singularValue = input.getColumn().singular().getValue();
+ // Use Nothing when the type is not known to enforce default value for a non-convertible type
+ final FhirPathType sourceType = input.getType().orElse(FhirPathType.NOTHING);
+ final Column result = sourceType == targetType
+ ? singularValue
+ : conversionLogic.apply(sourceType, singularValue);
+
+ return collectionBuilder.apply(new DefaultRepresentation(
+ // this triggers singularity check if the result is null
+ coalesce(result, input.getColumn().ensureSingular())
+ // implicit null otherwise
+ ));
+ }
+
+ /**
+ * Converts a value to Boolean based on source type.
+ *
+ * STRING: Special handling for "1.0"/"0.0", then cast
+ * INTEGER: Only 0 or 1
+ * DECIMAL: Only 0.0 or 1.0
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToBoolean(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case STRING ->
+ // String: Handle '1.0' and '0.0' specially, use SparkSQL cast for other values.
+ // SparkSQL cast handles 'true', 'false', 't', 'f', 'yes', 'no', 'y', 'n', '1', '0' (case-insensitive).
+ when(value.equalTo(lit("1.0")), lit(true))
+ .when(value.equalTo(lit("0.0")), lit(false))
+ .otherwise(value.try_cast(DataTypes.BooleanType));
+ case INTEGER ->
+ // Integer: Only 0 or 1 can be converted (1 → true, 0 → false, otherwise null).
+ when(value.equalTo(lit(1)), lit(true))
+ .when(value.equalTo(lit(0)), lit(false));
+ case DECIMAL ->
+ // Decimal: Only 0.0 or 1.0 can be converted (1.0 → true, 0.0 → false, otherwise null).
+ when(value.equalTo(lit(1.0)), lit(true))
+ .when(value.equalTo(lit(0.0)), lit(false));
+ default -> lit(null);
+ };
+ }
+
+ /**
+ * Converts a value to Integer based on source type.
+ *
+ * BOOLEAN: Direct cast (true → 1, false → 0)
+ * STRING: Validates integer format (regex: (\+|-)?\d+) then casts
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToInteger(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN ->
+ // Boolean: Use SparkSQL cast (true → 1, false → 0).
+ value.try_cast(DataTypes.IntegerType);
+ case STRING ->
+ // String: Only convert if it matches integer format (no decimal point).
+ // Per FHIRPath spec, valid integer strings match: (\+|-)?\d+
+ when(value.rlike(INTEGER_REGEX), value.try_cast(DataTypes.IntegerType));
+ default -> lit(null);
+ };
+ }
+
+ /**
+ * Converts a value to Decimal based on source type.
+ *
+ * BOOLEAN/INTEGER/STRING: Direct cast to Decimal
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToDecimal(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN, INTEGER, STRING ->
+ // Boolean/Integer/String: cast to decimal.
+ value.try_cast(DecimalCollection.getDecimalType());
+ default -> lit(null);
+ };
+ }
+
+ /**
+ * Converts a value to String based on source type.
+ *
+ * BOOLEAN, INTEGER, DATE, DATETIME, TIME: Direct cast to String
+ * DECIMAL: Use DecimalToLiteral UDF to format with trailing zeros removed
+ * QUANTITY: Use QuantityToLiteral UDF to format as FHIRPath quantity literal
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToString(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN, INTEGER, DATE, DATETIME, TIME ->
+ // Primitive types can be cast to string directly.
+ value.try_cast(DataTypes.StringType);
+ case DECIMAL ->
+ // Decimal: Use DecimalToLiteral UDF to strip trailing zeros.
+ // E.g., 101.990000 -> 101.99, 1.0 -> 1
+ callUDF(DecimalToLiteral.FUNCTION_NAME, value, lit(null));
+ case QUANTITY ->
+ // Quantity: Use QuantityToLiteral UDF to format as FHIRPath quantity literal.
+ // E.g., {value: 1, unitCode: "wk", system: "http://unitsofmeasure.org"} -> "1 'wk'"
+ callUDF(QuantityToLiteral.FUNCTION_NAME, value);
+ default -> lit(null);
+ };
+ }
+
+ /**
+ * Converts a value to Date based on source type.
+ *
+ * STRING: Validates format (YYYY or YYYY-MM or YYYY-MM-DD) and returns the string value
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToDate(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ if (sourceType == FhirPathType.STRING) {
+ // Date values are stored as strings in FHIR. Validate format before accepting.
+ // Date format: YYYY or YYYY-MM or YYYY-MM-DD
+ return when(value.rlike(DATE_REGEX), value);
+ }
+ return lit(null);
+ }
+
+ /**
+ * Converts a value to DateTime based on source type.
+ *
+ * STRING: Validates format (supports partial precision) and returns the string value
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToDateTime(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ if (sourceType == FhirPathType.STRING) {
+ // DateTime values are stored as strings in FHIR. Validate using simplified pattern.
+ // Supports partial precision: YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DDThh, etc.
+ return when(value.rlike(DATETIME_REGEX), value);
+ }
+ return lit(null);
+ }
+
+ /**
+ * Converts a value to Time based on source type.
+ *
+ * STRING: Validates format (supports partial precision) and returns the string value
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToTime(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ if (sourceType == FhirPathType.STRING) {
+ // Time values are stored as strings in FHIR. Validate using simplified pattern.
+ // Supports partial precision: hh, hh:mm, hh:mm:ss, hh:mm:ss.fff
+ return when(value.rlike(TIME_REGEX), value);
+ }
+ return lit(null);
+ }
+
+ /**
+ * Converts a value to Quantity based on source type.
+ *
+ * BOOLEAN: true → 1.0 '1', false → 0.0 '1' (null boolean → null)
+ * INTEGER/DECIMAL: Encode as quantity with unitCode '1' (null → null)
+ * STRING: Parse as FHIRPath quantity literal (validates format then calls StringToQuantity UDF)
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return The converted column
+ */
+ @Nonnull
+ Column convertToQuantity(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN ->
+ // Boolean: true → 1.0 '1', false → 0.0 '1', null → null
+ // First cast to decimal, then encode as quantity with unitCode '1'
+ // Use when() to return typed null for null values instead of empty struct
+ QuantityEncoding.encodeNumeric(value);
+ case INTEGER, DECIMAL ->
+ // Integer/Decimal: Encode as quantity with default unitCode '1', null → null
+ // Use when() to return typed null for null values instead of empty struct
+ QuantityEncoding.encodeNumeric(value);
+ case STRING ->
+ // String: Parse as FHIRPath quantity literal using UDF
+ // UDF returns null if string doesn't match quantity format
+ callUDF(StringToQuantity.FUNCTION_NAME, value);
+ default -> lit(null).try_cast(QuantityEncoding.dataType());
+ };
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ValidationLogic.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ValidationLogic.java
new file mode 100644
index 0000000000..079f7c0b35
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/ValidationLogic.java
@@ -0,0 +1,315 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.function.provider;
+
+import static org.apache.spark.sql.functions.coalesce;
+import static org.apache.spark.sql.functions.lit;
+import static org.apache.spark.sql.functions.when;
+
+import au.csiro.pathling.fhirpath.FhirPathType;
+import au.csiro.pathling.fhirpath.collection.BooleanCollection;
+import au.csiro.pathling.fhirpath.collection.Collection;
+import au.csiro.pathling.fhirpath.collection.EmptyCollection;
+import au.csiro.pathling.fhirpath.column.DefaultRepresentation;
+import jakarta.annotation.Nonnull;
+import java.util.Map;
+import java.util.function.BiFunction;
+import lombok.experimental.UtilityClass;
+import org.apache.spark.sql.Column;
+import org.apache.spark.sql.types.DataTypes;
+
+/**
+ * Package-private utility class containing conversion validation orchestration and logic.
+ *
+ * This class provides the template method for performing conversion validation and all
+ * type-specific validation helper methods used by {@link ConversionFunctions}.
+ *
+ * @author John Grimes
+ */
+@UtilityClass
+class ValidationLogic {
+
+ /**
+ * Regex pattern for validating FHIRPath decimal strings.
+ *
+ * Matches numeric values with optional sign and optional decimal part:
+ *
+ * Optional sign: + or - (e.g., "+3.14", "-2.5")
+ * Integer part: One or more digits (required)
+ * Decimal part: Period followed by one or more digits (optional)
+ *
+ *
+ * Examples: "123", "123.456", "+123.456", "-123.456", "0.5", "+0.5", "-0.5"
+ *
+ * Pattern: ^(\+|-)?\d+(\.\d+)?$
+ */
+ private static final String DECIMAL_REGEX = "^(\\+|-)?\\d+(\\.\\d+)?$";
+
+ private static final String INTEGER_REGEX = ConversionLogic.INTEGER_REGEX;
+ private static final String DATE_REGEX = ConversionLogic.DATE_REGEX;
+ private static final String DATETIME_REGEX = ConversionLogic.DATETIME_REGEX;
+ private static final String TIME_REGEX = ConversionLogic.TIME_REGEX;
+ private static final String QUANTITY_REGEX = ConversionLogic.QUANTITY_REGEX;
+
+ // Registry mapping target types to their validation functions
+ private static final Map> VALIDATION_REGISTRY =
+ Map.ofEntries(
+ Map.entry(FhirPathType.BOOLEAN, ValidationLogic::validateConversionToBoolean),
+ Map.entry(FhirPathType.INTEGER, ValidationLogic::validateConversionToInteger),
+ Map.entry(FhirPathType.DECIMAL, ValidationLogic::validateConversionToDecimal),
+ Map.entry(FhirPathType.STRING, ValidationLogic::validateConversionToString),
+ Map.entry(FhirPathType.DATE, ValidationLogic::validateConversionToDate),
+ Map.entry(FhirPathType.DATETIME, ValidationLogic::validateConversionToDateTime),
+ Map.entry(FhirPathType.TIME, ValidationLogic::validateConversionToTime),
+ Map.entry(FhirPathType.QUANTITY, ValidationLogic::validateConversionToQuantity)
+ );
+
+ /**
+ * Template method for performing conversion validation. Handles common orchestration for
+ * convertsToXXX functions.
+ *
+ * The validation function is automatically determined from the target type using the internal
+ * registry.
+ *
+ * @param input The input collection to validate
+ * @param targetType The target FHIRPath type
+ * @return BooleanCollection indicating whether conversion is possible
+ */
+ Collection performValidation(
+ @Nonnull final Collection input,
+ @Nonnull final FhirPathType targetType) {
+
+ // Handle explicit empty collection
+ if (input instanceof EmptyCollection) {
+ return EmptyCollection.getInstance();
+ }
+
+ // Look up validation function from registry
+ final BiFunction validationLogic = VALIDATION_REGISTRY.get(
+ targetType);
+
+ final Column singularValue = input.getColumn().singular().getValue();
+ // Use Nothing when the type is not known to enforce default value for a non-convertible type
+ final FhirPathType sourceType = input.getType().orElse(FhirPathType.NOTHING);
+ final Column result = sourceType == targetType
+ ? lit(true)
+ : validationLogic.apply(sourceType, singularValue);
+
+ return BooleanCollection.build(new DefaultRepresentation(
+ // this triggers singularity check if the result is null
+ coalesce(
+ when(singularValue.isNotNull(), result),
+ input.getColumn().ensureSingular()
+ )
+ // implicit null otherwise
+ ));
+ }
+
+ /**
+ * Validates if a value can be converted to Boolean.
+ *
+ * STRING: Check for "1.0"/"0.0" or valid boolean cast
+ * INTEGER: Must be 0 or 1
+ * DECIMAL: Must be 0.0 or 1.0
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToBoolean(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case STRING -> {
+ // For strings: check if '1.0'/'0.0' or if cast to boolean succeeds.
+ final Column is10or00 = value.equalTo(lit("1.0")).or(value.equalTo(lit("0.0")));
+ final Column castSucceeds = value.try_cast(DataTypes.BooleanType).isNotNull();
+ yield value.isNotNull().and(is10or00.or(castSucceeds));
+ }
+ case INTEGER ->
+ // Only 0 and 1 can be converted.
+ value.equalTo(lit(0)).or(value.equalTo(lit(1)));
+ case DECIMAL ->
+ // Only 0.0 and 1.0 can be converted.
+ value.equalTo(lit(0.0)).or(value.equalTo(lit(1.0)));
+ default ->
+ // Other types cannot be converted.
+ lit(false);
+ };
+ }
+
+ /**
+ * Validates if a value can be converted to Integer.
+ *
+ * BOOLEAN: Always {@code true} (any boolean can cast to int)
+ * STRING: Check if cast succeeds
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToInteger(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN ->
+ // Boolean can always be converted.
+ lit(true);
+ case STRING ->
+ // String must match integer format: (\+|-)?\d+ (no decimal point).
+ value.rlike(INTEGER_REGEX);
+ default ->
+ // Other types cannot be converted.
+ lit(false);
+ };
+ }
+
+ /**
+ * Validates if a value can be converted to Decimal.
+ *
+ * BOOLEAN/INTEGER: Always {@code true}
+ * STRING: Check if cast succeeds
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToDecimal(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN, INTEGER ->
+ // Boolean and integer can always be converted.
+ lit(true);
+ case STRING ->
+ // String must match decimal format: (\+|-)?\d+(\.\d+)?
+ value.rlike(DECIMAL_REGEX);
+ default ->
+ // Other types cannot be converted.
+ lit(false);
+ };
+ }
+
+ /**
+ * Validates if a value can be converted to String. All supported types can convert to String.
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToString(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN, INTEGER, DECIMAL, DATE, DATETIME, TIME, QUANTITY ->
+ // All primitive types can be converted to string.
+ lit(true);
+ default ->
+ // Other types cannot be converted.
+ lit(false);
+ };
+ }
+
+ /**
+ * Validates if a value can be converted to Date.
+ *
+ * STRING: Check if matches date pattern (YYYY or YYYY-MM or YYYY-MM-DD)
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToDate(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ if (sourceType == FhirPathType.STRING) {
+ // String can be converted only if it matches the date format: YYYY or YYYY-MM or YYYY-MM-DD
+ return value.rlike(DATE_REGEX);
+ }
+ // Other types cannot be converted.
+ return lit(false);
+ }
+
+ /**
+ * Validates if a value can be converted to DateTime.
+ *
+ * STRING: Check if matches datetime pattern (YYYY-MM-DDThh:mm:ss with optional timezone)
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToDateTime(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ if (sourceType == FhirPathType.STRING) {
+ // String can be converted only if it matches ISO 8601 datetime format.
+ // Supports partial precision: YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DDThh, etc.
+ return value.rlike(DATETIME_REGEX);
+ }
+ // Other types cannot be converted.
+ return lit(false);
+ }
+
+ /**
+ * Validates if a value can be converted to Time.
+ *
+ * STRING: Check if matches time pattern (hh:mm:ss with optional milliseconds)
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToTime(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ if (sourceType == FhirPathType.STRING) {
+ // String can be converted only if it matches time format.
+ // Supports partial precision: hh, hh:mm, hh:mm:ss, hh:mm:ss.fff
+ return value.rlike(TIME_REGEX);
+ }
+ // Other types cannot be converted.
+ return lit(false);
+ }
+
+ /**
+ * Validates if a value can be converted to Quantity.
+ *
+ * BOOLEAN/INTEGER/DECIMAL: Always {@code true}
+ * STRING: Check if matches quantity pattern (value + unit)
+ *
+ *
+ * @param sourceType The source FHIRPath type
+ * @param value The source column value
+ * @return Column expression evaluating to {@code true} if convertible, {@code false} otherwise
+ */
+ Column validateConversionToQuantity(@Nonnull final FhirPathType sourceType,
+ @Nonnull final Column value) {
+ return switch (sourceType) {
+ case BOOLEAN, INTEGER, DECIMAL ->
+ // Boolean, Integer, and Decimal can always be converted to Quantity
+ lit(true);
+ case STRING ->
+ // String can be converted only if it matches the quantity format:
+ // value (optional +/-) + optional whitespace + unit (quoted UCUM or bareword calendar)
+ value.rlike(QUANTITY_REGEX);
+ default ->
+ // Other types cannot be converted.
+ lit(false);
+ };
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/registry/StaticFunctionRegistry.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/registry/StaticFunctionRegistry.java
index 10dff1abd3..4de93520ce 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/registry/StaticFunctionRegistry.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/registry/StaticFunctionRegistry.java
@@ -20,6 +20,7 @@
import au.csiro.pathling.fhirpath.function.MethodDefinedFunction;
import au.csiro.pathling.fhirpath.function.NamedFunction;
import au.csiro.pathling.fhirpath.function.provider.BooleanLogicFunctions;
+import au.csiro.pathling.fhirpath.function.provider.ConversionFunctions;
import au.csiro.pathling.fhirpath.function.provider.ExistenceFunctions;
import au.csiro.pathling.fhirpath.function.provider.FhirFunctions;
import au.csiro.pathling.fhirpath.function.provider.FilteringAndProjectionFunctions;
@@ -46,6 +47,7 @@ public class StaticFunctionRegistry extends InMemoryFunctionRegistry {
public StaticFunctionRegistry() {
super(new Builder()
.putAll(MethodDefinedFunction.mapOf(BooleanLogicFunctions.class))
+ .putAll(MethodDefinedFunction.mapOf(ConversionFunctions.class))
.putAll(MethodDefinedFunction.mapOf(ExistenceFunctions.class))
.putAll(MethodDefinedFunction.mapOf(FhirFunctions.class))
.putAll(MethodDefinedFunction.mapOf(FilteringAndProjectionFunctions.class))
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/CalendarDurationUnit.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/CalendarDurationUnit.java
new file mode 100644
index 0000000000..20f2e20f12
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/CalendarDurationUnit.java
@@ -0,0 +1,379 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.unit;
+
+import jakarta.annotation.Nonnull;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import lombok.Getter;
+import org.apache.commons.lang3.tuple.Pair;
+
+/**
+ * Enumeration of valid FHIRPath calendar duration units from year to millisecond.
+ *
+ * Calendar duration units represent time periods as defined in the FHIRPath specification. These
+ * units are divided into two categories:
+ *
+ * Definite duration units : second, millisecond - have fixed lengths and can be
+ * converted to UCUM equivalents
+ * Non-definite duration units : year, month, week, day, hour, minute - have
+ * variable lengths (e.g., leap years, different month lengths) and cannot be reliably
+ * converted to other units in all contexts
+ *
+ *
+ * Each calendar duration unit has:
+ *
+ * A canonical code (e.g., "year", "second")
+ * A UCUM equivalent code (e.g., "a" for year, "s" for second)
+ * A definite/non-definite classification
+ * A millisecond conversion factor for unit conversions
+ *
+ *
+ * Calendar duration units support both singular and plural forms (e.g., "year" and "years").
+ *
+ * IMPORTANT: For non-definite duration units (year, month), the millisecond values are
+ * approximations as specified by the FHIRPath specification:
+ *
+ * 1 year = 365 days (does not account for leap years)
+ * 1 month = 30 days (does not account for varying month lengths)
+ *
+ * These approximations are used for conversion purposes only, not for calendar-aware arithmetic.
+ */
+public enum CalendarDurationUnit implements FhirPathUnit {
+ /**
+ * Calendar year unit with UCUM equivalent 'a'. This is a non-definite duration unit with variable
+ * length due to leap years. For conversion purposes, one year is approximated as 365 days
+ * (31,536,000,000 milliseconds) as specified by the FHIRPath specification.
+ *
+ * Millisecond calculation: 365 * 24 * 60 * 60 * 1000 (assumes 365 days/year, does NOT account for
+ * leap years; approximation per FHIRPath spec)
+ */
+ YEAR("year", false, "a", new BigDecimal("31536000000")),
+
+ /**
+ * Calendar month unit with UCUM equivalent 'mo'. This is a non-definite duration unit with
+ * variable length due to different month lengths. For conversion purposes, one month is
+ * approximated as 30 days (2,592,000,000 milliseconds) as specified by the FHIRPath
+ * specification.
+ *
+ * Millisecond calculation: 30 * 24 * 60 * 60 * 1000
+ */
+ MONTH("month", false, "mo", new BigDecimal("2592000000")),
+
+ /**
+ * Calendar week unit with UCUM equivalent 'wk'. This is a non-definite duration unit. One week
+ * equals 7 days (604,800,000 milliseconds).
+ *
+ * Millisecond calculation: 7 * 24 * 60 * 60 * 1000
+ */
+ WEEK("week", false, "wk", new BigDecimal("604800000")),
+
+ /**
+ * Calendar day unit with UCUM equivalent 'd'. This is a non-definite duration unit. One day
+ * equals 24 hours (86,400,000 milliseconds).
+ *
+ * Millisecond calculation: 24 * 60 * 60 * 1000
+ */
+ DAY("day", false, "d", new BigDecimal("86400000")),
+
+ /**
+ * Calendar hour unit with UCUM equivalent 'h'. This is a non-definite duration unit. One hour
+ * equals 60 minutes (3,600,000 milliseconds).
+ *
+ * Millisecond calculation: 60 * 60 * 1000
+ */
+ HOUR("hour", false, "h", new BigDecimal("3600000")),
+
+ /**
+ * Calendar minute unit with UCUM equivalent 'min'. This is a non-definite duration unit. One
+ * minute equals 60 seconds (60,000 milliseconds).
+ *
+ * Millisecond calculation: 60 * 1000
+ */
+ MINUTE("minute", false, "min", new BigDecimal("60000")),
+
+ /**
+ * Calendar second unit with UCUM equivalent 's'. This is a definite duration unit with a fixed
+ * length. One second equals 1,000 milliseconds.
+ *
+ * Millisecond calculation: 1000 ms
+ */
+ SECOND("second", true, "s", new BigDecimal("1000")),
+
+ /**
+ * Calendar millisecond unit with UCUM equivalent 'ms'. This is a definite duration unit with a
+ * fixed length. One millisecond equals 1 millisecond.
+ *
+ * Millisecond calculation: 1 ms
+ */
+ MILLISECOND("millisecond", true, "ms", BigDecimal.ONE);
+
+ /**
+ * The system URI for FHIRPath calendar duration units (e.g. year, month, day).
+ */
+ public static final String FHIRPATH_CALENDAR_DURATION_SYSTEM_URI = "https://hl7.org/fhirpath/N1/calendar-duration";
+ @Nonnull
+ private final String unit;
+
+ /**
+ * Indicates whether the unit is a definite duration (i.e., has a fixed length in time). For
+ * example seconds, and milliseconds are definite units, while years and months are not because
+ * their lengths can vary (e.g., due to leap years or different month lengths).
+ */
+ @Getter
+ private final boolean definite;
+
+ /**
+ * The UCUM equivalent of the calendar duration unit.
+ */
+ @Getter
+ @Nonnull
+ private final String ucumEquivalent;
+
+ /**
+ * The number of milliseconds equivalent to one unit of this calendar duration. For non-definite
+ * units (year, month), this is an approximation as specified by the FHIRPath specification (1
+ * year = 365 days, 1 month = 30 days).
+ */
+ @Getter
+ @Nonnull
+ private final BigDecimal millisecondsEquivalent;
+
+ private static final Map NAME_MAP = new HashMap<>();
+
+ static {
+ for (final CalendarDurationUnit unit : values()) {
+ NAME_MAP.put(unit.unit, unit);
+ NAME_MAP.put(unit.unit + "s", unit); // plural
+ }
+ }
+
+
+ CalendarDurationUnit(@Nonnull final String code, final boolean definite,
+ @Nonnull final String ucumEquivalent, @Nonnull final BigDecimal millisecondsEquivalent) {
+ this.unit = code;
+ this.definite = definite;
+ this.ucumEquivalent = ucumEquivalent;
+ this.millisecondsEquivalent = millisecondsEquivalent;
+ }
+
+ /**
+ * Returns the FHIRPath calendar duration system URI.
+ *
+ * @return the calendar duration system URI
+ * ({@value CalendarDurationUnit#FHIRPATH_CALENDAR_DURATION_SYSTEM_URI})
+ */
+ @Override
+ @Nonnull
+ public String system() {
+ return FHIRPATH_CALENDAR_DURATION_SYSTEM_URI;
+ }
+
+ /**
+ * Returns the canonical code for this calendar duration unit.
+ *
+ * @return the unit code (e.g., "year", "month", "second")
+ */
+ @Override
+ @Nonnull
+ public String code() {
+ return unit;
+ }
+
+ /**
+ * Checks if the given unit name is a valid representation of this calendar duration unit.
+ * Validates that the unit name resolves to this specific enum value (case-sensitive, supports
+ * both singular and plural forms).
+ *
+ * @param unitName the unit name to validate (e.g., "year", "years")
+ * @return true if unitName is valid for this calendar duration unit, false otherwise
+ */
+ @Override
+ public boolean isValidName(@Nonnull final String unitName) {
+ return fromString(unitName)
+ .map(this::equals)
+ .orElse(false);
+ }
+
+ /**
+ * Gets the CalendarDurationUnit from its string representation (case-insensitive, singular or
+ * plural).
+ *
+ * @param name the name of the unit (e.g. "year", "years")
+ * @return the corresponding CalendarDurationUnit
+ * @throws IllegalArgumentException if the name is not valid
+ */
+ @Nonnull
+ public static CalendarDurationUnit parseString(@Nonnull final String name) {
+ return fromString(name).orElseThrow(
+ () -> new IllegalArgumentException("Unknown calendar duration unit: " + name));
+ }
+
+ /**
+ * Gets the CalendarDurationUnit from its string representation (case-insensitive, singular or
+ * plural).
+ *
+ * @param name the name of the unit (e.g. "year", "years")
+ * @return an Optional containing the corresponding CalendarDurationUnit, or empty if not found
+ */
+ @Nonnull
+ public static Optional fromString(@Nonnull final String name) {
+ return Optional.ofNullable(NAME_MAP.get(name));
+ }
+
+ /**
+ * Converts a value from this calendar duration unit to the target calendar duration unit. Only
+ * supports conversions to definite duration units (second, millisecond) as per the FHIRPath
+ * specification.
+ *
+ * @param value the value to convert
+ * @param targetUnit the target calendar duration unit to convert to
+ * @return an Optional containing the converted value if conversion is possible, or empty if the
+ * units are incompatible (e.g., week to month)
+ */
+ @Nonnull
+ public Optional convertValue(@Nonnull final BigDecimal value,
+ @Nonnull final CalendarDurationUnit targetUnit) {
+ return conversionFactorTo(targetUnit).map(cf -> cf.apply(value));
+ }
+
+ /**
+ * Converts a value from this calendar duration unit to a target UCUM unit.
+ *
+ * This is a cross-type conversion that works by:
+ *
+ * Converting this calendar unit value to seconds (if possible)
+ * Converting the seconds value to the target UCUM unit
+ *
+ *
+ * @param value the value to convert
+ * @param ucumUnitTarget the target UCUM unit to convert to
+ * @return an Optional containing the converted value if conversion is possible, or empty
+ * otherwise
+ */
+ @Nonnull
+ public Optional convertValue(@Nonnull final BigDecimal value,
+ @Nonnull final UcumUnit ucumUnitTarget) {
+ return convertValue(value, SECOND)
+ .flatMap(secondsValue ->
+ SECOND.asUcum().convertValue(secondsValue, ucumUnitTarget)
+ );
+ }
+
+ /**
+ * Converts a value from a source UCUM unit to this calendar duration unit.
+ *
+ * This is a cross-type conversion that works by:
+ *
+ * Converting the UCUM source value to seconds ('s')
+ * Converting the seconds value to this calendar unit
+ *
+ *
+ * @param value the value to convert
+ * @param ucumUnitSource the source UCUM unit to convert from
+ * @return an Optional containing the converted value if conversion is possible, or empty
+ * otherwise
+ */
+ @Nonnull
+ public Optional convertValueFrom(@Nonnull final BigDecimal value,
+ @Nonnull final UcumUnit ucumUnitSource) {
+ return ucumUnitSource.convertValue(value, SECOND.asUcum())
+ .flatMap(secondsValue ->
+ SECOND.convertValue(secondsValue, this)
+ );
+ }
+
+ /**
+ * Attempts to convert this calendar duration to its UCUM equivalent, if one exists.
+ *
+ * Only definite duration units (second, millisecond) have UCUM equivalents because they have
+ * fixed lengths. Non-definite units (year, month, week, day, hour, minute) have variable lengths
+ * and cannot be represented as UCUM units.
+ *
+ * @return the equivalent UCUM unit
+ * @throws IllegalArgumentException if this calendar duration unit has no UCUM equivalent (i.e.,
+ * it is not a definite duration)
+ */
+ @Nonnull
+ public UcumUnit asUcum() {
+ return Optional.of(this)
+ .filter(CalendarDurationUnit::isDefinite)
+ .map(cdu -> new UcumUnit(cdu.getUcumEquivalent()))
+ .orElseThrow(() -> new IllegalArgumentException("Cannot convert to Ucum: " + unit));
+ }
+
+ /**
+ * Computes the conversion factor to convert values from this calendar duration unit to the target
+ * calendar duration unit. Only supports conversions to definite duration units (second,
+ * millisecond) as per the FHIRPath specification.
+ *
+ * @param targetUnit the target calendar duration unit to convert to
+ * @return an Optional containing the conversion factor if conversion is possible, or empty if the
+ * units are incompatible (e.g., week to month)
+ */
+ @Nonnull
+ private Optional conversionFactorTo(
+ @Nonnull final CalendarDurationUnit targetUnit) {
+
+ // Check for explicitly incompatible conversions first
+ if (INCOMPATIBLE_CONVERSIONS.contains(Pair.of(this, targetUnit))) {
+ return Optional.empty();
+ }
+
+ // Check for special case conversions (e.g., year to month)
+ return Optional.ofNullable(SPECIAL_CASES.get(Pair.of(this, targetUnit)))
+ .or(() ->
+ // Fall back to milliseconds-based conversion for compatible units
+ Optional.of(ConversionFactor.ofFraction(
+ getMillisecondsEquivalent(),
+ targetUnit.getMillisecondsEquivalent()
+ ))
+ );
+ }
+
+
+ private static final BigDecimal MONTHS_IN_YEAR = new BigDecimal(12);
+
+ /**
+ * Conversions that are incompatible and have no defined conversion factor. These conversions
+ * cannot use the milliseconds-based calculation because they involve non-definite duration units
+ * with no meaningful relationship (e.g., weeks to months - a month isn't a whole number of
+ * weeks).
+ */
+ private static final Set> INCOMPATIBLE_CONVERSIONS =
+ Set.of(
+ Pair.of(WEEK, YEAR), Pair.of(YEAR, WEEK),
+ Pair.of(WEEK, MONTH), Pair.of(MONTH, WEEK)
+ );
+
+ /**
+ * Special case conversions that have defined relationships different from the standard
+ * milliseconds-based calculation (e.g., year to month uses 12, not the millisecond
+ * approximation).
+ */
+ private static final Map, ConversionFactor> SPECIAL_CASES =
+ Map.ofEntries(
+ Map.entry(Pair.of(YEAR, MONTH), ConversionFactor.of(MONTHS_IN_YEAR)),
+ Map.entry(Pair.of(MONTH, YEAR), ConversionFactor.inverseOf(MONTHS_IN_YEAR))
+ );
+
+}
+
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/ConversionFactor.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/ConversionFactor.java
new file mode 100644
index 0000000000..663bf712f9
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/ConversionFactor.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.unit;
+
+import jakarta.annotation.Nonnull;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Value;
+
+/**
+ * Represents a conversion factor between two units of measure, expressed as a fraction.
+ *
+ * A conversion factor is stored as a fraction (numerator/denominator) rather than a decimal to
+ * preserve precision during conversions. This is particularly important for conversions involving
+ * irrational or repeating decimals.
+ *
+ * Example usage:
+ *
+ * // Convert 1000 mg to kg (factor = 1/1000)
+ * ConversionFactor factor = ConversionFactor.ofFraction(BigDecimal.ONE, new BigDecimal(1000));
+ * BigDecimal result = factor.apply(new BigDecimal(1000)); // = 1
+ *
+ */
+@Value
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+class ConversionFactor {
+
+ /**
+ * The numerator of the conversion factor fraction.
+ */
+ @Nonnull
+ BigDecimal numerator;
+
+ /**
+ * The denominator of the conversion factor fraction.
+ */
+ @Nonnull
+ BigDecimal denominator;
+
+
+ /**
+ * Applies this conversion factor to a numeric value with the default precision.
+ *
+ * The conversion is performed as: {@code value * numerator / denominator}
+ *
+ * The result is calculated with {@value FhirPathUnit#DEFAULT_PRECISION} decimal places of
+ * precision using {@link RoundingMode#HALF_UP}, and trailing zeros are stripped.
+ *
+ * @param value the value to convert
+ * @return the converted value
+ */
+ @Nonnull
+ public BigDecimal apply(@Nonnull final BigDecimal value) {
+ return apply(value, FhirPathUnit.DEFAULT_PRECISION);
+ }
+
+ /**
+ * Applies this conversion factor to a numeric value with the specified precision.
+ *
+ * The conversion is performed as: {@code value * numerator / denominator}
+ *
+ * The result is calculated with the specified number of decimal places of precision using
+ * {@link RoundingMode#HALF_UP}, and trailing zeros are stripped.
+ *
+ * @param value the value to convert
+ * @param precision the number of decimal places to use (must be between 1 and 100)
+ * @return the converted value
+ * @throws IllegalArgumentException if precision is not between 1 and 100
+ */
+ @Nonnull
+ public BigDecimal apply(@Nonnull final BigDecimal value, final int precision) {
+ if (precision < 1 || precision > 100) {
+ throw new IllegalArgumentException(
+ "precision must be between 1 and 100, got: " + precision);
+ }
+ return value.multiply(numerator)
+ .divide(denominator, precision, RoundingMode.HALF_UP)
+ .stripTrailingZeros();
+ }
+
+ /**
+ * Creates a conversion factor from a single numeric value.
+ *
+ * This is a convenience method for creating a conversion factor with denominator 1.
+ *
+ * @param factor the conversion factor value
+ * @return a ConversionFactor representing the given value
+ */
+ @Nonnull
+ public static ConversionFactor of(@Nonnull final BigDecimal factor) {
+ return ConversionFactor.ofFraction(factor, BigDecimal.ONE);
+ }
+
+ /**
+ * Creates a conversion factor from a numerator and denominator.
+ *
+ * @param numerator the numerator of the fraction
+ * @param denominator the denominator of the fraction
+ * @return a ConversionFactor representing the fraction
+ * @throws IllegalArgumentException if denominator is zero
+ */
+ @Nonnull
+ static ConversionFactor ofFraction(@Nonnull final BigDecimal numerator,
+ @Nonnull final BigDecimal denominator) {
+ if (BigDecimal.ZERO.equals(denominator)) {
+ throw new IllegalArgumentException("denominator cannot be zero");
+ }
+ return new ConversionFactor(numerator, denominator);
+ }
+
+ /**
+ * Creates a conversion factor representing the inverse (1/value) of the given value.
+ *
+ * This is useful for creating reciprocal conversions (e.g., if kg→mg is 1000, then mg→kg is
+ * 1/1000).
+ *
+ * @param value the value to invert
+ * @return a ConversionFactor representing 1/value
+ */
+ @Nonnull
+ static ConversionFactor inverseOf(@Nonnull final BigDecimal value) {
+ return ofFraction(BigDecimal.ONE, value);
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/FhirPathUnit.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/FhirPathUnit.java
new file mode 100644
index 0000000000..d89c34b844
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/FhirPathUnit.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.unit;
+
+import jakarta.annotation.Nonnull;
+import java.math.BigDecimal;
+import java.util.Optional;
+
+/**
+ * Represents a FHIRPath unit of measure, which can be either a UCUM unit, a calendar duration unit,
+ * or a custom unit from another system.
+ *
+ * This sealed interface has three permitted implementations:
+ *
+ * {@link UcumUnit} - UCUM (Unified Code for Units of Measure) units
+ * {@link CalendarDurationUnit} - FHIRPath calendar duration units (year, month, week, etc.)
+ *
+ *
+ * Units can be converted between compatible types using the {@link #convertValue} method,
+ * which converts values for both same-type conversions (UCUM-to-UCUM, calendar-to-calendar)
+ * and cross-type conversions (calendar-to-UCUM, UCUM-to-calendar) where applicable.
+ */
+public sealed interface FhirPathUnit permits UcumUnit, CalendarDurationUnit {
+
+ /**
+ * The precision (number of decimal places) to use when computing unit conversions.
+ */
+ int DEFAULT_PRECISION = 15;
+
+ /**
+ * Gets the system URI for this unit (e.g., UCUM system URI, calendar duration system URI).
+ *
+ * @return the system URI
+ */
+ @Nonnull
+ String system();
+
+ /**
+ * Gets the canonical code for this unit (e.g., "mg", "second").
+ *
+ * @return the unit code
+ */
+ @Nonnull
+ String code();
+
+ /**
+ * Validates if the given unit name is a valid representation for this unit. The validation rules
+ * depend on the unit type:
+ *
+ * For {@link UcumUnit}: the name must exactly match the code
+ * For {@link CalendarDurationUnit}: the name must match either the singular or plural form
+ * (e.g., "year" or "years" for YEAR)
+ *
+ *
+ * @param unitName the unit name to validate
+ * @return true if unitName is valid for this unit, false otherwise
+ */
+ boolean isValidName(@Nonnull final String unitName);
+
+ /**
+ * Converts a value from one unit to another, supporting same-type conversions (UCUM-to-UCUM,
+ * calendar-to-calendar) and cross-type conversions (calendar-to-UCUM, UCUM-to-calendar) where
+ * applicable. Handles both multiplicative conversions (e.g., mg → kg) and additive conversions
+ * (e.g., Celsius → Kelvin).
+ *
+ * Cross-type conversions work by finding intermediate representations:
+ *
+ * Calendar → UCUM: Converts this calendar unit to seconds, then seconds to UCUM equivalent,
+ * then performs UCUM-to-UCUM conversion to target
+ * UCUM → Calendar: Converts source UCUM to seconds equivalent, then performs
+ * calendar-to-calendar conversion from seconds to target
+ *
+ *
+ * Examples of valid conversions:
+ *
+ * UCUM → UCUM: 1000 mg → 1 kg
+ * UCUM → UCUM (additive): 0 Cel → 273.15 K
+ * Calendar → Calendar: 1 day → 24 hour
+ * Calendar → UCUM: 1 millisecond → 1 ms
+ * UCUM → Calendar: 60 s → 1 minute
+ *
+ *
+ * Conversions return empty when:
+ *
+ * Units measure incompatible dimensions (e.g., mass vs volume)
+ * Non-definite calendar units are involved in certain conversions
+ *
+ *
+ * @param value the value to convert
+ * @param sourceUnit the source unit to convert from
+ * @param targetUnit the target unit to convert to
+ * @return an Optional containing the converted value if conversion is possible, or empty if the
+ * units are incompatible
+ */
+ @Nonnull
+ static Optional convertValue(@Nonnull final BigDecimal value,
+ @Nonnull final FhirPathUnit sourceUnit, @Nonnull final FhirPathUnit targetUnit) {
+ // This handles cross-unit conversions.
+ return switch (sourceUnit) {
+ case final CalendarDurationUnit cdUnitSource -> switch (targetUnit) {
+ case final CalendarDurationUnit cdUnitTarget ->
+ cdUnitSource.convertValue(value, cdUnitTarget);
+ case final UcumUnit ucumUnitTarget -> cdUnitSource.convertValue(value, ucumUnitTarget);
+ };
+ case final UcumUnit ucumUnitSource -> switch (targetUnit) {
+ case final UcumUnit ucumUnitTarget -> ucumUnitSource.convertValue(value, ucumUnitTarget);
+ case final CalendarDurationUnit cdUnitTarget ->
+ cdUnitTarget.convertValueFrom(value, ucumUnitSource);
+ };
+ };
+ }
+
+ /**
+ * Parses a unit string and creates the appropriate FhirPathUnit instance.
+ *
+ * The parsing strategy is:
+ *
+ * First attempts to parse as a calendar duration unit (year, month, week, day, hour,
+ * minute, second, millisecond - both singular and plural forms)
+ * If not recognized as a calendar duration, assumes it's a UCUM unit code
+ *
+ *
+ * using their constructor with both system and code parameters.
+ *
+ * Examples:
+ *
+ * "year" → {@link CalendarDurationUnit#YEAR}
+ * "seconds" → {@link CalendarDurationUnit#SECOND}
+ * "mg" → {@link UcumUnit} with code "mg"
+ * "unknown-unit" → {@link UcumUnit} with code "unknown-unit" (may fail on conversion)
+ *
+ *
+ * @param unit the unit string (e.g., "year", "seconds", "mg", "kg")
+ * @return a FhirPathUnit instance (CalendarDurationUnit if recognized, otherwise UcumUnit)
+ */
+ @Nonnull
+ static FhirPathUnit fromString(@Nonnull final String unit) {
+ return CalendarDurationUnit.fromString(unit)
+ .map(FhirPathUnit.class::cast)
+ .orElseGet(() -> new UcumUnit(unit));
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/UcumUnit.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/UcumUnit.java
new file mode 100644
index 0000000000..c964678856
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/unit/UcumUnit.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.unit;
+
+import au.csiro.pathling.encoders.terminology.ucum.Ucum;
+import jakarta.annotation.Nonnull;
+import java.math.BigDecimal;
+import java.util.Optional;
+
+/**
+ * Represents a UCUM (Unified Code for Units of Measure) unit.
+ *
+ * UCUM is an international standard for representing units of measure in a machine-readable form.
+ * This record encapsulates a UCUM unit code and provides methods for converting between compatible
+ * UCUM units using the UCUM conversion service.
+ *
+ * Examples of UCUM codes include:
+ *
+ * "mg" - milligram
+ * "kg" - kilogram
+ * "mL" - milliliter
+ * "s" - second
+ * "ms" - millisecond
+ *
+ *
+ * @param code the UCUM unit code (e.g., "mg", "kg", "mL")
+ */
+public record UcumUnit(@Nonnull String code) implements FhirPathUnit {
+
+ /**
+ * The UCUM unit representing dimensionless unity ("1"). This is used as the default unit when no
+ * unit is specified in FHIRPath quantity literals.
+ */
+ public static final UcumUnit ONE = new UcumUnit("1");
+ /**
+ * The system URI for UCUM units.
+ */
+ public static final String UCUM_SYSTEM_URI = "http://unitsofmeasure.org";
+
+ /**
+ * Returns the UCUM system URI.
+ *
+ * @return the UCUM system URI ({@value UcumUnit#UCUM_SYSTEM_URI})
+ */
+ @Override
+ @Nonnull
+ public String system() {
+ return UCUM_SYSTEM_URI;
+ }
+
+ /**
+ * Converts a value from this UCUM unit to the target UCUM unit. Handles both multiplicative
+ * conversions (e.g., mg → kg) and additive conversions (e.g., Celsius → Kelvin).
+ *
+ * This method uses the UCUM conversion service to determine if the two units are compatible and
+ * perform the conversion. Units are compatible if they measure the same dimension (e.g., both
+ * measure mass, length, or time).
+ *
+ * Examples of valid conversions:
+ *
+ * 1000 mg → 1 kg (mass, multiplicative)
+ * 1 mL → 0.001 L (volume, multiplicative)
+ * 1000 s → 1000000 ms (time, multiplicative)
+ * 0 Cel → 273.15 K (temperature, additive)
+ * 100 Cel → 373.15 K (temperature, additive)
+ *
+ *
+ * Examples of invalid conversions that return empty:
+ *
+ * mg → mL (mass vs volume)
+ * s → kg (time vs mass)
+ *
+ *
+ * @param value the value to convert
+ * @param targetUnit the target UCUM unit to convert to
+ * @return an Optional containing the converted value if conversion is possible, or empty if the
+ * units are incompatible
+ */
+ @Nonnull
+ public Optional convertValue(@Nonnull final BigDecimal value,
+ @Nonnull final UcumUnit targetUnit) {
+ return Optional.ofNullable(Ucum.convertValue(value, code(), targetUnit.code()));
+ }
+
+ /**
+ * Checks if the given unit name is valid for this UCUM unit. For UCUM units, the name must
+ * exactly match the code.
+ *
+ * @param unitName the unit name to validate
+ * @return true if unitName equals this unit's code, false otherwise
+ */
+ @Override
+ public boolean isValidName(@Nonnull final String unitName) {
+ return this.code.equals(unitName);
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/ColumnSelection.java b/fhirpath/src/main/java/au/csiro/pathling/projection/ColumnSelection.java
index 950b61699b..e231c78040 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/projection/ColumnSelection.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/ColumnSelection.java
@@ -78,32 +78,19 @@ public ProjectionResult evaluate(@Nonnull final ProjectionContext context) {
return collections.iterator();
}
- @Nonnull
- @Override
- public String toString() {
- return "ColumnSelection{" +
- "columns=[" + columns.stream()
- .map(RequestedColumn::toString)
- .collect(Collectors.joining(", ")) +
- "]}";
- }
-
/**
* Returns a string expression representation of this column selection.
*
* @return the string expression "columns"
*/
@Nonnull
- public String toExpression() {
- return "columns";
- }
-
@Override
- @Nonnull
- public String toTreeString(final int level) {
- return " ".repeat(level) + toExpression() + "\n" +
+ public String toExpression() {
+ return "columns[" +
columns.stream()
- .map(c -> " ".repeat(level + 1) + c.toExpression())
- .collect(Collectors.joining("\n"));
+ .map(RequestedColumn::toExpression)
+ .collect(Collectors.joining(", ")) +
+ "]";
}
+
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/CompositeSelection.java b/fhirpath/src/main/java/au/csiro/pathling/projection/CompositeSelection.java
new file mode 100644
index 0000000000..350e7fdaac
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/CompositeSelection.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.projection;
+
+import jakarta.annotation.Nonnull;
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Represents a selection that contains multiple child components.
+ *
+ * This interface provides default implementations for tree traversal methods,
+ * allowing concrete implementations to focus on their specific behavior.
+ */
+public interface CompositeSelection extends ProjectionClause {
+
+ /**
+ * Returns the list of child components of this selection.
+ *
+ * This method is automatically implemented by record classes with a {@code components} field.
+ *
+ * @return the list of child projection clauses
+ */
+ @Nonnull
+ List components();
+
+ /**
+ * Returns a stream of all child components.
+ *
+ * @return a stream of the components
+ */
+ @Nonnull
+ @Override
+ default Stream getChildren() {
+ return components().stream();
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/GroupingSelection.java b/fhirpath/src/main/java/au/csiro/pathling/projection/GroupingSelection.java
index 057b5f0d6f..edb3cd9a1a 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/projection/GroupingSelection.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/GroupingSelection.java
@@ -17,38 +17,31 @@
package au.csiro.pathling.projection;
-import static java.util.stream.Collectors.joining;
-
import jakarta.annotation.Nonnull;
import java.util.List;
/**
- * Groups multiple selections together using a cross join.
+ * Groups multiple selections together using a cross join (Cartesian product).
+ *
+ * This is the primary mechanism for composing multiple projection clauses into a single result
+ * where all combinations of the component results are included.
+ *
*
- * @param components the list of projection clauses to be grouped
+ * @param components the list of projection clauses to be combined via product
* @author John Grimes
* @author Piotr Szul
*/
public record GroupingSelection(@Nonnull List components) implements
- ProjectionClause {
+ CompositeSelection {
@Override
@Nonnull
public ProjectionResult evaluate(@Nonnull final ProjectionContext context) {
- // evaluate and cross join the subcomponents
+ // Evaluate all components
final List subResults = components.stream().map(c -> c.evaluate(context))
.toList();
- return ProjectionResult.combine(subResults);
- }
-
- @Nonnull
- @Override
- public String toString() {
- return "GroupingSelection{" +
- "components=[" + components.stream()
- .map(ProjectionClause::toString)
- .collect(joining(", ")) +
- "]}";
+ // Use the explicit product method for clarity
+ return ProjectionResult.product(subResults);
}
/**
@@ -57,17 +50,8 @@ public String toString() {
* @return the string expression "group"
*/
@Nonnull
+ @Override
public String toExpression() {
return "group";
}
-
- @Override
- @Nonnull
- public String toTreeString(final int level) {
- final String indent = " ".repeat(level);
- return indent + toExpression() + "\n" +
- components.stream()
- .map(c -> c.toTreeString(level + 1))
- .collect(joining("\n"));
- }
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionClause.java b/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionClause.java
index 9875294a5d..8f70077452 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionClause.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionClause.java
@@ -17,7 +17,12 @@
package au.csiro.pathling.projection;
+import au.csiro.pathling.fhirpath.column.DefaultRepresentation;
import jakarta.annotation.Nonnull;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.spark.sql.Column;
/**
* Represents a component of an abstract query for projecting FHIR data.
@@ -33,13 +38,95 @@ public interface ProjectionClause {
@Nonnull
ProjectionResult evaluate(@Nonnull final ProjectionContext context);
+ /**
+ * Converts this projection clause into a column operator that can be applied to individual
+ * elements in a collection.
+ *
+ * This is useful for per-element evaluation in transformations like unnesting and recursive
+ * traversal. The returned operator evaluates this projection clause using the provided column as
+ * input context.
+ *
+ *
+ * @param context The projection context containing execution environment
+ * @return A unary operator that takes a column and returns the projection result column
+ */
+ @Nonnull
+ default UnaryOperator asColumnOperator(@Nonnull final ProjectionContext context) {
+ return column -> evaluate(context.withInputColumn(column)).getResultColumn();
+ }
+
+
+ /**
+ * Evaluates this projection clause element-wise on the input column in the provided context.
+ *
+ * @param context The projection context containing execution environment
+ * @return The resulting column after element-wise evaluation
+ */
+ @Nonnull
+ default Column evaluateElementWise(@Nonnull final ProjectionContext context) {
+ return new DefaultRepresentation(context.inputContext().getColumnValue())
+ .transform(asColumnOperator(context))
+ .flatten()
+ .getValue();
+ }
+
+ /**
+ * Returns a stream of child projection clauses.
+ *
+ * The default implementation returns an empty stream for leaf nodes. Implementations with
+ * children should override this method.
+ *
+ *
+ * @return a stream of child projection clauses
+ */
+ @Nonnull
+ default Stream getChildren() {
+ return Stream.empty();
+ }
+
+ /**
+ * Returns an expression string representation of this clause.
+ *
+ * Used by {@link #toExpressionTree(int)} to display this node in the tree.
+ *
+ *
+ * @return a string representation of this clause's expression
+ */
+ @Nonnull
+ String toExpression();
+
+
/**
* Returns a tree-like string representation of this clause for debugging purposes.
*
+ * @return a formatted tree string representation
+ */
+ @Nonnull
+ default String toExpressionTree() {
+ return toExpressionTree(0);
+ }
+
+ /**
+ * Returns a tree-like string representation of this clause for debugging purposes.
+ *
+ * The default implementation uses {@link #toExpression()} for this node and recursively calls
+ * itself on children from {@link #getChildren()}.
+ *
+ *
* @param level the indentation level for the tree structure
* @return a formatted tree string representation
*/
@Nonnull
- String toTreeString(final int level);
+ default String toExpressionTree(final int level) {
+ final String indent = " ".repeat(level);
+ final String childrenStr = getChildren()
+ .map(child -> child.toExpressionTree(level + 1))
+ .collect(Collectors.joining("\n"));
+
+ if (childrenStr.isEmpty()) {
+ return indent + toExpression();
+ }
+ return indent + toExpression() + "\n" + childrenStr;
+ }
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionContext.java b/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionContext.java
index af4e80b9ae..82dc583f9f 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionContext.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionContext.java
@@ -21,12 +21,16 @@
import au.csiro.pathling.fhirpath.FhirPath;
import au.csiro.pathling.fhirpath.collection.Collection;
+import au.csiro.pathling.fhirpath.collection.EmptyCollection;
+import au.csiro.pathling.fhirpath.column.DefaultRepresentation;
import au.csiro.pathling.fhirpath.execution.FhirPathEvaluator;
import au.csiro.pathling.fhirpath.function.registry.StaticFunctionRegistry;
import au.csiro.pathling.views.ConstantDeclaration;
import jakarta.annotation.Nonnull;
import java.util.List;
import java.util.Map;
+import java.util.function.UnaryOperator;
+import org.apache.spark.sql.Column;
import org.apache.spark.sql.Dataset;
import org.apache.spark.sql.Row;
import org.hl7.fhir.r4.model.Enumerations.ResourceType;
@@ -64,6 +68,54 @@ public ProjectionContext withInputContext(@Nonnull final Collection inputContext
return new ProjectionContext(executor, inputContext);
}
+ /**
+ * Creates a new ProjectionContext with the current input context collection with the value set to
+ * null.
+ *
+ * This is useful for creating stub contexts when determining result schemas without evaluating
+ * actual data, or when no input data is available.
+ *
+ * This is different from {@link #withEmptyInput()} in that it preserves the type of the input
+ * context collection, but replaces the underlying data with an empty representation.
+ *
+ * @return a new ProjectionContext with an empty input context
+ */
+ @Nonnull
+ public ProjectionContext asStubContext() {
+ return withInputContext(inputContext.copyWith(DefaultRepresentation.empty()));
+ }
+
+ /**
+ * Creates a new ProjectionContext with the input context replaced by a new column.
+ *
+ * This is a convenience method that wraps the column in a new input context while preserving
+ * other context properties. It is particularly useful when transforming input data during
+ * projection evaluation.
+ *
+ *
+ * @param inputColumn the new input column to use
+ * @return a new ProjectionContext with the specified input column
+ */
+ @Nonnull
+ public ProjectionContext withInputColumn(@Nonnull final Column inputColumn) {
+ return withInputContext(inputContext.copyWithColumn(inputColumn));
+ }
+
+ /**
+ * Creates a new ProjectionContext with an empty input context.
+ *
+ * This is useful for creating stub contexts when determining result schemas without evaluating
+ * actual data, or when no input data is available.
+ *
+ *
+ * @return a new ProjectionContext with an empty input context
+ */
+ @Nonnull
+ public ProjectionContext withEmptyInput() {
+ return withInputContext(EmptyCollection.getInstance());
+ }
+
+
/**
* Evaluates the given FHIRPath path and returns the result as a column.
*
@@ -75,6 +127,30 @@ public Collection evalExpression(@Nonnull final FhirPath path) {
return executor.evaluate(path, inputContext);
}
+ /**
+ * Creates a unary operator that evaluates a FHIRPath expression on a given column.
+ *
+ * This method returns a function that takes a column as input, evaluates the specified FHIRPath
+ * expression using that column as the input context, and returns the resulting column value. This
+ * is particularly useful for creating reusable transformations that can be applied to multiple
+ * columns or used in higher-order operations like tree traversal.
+ *
+ *
+ * Example use case: Creating a traversal operation for recursive tree structures where the same
+ * FHIRPath expression needs to be evaluated on each node.
+ *
+ *
+ * @param path the FHIRPath expression to evaluate
+ * @return a unary operator that takes a column and returns the result of evaluating the
+ * expression on that column
+ */
+ @Nonnull
+ public UnaryOperator asColumnOperator(@Nonnull final FhirPath path) {
+ return c -> withInputColumn(c)
+ .evalExpression(path)
+ .getColumnValue();
+ }
+
/**
* Creates a new ProjectionContext from the given execution context, subject resource, and
* constants.
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionResult.java b/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionResult.java
index 1c95502ce4..ed0009b065 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionResult.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/ProjectionResult.java
@@ -18,8 +18,12 @@
package au.csiro.pathling.projection;
+import static org.apache.spark.sql.functions.concat;
+
import au.csiro.pathling.encoders.ColumnFunctions;
import au.csiro.pathling.fhirpath.collection.Collection;
+import au.csiro.pathling.fhirpath.column.ColumnRepresentation;
+import au.csiro.pathling.fhirpath.column.DefaultRepresentation;
import jakarta.annotation.Nonnull;
import java.util.List;
import lombok.Value;
@@ -48,50 +52,86 @@ public class ProjectionResult {
@Nonnull
Column resultColumn;
+
/**
- * Combine the results of multiple projections into a single result.
+ * Creates a new ProjectionResult with the specified result column, retaining the existing
+ * results list.
*
- * @param results The results to combine
- * @return The combined result
+ * @param newResultColumn The new result column
+ * @return A new ProjectionResult with the updated result column
*/
@Nonnull
- public static ProjectionResult combine(@Nonnull final List results) {
- return combine(results, false);
+ ProjectionResult withResultColumn(@Nonnull final Column newResultColumn) {
+ return of(this.results, newResultColumn);
}
/**
- * Combine the results of multiple projections into a single result, with outer join semantics.
+ * Creates a product (Cartesian product) of multiple projection results.
+ *
+ * This combines results using struct product semantics, where each result is expanded to include
+ * all combinations from the inputs.
+ *
*
- * @param results The results to combine
- * @param outer Whether to use outer join semantics
- * @return The combined result
+ * @param results The results to combine via product
+ * @return The product of all results
*/
@Nonnull
- public static ProjectionResult combine(@Nonnull final List results,
- final boolean outer) {
- if (results.size() == 1 && !outer) {
- return results.get(0);
+ public static ProjectionResult product(@Nonnull final List results) {
+ if (results.isEmpty()) {
+ throw new IllegalArgumentException("Cannot create product of empty list of results");
+ }
+ if (results.size() == 1) {
+ return results.getFirst();
} else {
return of(
results.stream().flatMap(r -> r.getResults().stream()).toList(),
- structProduct(outer,
+ ColumnFunctions.structProduct(
results.stream().map(ProjectionResult::getResultColumn).toArray(Column[]::new))
);
}
}
/**
- * Creates a struct product column from the given columns.
+ * Creates a concatenation (union) of multiple projection results.
+ *
+ * This combines results by concatenating their arrays, ensuring each result is converted to an
+ * array format before concatenation.
+ *
*
- * @param outer whether to use outer join semantics
- * @param columns the columns to include in the struct product
- * @return a new struct product column
+ * @param results The results to concatenate
+ * @return The concatenated result
*/
@Nonnull
- public static Column structProduct(final boolean outer, @Nonnull final Column... columns) {
- return outer
- ? ColumnFunctions.structProductOuter(columns)
- : ColumnFunctions.structProduct(columns);
+ public static ProjectionResult concatenate(@Nonnull final List results) {
+ if (results.isEmpty()) {
+ throw new IllegalArgumentException("Cannot concatenate empty list of results");
+ }
+ // Process each result to ensure they are all arrays
+ final Column[] converted = results.stream()
+ .map(ProjectionResult::getResultColumn)
+ .map(DefaultRepresentation::new)
+ .map(ColumnRepresentation::plural)
+ .map(ColumnRepresentation::getValue)
+ .toArray(Column[]::new);
+ // Concatenate the converted columns
+ final Column combinedResult = concat(converted);
+ // Use the schema from the first result
+ return results.getFirst().withResultColumn(combinedResult);
}
+
+ /**
+ * Creates a new ProjectionResult where the null value is added if the result is empty for outer
+ * join.
+ *
+ * @param outerJoin Whether to use outer join semantics
+ * @return A new ProjectionResult with null values
+ */
+ @Nonnull
+ public ProjectionResult orNull(final boolean outerJoin) {
+ return outerJoin
+ ? withResultColumn(ColumnFunctions.structProductOuter(resultColumn))
+ : this;
+
+ }
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/RepeatSelection.java b/fhirpath/src/main/java/au/csiro/pathling/projection/RepeatSelection.java
new file mode 100644
index 0000000000..21ea8001e5
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/RepeatSelection.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.projection;
+
+import static org.apache.spark.sql.functions.concat;
+
+import au.csiro.pathling.encoders.ValueFunctions;
+import au.csiro.pathling.fhirpath.FhirPath;
+import au.csiro.pathling.fhirpath.collection.Collection;
+import au.csiro.pathling.fhirpath.column.DefaultRepresentation;
+import jakarta.annotation.Nonnull;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.spark.sql.Column;
+
+/**
+ * Represents a selection that performs recursive traversal of nested data structures using the
+ * repeat directive.
+ *
+ * This enables automatic flattening of hierarchical data to any depth by recursively following the
+ * specified paths and applying a projection clause at each level. When multiple projections are
+ * needed, wrap them in a {@link GroupingSelection} first.
+ *
+ *
+ * @param paths the list of FHIRPath expressions that define paths to recursively traverse
+ * @param component the projection clause to apply at each level (use GroupingSelection for
+ * multiple)
+ * @param maxDepth the maximum depth for self-referencing structure traversals
+ * @author Piotr Szul
+ */
+public record RepeatSelection(
+ @Nonnull List paths,
+ @Nonnull ProjectionClause component,
+ int maxDepth
+) implements UnarySelection {
+
+ @Nonnull
+ @Override
+ public ProjectionResult evaluate(@Nonnull final ProjectionContext context) {
+
+ // Create the list of non-empty starting contexts from current context and provided paths
+ final List startingNodes = paths.stream()
+ .map(context::evalExpression)
+ .filter(Collection::isNotEmpty)
+ .map(context::withInputContext)
+ .toList();
+
+ // Map starting nodes to transformTree expressions and concatenate the results
+ final Column[] nodeResults = startingNodes.stream()
+ .map(ctx -> ValueFunctions.transformTree(
+ ctx.inputContext().getColumnValue(),
+ c -> component.evaluateElementWise(ctx.withInputColumn(c)),
+ paths.stream().map(ctx::asColumnOperator).toList(),
+ maxDepth
+ )
+ ).toArray(Column[]::new);
+
+ final Column result = nodeResults.length > 0
+ ? concat(nodeResults)
+ : DefaultRepresentation.empty().plural()
+ .transform(component.asColumnOperator(context.withEmptyInput()))
+ .flatten().getValue();
+
+ // Compute the output schema based on first non-empty context or empty context
+ final ProjectionContext schemaContext = startingNodes.stream()
+ .findFirst()
+ .orElse(context.withEmptyInput());
+
+ return component.evaluate(schemaContext).withResultColumn(result);
+ }
+
+ /**
+ * Returns the FHIRPath expression representation of this repeat selection.
+ *
+ * @return the expression string containing repeat with paths
+ */
+ @Nonnull
+ @Override
+ public String toExpression() {
+ return "repeat: [" + paths.stream()
+ .map(FhirPath::toExpression)
+ .collect(Collectors.joining(", ")) + "]";
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/UnarySelection.java b/fhirpath/src/main/java/au/csiro/pathling/projection/UnarySelection.java
new file mode 100644
index 0000000000..b4b9facac8
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/UnarySelection.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.projection;
+
+import jakarta.annotation.Nonnull;
+import java.util.stream.Stream;
+
+/**
+ * Represents a selection that wraps a single child component.
+ *
+ * This interface provides default implementations for tree traversal methods,
+ * allowing concrete implementations to focus on their specific behavior.
+ */
+public interface UnarySelection extends ProjectionClause {
+
+ /**
+ * Returns the single child component of this selection.
+ *
+ * This method is automatically implemented by record classes with a {@code component} field.
+ *
+ * @return the child projection clause
+ */
+ @Nonnull
+ ProjectionClause component();
+
+ /**
+ * Returns a stream containing the single child component.
+ *
+ * @return a stream with one element - the component
+ */
+ @Nonnull
+ @Override
+ default Stream getChildren() {
+ return Stream.of(component());
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/UnionSelection.java b/fhirpath/src/main/java/au/csiro/pathling/projection/UnionSelection.java
index 7dd5ad49bc..d9439c6af0 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/projection/UnionSelection.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/UnionSelection.java
@@ -17,54 +17,33 @@
package au.csiro.pathling.projection;
-import static au.csiro.pathling.encoders.ValueFunctions.ifArray;
-import static java.util.stream.Collectors.joining;
-import static org.apache.spark.sql.functions.array;
-import static org.apache.spark.sql.functions.concat;
-import static org.apache.spark.sql.functions.isnull;
-import static org.apache.spark.sql.functions.when;
-
import jakarta.annotation.Nonnull;
import java.util.List;
-import org.apache.spark.sql.Column;
-import org.apache.spark.sql.functions;
-import org.jetbrains.annotations.NotNull;
/**
- * Groups multiple selections together using a union.
+ * Groups multiple selections together using a union (concatenation).
+ *
+ * This is the primary mechanism for combining multiple projection clauses into a single result
+ * where all component results are concatenated sequentially.
+ *
*
- * @param components The list of projection clauses to be combined in the union
+ * @param components The list of projection clauses to be combined via concatenation
* @author John Grimes
* @author Piotr Szul
*/
public record UnionSelection(@Nonnull List components) implements
- ProjectionClause {
+ CompositeSelection {
@Nonnull
@Override
public ProjectionResult evaluate(@Nonnull final ProjectionContext context) {
- // Evaluate each component of the union.
+ // Evaluate each component
final List results = components.stream()
.map(c -> c.evaluate(context))
.toList();
- // Process each result to ensure that they are all arrays.
- final Column[] converted = results.stream()
- .map(ProjectionResult::getResultColumn)
- // When the result is a singular null, convert it to an empty array.
- .map(col -> when(isnull(col), array())
- .otherwise(ifArray(col,
- // If the column is an array, return it as is.
- c -> c,
- // If the column is a singular value, convert it to an array.
- functions::array
- )))
- .toArray(Column[]::new);
-
- // Concatenate the converted columns.
- final Column combinedResult = concat(converted);
-
- return ProjectionResult.of(results.get(0).getResults(), combinedResult);
+ // Use the explicit concatenate method
+ return ProjectionResult.concatenate(results);
}
/**
@@ -73,17 +52,8 @@ public ProjectionResult evaluate(@Nonnull final ProjectionContext context) {
* @return the expression string "union"
*/
@Nonnull
+ @Override
public String toExpression() {
return "union";
}
-
- @Override
- @Nonnull
- public @NotNull String toTreeString(final int level) {
- final String indent = " ".repeat(level);
- return indent + toExpression() + "\n" +
- components.stream()
- .map(c -> c.toTreeString(level + 1))
- .collect(joining("\n"));
- }
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/projection/UnnestingSelection.java b/fhirpath/src/main/java/au/csiro/pathling/projection/UnnestingSelection.java
index 1cf3434f5d..2645c55445 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/projection/UnnestingSelection.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/projection/UnnestingSelection.java
@@ -17,25 +17,23 @@
package au.csiro.pathling.projection;
-import static au.csiro.pathling.encoders.ColumnFunctions.structProduct;
-import static au.csiro.pathling.encoders.ColumnFunctions.structProductOuter;
-import static org.apache.spark.sql.functions.flatten;
-import static org.apache.spark.sql.functions.transform;
-
import au.csiro.pathling.fhirpath.FhirPath;
import au.csiro.pathling.fhirpath.collection.Collection;
-import au.csiro.pathling.fhirpath.column.DefaultRepresentation;
import jakarta.annotation.Nonnull;
-import java.util.List;
-import java.util.stream.Collectors;
import org.apache.spark.sql.Column;
/**
* Represents a selection that unnests a nested data structure, with either inner or outer join
* semantics.
+ *
+ * This selection evaluates a FHIRPath expression to get a collection, then applies a projection
+ * clause to each element of that collection. The results are flattened into a single array. When
+ * multiple projections are needed, wrap them in a {@link GroupingSelection} first.
+ *
*
* @param path the FHIRPath expression that identifies the collection to unnest
- * @param components the list of components to select from the unnesting collection
+ * @param component the projection clause to apply to each element (use GroupingSelection for
+ * multiple)
* @param joinOuter whether to use outer join semantics (i.e., return a row even if the unnesting
* collection is empty)
* @author John Grimes
@@ -43,70 +41,21 @@
*/
public record UnnestingSelection(
@Nonnull FhirPath path,
- @Nonnull List components,
+ @Nonnull ProjectionClause component,
boolean joinOuter
-) implements ProjectionClause {
+) implements UnarySelection {
@Nonnull
@Override
public ProjectionResult evaluate(@Nonnull final ProjectionContext context) {
// Evaluate the path to get the collection that will serve as the basis for unnesting.
final Collection unnestingCollection = context.evalExpression(path);
-
- // Get the column that represents the unnesting collection.
- final Column unnestingColumn = unnestingCollection.getColumn().toArray().getValue();
-
- // Unnest the components of the unnesting selection.
- Column columnResult = flatten(
- transform(unnestingColumn, c -> unnestComponents(c, unnestingCollection, context)));
-
- if (joinOuter) {
- // If we are doing an outer join, we need to use structProductOuter to ensure that a row is
- // always returned, even if the unnesting collection is empty.
- columnResult = structProductOuter(columnResult);
- }
-
- // This is a way to evaluate the expression for the purpose of getting the types of the result.
- final ProjectionContext stubContext = context.withInputContext(
- unnestingCollection.map(c -> DefaultRepresentation.empty()));
- final List stubResults = components.stream()
- .map(s -> s.evaluate(stubContext))
- .toList();
- final List columnDescriptors = stubResults.stream()
- .flatMap(sr -> sr.getResults().stream())
- .toList();
-
- // Return a new projection result from the column result and the column descriptors.
- return ProjectionResult.of(columnDescriptors, columnResult);
- }
-
- @Nonnull
- private Column unnestComponents(@Nonnull final Column unnestingColumn,
- @Nonnull final Collection unnestingCollection, @Nonnull final ProjectionContext context) {
- // Create a new projection context based upon the unnesting collection.
- final ProjectionContext projectionContext = context.withInputContext(
- unnestingCollection.map(c -> new DefaultRepresentation(unnestingColumn)));
-
- // Evaluate each of the components of the unnesting selection, and get the result
- // columns.
- final Column[] subSelectionColumns = components.stream()
- .map(s -> s.evaluate(projectionContext).getResultColumn())
- .toArray(Column[]::new);
-
- // Combine the result columns into a struct.
- return structProduct(subSelectionColumns);
- }
-
- @Nonnull
- @Override
- public String toString() {
- return "UnnestingSelection{" +
- "path=" + path +
- ", components=[" + components.stream()
- .map(ProjectionClause::toString)
- .collect(Collectors.joining(", ")) +
- "], joinOuter=" + joinOuter +
- '}';
+ final ProjectionContext unnestingContext = context.withInputContext(unnestingCollection);
+ final Column columnResult = component.evaluateElementWise(unnestingContext);
+ return component
+ .evaluate(unnestingContext.asStubContext())
+ .withResultColumn(columnResult)
+ .orNull(joinOuter);
}
/**
@@ -115,6 +64,7 @@ public String toString() {
* @return the expression string containing forEach or forEachOrNull with path
*/
@Nonnull
+ @Override
public String toExpression() {
return (joinOuter
? "forEachOrNull"
@@ -122,14 +72,4 @@ public String toExpression() {
+ ": " + path.toExpression();
}
- @Override
- @Nonnull
- public String toTreeString(final int level) {
- final String indent = " ".repeat(level);
- return indent + toExpression() + "\n" +
- components.stream()
- .map(c -> c.toTreeString(level + 1))
- .collect(Collectors.joining("\n"));
- }
-
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/sql/misc/ConvertQuantityToUnit.java b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/ConvertQuantityToUnit.java
new file mode 100644
index 0000000000..610b6b2cc8
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/ConvertQuantityToUnit.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.sql.misc;
+
+import static java.util.Objects.requireNonNull;
+
+import au.csiro.pathling.fhirpath.FhirPathQuantity;
+import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
+import au.csiro.pathling.sql.udf.SqlFunction2;
+import jakarta.annotation.Nullable;
+import java.io.Serial;
+import java.util.Optional;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.types.DataType;
+
+/**
+ * Spark UDF to convert a Quantity from its current unitCode to a target unitCode using UCUM
+ * conversions.
+ *
+ * This UDF wraps {@link FhirPathQuantity#convertToUnit(String)} for use in Spark SQL. It decodes
+ * the quantity Row, delegates to the conversion logic, and encodes the result back to a Row.
+ *
+ * Returns null if either input is null or if the conversion fails.
+ *
+ * @see FhirPathQuantity#convertToUnit(String)
+ * @see QuantityEncoding
+ */
+public class ConvertQuantityToUnit implements SqlFunction2 {
+
+ /**
+ * The name of this function when used within SQL.
+ */
+ public static final String FUNCTION_NAME = "convert_quantity_to_unit";
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public String getName() {
+ return FUNCTION_NAME;
+ }
+
+ @Override
+ public DataType getReturnType() {
+ return QuantityEncoding.dataType();
+ }
+
+ @Override
+ @Nullable
+ public Row call(@Nullable final Row quantityRow, @Nullable final String targetUnit) {
+ if (quantityRow == null || targetUnit == null) {
+ return null;
+ }
+ // Decode the quantity from Row to FhirPathQuantity
+ return Optional.ofNullable(QuantityEncoding.decode(quantityRow))
+ .flatMap(q -> q.convertToUnit(requireNonNull(targetUnit)))
+ .map(QuantityEncoding::encode)
+ .orElse(null);
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/sql/misc/PathlingUdfRegistrar.java b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/PathlingUdfRegistrar.java
index 51b9388477..bffa1aafc0 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/sql/misc/PathlingUdfRegistrar.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/PathlingUdfRegistrar.java
@@ -34,6 +34,8 @@ protected void registerUDFs(@Nonnull final UDFRegistrar udfRegistrar) {
.register(new HighBoundaryForDateTime())
.register(new LowBoundaryForTime())
.register(new HighBoundaryForTime())
- .register(new QuantityToLiteral());
+ .register(new QuantityToLiteral())
+ .register(new StringToQuantity())
+ .register(new ConvertQuantityToUnit());
}
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/sql/misc/QuantityToLiteral.java b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/QuantityToLiteral.java
index 802f8e40f3..ae3c64f52d 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/sql/misc/QuantityToLiteral.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/QuantityToLiteral.java
@@ -17,20 +17,15 @@
package au.csiro.pathling.sql.misc;
-import static au.csiro.pathling.fhirpath.FhirPathQuantity.FHIRPATH_CALENDAR_DURATION_SYSTEM_URI;
-import static au.csiro.pathling.fhirpath.FhirPathQuantity.UCUM_SYSTEM_URI;
-import static java.util.Objects.nonNull;
-import static java.util.Objects.requireNonNull;
-
+import au.csiro.pathling.fhirpath.FhirPathQuantity;
import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
import au.csiro.pathling.sql.udf.SqlFunction1;
import jakarta.annotation.Nullable;
import java.io.Serial;
-import java.math.BigDecimal;
+import java.util.Optional;
import org.apache.spark.sql.Row;
import org.apache.spark.sql.types.DataType;
import org.apache.spark.sql.types.DataTypes;
-import org.hl7.fhir.r4.model.Quantity;
/**
* Spark UDF to convert a Quantity represented as a Row to a valid Quantity literal string.
@@ -63,32 +58,8 @@ public DataType getReturnType() {
@Override
@Nullable
public String call(@Nullable final Row row) {
- if (row == null) {
- return null;
- }
-
- // Decode the row into a Quantity object using QuantityEncoding
- final Quantity quantity = QuantityEncoding.decode(requireNonNull(row));
- @Nullable final BigDecimal value = quantity.getValue();
- @Nullable final String system = quantity.getSystem();
- @Nullable final String code = quantity.getCode();
- @Nullable final String unit = quantity.getUnit();
-
- if (value == null || system == null || code == null) {
- return null;
- }
- if (UCUM_SYSTEM_URI.equals(system)) {
- // UCUM units are quoted
- return String.format("%s '%s'", value.toPlainString(), code);
- } else if (FHIRPATH_CALENDAR_DURATION_SYSTEM_URI.equals(system)) {
- // Time duration units are not quoted
- return String.format("%s %s", value.toPlainString(),
- nonNull(unit)
- ? unit
- : code);
- } else {
- // For other systems, return null
- return null;
- }
+ return Optional.ofNullable(QuantityEncoding.decode(row))
+ .map(FhirPathQuantity::toString)
+ .orElse(null);
}
}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/sql/misc/StringToQuantity.java b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/StringToQuantity.java
new file mode 100644
index 0000000000..e1e0f96689
--- /dev/null
+++ b/fhirpath/src/main/java/au/csiro/pathling/sql/misc/StringToQuantity.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.sql.misc;
+
+import au.csiro.pathling.fhirpath.FhirPathQuantity;
+import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
+import au.csiro.pathling.sql.udf.SqlFunction1;
+import jakarta.annotation.Nullable;
+import java.io.Serial;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.types.DataType;
+
+/**
+ * Spark UDF to parse a FHIRPath quantity literal string to a Quantity struct.
+ *
+ * Parses strings matching the FHIRPath quantity literal format:
+ *
+ * UCUM units: {@code "value 'unit'"} (e.g., {@code "10 'mg'"}, {@code "1.5 'kg'"})
+ * Calendar duration units: {@code "value unit"} (e.g., {@code "4 days"}, {@code "1 year"})
+ *
+ * Returns null if the string cannot be parsed as a valid quantity.
+ *
+ * @see FhirPathQuantity#parse(String)
+ * @see QuantityEncoding#encode(FhirPathQuantity)
+ */
+public class StringToQuantity implements SqlFunction1 {
+
+ /**
+ * The name of this function when used within SQL.
+ */
+ public static final String FUNCTION_NAME = "string_to_quantity";
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public String getName() {
+ return FUNCTION_NAME;
+ }
+
+ @Override
+ public DataType getReturnType() {
+ return QuantityEncoding.dataType();
+ }
+
+ @Override
+ @Nullable
+ public Row call(@Nullable final String quantityString) {
+ if (quantityString == null) {
+ return null;
+ }
+ try {
+ final FhirPathQuantity quantity = FhirPathQuantity.parse(quantityString);
+ return QuantityEncoding.encode(quantity);
+ } catch (final IllegalArgumentException e) {
+ // Invalid quantity literal format
+ return null;
+ }
+ }
+}
diff --git a/fhirpath/src/main/java/au/csiro/pathling/views/FhirView.java b/fhirpath/src/main/java/au/csiro/pathling/views/FhirView.java
index 6070da2800..d4636e6624 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/views/FhirView.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/views/FhirView.java
@@ -184,6 +184,36 @@ public static SelectClause forEachOrNull(@Nonnull final String path,
.select(selects).build();
}
+ /**
+ * Creates a new 'repeat' SelectClause with the specified paths and columns.
+ *
+ * @param paths the FHIRPath expressions for recursive traversal
+ * @param columns the columns to include
+ * @return a new SelectClause instance
+ */
+ @Nonnull
+ public static SelectClause repeat(@Nonnull final List paths,
+ @Nonnull final Column... columns) {
+ return SelectClause.builder()
+ .repeat(paths.toArray(new String[0]))
+ .column(columns).build();
+ }
+
+ /**
+ * Creates a new 'repeat' SelectClause with the specified paths and nested selects.
+ *
+ * @param paths the FHIRPath expressions for recursive traversal
+ * @param selects the nested select clauses to include
+ * @return a new SelectClause instance
+ */
+ @Nonnull
+ public static SelectClause repeat(@Nonnull final List paths,
+ @Nonnull final SelectClause... selects) {
+ return SelectClause.builder()
+ .repeat(paths.toArray(new String[0]))
+ .select(selects).build();
+ }
+
/**
* Creates a new {@link SelectClause} that unions all the given select clauses.
*
diff --git a/fhirpath/src/main/java/au/csiro/pathling/views/FhirViewExecutor.java b/fhirpath/src/main/java/au/csiro/pathling/views/FhirViewExecutor.java
index f7d2ba90ca..fcc801f3f1 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/views/FhirViewExecutor.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/views/FhirViewExecutor.java
@@ -22,6 +22,7 @@
import static java.util.Objects.nonNull;
import static java.util.Objects.requireNonNull;
+import au.csiro.pathling.config.QueryConfiguration;
import au.csiro.pathling.fhirpath.FhirPath;
import au.csiro.pathling.fhirpath.execution.FhirPathEvaluators.SingleEvaluatorFactory;
import au.csiro.pathling.fhirpath.parser.Parser;
@@ -31,6 +32,7 @@
import au.csiro.pathling.projection.GroupingSelection;
import au.csiro.pathling.projection.Projection;
import au.csiro.pathling.projection.ProjectionClause;
+import au.csiro.pathling.projection.RepeatSelection;
import au.csiro.pathling.projection.RequestedColumn;
import au.csiro.pathling.projection.UnionSelection;
import au.csiro.pathling.projection.UnnestingSelection;
@@ -74,19 +76,35 @@ public class FhirViewExecutor {
@Nonnull
private final Parser parser;
+ @Nonnull
+ private final QueryConfiguration queryConfiguration;
+
/**
* @param fhirContext The FHIR context to use for the execution context
* @param sparkSession The Spark session to use for the execution context
* @param dataset The data source to use for the execution context
+ * @param queryConfiguration The query configuration to control query execution behavior
*/
public FhirViewExecutor(@Nonnull final FhirContext fhirContext,
- @Nonnull final SparkSession sparkSession, @Nonnull final DataSource dataset) {
+ @Nonnull final SparkSession sparkSession, @Nonnull final DataSource dataset,
+ @Nonnull final QueryConfiguration queryConfiguration) {
this.fhirContext = fhirContext;
this.sparkSession = sparkSession;
this.dataSource = dataset;
+ this.queryConfiguration = queryConfiguration;
this.parser = new Parser();
}
+ /**
+ * @param fhirContext The FHIR context to use for the execution context
+ * @param sparkSession The Spark session to use for the execution context
+ * @param dataset The data source to use for the execution context
+ */
+ public FhirViewExecutor(@Nonnull final FhirContext fhirContext,
+ @Nonnull final SparkSession sparkSession, @Nonnull final DataSource dataset) {
+ this(fhirContext, sparkSession, dataset, QueryConfiguration.builder().build());
+ }
+
/**
* Builds a Spark SQL query for a given FHIR view.
*
@@ -142,30 +160,40 @@ private Projection buildProjection(@Nonnull final FhirView fhirView) {
*/
@Nonnull
private ProjectionClause parseSelection(@Nonnull final SelectClause select) {
- // There are three types of select:
+ // There are four types of select:
// (1) A direct column selection
// (2) A "for each" selection, which unnests a set of sub-select based on a parent path
// (3) A "for each or null" selection, which is the same as (2) but creates a null row if
// the parent path evaluates to an empty collection
+ // (4) A "repeat" selection, which recursively traverses nested structures
- if (isNull(select.getForEach()) && isNull(select.getForEachOrNull())) {
- // If this is a direct column selection, we use a FromSelection. This will produce the
+ if (isNull(select.getForEach()) && isNull(select.getForEachOrNull())
+ && isNull(select.getRepeat())) {
+ // If this is a direct column selection, we use a GroupingSelection. This will produce the
// cartesian product of the collections that are produced by the FHIRPath expressions.
return new GroupingSelection(parseSubSelection(select));
+ } else if (nonNull(select.getRepeat())) {
+ // If this is a "repeat" selection, we use a RepeatSelection. This will recursively traverse
+ // the nested structures defined by the repeat paths, unioning all results from all levels.
+ final List repeatPaths = select.getRepeat().stream()
+ .map(parser::parse)
+ .toList();
+ return new RepeatSelection(repeatPaths, wrapInGroupingIfNeeded(parseSubSelection(select)),
+ queryConfiguration.getMaxUnboundTraversalDepth());
} else if (nonNull(select.getForEach()) && nonNull(select.getForEachOrNull())) {
throw new IllegalStateException(
"Both forEach and forEachOrNull are set in the select clause");
} else if (nonNull(select.getForEach())) {
- // If this is a "for each" selection, we use a ForEachSelectionX. This will produce a row for
- // each item in the collection produced by the parent path.
+ // If this is a "for each" selection, we use an UnnestingSelection. This will produce a row
+ // for each item in the collection produced by the parent path.
return new UnnestingSelection(parser.parse(requireNonNull(select.getForEach())),
- parseSubSelection(select), false);
+ wrapInGroupingIfNeeded(parseSubSelection(select)), false);
} else { // this implies that forEachOrNull is non-null
- // If this is a "for each or null" selection, we use a ForEachSelectionX with a flag set to
+ // If this is a "for each or null" selection, we use an UnnestingSelection with a flag set to
// true. This will produce a row for each item in the collection produced by the parent path,
// or a single null row if the parent path evaluates to an empty collection.
return new UnnestingSelection(parser.parse(requireNonNull(select.getForEachOrNull())),
- parseSubSelection(select), true);
+ wrapInGroupingIfNeeded(parseSubSelection(select)), true);
}
}
@@ -215,6 +243,23 @@ private List parseSubSelection(@Nonnull final SelectClause sel
.toList();
}
+ /**
+ * Wraps a list of projection clauses in a {@link GroupingSelection} if needed.
+ *
+ * This helper method is used to adapt multiple projection clauses into a single clause, which is
+ * required by {@link UnnestingSelection} and {@link RepeatSelection}. If the list contains a
+ * single clause, it is returned as-is. If the list contains multiple clauses, they are wrapped in
+ * a {@link GroupingSelection} to produce their Cartesian product.
+ *
+ *
+ * @param clauses the list of projection clauses to wrap
+ * @return a single projection clause
+ */
+ @Nonnull
+ private ProjectionClause wrapInGroupingIfNeeded(@Nonnull final List clauses) {
+ return clauses.size() == 1 ? clauses.getFirst() : new GroupingSelection(clauses);
+ }
+
/**
* Parses a {@link Column} into a {@link RequestedColumn} object.
*
diff --git a/fhirpath/src/main/java/au/csiro/pathling/views/SelectClause.java b/fhirpath/src/main/java/au/csiro/pathling/views/SelectClause.java
index d0119028cf..7b86d51420 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/views/SelectClause.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/views/SelectClause.java
@@ -42,7 +42,7 @@
@Data
@AllArgsConstructor()
@NoArgsConstructor()
-@AtMostOneNonNull({"forEach", "forEachOrNull"})
+@AtMostOneNonNull({"forEach", "forEachOrNull", "repeat"})
public class SelectClause implements SelectionElement {
/**
@@ -100,6 +100,18 @@ public static SelectClauseBuilder builder() {
@Nullable
String forEachOrNull;
+ /**
+ * An array of FHIRPath expressions that define paths to recursively traverse. The view runner
+ * will start at the current context node, evaluate each path expression, and for each result,
+ * recursively apply the same path patterns. This continues until no more matches are found at
+ * any depth. All results from all levels and all paths are unioned together.
+ *
+ * @see ViewDefinition.select.repeat
+ */
+ @Nullable
+ List repeat;
+
/**
* A `unionAll` combines the results of multiple selection structures. Each structure under the
* `unionAll` must produce the same column names and types.
diff --git a/fhirpath/src/main/java/au/csiro/pathling/views/SelectClauseBuilder.java b/fhirpath/src/main/java/au/csiro/pathling/views/SelectClauseBuilder.java
index 9aee2e6a0f..c9d29637cb 100644
--- a/fhirpath/src/main/java/au/csiro/pathling/views/SelectClauseBuilder.java
+++ b/fhirpath/src/main/java/au/csiro/pathling/views/SelectClauseBuilder.java
@@ -20,6 +20,7 @@
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -63,6 +64,9 @@ public class SelectClauseBuilder {
@Nullable
private String forEachOrNull;
+ @Nullable
+ private List repeat;
+
@Nonnull
private final List unionAll = new ArrayList<>();
@@ -127,6 +131,22 @@ public SelectClauseBuilder forEachOrNull(@Nonnull final String forEachOrNull) {
return this;
}
+ /**
+ * Sets the repeat expressions for recursive traversal of nested structures.
+ *
+ * The repeat operation enables recursive traversal of nested structures, automatically flattening
+ * hierarchical data to any depth. For each path expression, the view runner will recursively
+ * apply the same patterns until no more matches are found. All results from all levels and all
+ * paths are unioned together.
+ *
+ * @param repeat one or more FHIRPath expressions defining paths to recursively traverse
+ * @return this builder instance for method chaining
+ */
+ public SelectClauseBuilder repeat(@Nonnull final String... repeat) {
+ this.repeat = Arrays.asList(repeat);
+ return this;
+ }
+
/**
* Adds select clauses to be combined using a union operation.
*
@@ -152,7 +172,7 @@ public SelectClauseBuilder unionAll(@Nonnull final SelectClause... selects) {
*/
@Nonnull
public SelectClause build() {
- return new SelectClause(column, select, forEach, forEachOrNull, unionAll);
+ return new SelectClause(column, select, forEach, forEachOrNull, repeat, unionAll);
}
}
diff --git a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/FhirPathQuantityTest.java b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/FhirPathQuantityTest.java
index cd10bfa8d2..302496ace6 100644
--- a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/FhirPathQuantityTest.java
+++ b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/FhirPathQuantityTest.java
@@ -18,7 +18,12 @@
package au.csiro.pathling.fhirpath;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import au.csiro.pathling.fhirpath.unit.CalendarDurationUnit;
+import au.csiro.pathling.fhirpath.unit.UcumUnit;
+import jakarta.annotation.Nullable;
import java.math.BigDecimal;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
@@ -30,52 +35,523 @@ class FhirPathQuantityTest {
static Stream quantityLiterals() {
return Stream.of(
// UCUM cases
- Arguments.of("5.4 'mg'", FhirPathQuantity.ofUCUM(new BigDecimal("5.4"), "mg")),
- Arguments.of("-2 'kg'", FhirPathQuantity.ofUCUM(new BigDecimal("-2"), "kg")),
- Arguments.of("1.0 'mL'", FhirPathQuantity.ofUCUM(new BigDecimal("1.0"), "mL")),
+ Arguments.of("5.4 'mg'", FhirPathQuantity.ofUcum(new BigDecimal("5.4"), "mg")),
+ Arguments.of("-2 'kg'", FhirPathQuantity.ofUcum(new BigDecimal("-2"), "kg")),
+ Arguments.of("1.0 'mL'", FhirPathQuantity.ofUcum(new BigDecimal("1.0"), "mL")),
// Calendar duration cases (singular and plural)
Arguments.of("1 year",
- FhirPathQuantity.ofCalendar(new BigDecimal("1"), CalendarDurationUnit.YEAR)),
+ FhirPathQuantity.ofUnit(new BigDecimal("1"), CalendarDurationUnit.YEAR)),
Arguments.of("2 years",
- FhirPathQuantity.ofCalendar(new BigDecimal("2"), CalendarDurationUnit.YEAR, "years")),
+ FhirPathQuantity.ofUnit(new BigDecimal("2"), CalendarDurationUnit.YEAR, "years")),
Arguments.of("3 month",
- FhirPathQuantity.ofCalendar(new BigDecimal("3"), CalendarDurationUnit.MONTH)),
+ FhirPathQuantity.ofUnit(new BigDecimal("3"), CalendarDurationUnit.MONTH)),
Arguments.of("4 months",
- FhirPathQuantity.ofCalendar(new BigDecimal("4"), CalendarDurationUnit.MONTH, "months")),
+ FhirPathQuantity.ofUnit(new BigDecimal("4"), CalendarDurationUnit.MONTH, "months")),
Arguments.of("1 week",
- FhirPathQuantity.ofCalendar(new BigDecimal("1"), CalendarDurationUnit.WEEK)),
+ FhirPathQuantity.ofUnit(new BigDecimal("1"), CalendarDurationUnit.WEEK)),
Arguments.of("2 weeks",
- FhirPathQuantity.ofCalendar(new BigDecimal("2"), CalendarDurationUnit.WEEK, "weeks")),
+ FhirPathQuantity.ofUnit(new BigDecimal("2"), CalendarDurationUnit.WEEK, "weeks")),
Arguments.of("5 day",
- FhirPathQuantity.ofCalendar(new BigDecimal("5"), CalendarDurationUnit.DAY)),
+ FhirPathQuantity.ofUnit(new BigDecimal("5"), CalendarDurationUnit.DAY)),
Arguments.of("6 days",
- FhirPathQuantity.ofCalendar(new BigDecimal("6"), CalendarDurationUnit.DAY, "days")),
+ FhirPathQuantity.ofUnit(new BigDecimal("6"), CalendarDurationUnit.DAY, "days")),
Arguments.of("7 hour",
- FhirPathQuantity.ofCalendar(new BigDecimal("7"), CalendarDurationUnit.HOUR)),
+ FhirPathQuantity.ofUnit(new BigDecimal("7"), CalendarDurationUnit.HOUR)),
Arguments.of("8 hours",
- FhirPathQuantity.ofCalendar(new BigDecimal("8"), CalendarDurationUnit.HOUR, "hours")),
+ FhirPathQuantity.ofUnit(new BigDecimal("8"), CalendarDurationUnit.HOUR, "hours")),
Arguments.of("9 minute",
- FhirPathQuantity.ofCalendar(new BigDecimal("9"), CalendarDurationUnit.MINUTE)),
+ FhirPathQuantity.ofUnit(new BigDecimal("9"), CalendarDurationUnit.MINUTE)),
Arguments.of("10 minutes",
- FhirPathQuantity.ofCalendar(new BigDecimal("10"), CalendarDurationUnit.MINUTE,
+ FhirPathQuantity.ofUnit(new BigDecimal("10"), CalendarDurationUnit.MINUTE,
"minutes")),
Arguments.of("11 second",
- FhirPathQuantity.ofCalendar(new BigDecimal("11"), CalendarDurationUnit.SECOND)),
+ FhirPathQuantity.ofUnit(new BigDecimal("11"), CalendarDurationUnit.SECOND)),
Arguments.of("12 seconds",
- FhirPathQuantity.ofCalendar(new BigDecimal("12"), CalendarDurationUnit.SECOND,
+ FhirPathQuantity.ofUnit(new BigDecimal("12"), CalendarDurationUnit.SECOND,
"seconds")),
Arguments.of("13 millisecond",
- FhirPathQuantity.ofCalendar(new BigDecimal("13"), CalendarDurationUnit.MILLISECOND)),
+ FhirPathQuantity.ofUnit(new BigDecimal("13"), CalendarDurationUnit.MILLISECOND)),
Arguments.of("14 milliseconds",
- FhirPathQuantity.ofCalendar(new BigDecimal("14"), CalendarDurationUnit.MILLISECOND,
+ FhirPathQuantity.ofUnit(new BigDecimal("14"), CalendarDurationUnit.MILLISECOND,
"milliseconds"))
);
}
@ParameterizedTest
@MethodSource("quantityLiterals")
- void testParseQuantity(String literal, FhirPathQuantity expected) {
- FhirPathQuantity actual = FhirPathQuantity.parse(literal);
+ void testParseQuantity(final String literal, final FhirPathQuantity expected) {
+ final FhirPathQuantity actual = FhirPathQuantity.parse(literal);
assertEquals(expected, actual, "Parsed quantity should match expected");
}
+
+ static Stream calendarDurationConversions() {
+ return Stream.of(
+ // 1. Identity Conversions - unit to itself (preserves target unit plurality)
+ Arguments.of(CalendarDurationUnit.YEAR, "year", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.YEAR, "years", new BigDecimal("2"), new BigDecimal("2")),
+ Arguments.of(CalendarDurationUnit.MONTH, "month", new BigDecimal("3"), new BigDecimal("3")),
+ Arguments.of(CalendarDurationUnit.WEEK, "week", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.DAY, "day", new BigDecimal("5"), new BigDecimal("5")),
+ Arguments.of(CalendarDurationUnit.HOUR, "hour", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.MINUTE, "minute", new BigDecimal("10"),
+ new BigDecimal("10")),
+ Arguments.of(CalendarDurationUnit.SECOND, "second", new BigDecimal("5"),
+ new BigDecimal("5")),
+ Arguments.of(CalendarDurationUnit.MILLISECOND, "millisecond", new BigDecimal("1000"),
+ new BigDecimal("1000")),
+
+ // 2. Direct Conversions (one-hop)
+ Arguments.of(CalendarDurationUnit.YEAR, "months", new BigDecimal("1"),
+ new BigDecimal("12")),
+ Arguments.of(CalendarDurationUnit.YEAR, "days", new BigDecimal("1"), new BigDecimal("365")),
+ Arguments.of(CalendarDurationUnit.MONTH, "days", new BigDecimal("1"), new BigDecimal("30")),
+ Arguments.of(CalendarDurationUnit.WEEK, "days", new BigDecimal("1"), new BigDecimal("7")),
+ Arguments.of(CalendarDurationUnit.DAY, "hours", new BigDecimal("1"), new BigDecimal("24")),
+ Arguments.of(CalendarDurationUnit.HOUR, "minutes", new BigDecimal("1"),
+ new BigDecimal("60")),
+ Arguments.of(CalendarDurationUnit.MINUTE, "seconds", new BigDecimal("1"),
+ new BigDecimal("60")),
+ Arguments.of(CalendarDurationUnit.SECOND, "milliseconds", new BigDecimal("1"),
+ new BigDecimal("1000")),
+
+ // 3. Reverse Conversions
+ Arguments.of(CalendarDurationUnit.MONTH, "years", new BigDecimal("12"),
+ new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.DAY, "years", new BigDecimal("365"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.DAY, "months", new BigDecimal("30"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.DAY, "weeks", new BigDecimal("7"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.HOUR, "days", new BigDecimal("24"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.MINUTE, "hours", new BigDecimal("60"),
+ new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.SECOND, "minutes", new BigDecimal("60"),
+ new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.MILLISECOND, "seconds", new BigDecimal("1000"),
+ new BigDecimal("1")),
+
+ // 4. Transitive Conversions (multi-hop via shortest path)
+ // year -> day -> hour
+ Arguments.of(CalendarDurationUnit.YEAR, "hours", new BigDecimal("1"),
+ new BigDecimal("8760")),
+ // year -> day -> hour -> minute
+ Arguments.of(CalendarDurationUnit.YEAR, "minutes", new BigDecimal("1"),
+ new BigDecimal("525600")),
+ // year -> day -> hour -> minute -> second
+ Arguments.of(CalendarDurationUnit.YEAR, "seconds", new BigDecimal("1"),
+ new BigDecimal("31536000")),
+ // year -> day -> hour -> minute -> second -> millisecond
+ Arguments.of(CalendarDurationUnit.YEAR, "milliseconds", new BigDecimal("1"),
+ new BigDecimal("31536000000")),
+
+ // month -> day -> hour
+ Arguments.of(CalendarDurationUnit.MONTH, "hours", new BigDecimal("1"),
+ new BigDecimal("720")),
+ // month -> day -> hour -> minute
+ Arguments.of(CalendarDurationUnit.MONTH, "minutes", new BigDecimal("1"),
+ new BigDecimal("43200")),
+ // month -> day -> hour -> minute -> second
+ Arguments.of(CalendarDurationUnit.MONTH, "seconds", new BigDecimal("1"),
+ new BigDecimal("2592000")),
+ // month -> day -> hour -> minute -> second -> millisecond
+ Arguments.of(CalendarDurationUnit.MONTH, "milliseconds", new BigDecimal("1"),
+ new BigDecimal("2592000000")),
+
+ // week -> day -> hour
+ Arguments.of(CalendarDurationUnit.WEEK, "hours", new BigDecimal("1"),
+ new BigDecimal("168")),
+ // week -> day -> hour -> minute
+ Arguments.of(CalendarDurationUnit.WEEK, "minutes", new BigDecimal("1"),
+ new BigDecimal("10080")),
+ // week -> day -> hour -> minute -> second
+ Arguments.of(CalendarDurationUnit.WEEK, "seconds", new BigDecimal("1"),
+ new BigDecimal("604800")),
+ // week -> day -> hour -> minute -> second -> millisecond
+ Arguments.of(CalendarDurationUnit.WEEK, "milliseconds", new BigDecimal("1"),
+ new BigDecimal("604800000")),
+
+ // day -> hour -> minute
+ Arguments.of(CalendarDurationUnit.DAY, "minutes", new BigDecimal("1"),
+ new BigDecimal("1440")),
+ // day -> hour -> minute -> second
+ Arguments.of(CalendarDurationUnit.DAY, "seconds", new BigDecimal("1"),
+ new BigDecimal("86400")),
+ // day -> hour -> minute -> second -> millisecond
+ Arguments.of(CalendarDurationUnit.DAY, "milliseconds", new BigDecimal("1"),
+ new BigDecimal("86400000")),
+
+ // hour -> minute -> second
+ Arguments.of(CalendarDurationUnit.HOUR, "seconds", new BigDecimal("1"),
+ new BigDecimal("3600")),
+ // hour -> minute -> second -> millisecond
+ Arguments.of(CalendarDurationUnit.HOUR, "milliseconds", new BigDecimal("1"),
+ new BigDecimal("3600000")),
+
+ // minute -> second -> millisecond
+ Arguments.of(CalendarDurationUnit.MINUTE, "milliseconds", new BigDecimal("1"),
+ new BigDecimal("60000")),
+
+ // 5. Calendar Duration to UCUM Conversions (only 's' and 'ms' as targets)
+ // Direct UCUM conversions for definite duration units
+ Arguments.of(CalendarDurationUnit.SECOND, "s", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.SECOND, "ms", new BigDecimal("1"),
+ new BigDecimal("1000")),
+ Arguments.of(CalendarDurationUnit.MILLISECOND, "ms", new BigDecimal("1500"),
+ new BigDecimal("1500")),
+
+ // Transitive Calendar → UCUM (via second/millisecond bridge)
+ // All calendar units can convert to 's' via: calendar → second → 's'
+ Arguments.of(CalendarDurationUnit.MINUTE, "s", new BigDecimal("2"), new BigDecimal("120")),
+ Arguments.of(CalendarDurationUnit.HOUR, "s", new BigDecimal("1"), new BigDecimal("3600")),
+ Arguments.of(CalendarDurationUnit.DAY, "s", new BigDecimal("1"), new BigDecimal("86400")),
+ Arguments.of(CalendarDurationUnit.WEEK, "s", new BigDecimal("1"), new BigDecimal("604800")),
+ Arguments.of(CalendarDurationUnit.MONTH, "s", new BigDecimal("1"),
+ new BigDecimal("2592000")),
+ Arguments.of(CalendarDurationUnit.YEAR, "s", new BigDecimal("1"),
+ new BigDecimal("31536000")),
+
+ // All calendar units can convert to 'ms' via: calendar → millisecond → 'ms'
+ Arguments.of(CalendarDurationUnit.MINUTE, "ms", new BigDecimal("1"),
+ new BigDecimal("60000")),
+ Arguments.of(CalendarDurationUnit.HOUR, "ms", new BigDecimal("1"),
+ new BigDecimal("3600000")),
+ Arguments.of(CalendarDurationUnit.DAY, "ms", new BigDecimal("1"),
+ new BigDecimal("86400000")),
+
+ // Transitive Calendar → UCUM via 's' bridge: calendar → second → 's' → UCUM time unit
+ // NOTE: Calendar durations and UCUM time units have different definitions!
+ // - Calendar minute = 60s, UCUM 'min' = 60s → 1:1 conversion ✓
+ // - Calendar hour = 3600s, UCUM 'h' = 3600s → 1:1 conversion ✓
+ // - Calendar day = 86400s, UCUM 'd' = 86400s → 1:1 conversion ✓
+ // - Calendar week = 7d, UCUM 'wk' = 7d → 1:1 conversion ✓
+ // - Calendar month = 30d = 2592000s, UCUM 'mo' = 30.4375d = 2629800s → NOT 1:1 but convertible
+ // - Calendar year = 365d = 31536000s, UCUM 'a' = 365.25d = 31557600s → NOT 1:1 but convertible
+
+ Arguments.of(CalendarDurationUnit.MINUTE, "min", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.HOUR, "h", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.DAY, "d", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.WEEK, "wk", new BigDecimal("1"), new BigDecimal("1")),
+
+ // Calendar month/year to UCUM with non-1:1 factors
+ // 1 calendar month (2592000s) to 'mo' (2629800s): factor = 2592000/2629800 (15 decimals)
+ Arguments.of(CalendarDurationUnit.MONTH, "mo", new BigDecimal("1"),
+ new BigDecimal("0.985626283367556")),
+
+ // 1 calendar year (31536000s) to 'a' (31557600s): factor = 31536000/31557600 (15 decimals)
+ Arguments.of(CalendarDurationUnit.YEAR, "a", new BigDecimal("1"),
+ new BigDecimal("0.999315537303217")),
+
+ // 1 calendar year to UCUM 'mo': 31536000s / 2629800s (15 decimals)
+ Arguments.of(CalendarDurationUnit.YEAR, "mo", new BigDecimal("1"),
+ new BigDecimal("11.991786447638604")),
+
+ // Additional cross-unit UCUM conversions via 's' bridge
+ Arguments.of(CalendarDurationUnit.HOUR, "min", new BigDecimal("1"), new BigDecimal("60")),
+ Arguments.of(CalendarDurationUnit.DAY, "h", new BigDecimal("1"), new BigDecimal("24")),
+ Arguments.of(CalendarDurationUnit.WEEK, "d", new BigDecimal("1"), new BigDecimal("7"))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("calendarDurationConversions")
+ void testCalendarDurationConversion(final CalendarDurationUnit sourceUnit,
+ final String targetUnit,
+ final BigDecimal sourceValue, final BigDecimal expectedValue) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUnit(sourceValue, sourceUnit);
+ final FhirPathQuantity result = source.convertToUnit(targetUnit).orElse(null);
+
+ // Compare values with compareTo for proper BigDecimal equality (ignoring scale)
+ assertNotNull(result);
+ assertEquals(0, expectedValue.compareTo(result.getValue()),
+ String.format("Converting %s %s to %s should produce %s, but got %s",
+ sourceValue, sourceUnit.code(), targetUnit, expectedValue, result.getValue()));
+
+ // Check that result has correct system and unit
+ // Determine if target is calendar duration or UCUM by attempting to parse
+ final boolean isCalendarDuration = CalendarDurationUnit.fromString(targetUnit).isPresent();
+ if (isCalendarDuration) {
+ // Calendar duration conversion
+ assertEquals(CalendarDurationUnit.FHIRPATH_CALENDAR_DURATION_SYSTEM_URI, result.getSystem(),
+ "Result should be calendar duration system");
+ assertEquals(targetUnit, result.getUnitName(), "Result unit should be " + targetUnit);
+ } else {
+ // UCUM conversion (includes 's', 'ms', 'min', 'h', 'd', 'wk', 'mo', 'a', etc.)
+ assertEquals(UcumUnit.UCUM_SYSTEM_URI, result.getSystem(),
+ "Result should be UCUM system");
+ assertEquals(targetUnit, result.getUnitName(), "Result unit should be " + targetUnit);
+ }
+ }
+
+ static Stream ucumToCalendarDurationConversions() {
+ return Stream.of(
+ // 6. UCUM to Calendar Duration Conversions (reflexive of calendar → UCUM)
+ // Direct conversions: 's' ↔ 'second', 'ms' ↔ 'millisecond'
+ Arguments.of("s", "second", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of("s", "seconds", new BigDecimal("5"), new BigDecimal("5")),
+ Arguments.of("ms", "millisecond", new BigDecimal("1000"), new BigDecimal("1000")),
+ Arguments.of("ms", "milliseconds", new BigDecimal("500"), new BigDecimal("500")),
+
+ // Transitive UCUM → Calendar via 's' bridge: UCUM → 's' → 'second' → calendar unit
+ // 's' → 'second' → 'minute'
+ Arguments.of("s", "minute", new BigDecimal("60"), new BigDecimal("1")),
+ Arguments.of("s", "minutes", new BigDecimal("120"), new BigDecimal("2")),
+ // 's' → 'second' → 'hour'
+ Arguments.of("s", "hour", new BigDecimal("3600"), new BigDecimal("1")),
+ Arguments.of("s", "hours", new BigDecimal("7200"), new BigDecimal("2")),
+ // 's' → 'second' → 'day'
+ Arguments.of("s", "day", new BigDecimal("86400"), new BigDecimal("1")),
+ Arguments.of("s", "days", new BigDecimal("172800"), new BigDecimal("2")),
+ // 's' → 'second' → 'week'
+ Arguments.of("s", "week", new BigDecimal("604800"), new BigDecimal("1")),
+ Arguments.of("s", "weeks", new BigDecimal("1209600"), new BigDecimal("2")),
+ // 's' → 'second' → 'month'
+ Arguments.of("s", "month", new BigDecimal("2592000"), new BigDecimal("1")),
+ Arguments.of("s", "months", new BigDecimal("5184000"), new BigDecimal("2")),
+ // 's' → 'second' → 'year'
+ Arguments.of("s", "year", new BigDecimal("31536000"), new BigDecimal("1")),
+ Arguments.of("s", "years", new BigDecimal("63072000"), new BigDecimal("2")),
+
+ // Transitive UCUM → Calendar via 'ms' bridge: UCUM → 'ms' → 'millisecond' → calendar unit
+ // 'ms' → 'millisecond' → 'second'
+ Arguments.of("ms", "second", new BigDecimal("1000"), new BigDecimal("1")),
+ Arguments.of("ms", "seconds", new BigDecimal("5000"), new BigDecimal("5")),
+ // 'ms' → 'millisecond' → 'minute'
+ Arguments.of("ms", "minute", new BigDecimal("60000"), new BigDecimal("1")),
+ Arguments.of("ms", "minutes", new BigDecimal("120000"), new BigDecimal("2")),
+ // 'ms' → 'millisecond' → 'hour'
+ Arguments.of("ms", "hour", new BigDecimal("3600000"), new BigDecimal("1")),
+ // 'ms' → 'millisecond' → 'day'
+ Arguments.of("ms", "day", new BigDecimal("86400000"), new BigDecimal("1")),
+
+ // Transitive UCUM time unit → Calendar via 's' bridge: UCUM → 's' → 'second' → calendar
+ // NOTE: Only UCUM units with same definition as calendar can convert 1:1
+
+ // UCUM 'min' → 's' → 'second' → calendar units (1:1 since both = 60s)
+ Arguments.of("min", "minute", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of("min", "minutes", new BigDecimal("2"), new BigDecimal("2")),
+ Arguments.of("min", "second", new BigDecimal("1"), new BigDecimal("60")),
+ Arguments.of("min", "hour", new BigDecimal("60"), new BigDecimal("1")),
+
+ // UCUM 'h' → 's' → 'second' → calendar units (1:1 since both = 3600s)
+ Arguments.of("h", "hour", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of("h", "hours", new BigDecimal("3"), new BigDecimal("3")),
+ Arguments.of("h", "minute", new BigDecimal("1"), new BigDecimal("60")),
+ Arguments.of("h", "day", new BigDecimal("24"), new BigDecimal("1")),
+
+ // UCUM 'd' → 's' → 'second' → calendar units (1:1 since both = 86400s)
+ Arguments.of("d", "day", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of("d", "days", new BigDecimal("5"), new BigDecimal("5")),
+ Arguments.of("d", "hour", new BigDecimal("1"), new BigDecimal("24")),
+ Arguments.of("d", "week", new BigDecimal("7"), new BigDecimal("1")),
+
+ // UCUM 'wk' → 's' → 'second' → calendar units (1:1 since both = 7d)
+ Arguments.of("wk", "week", new BigDecimal("1"), new BigDecimal("1")),
+ Arguments.of("wk", "weeks", new BigDecimal("2"), new BigDecimal("2")),
+ Arguments.of("wk", "day", new BigDecimal("1"), new BigDecimal("7")),
+
+ // UCUM 'mo' → 's' → 'second' → calendar units (NOT 1:1 but convertible)
+ // 1 'mo' (2629800s) to calendar month (2592000s): factor = 2629800/2592000 (15 decimals)
+ Arguments.of("mo", "month", new BigDecimal("1"), new BigDecimal("1.014583333333333")),
+ Arguments.of("mo", "months", new BigDecimal("2"), new BigDecimal("2.029166666666667")),
+ // 1 'mo' to calendar day: 2629800 / 86400 = 30.4375
+ Arguments.of("mo", "day", new BigDecimal("1"), new BigDecimal("30.4375")),
+
+ // UCUM 'a' → 's' → 'second' → calendar units (NOT 1:1 but convertible)
+ // 1 'a' (31557600s) to calendar year (31536000s): factor = 31557600/31536000 (15 decimals)
+ Arguments.of("a", "year", new BigDecimal("1"), new BigDecimal("1.000684931506849")),
+ Arguments.of("a", "years", new BigDecimal("2"), new BigDecimal("2.001369863013699")),
+ // 1 'a' to calendar month: 31557600 / 2592000 = 12.175
+ Arguments.of("a", "month", new BigDecimal("1"), new BigDecimal("12.175")),
+ // 1 'a' to calendar day: 31557600 / 86400 = 365.25
+ Arguments.of("a", "day", new BigDecimal("1"), new BigDecimal("365.25"))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("ucumToCalendarDurationConversions")
+ void testUcumToCalendarDurationConversion(final String sourceUcumUnit,
+ final String targetCalendarUnit,
+ final BigDecimal sourceValue, final BigDecimal expectedValue) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUcum(sourceValue, sourceUcumUnit);
+ final FhirPathQuantity result = source.convertToUnit(targetCalendarUnit).orElse(null);
+
+ // Compare values with compareTo for proper BigDecimal equality (ignoring scale)
+ assertNotNull(result);
+ assertEquals(0, expectedValue.compareTo(result.getValue()),
+ String.format("Converting %s '%s' to %s should produce %s, but got %s",
+ sourceValue, sourceUcumUnit, targetCalendarUnit, expectedValue, result.getValue()));
+
+ // Check that result has correct system and unit
+ assertEquals(CalendarDurationUnit.FHIRPATH_CALENDAR_DURATION_SYSTEM_URI, result.getSystem(),
+ "Result should be calendar duration system");
+ assertEquals(targetCalendarUnit, result.getUnitName(),
+ "Result unit should be " + targetCalendarUnit);
+ }
+
+ static Stream unsupportedCalendarDurationConversions() {
+ return Stream.of(
+ // Blocked Conversions (week <-> month/year explicitly disallowed per FHIRPath spec)
+ Arguments.of(CalendarDurationUnit.WEEK, "months"),
+ Arguments.of(CalendarDurationUnit.MONTH, "weeks"),
+ Arguments.of(CalendarDurationUnit.WEEK, "years"),
+ Arguments.of(CalendarDurationUnit.YEAR, "weeks"),
+
+ // Invalid targets (non-time units)
+ Arguments.of(CalendarDurationUnit.DAY, "kg"), // kilogram - mass unit
+ Arguments.of(CalendarDurationUnit.HOUR, "L"), // liter - volume unit
+ Arguments.of(CalendarDurationUnit.SECOND, "m"), // meter - length unit
+ Arguments.of(CalendarDurationUnit.MINUTE, "g"), // gram - mass unit
+ Arguments.of(CalendarDurationUnit.HOUR, "invalid_unit") // non-existent unit
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("unsupportedCalendarDurationConversions")
+ void testUnsupportedCalendarDurationConversion(final CalendarDurationUnit sourceUnit,
+ final String targetUnit) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUnit(new BigDecimal("1"), sourceUnit);
+ @Nullable final FhirPathQuantity result = source.convertToUnit(targetUnit).orElse(null);
+
+ assertNull(result,
+ String.format("Converting %s to %s should return null (not supported)",
+ sourceUnit.code(), targetUnit));
+ }
+
+ static Stream unsupportedUcumToCalendarConversions() {
+ return Stream.of(
+ // Non-time UCUM units to calendar durations (invalid - wrong dimension)
+ Arguments.of("kg", "second"), // kilogram - mass unit
+ Arguments.of("g", "minute"), // gram - mass unit
+ Arguments.of("m", "hour"), // meter - length unit
+ Arguments.of("cm", "day"), // centimeter - length unit
+ Arguments.of("L", "day"), // liter - volume unit
+ Arguments.of("mL", "hour"), // milliliter - volume unit
+ Arguments.of("invalid_unit", "minute"), // non-existent unit
+ Arguments.of("xyz", "second") // non-existent unit
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("unsupportedUcumToCalendarConversions")
+ void testUnsupportedUcumToCalendarConversion(final String sourceUcumUnit,
+ final String targetCalendarUnit) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUcum(new BigDecimal("1"), sourceUcumUnit);
+ @Nullable final FhirPathQuantity result = source.convertToUnit(targetCalendarUnit).orElse(null);
+
+ assertNull(result,
+ String.format("Converting UCUM '%s' to calendar '%s' should return null (not supported)",
+ sourceUcumUnit, targetCalendarUnit));
+ }
+
+ static Stream ucumConversions() {
+ return Stream.of(
+ // Multiplicative conversions (simple scaling factor)
+ Arguments.of("kg", "g", new BigDecimal("1"), new BigDecimal("1000")),
+ Arguments.of("g", "kg", new BigDecimal("2000"), new BigDecimal("2")),
+
+ // Additive conversions (offset-based)
+ Arguments.of("Cel", "K", new BigDecimal("0"), new BigDecimal("273.15")),
+ Arguments.of("K", "Cel", new BigDecimal("373.15"), new BigDecimal("100"))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("ucumConversions")
+ void testUcumConversion(final String sourceUnit, final String targetUnit,
+ final BigDecimal sourceValue, final BigDecimal expectedValue) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUcum(sourceValue, sourceUnit);
+ final FhirPathQuantity result = source.convertToUnit(targetUnit).orElse(null);
+
+ assertNotNull(result,
+ String.format("Converting %s '%s' to '%s' should succeed",
+ sourceValue, sourceUnit, targetUnit));
+ assertEquals(0, expectedValue.compareTo(result.getValue()),
+ String.format("Converting %s '%s' to '%s' should produce %s, but got %s",
+ sourceValue, sourceUnit, targetUnit, expectedValue, result.getValue()));
+
+ // Check that result has correct system and unit
+ assertEquals(UcumUnit.UCUM_SYSTEM_URI, result.getSystem(),
+ "Result should be UCUM system");
+ assertEquals(targetUnit, result.getUnitName(), "Result unit should be " + targetUnit);
+ }
+
+ static Stream ucumCanonicalization() {
+ return Stream.of(
+ // Multiplicative conversions to canonical units
+ Arguments.of("kg", new BigDecimal("2"), "g", new BigDecimal("2000")),
+ Arguments.of("cm", new BigDecimal("100"), "m", new BigDecimal("1")),
+ Arguments.of("L", new BigDecimal("500"), "m+3", new BigDecimal("0.5")),
+
+ // Additive conversion to canonical unit (Celsius -> Kelvin)
+ Arguments.of("Cel", new BigDecimal("100"), "K", new BigDecimal("373.15"))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("ucumCanonicalization")
+ void testUcumCanonicalization(final String sourceUnit, final BigDecimal sourceValue,
+ final String expectedCanonicalUnit, final BigDecimal expectedCanonicalValue) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUcum(sourceValue, sourceUnit);
+ final FhirPathQuantity canonical = source.asCanonical().orElse(null);
+
+ assertNotNull(canonical,
+ String.format("Canonicalizing %s '%s' should succeed", sourceValue, sourceUnit));
+ assertEquals(expectedCanonicalUnit, canonical.getUnitName(),
+ String.format("Canonical unit should be '%s'", expectedCanonicalUnit));
+ assertEquals(0, expectedCanonicalValue.compareTo(canonical.getValue()),
+ String.format("Canonical value should be %s, but got %s",
+ expectedCanonicalValue, canonical.getValue()));
+ assertEquals(UcumUnit.UCUM_SYSTEM_URI, canonical.getSystem(),
+ "Canonical quantity should be UCUM system");
+ }
+
+ static Stream calendarDurationCanonicalization() {
+ return Stream.of(
+ // Only definite calendar durations (second and millisecond) can be canonicalized to UCUM
+ Arguments.of(CalendarDurationUnit.SECOND, new BigDecimal("5"), "s", new BigDecimal("5")),
+ Arguments.of(CalendarDurationUnit.MILLISECOND, new BigDecimal("1000"), "s",
+ new BigDecimal("1"))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("calendarDurationCanonicalization")
+ void testCalendarDurationCanonicalization(final CalendarDurationUnit sourceUnit,
+ final BigDecimal sourceValue,
+ final String expectedCanonicalUnit, final BigDecimal expectedCanonicalValue) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUnit(sourceValue, sourceUnit);
+ final FhirPathQuantity canonical = source.asCanonical().orElse(null);
+
+ assertNotNull(canonical,
+ String.format("Canonicalizing %s %s should succeed", sourceValue, sourceUnit.code()));
+ assertEquals(expectedCanonicalUnit, canonical.getUnitName(),
+ String.format("Canonical unit should be '%s'", expectedCanonicalUnit));
+ assertEquals(0, expectedCanonicalValue.compareTo(canonical.getValue()),
+ String.format("Canonical value should be %s, but got %s",
+ expectedCanonicalValue, canonical.getValue()));
+ assertEquals(UcumUnit.UCUM_SYSTEM_URI, canonical.getSystem(),
+ "Canonical quantity should be UCUM system");
+ }
+
+ static Stream indefiniteCalendarDurationCanonicalization() {
+ return Stream.of(
+ // Indefinite calendar durations cannot be canonicalized
+ Arguments.of(CalendarDurationUnit.MINUTE, new BigDecimal("2")),
+ Arguments.of(CalendarDurationUnit.HOUR, new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.DAY, new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.WEEK, new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.MONTH, new BigDecimal("1")),
+ Arguments.of(CalendarDurationUnit.YEAR, new BigDecimal("1"))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("indefiniteCalendarDurationCanonicalization")
+ void testIndefiniteCalendarDurationCanonicalization(final CalendarDurationUnit sourceUnit,
+ final BigDecimal sourceValue) {
+ final FhirPathQuantity source = FhirPathQuantity.ofUnit(sourceValue, sourceUnit);
+ @Nullable final FhirPathQuantity canonical = source.asCanonical().orElse(null);
+
+ assertNull(canonical,
+ String.format("Canonicalizing indefinite duration %s %s should return null",
+ sourceValue, sourceUnit.code()));
+ }
}
diff --git a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/ConversionFunctionsDslTest.java b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/ConversionFunctionsDslTest.java
new file mode 100644
index 0000000000..bf2b9812f2
--- /dev/null
+++ b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/ConversionFunctionsDslTest.java
@@ -0,0 +1,982 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.dsl;
+
+import au.csiro.pathling.test.dsl.FhirPathDslTestBase;
+import au.csiro.pathling.test.dsl.FhirPathTest;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DynamicTest;
+
+/**
+ * Tests for FHIRPath conversion functions.
+ *
+ * @author Piotr Szul
+ */
+public class ConversionFunctionsDslTest extends FhirPathDslTestBase {
+
+ @FhirPathTest
+ public Stream testToBoolean() {
+ return builder()
+ .withSubject(sb -> sb
+ .boolArray("boolArray", true, false, true)
+ .integerArray("intArray", 0, 1, 2)
+ .stringArray("stringArray", "true", "false", "yes")
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .boolEmpty("emptyBool")
+ .integerEmpty("emptyInt")
+ .dateEmpty("emptyDate")
+ )
+ .group("toBoolean() with Boolean literals")
+ .testTrue("true.toBoolean()", "toBoolean() returns true for true")
+ .testFalse("false.toBoolean()", "toBoolean() returns false for false")
+
+ .group("toBoolean() with String literals - true values")
+ .testTrue("'true'.toBoolean()", "toBoolean() converts 'true'")
+ .testTrue("'True'.toBoolean()", "toBoolean() converts 'True'")
+ .testTrue("'TRUE'.toBoolean()", "toBoolean() converts 'TRUE'")
+ .testTrue("'t'.toBoolean()", "toBoolean() converts 't'")
+ .testTrue("'T'.toBoolean()", "toBoolean() converts 'T'")
+ .testTrue("'yes'.toBoolean()", "toBoolean() converts 'yes'")
+ .testTrue("'Yes'.toBoolean()", "toBoolean() converts 'Yes'")
+ .testTrue("'YES'.toBoolean()", "toBoolean() converts 'YES'")
+ .testTrue("'y'.toBoolean()", "toBoolean() converts 'y'")
+ .testTrue("'Y'.toBoolean()", "toBoolean() converts 'Y'")
+ .testTrue("'1'.toBoolean()", "toBoolean() converts '1'")
+ .testTrue("'1.0'.toBoolean()", "toBoolean() converts '1.0'")
+
+ .group("toBoolean() with String literals - false values")
+ .testFalse("'false'.toBoolean()", "toBoolean() converts 'false'")
+ .testFalse("'False'.toBoolean()", "toBoolean() converts 'False'")
+ .testFalse("'FALSE'.toBoolean()", "toBoolean() converts 'FALSE'")
+ .testFalse("'f'.toBoolean()", "toBoolean() converts 'f'")
+ .testFalse("'F'.toBoolean()", "toBoolean() converts 'F'")
+ .testFalse("'no'.toBoolean()", "toBoolean() converts 'no'")
+ .testFalse("'No'.toBoolean()", "toBoolean() converts 'No'")
+ .testFalse("'NO'.toBoolean()", "toBoolean() converts 'NO'")
+ .testFalse("'n'.toBoolean()", "toBoolean() converts 'n'")
+ .testFalse("'N'.toBoolean()", "toBoolean() converts 'N'")
+ .testFalse("'0'.toBoolean()", "toBoolean() converts '0'")
+ .testFalse("'0.0'.toBoolean()", "toBoolean() converts '0.0'")
+
+ .group("toBoolean() with String literals - invalid values")
+ .testEmpty("'notBoolean'.toBoolean()", "toBoolean() returns empty for invalid string")
+ .testEmpty("'2'.toBoolean()", "toBoolean() returns empty for '2'")
+ .testEmpty("'maybe'.toBoolean()", "toBoolean() returns empty for 'maybe'")
+
+ .group("toBoolean() with Integer literals")
+ .testTrue("1.toBoolean()", "toBoolean() converts 1 to true")
+ .testFalse("0.toBoolean()", "toBoolean() converts 0 to false")
+ .testEmpty("42.toBoolean()", "toBoolean() returns empty for other integers")
+
+ .group("toBoolean() with Decimal literals")
+ .testTrue("1.0.toBoolean()", "toBoolean() converts 1.0 to true")
+ .testFalse("0.0.toBoolean()", "toBoolean() converts 0.0 to false")
+ .testEmpty("3.14.toBoolean()", "toBoolean() returns empty for other decimals")
+
+ .group("toBoolean() with non-convertible types")
+ .testEmpty("@2023-01-15.toBoolean()", "toBoolean() returns empty for Date")
+
+ .group("toBoolean() with empty values")
+ .testEmpty("emptyBool.toBoolean()", "toBoolean() returns empty for empty Boolean")
+ .testEmpty("emptyInt.toBoolean()", "toBoolean() returns empty for empty Integer")
+ .testEmpty("emptyDate.toBoolean()", "toBoolean() returns empty for empty Date")
+
+ .group("toBoolean() error cases with arrays")
+ .testEmpty("{}.toBoolean()", "toBoolean() returns empty for empty collection")
+ .testError("boolArray.toBoolean()", "toBoolean() errors on array of source type (Boolean)")
+ .testError("intArray.toBoolean()",
+ "toBoolean() errors on array of convertible type (Integer)")
+ .testError("dateArray.toBoolean()",
+ "toBoolean() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToBoolean() {
+ return builder()
+ .withSubject(sb -> sb
+ .boolArray("boolArray", true, false)
+ .decimalArray("decArray", 1.0, 0.0)
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .boolEmpty("emptyBool")
+ .decimalEmpty("emptyDec")
+ .dateEmpty("emptyDate")
+ )
+ .group("convertsToBoolean() with convertible literals")
+ .testTrue("true.convertsToBoolean()",
+ "convertsToBoolean() returns true for Boolean")
+ .testTrue("'true'.convertsToBoolean()",
+ "convertsToBoolean() returns true for 'true'")
+ .testTrue("'t'.convertsToBoolean()", "convertsToBoolean() returns true for 't'")
+ .testTrue("'yes'.convertsToBoolean()", "convertsToBoolean() returns true for 'yes'")
+ .testTrue("'y'.convertsToBoolean()", "convertsToBoolean() returns true for 'y'")
+ .testTrue("'1'.convertsToBoolean()", "convertsToBoolean() returns true for '1'")
+ .testTrue("'1.0'.convertsToBoolean()", "convertsToBoolean() returns true for '1.0'")
+ .testTrue("'false'.convertsToBoolean()",
+ "convertsToBoolean() returns true for 'false'")
+ .testTrue("1.convertsToBoolean()", "convertsToBoolean() returns true for 1")
+ .testTrue("0.convertsToBoolean()", "convertsToBoolean() returns true for 0")
+ .testTrue("1.0.convertsToBoolean()", "convertsToBoolean() returns true for 1.0")
+ .testTrue("0.0.convertsToBoolean()", "convertsToBoolean() returns true for 0.0")
+
+ .group("convertsToBoolean() with non-convertible literals")
+ .testFalse("'notBoolean'.convertsToBoolean()",
+ "convertsToBoolean() returns false for invalid string")
+ .testFalse("42.convertsToBoolean()",
+ "convertsToBoolean() returns false for other integer")
+ .testFalse("3.14.convertsToBoolean()",
+ "convertsToBoolean() returns false for other decimal")
+ .testFalse("@2023-01-15.convertsToBoolean()",
+ "convertsToBoolean() returns false for date")
+
+ .group("convertsToBoolean() with empty values")
+ .testEmpty("emptyBool.convertsToBoolean()",
+ "convertsToBoolean() returns empty for empty Boolean")
+ .testEmpty("emptyDec.convertsToBoolean()",
+ "convertsToBoolean() returns empty for empty Decimal")
+ .testEmpty("emptyDate.convertsToBoolean()",
+ "convertsToBoolean() returns empty for empty Date")
+
+ .group("convertsToBoolean() error cases with arrays")
+ .testEmpty("{}.convertsToBoolean()",
+ "convertsToBoolean() returns empty for empty collection")
+ .testError("boolArray.convertsToBoolean()",
+ "convertsToBoolean() errors on array of source type (Boolean)")
+ .testError("decArray.convertsToBoolean()",
+ "convertsToBoolean() errors on array of convertible type (Decimal)")
+ .testError("dateArray.convertsToBoolean()",
+ "convertsToBoolean() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToInteger() {
+ return builder()
+ .withSubject(sb -> sb
+ .integerArray("intArray", 1, 2, 3)
+ .stringArray("stringArray", "1", "2", "3")
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .integerEmpty("emptyInt")
+ .boolEmpty("emptyBool")
+ .dateEmpty("emptyDate")
+ )
+ .group("toInteger() with literal values")
+ .testEquals(1, "true.toInteger()", "toInteger() converts true to 1")
+ .testEquals(0, "false.toInteger()", "toInteger() converts false to 0")
+ .testEquals(42, "42.toInteger()", "toInteger() returns integer as-is")
+ .testEquals(123, "'123'.toInteger()", "toInteger() converts valid string")
+ .testEquals(-42, "'-42'.toInteger()", "toInteger() converts negative string")
+ .testEmpty("'notNumber'.toInteger()", "toInteger() returns empty for invalid string")
+ .testEmpty("3.14.toInteger()", "toInteger() returns empty for Decimal (not in spec)")
+
+ .group("toInteger() with non-convertible types")
+ .testEmpty("@2023-01-15.toInteger()", "toInteger() returns empty for Date")
+
+ .group("toInteger() with empty values")
+ .testEmpty("emptyInt.toInteger()", "toInteger() returns empty for empty Integer")
+ .testEmpty("emptyBool.toInteger()", "toInteger() returns empty for empty Boolean")
+ .testEmpty("emptyDate.toInteger()", "toInteger() returns empty for empty Date")
+
+ .group("toInteger() error cases with arrays")
+ .testEmpty("{}.toInteger()", "toInteger() returns empty for empty collection")
+ .testError("intArray.toInteger()", "toInteger() errors on array of source type (Integer)")
+ .testError("stringArray.toInteger()",
+ "toInteger() errors on array of convertible type (String)")
+ .testError("dateArray.toInteger()",
+ "toInteger() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToInteger() {
+ return builder()
+ .withSubject(sb -> sb
+ .integerArray("intArray", 1, 2, 3)
+ .boolArray("boolArray", true, false)
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .integerEmpty("emptyInt")
+ .boolEmpty("emptyBool")
+ .dateEmpty("emptyDate")
+ )
+ .group("convertsToInteger() with convertible literals")
+ .testTrue("true.convertsToInteger()",
+ "convertsToInteger() returns true for Boolean")
+ .testTrue("42.convertsToInteger()", "convertsToInteger() returns true for Integer")
+ .testTrue("'123'.convertsToInteger()",
+ "convertsToInteger() returns true for valid string")
+
+ .group("convertsToInteger() with non-convertible literals")
+ .testFalse("'notNumber'.convertsToInteger()",
+ "convertsToInteger() returns false for invalid string")
+ .testFalse("3.14.convertsToInteger()",
+ "convertsToInteger() returns false for Decimal")
+ .testFalse("@2023-01-15.convertsToInteger()",
+ "convertsToInteger() returns false for Date")
+
+ .group("convertsToInteger() with empty values")
+ .testEmpty("emptyInt.convertsToInteger()",
+ "convertsToInteger() returns empty for empty Integer")
+ .testEmpty("emptyBool.convertsToInteger()",
+ "convertsToInteger() returns empty for empty Boolean")
+ .testEmpty("emptyDate.convertsToInteger()",
+ "convertsToInteger() returns empty for empty Date")
+
+ .group("convertsToInteger() error cases with arrays")
+ .testEmpty("{}.convertsToInteger()",
+ "convertsToInteger() returns empty for empty collection")
+ .testError("intArray.convertsToInteger()",
+ "convertsToInteger() errors on array of source type (Integer)")
+ .testError("boolArray.convertsToInteger()",
+ "convertsToInteger() errors on array of convertible type (Boolean)")
+ .testError("dateArray.convertsToInteger()",
+ "convertsToInteger() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToDecimal() {
+ return builder()
+ .withSubject(sb -> sb
+ .decimalArray("decArray", 1.1, 2.2, 3.3)
+ .integerArray("intArray", 1, 2, 3)
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .decimalEmpty("emptyDec")
+ .integerEmpty("emptyInt")
+ .dateEmpty("emptyDate")
+ )
+ .group("toDecimal() with literal values")
+ .testEquals(1.0, "true.toDecimal()", "toDecimal() converts true to 1.0")
+ .testEquals(0.0, "false.toDecimal()", "toDecimal() converts false to 0.0")
+ .testEquals(42.0, "42.toDecimal()", "toDecimal() converts integer")
+ .testEquals(3.14, "3.14.toDecimal()", "toDecimal() returns decimal as-is")
+ .testEquals(3.14159, "'3.14159'.toDecimal()", "toDecimal() converts valid string")
+ .testEmpty("'notNumber'.toDecimal()", "toDecimal() returns empty for invalid string")
+
+ .group("toDecimal() with non-convertible types")
+ .testEmpty("@2023-01-15.toDecimal()", "toDecimal() returns empty for Date")
+
+ .group("toDecimal() with empty values")
+ .testEmpty("emptyDec.toDecimal()", "toDecimal() returns empty for empty Decimal")
+ .testEmpty("emptyInt.toDecimal()", "toDecimal() returns empty for empty Integer")
+ .testEmpty("emptyDate.toDecimal()", "toDecimal() returns empty for empty Date")
+
+ .group("toDecimal() error cases with arrays")
+ .testEmpty("{}.toDecimal()", "toDecimal() returns empty for empty collection")
+ .testError("decArray.toDecimal()", "toDecimal() errors on array of source type (Decimal)")
+ .testError("intArray.toDecimal()",
+ "toDecimal() errors on array of convertible type (Integer)")
+ .testError("dateArray.toDecimal()",
+ "toDecimal() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToString() {
+ return builder()
+ .withSubject(sb -> sb
+ .stringArray("stringArray", "hello", "world")
+ .decimalArray("decArray", 1.1, 2.2)
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .stringEmpty("emptyStr")
+ .decimalEmpty("emptyDec")
+ .dateEmpty("emptyDate")
+ )
+ .group("toString() with primitive type literals")
+ .testEquals("true", "true.toString()", "toString() converts true")
+ .testEquals("false", "false.toString()", "toString() converts false")
+ .testEquals("42", "42.toString()", "toString() converts integer")
+ .testEquals("3.14", "3.14.toString()", "toString() converts decimal without trailing zeros")
+ .testEquals("hello", "'hello'.toString()", "toString() returns string as-is")
+
+ .group("toString() with date/time literals")
+ .testEquals("2023-01-15", "@2023-01-15.toString()", "toString() converts date")
+ .testEquals("2023-01-15T10:30:00Z", "@2023-01-15T10:30:00Z.toString()",
+ "toString() converts datetime")
+ .testEquals("10:30:00", "@T10:30:00.toString()", "toString() converts time")
+
+ .group("toString() with empty values")
+ .testEmpty("emptyStr.toString()", "toString() returns empty for empty String")
+ .testEmpty("emptyDec.toString()", "toString() returns empty for empty Decimal")
+ .testEmpty("emptyDate.toString()", "toString() returns empty for empty Date")
+
+ .group("toString() error cases with arrays")
+ .testEmpty("{}.toString()", "toString() returns empty for empty collection")
+ .testError("stringArray.toString()", "toString() errors on array of source type (String)")
+ .testError("decArray.toString()",
+ "toString() errors on array of convertible type (Decimal)")
+ .testError("dateArray.toString()", "toString() errors on array of convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToString() {
+ return builder()
+ .withSubject(sb -> sb
+ .stringArray("stringArray", "hello", "world")
+ .integerArray("intArray", 1, 2, 3)
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .stringEmpty("emptyStr")
+ .integerEmpty("emptyInt")
+ .dateEmpty("emptyDate")
+ )
+ .group("convertsToString() with convertible types")
+ .testTrue("true.convertsToString()", "convertsToString() returns true for Boolean")
+ .testTrue("42.convertsToString()", "convertsToString() returns true for Integer")
+ .testTrue("3.14.convertsToString()", "convertsToString() returns true for Decimal")
+ .testTrue("'hello'.convertsToString()",
+ "convertsToString() returns true for String")
+ .testTrue("@2023-01-15.convertsToString()",
+ "convertsToString() returns true for Date")
+ .testTrue("@2023-01-15T10:30:00Z.convertsToString()",
+ "convertsToString() returns true for DateTime")
+ .testTrue("@T10:30:00.convertsToString()",
+ "convertsToString() returns true for Time")
+
+ .group("convertsToString() with empty values")
+ .testEmpty("emptyStr.convertsToString()",
+ "convertsToString() returns empty for empty String")
+ .testEmpty("emptyInt.convertsToString()",
+ "convertsToString() returns empty for empty Integer")
+ .testEmpty("emptyDate.convertsToString()",
+ "convertsToString() returns empty for empty Date")
+
+ .group("convertsToString() error cases with arrays")
+ .testEmpty("{}.convertsToString()", "convertsToString() returns empty for empty collection")
+ .testError("stringArray.convertsToString()",
+ "convertsToString() errors on array of source type (String)")
+ .testError("intArray.convertsToString()",
+ "convertsToString() errors on array of convertible type (Integer)")
+ .testError("dateArray.convertsToString()",
+ "convertsToString() errors on array of convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToDate() {
+ return builder()
+ .withSubject(sb -> sb
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .stringArray("stringArray", "2023-01-15", "2023-12-25")
+ .integerArray("intArray", 1, 2, 3)
+ .dateEmpty("emptyDate")
+ .stringEmpty("emptyStr")
+ .integerEmpty("emptyInt")
+ )
+ .group("toDate() with String and Date literals")
+ .testEquals("2023-01-15", "'2023-01-15'.toDate()", "toDate() converts valid date string")
+ .testEquals("2023-12-25", "@2023-12-25.toDate()", "toDate() returns date as-is")
+ .testEmpty("'notADate'.toDate()", "toDate() returns empty for invalid date string")
+
+ .group("toDate() with invalid types")
+ .testEmpty("42.toDate()", "toDate() returns empty for non-string")
+
+ .group("toDate() with empty values")
+ .testEmpty("emptyDate.toDate()", "toDate() returns empty for empty Date")
+ .testEmpty("emptyStr.toDate()", "toDate() returns empty for empty String")
+ .testEmpty("emptyInt.toDate()", "toDate() returns empty for empty Integer")
+
+ .group("toDate() error cases with arrays")
+ .testEmpty("{}.toDate()", "toDate() returns empty for empty collection")
+ .testError("dateArray.toDate()", "toDate() errors on array of source type (Date)")
+ .testError("stringArray.toDate()", "toDate() errors on array of convertible type (String)")
+ .testError("intArray.toDate()",
+ "toDate() errors on array of non-convertible type (Integer)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToDate() {
+ return builder()
+ .withSubject(sb -> sb
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .stringArray("stringArray", "2023-01-15", "2023-12-25")
+ .integerArray("intArray", 1, 2, 3)
+ .dateEmpty("emptyDate")
+ .stringEmpty("emptyStr")
+ .integerEmpty("emptyInt")
+ )
+ .group("convertsToDate() with convertible types")
+ .testTrue("'2023-01-15'.convertsToDate()",
+ "convertsToDate() returns true for string")
+ .testTrue("@2023-12-25.convertsToDate()", "convertsToDate() returns true for date")
+
+ .group("convertsToDate() with non-convertible types")
+ .testFalse("'notADate'.convertsToDate()",
+ "convertsToDate() returns false for invalid date string")
+ .testFalse("42.convertsToDate()", "convertsToDate() returns false for integer")
+
+ .group("convertsToDate() with empty values")
+ .testEmpty("emptyDate.convertsToDate()", "convertsToDate() returns empty for empty Date")
+ .testEmpty("emptyStr.convertsToDate()", "convertsToDate() returns empty for empty String")
+ .testEmpty("emptyInt.convertsToDate()", "convertsToDate() returns empty for empty Integer")
+
+ .group("convertsToDate() error cases with arrays")
+ .testEmpty("{}.convertsToDate()", "convertsToDate() returns empty for empty collection")
+ .testError("dateArray.convertsToDate()",
+ "convertsToDate() errors on array of source type (Date)")
+ .testError("stringArray.convertsToDate()",
+ "convertsToDate() errors on array of convertible type (String)")
+ .testError("intArray.convertsToDate()",
+ "convertsToDate() errors on array of non-convertible type (Integer)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToDateTime() {
+ return builder()
+ .withSubject(sb -> sb
+ .dateTimeArray("dateTimeArray", "2023-01-15T10:30:00Z", "2023-12-25T12:00:00Z")
+ .stringArray("stringArray", "2023-01-15T10:30:00Z", "2023-12-25T12:00:00Z")
+ .integerArray("intArray", 1, 2, 3)
+ .dateTimeEmpty("emptyDateTime")
+ .stringEmpty("emptyStr")
+ .integerEmpty("emptyInt")
+ )
+ .group("toDateTime() with String and DateTime literals")
+ .testEquals("2023-01-15T10:30:00Z", "'2023-01-15T10:30:00Z'.toDateTime()",
+ "toDateTime() converts valid datetime string")
+ .testEquals("2023-12-25T12:00:00Z", "@2023-12-25T12:00:00Z.toDateTime()",
+ "toDateTime() returns datetime as-is")
+ .testEmpty("'not-a-datetime'.toDateTime()",
+ "toDateTime() returns empty for invalid datetime string")
+
+ .group("toDateTime() with invalid types")
+ .testEmpty("42.toDateTime()", "toDateTime() returns empty for non-string")
+
+ .group("toDateTime() with empty values")
+ .testEmpty("emptyDateTime.toDateTime()", "toDateTime() returns empty for empty DateTime")
+ .testEmpty("emptyStr.toDateTime()", "toDateTime() returns empty for empty String")
+ .testEmpty("emptyInt.toDateTime()", "toDateTime() returns empty for empty Integer")
+
+ .group("toDateTime() error cases with arrays")
+ .testEmpty("{}.toDateTime()", "toDateTime() returns empty for empty collection")
+ .testError("dateTimeArray.toDateTime()",
+ "toDateTime() errors on array of source type (DateTime)")
+ .testError("stringArray.toDateTime()",
+ "toDateTime() errors on array of convertible type (String)")
+ .testError("intArray.toDateTime()",
+ "toDateTime() errors on array of non-convertible type (Integer)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToDateTime() {
+ return builder()
+ .withSubject(sb -> sb
+ .dateTimeArray("dateTimeArray", "2023-01-15T10:30:00Z", "2023-12-25T12:00:00Z")
+ .stringArray("stringArray", "2023-01-15T10:30:00Z", "2023-12-25T12:00:00Z")
+ .integerArray("intArray", 1, 2, 3)
+ .dateTimeEmpty("emptyDateTime")
+ .stringEmpty("emptyStr")
+ .integerEmpty("emptyInt")
+ )
+ .group("convertsToDateTime() with convertible types")
+ .testTrue("'2023-01-15T10:30:00Z'.convertsToDateTime()",
+ "convertsToDateTime() returns true for string")
+ .testTrue("@2023-12-25T12:00:00Z.convertsToDateTime()",
+ "convertsToDateTime() returns true for datetime")
+
+ .group("convertsToDateTime() with non-convertible types")
+ .testFalse("'not-a-datetime'.convertsToDateTime()",
+ "convertsToDateTime() returns false for invalid datetime string")
+ .testFalse("42.convertsToDateTime()",
+ "convertsToDateTime() returns false for integer")
+
+ .group("convertsToDateTime() with empty values")
+ .testEmpty("emptyDateTime.convertsToDateTime()",
+ "convertsToDateTime() returns empty for empty DateTime")
+ .testEmpty("emptyStr.convertsToDateTime()",
+ "convertsToDateTime() returns empty for empty String")
+ .testEmpty("emptyInt.convertsToDateTime()",
+ "convertsToDateTime() returns empty for empty Integer")
+
+ .group("convertsToDateTime() error cases with arrays")
+ .testEmpty("{}.convertsToDateTime()",
+ "convertsToDateTime() returns empty for empty collection")
+ .testError("dateTimeArray.convertsToDateTime()",
+ "convertsToDateTime() errors on array of source type (DateTime)")
+ .testError("stringArray.convertsToDateTime()",
+ "convertsToDateTime() errors on array of convertible type (String)")
+ .testError("intArray.convertsToDateTime()",
+ "convertsToDateTime() errors on array of non-convertible type (Integer)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToTime() {
+ return builder()
+ .withSubject(sb -> sb
+ .timeArray("timeArray", "10:30:00", "12:00:00")
+ .stringArray("stringArray", "10:30:00", "12:00:00")
+ .integerArray("intArray", 1, 2, 3)
+ .timeEmpty("emptyTime")
+ .stringEmpty("emptyStr")
+ .integerEmpty("emptyInt")
+ )
+ .group("toTime() with String and Time literals")
+ .testEquals("10:30:00", "'10:30:00'.toTime()", "toTime() converts valid time string")
+ .testEquals("12:00:00", "@T12:00:00.toTime()", "toTime() returns time as-is")
+ .testEmpty("'not-a-time'.toTime()", "toTime() returns empty for invalid time string")
+
+ .group("toTime() with invalid types")
+ .testEmpty("42.toTime()", "toTime() returns empty for non-string")
+
+ .group("toTime() with empty values")
+ .testEmpty("emptyTime.toTime()", "toTime() returns empty for empty Time")
+ .testEmpty("emptyStr.toTime()", "toTime() returns empty for empty String")
+ .testEmpty("emptyInt.toTime()", "toTime() returns empty for empty Integer")
+
+ .group("toTime() error cases with arrays")
+ .testEmpty("{}.toTime()", "toTime() returns empty for empty collection")
+ .testError("timeArray.toTime()", "toTime() errors on array of source type (Time)")
+ .testError("stringArray.toTime()", "toTime() errors on array of convertible type (String)")
+ .testError("intArray.toTime()",
+ "toTime() errors on array of non-convertible type (Integer)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToTime() {
+ return builder()
+ .withSubject(sb -> sb
+ .timeArray("timeArray", "10:30:00", "12:00:00")
+ .stringArray("stringArray", "10:30:00", "12:00:00")
+ .integerArray("intArray", 1, 2, 3)
+ .timeEmpty("emptyTime")
+ .stringEmpty("emptyStr")
+ .integerEmpty("emptyInt")
+ )
+ .group("convertsToTime() with convertible types")
+ .testTrue("'10:30:00'.convertsToTime()",
+ "convertsToTime() returns true for time string")
+ .testFalse("'non-time'.convertsToTime()",
+ "convertsToTime() returns false for non-time string")
+ .testTrue("@T12:00:00.convertsToTime()", "convertsToTime() returns true for time")
+
+ .group("convertsToTime() with non-convertible types")
+ .testFalse("42.convertsToTime()", "convertsToTime() returns false for integer")
+
+ .group("convertsToTime() with empty values")
+ .testEmpty("emptyTime.convertsToTime()", "convertsToTime() returns empty for empty Time")
+ .testEmpty("emptyStr.convertsToTime()", "convertsToTime() returns empty for empty String")
+ .testEmpty("emptyInt.convertsToTime()", "convertsToTime() returns empty for empty Integer")
+
+ .group("convertsToTime() error cases with arrays")
+ .testEmpty("{}.convertsToTime()", "convertsToTime() returns empty for empty collection")
+ .testError("timeArray.convertsToTime()",
+ "convertsToTime() errors on array of source type (Time)")
+ .testError("stringArray.convertsToTime()",
+ "convertsToTime() errors on array of convertible type (String)")
+ .testError("intArray.convertsToTime()",
+ "convertsToTime() errors on array of non-convertible type (Integer)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToDecimal() {
+ return builder()
+ .withSubject(sb -> sb
+ .decimalArray("decArray", 1.1, 2.2, 3.3)
+ .stringArray("stringArray", "1.1", "2.2")
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .decimalEmpty("emptyDec")
+ .integerEmpty("emptyInt")
+ .dateEmpty("emptyDate")
+ )
+ .group("convertsToDecimal() with convertible literals")
+ .testTrue("true.convertsToDecimal()",
+ "convertsToDecimal() returns true for Boolean")
+ .testTrue("42.convertsToDecimal()", "convertsToDecimal() returns true for Integer")
+ .testTrue("3.14.convertsToDecimal()",
+ "convertsToDecimal() returns true for Decimal")
+ .testTrue("'3.14159'.convertsToDecimal()",
+ "convertsToDecimal() returns true for valid string")
+
+ .group("convertsToDecimal() with non-convertible literals")
+ .testFalse("'notNumber'.convertsToDecimal()",
+ "convertsToDecimal() returns false for invalid string")
+
+ .group("convertsToDecimal() with empty values")
+ .testEmpty("emptyDec.convertsToDecimal()",
+ "convertsToDecimal() returns empty for empty Decimal")
+ .testEmpty("emptyInt.convertsToDecimal()",
+ "convertsToDecimal() returns empty for empty Integer")
+ .testEmpty("emptyDate.convertsToDecimal()",
+ "convertsToDecimal() returns empty for empty Date")
+
+ .group("convertsToDecimal() error cases with arrays")
+ .testEmpty("{}.convertsToDecimal()",
+ "convertsToDecimal() returns empty for empty collection")
+ .testError("decArray.convertsToDecimal()",
+ "convertsToDecimal() errors on array of source type (Decimal)")
+ .testError("stringArray.convertsToDecimal()",
+ "convertsToDecimal() errors on array of convertible type (String)")
+ .testError("dateArray.convertsToDecimal()",
+ "convertsToDecimal() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToQuantity() {
+ return builder()
+ .withSubject(sb -> sb
+ .integerArray("intArray", 1, 2, 3)
+ .decimalArray("decArray", 1.5, 2.5, 3.5)
+ .stringArray("stringArray", "10 'mg'", "4 days", "1.5 'kg'")
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .boolEmpty("emptyBool")
+ .integerEmpty("emptyInt")
+ .stringEmpty("emptyStr")
+ .dateEmpty("emptyDate")
+ )
+ .group("toQuantity() - Boolean sources")
+ .testEquals("1 '1'", "true.toQuantity()", "toQuantity() converts true to 1.0 '1'")
+ .testEquals("0 '1'", "false.toQuantity()", "toQuantity() converts false to 0.0 '1'")
+
+ .group("toQuantity() - Integer sources")
+ .testEquals("42 '1'", "42.toQuantity()",
+ "toQuantity() converts integer to quantity with unitCode '1'")
+
+ .group("toQuantity() - Decimal sources")
+ .testEquals("3.14 '1'", "3.14.toQuantity()",
+ "toQuantity() converts decimal to quantity with unitCode '1'")
+
+ .group("toQuantity() - String sources (UCUM literals)")
+ .testEquals("10 'mg'", "'10 \\'mg\\''.toQuantity()",
+ "toQuantity() parses UCUM quantity string")
+ .testEquals("1.5 'kg'", "'1.5 \\'kg\\''.toQuantity()",
+ "toQuantity() parses decimal UCUM quantity")
+ .testEquals("-5.2 'cm'", "'-5.2 \\'cm\\''.toQuantity()",
+ "toQuantity() parses negative UCUM quantity")
+
+ .group("toQuantity() - String sources (calendar duration literals)")
+ .testEquals("4 days", "'4 days'.toQuantity()",
+ "toQuantity() parses calendar duration (days)")
+ .testEquals("1 year", "'1 year'.toQuantity()",
+ "toQuantity() parses calendar duration (year)")
+ .testEquals("3 months", "'3 months'.toQuantity()",
+ "toQuantity() parses calendar duration (months)")
+
+ .group("toQuantity() - String sources (numeric literals)")
+ .testEquals("42 '1'", "'42'.toQuantity()",
+ "toQuantity() converts number string without unitCode to quantity with unitCode '1'")
+ .testEquals("3.14 '1'", "'3.14'.toQuantity()",
+ "toQuantity() converts decimal string without unitCode to quantity with unitCode '1'")
+
+ .group("toQuantity() - String sources (invalid)")
+ .testEmpty("'notQuantity'.toQuantity()", "toQuantity() returns empty for invalid string")
+ .testEmpty("'true'.toQuantity()",
+ "toQuantity() returns empty for non-quantity string with boolean content")
+ .testEmpty("'mg'.toQuantity()", "toQuantity() returns empty for unitCode without value")
+
+ .group("toQuantity() - Non-convertible sources")
+ .testEmpty("@2023-01-15.toQuantity()", "toQuantity() returns empty for Date")
+
+ .group("toQuantity() - Empty values")
+ .testEmpty("emptyBool.toQuantity()", "toQuantity() returns empty for empty Boolean")
+ .testEmpty("emptyInt.toQuantity()", "toQuantity() returns empty for empty Integer")
+ .testEmpty("emptyStr.toQuantity()", "toQuantity() returns empty for empty String")
+ .testEmpty("emptyDate.toQuantity()", "toQuantity() returns empty for empty Date")
+
+ .group("toQuantity() - Error cases (arrays)")
+ .testEmpty("{}.toQuantity()", "toQuantity() returns empty for empty collection")
+ .testError("intArray.toQuantity()",
+ "toQuantity() errors on array of convertible type (Integer)")
+ .testError("decArray.toQuantity()",
+ "toQuantity() errors on array of convertible type (Decimal)")
+ .testError("stringArray.toQuantity()",
+ "toQuantity() errors on array of convertible type (String)")
+ .testError("dateArray.toQuantity()",
+ "toQuantity() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testToQuantityWithUnit() {
+ return builder()
+ .withSubject(sb -> sb
+ .stringArray("stringArray", "1 'wk'", "1 'cm'", "1000 'g'")
+ .stringEmpty("emptyStr")
+ )
+ .group("toQuantity(unitCode) - Numeric sources → exact unitCode match")
+ .testEquals("42 '1'", "42.toQuantity('1')",
+ "toQuantity() with matching unitCode '1' returns quantity for integer")
+ .testEquals("3.14 '1'", "3.14.toQuantity('1')",
+ "toQuantity() with matching unitCode '1' returns quantity for decimal")
+ .testEquals("1 '1'", "true.toQuantity('1')",
+ "toQuantity() with matching unitCode '1' returns quantity for true")
+ .testEquals("0 '1'", "false.toQuantity('1')",
+ "toQuantity() with matching unitCode '1' returns quantity for false")
+
+ .group("toQuantity(unitCode) - Numeric sources → different unitCode (incompatible)")
+ .testEmpty("42.toQuantity('mg')",
+ "toQuantity() with different unitCode returns empty for integer")
+ .testEmpty("3.14.toQuantity('kg')",
+ "toQuantity() with different unitCode returns empty for decimal")
+ .testEmpty("true.toQuantity('mg')",
+ "toQuantity() with different unitCode returns empty for boolean")
+
+ .group("toQuantity(unitCode) - UCUM string sources → exact unitCode match")
+ .testEquals("10 'mg'", "'10 \\'mg\\''.toQuantity('mg')",
+ "toQuantity() with matching UCUM unitCode returns quantity")
+ .testEquals("1.5 'kg'", "'1.5 \\'kg\\''.toQuantity('kg')",
+ "toQuantity() with matching UCUM unitCode returns quantity for decimal")
+ .testEquals("-5.2 'cm'", "'-5.2 \\'cm\\''.toQuantity('cm')",
+ "toQuantity() with matching UCUM unitCode returns quantity for negative")
+
+ .group("toQuantity(unitCode) - UCUM string sources → UCUM conversion (compatible)")
+ .testEquals("0.01 'g'", "'10 \\'mg\\''.toQuantity('g')",
+ "toQuantity() converts mg to g (mass)")
+ .testEquals("10 'mm'", "'1 \\'cm\\''.toQuantity('mm')",
+ "toQuantity() converts cm to mm (length)")
+ .testEquals("1000 'mL'", "'1 \\'L\\''.toQuantity('mL')",
+ "toQuantity() converts L to mL (volume)")
+ .testEquals("273.15 'K'", "'0 \\'Cel\\''.toQuantity('K')",
+ "toQuantity() converts Celsius to Kelvin (temperature, additive)")
+
+
+ .group("toQuantity(unitCode) - UCUM string sources → incompatible units")
+ .testEmpty("'1 \\'kg\\''.toQuantity('m')",
+ "toQuantity() returns empty for incompatible units (mass to length)")
+
+ .group("toQuantity(unitCode) - UCUM string sources → invalid target units")
+ .testEmpty("'1 \\'kg\\''.toQuantity('invalid_unit')",
+ "toQuantity() returns empty for invalid target unitCode")
+
+ .group("toQuantity(unitCode) - Calendar duration sources → exact unitCode match")
+ .testEquals("4 days", "'4 days'.toQuantity('days')",
+ "toQuantity() with matching calendar unitCode returns quantity")
+
+ .group(
+ "toQuantity(unitCode) - Calendar duration sources → calendar-to-calendar conversions")
+ .testEquals("86400 seconds", "'1 day'.toQuantity('seconds')",
+ "toQuantity() converts calendar day to seconds")
+ .testEquals("86400000 milliseconds", "'1 day'.toQuantity('milliseconds')",
+ "toQuantity() converts calendar day to milliseconds")
+
+ .group("toQuantity(unitCode) - Calendar duration sources → calendar-to-UCUM conversions")
+ .testEquals("120 's'", "'2 minutes'.toQuantity('s')",
+ "toQuantity() converts calendar minutes to UCUM 's'")
+ .testEquals("1500 'ms'", "'1500 milliseconds'.toQuantity('ms')",
+ "toQuantity() converts calendar milliseconds to UCUM 'ms'")
+
+ .group("toQuantity(unitCode) - Calendar duration sources → unsupported conversions")
+ .testEmpty("'1 week'.toQuantity('months')",
+ "toQuantity() returns empty for week to months (blocked)")
+
+ .group("toQuantity(unitCode) - Numeric string sources → exact unitCode match")
+ .testEquals("42 '1'", "'42'.toQuantity('1')",
+ "toQuantity() with matching unitCode '1' returns quantity for numeric string")
+ .testEquals("3.14 '1'", "'3.14'.toQuantity('1')",
+ "toQuantity() with matching unitCode '1' returns quantity for decimal string")
+
+ .group("toQuantity(unitCode) - Numeric string sources → different unitCode (incompatible)")
+ .testEmpty("'42'.toQuantity('mg')",
+ "toQuantity() with different unitCode returns empty for numeric string")
+ .testEmpty("'3.14'.toQuantity('kg')",
+ "toQuantity() with different unitCode returns empty for decimal string")
+
+ .group("toQuantity(unitCode) - Empty values")
+ .testEmpty("emptyStr.toQuantity('mg')",
+ "toQuantity() returns empty for empty String with unitCode parameter")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToQuantity() {
+ return builder()
+ .withSubject(sb -> sb
+ .integerArray("intArray", 1, 2, 3)
+ .boolArray("boolArray", true, false)
+ .stringArray("stringArray", "10 'mg'", "4 days")
+ .dateArray("dateArray", "2023-01-15", "2023-12-25")
+ .boolEmpty("emptyBool")
+ .integerEmpty("emptyInt")
+ .stringEmpty("emptyStr")
+ .dateEmpty("emptyDate")
+ )
+ .group("convertsToQuantity() - Boolean sources")
+ .testTrue("true.convertsToQuantity()",
+ "convertsToQuantity() returns true for Boolean")
+
+ .group("convertsToQuantity() - Integer sources")
+ .testTrue("42.convertsToQuantity()",
+ "convertsToQuantity() returns true for Integer")
+
+ .group("convertsToQuantity() - Decimal sources")
+ .testTrue("3.14.convertsToQuantity()",
+ "convertsToQuantity() returns true for Decimal")
+
+ .group("convertsToQuantity() - String sources (UCUM literals)")
+ .testTrue("'10 \\'mg\\''.convertsToQuantity()",
+ "convertsToQuantity() returns true for UCUM string")
+
+ .group("convertsToQuantity() - String sources (calendar duration literals)")
+ .testTrue("'4 days'.convertsToQuantity()",
+ "convertsToQuantity() returns true for calendar duration string")
+
+ .group("convertsToQuantity() - String sources (numeric literals)")
+ .testTrue("'42'.convertsToQuantity()",
+ "convertsToQuantity() returns true for integer string without unitCode")
+ .testTrue("'3.14'.convertsToQuantity()",
+ "convertsToQuantity() returns true for decimal string without unitCode")
+
+ .group("convertsToQuantity() - String sources (invalid)")
+ .testFalse("'notQuantity'.convertsToQuantity()",
+ "convertsToQuantity() returns false for invalid string")
+ .testFalse("'true'.convertsToQuantity()",
+ "convertsToQuantity() returns false for string with boolean content")
+
+ .group("convertsToQuantity() - Non-convertible sources")
+ .testFalse("@2023-01-15.convertsToQuantity()",
+ "convertsToQuantity() returns false for Date")
+
+ .group("convertsToQuantity() - Empty values")
+ .testEmpty("emptyBool.convertsToQuantity()",
+ "convertsToQuantity() returns empty for empty Boolean")
+ .testEmpty("emptyInt.convertsToQuantity()",
+ "convertsToQuantity() returns empty for empty Integer")
+ .testEmpty("emptyStr.convertsToQuantity()",
+ "convertsToQuantity() returns empty for empty String")
+ .testEmpty("emptyDate.convertsToQuantity()",
+ "convertsToQuantity() returns empty for empty Date")
+
+ .group("convertsToQuantity() - Error cases (arrays)")
+ .testEmpty("{}.convertsToQuantity()",
+ "convertsToQuantity() returns empty for empty collection")
+ .testError("boolArray.convertsToQuantity()",
+ "convertsToQuantity() errors on array of convertible type (Boolean)")
+ .testError("intArray.convertsToQuantity()",
+ "convertsToQuantity() errors on array of convertible type (Integer)")
+ .testError("stringArray.convertsToQuantity()",
+ "convertsToQuantity() errors on array of convertible type (String)")
+ .testError("dateArray.convertsToQuantity()",
+ "convertsToQuantity() errors on array of non-convertible type (Date)")
+
+ .build();
+ }
+
+ @FhirPathTest
+ public Stream testConvertsToQuantityWithUnit() {
+ return builder()
+ .withSubject(sb -> sb
+ .stringArray("stringArray", "1 'wk'", "1 'cm'", "1000 'g'")
+ .stringEmpty("emptyStr")
+ )
+ .group("convertsToQuantity(unitCode) - Numeric sources → exact unitCode match")
+ .testTrue("42.convertsToQuantity('1')",
+ "convertsToQuantity() with matching unitCode '1' returns true for integer")
+ .testTrue("3.14.convertsToQuantity('1')",
+ "convertsToQuantity() with matching unitCode '1' returns true for decimal")
+ .testTrue("true.convertsToQuantity('1')",
+ "convertsToQuantity() with matching unitCode '1' returns true for boolean")
+
+ .group("convertsToQuantity(unitCode) - Numeric sources → different unitCode (incompatible)")
+ .testFalse("42.convertsToQuantity('mg')",
+ "convertsToQuantity() with different unitCode returns false for integer")
+ .testFalse("3.14.convertsToQuantity('kg')",
+ "convertsToQuantity() with different unitCode returns false for decimal")
+ .testFalse("true.convertsToQuantity('mg')",
+ "convertsToQuantity() with different unitCode returns false for boolean")
+
+ .group("convertsToQuantity(unitCode) - UCUM string sources → exact unitCode match")
+ .testTrue("'10 \\'mg\\''.convertsToQuantity('mg')",
+ "convertsToQuantity() with matching UCUM unitCode returns true")
+ .testTrue("'1.5 \\'kg\\''.convertsToQuantity('kg')",
+ "convertsToQuantity() with matching UCUM unitCode returns true for decimal")
+
+ .group("convertsToQuantity(unitCode) - UCUM string sources → UCUM conversion (compatible)")
+ .testTrue("'10 \\'mg\\''.convertsToQuantity('g')",
+ "convertsToQuantity() returns true for UCUM mass conversion")
+ .testTrue("'1 \\'cm\\''.convertsToQuantity('mm')",
+ "convertsToQuantity() returns true for UCUM length conversion")
+ .testTrue("'1 \\'L\\''.convertsToQuantity('mL')",
+ "convertsToQuantity() returns true for UCUM volume conversion")
+
+ .group("convertsToQuantity(unitCode) - UCUM string sources → incompatible units")
+ .testFalse("'1 \\'kg\\''.convertsToQuantity('m')",
+ "convertsToQuantity() returns false for incompatible units (mass to length)")
+
+ .group("convertsToQuantity(unitCode) - UCUM string sources → invalid target units")
+ .testFalse("'1 \\'kg\\''.convertsToQuantity('invalid_unit')",
+ "convertsToQuantity() returns false for invalid target unitCode")
+
+ .group("convertsToQuantity(unitCode) - Calendar duration sources → exact unitCode match")
+ .testTrue("'4 days'.convertsToQuantity('days')",
+ "convertsToQuantity() with matching calendar unitCode returns true")
+
+ .group(
+ "convertsToQuantity(unitCode) - Calendar duration sources → calendar-to-calendar conversions")
+ .testTrue("'1 day'.convertsToQuantity('seconds')",
+ "convertsToQuantity() returns true for calendar day to seconds")
+ .testTrue("'1 day'.convertsToQuantity('milliseconds')",
+ "convertsToQuantity() returns true for calendar day to milliseconds")
+
+ .group(
+ "convertsToQuantity(unitCode) - Calendar duration sources → calendar-to-UCUM conversions")
+ .testTrue("'2 minutes'.convertsToQuantity('s')",
+ "convertsToQuantity() returns true for calendar minutes to UCUM 's'")
+ .testTrue("'1500 milliseconds'.convertsToQuantity('ms')",
+ "convertsToQuantity() returns true for calendar milliseconds to UCUM 'ms'")
+
+ .group("convertsToQuantity(unitCode) - Calendar duration sources → unsupported conversions")
+ .testFalse("'1 week'.convertsToQuantity('months')",
+ "convertsToQuantity() returns false for week to months (blocked)")
+
+ .group("convertsToQuantity(unitCode) - Numeric string sources → exact unitCode match")
+ .testTrue("'42'.convertsToQuantity('1')",
+ "convertsToQuantity() with matching unitCode '1' returns true for numeric string")
+ .testTrue("'3.14'.convertsToQuantity('1')",
+ "convertsToQuantity() with matching unitCode '1' returns true for decimal string")
+
+ .group(
+ "convertsToQuantity(unitCode) - Numeric string sources → different unitCode (incompatible)")
+ .testFalse("'42'.convertsToQuantity('mg')",
+ "convertsToQuantity() with different unitCode returns false for numeric string")
+ .testFalse("'3.14'.convertsToQuantity('kg')",
+ "convertsToQuantity() with different unitCode returns false for decimal string")
+
+ .group("convertsToQuantity(unitCode) - Non-convertible sources")
+ .testFalse("@2023-01-15.convertsToQuantity('1')",
+ "convertsToQuantity() with unitCode returns false for non-convertible Date")
+ .testFalse("'notQuantity'.convertsToQuantity('mg')",
+ "convertsToQuantity() with unitCode returns false for invalid string")
+
+ .group("convertsToQuantity(unitCode) - Empty values")
+ .testEmpty("emptyStr.convertsToQuantity('mg')",
+ "convertsToQuantity() returns empty for empty String with unitCode parameter")
+
+ .build();
+ }
+}
diff --git a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/JoinKeyFunctionsDslTest.java b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/JoinKeyFunctionsDslTest.java
index 8d018d9989..8ff4a019fb 100644
--- a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/JoinKeyFunctionsDslTest.java
+++ b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/JoinKeyFunctionsDslTest.java
@@ -35,8 +35,9 @@ public class JoinKeyFunctionsDslTest extends FhirPathDslTestBase {
@FhirPathTest
public Stream testGetResourceKey() {
return builder()
- .withSubject(sb -> sb.string("id_versioned", "Patient/1")
- )
+ .withSubject(sb -> sb
+ .string("resourceType", "Patient")
+ .string("id", "1"))
.group("getResourceKey() function")
.testEquals("Patient/1", "getResourceKey()",
"getResourceKey() returns a non-empty value for a Patient resource")
@@ -109,4 +110,27 @@ public Stream testGetReferenceKey() {
"getReferenceKey() throws an error when called with more than one parameter")
.build();
}
+
+ @FhirPathTest
+ public Stream testResourceKeyMatchesReferenceKeyWithVersionedId() {
+ // This test demonstrates issue #2519: when a resource has a versioned ID in id_versioned,
+ // getResourceKey() should return an unversioned key that matches getReferenceKey().
+ // References typically don't include version info, so the keys must match for joining.
+ return builder()
+ .withSubject(sb -> sb
+ .string("resourceType", "Patient")
+ .string("id", "patient-123")
+ // Simulate a resource that was encoded with versioned IdType - this populates
+ // id_versioned with the full versioned reference format.
+ .string("id_versioned", "Patient/patient-123/_history/1")
+ .element("selfReference", ref -> ref
+ .fhirType(REFERENCE)
+ .string("reference", "Patient/patient-123")))
+ .group("getResourceKey() and getReferenceKey() matching with versioned IDs")
+ .testEquals("Patient/patient-123", "getResourceKey()",
+ "getResourceKey() should return unversioned key (not id_versioned) to match reference format")
+ .testEquals("Patient/patient-123", "selfReference.getReferenceKey()",
+ "getReferenceKey() returns unversioned reference")
+ .build();
+ }
}
diff --git a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/unit/ConversionFactorTest.java b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/unit/ConversionFactorTest.java
new file mode 100644
index 0000000000..17c7f0f1b0
--- /dev/null
+++ b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/unit/ConversionFactorTest.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.fhirpath.unit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.math.BigDecimal;
+import org.junit.jupiter.api.Test;
+
+class ConversionFactorTest {
+
+
+ @Test
+ void testInverseProducesExactResult() {
+ assertEquals(BigDecimal.ONE,
+ ConversionFactor.inverseOf(new BigDecimal(12)).apply(new BigDecimal(12)));
+ }
+
+
+ @Test
+ void testFractionProducesExactResult() {
+ assertEquals(new BigDecimal(4),
+ ConversionFactor.ofFraction(new BigDecimal(2), new BigDecimal(3)).apply(new BigDecimal(6)));
+ }
+}
diff --git a/fhirpath/src/test/java/au/csiro/pathling/projection/ProjectionClauseTest.java b/fhirpath/src/test/java/au/csiro/pathling/projection/ProjectionClauseTest.java
new file mode 100644
index 0000000000..88b75c9997
--- /dev/null
+++ b/fhirpath/src/test/java/au/csiro/pathling/projection/ProjectionClauseTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * 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 au.csiro.pathling.projection;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import au.csiro.pathling.fhirpath.path.Paths.Traversal;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class ProjectionClauseTest {
+
+ @Test
+ void testToExpressionTree() {
+
+ // create a complex ProjectionClause implementation for testing
+
+ final GroupingSelection selection = new GroupingSelection(
+ List.of(
+ new UnnestingSelection(new Traversal("path1"), new GroupingSelection(List.of()), false),
+ new UnnestingSelection(new Traversal("path2"), new GroupingSelection(List.of()), true),
+ new RepeatSelection(List.of(new Traversal("path3"), new Traversal("path4")),
+ new GroupingSelection(List.of()), 10),
+ new UnionSelection(
+ List.of(new GroupingSelection(List.of()), new GroupingSelection(List.of()))),
+ new ColumnSelection(
+ List.of(
+ new RequestedColumn(new Traversal("col1"), "name1", false, Optional.empty(),
+ Optional.empty()),
+ new RequestedColumn(new Traversal("col2"), "name2", true, Optional.empty(),
+ Optional.empty())
+ )
+ )
+ )
+ );
+ assertEquals("""
+ group
+ forEach: path1
+ group
+ forEachOrNull: path2
+ group
+ repeat: [path3, path4]
+ group
+ union
+ group
+ group
+ columns[one: col1 as name1, many: col2 as name2]""",
+ selection.toExpressionTree());
+ }
+}
diff --git a/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewTest.java b/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewTest.java
index 804ceeb705..99d9e71582 100644
--- a/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewTest.java
+++ b/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewTest.java
@@ -39,6 +39,9 @@
import au.csiro.pathling.test.SpringBootUnitTest;
import au.csiro.pathling.utilities.Streams;
import ca.uhn.fhir.context.FhirContext;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;
@@ -99,8 +102,33 @@
@SpringBootUnitTest
@TestInstance(Lifecycle.PER_CLASS)
@Slf4j
+@Import(FhirViewTest.CustomEncoderConfiguration.class)
abstract class FhirViewTest {
+ /**
+ * Custom configuration for FhirViewTest to provide FhirEncoders with increased maxNestingLevel.
+ * This is necessary to support the repeat directive which can create deeply nested structures.
+ */
+ @TestConfiguration
+ static class CustomEncoderConfiguration {
+
+ /**
+ * Provides FhirEncoders configured with maxNestingLevel=3 to handle nested structures
+ * created by the repeat directive.
+ *
+ * @return configured FhirEncoders instance
+ */
+ @Bean
+ @Nonnull
+ FhirEncoders fhirEncoders() {
+ return FhirEncoders.forR4()
+ .withMaxNestingLevel(3)
+ .withExtensionsEnabled(true)
+ .withAllOpenTypes()
+ .getOrCreate();
+ }
+ }
+
private static final DateTimeFormatter FHIR_DATE_PARSER = new DateTimeFormatterBuilder()
.appendPattern("yyyy[-MM[-dd]]")
.parseDefaulting(ChronoField.MONTH_OF_YEAR, 1)
@@ -231,6 +259,8 @@ public void expectResult(@Nonnull final Dataset rowDataset) {
final Dataset selectedActualResult = rowDataset.select(
asScala(selectColumns).toSeq());
+ log.debug("Actual schema:\n {}", selectedActualResult.schema().treeString());
+
// Assert that the rowDataset has rows unordered as in selectedExpectedResult.
assertThat(selectedActualResult).hasRowsAndColumnsUnordered(selectedExpectedResult);
}
diff --git a/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewValidationTest.java b/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewValidationTest.java
index 69c84a0183..c89b1167c2 100644
--- a/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewValidationTest.java
+++ b/fhirpath/src/test/java/au/csiro/pathling/views/FhirViewValidationTest.java
@@ -200,11 +200,50 @@ void testAtMostOneNonNullForEachFields() {
final Set> validationResult = ValidationUtils.validate(fhirView);
assertEquals(1, validationResult.size());
final ConstraintViolation violation = validationResult.iterator().next();
- assertEquals("Only one of the fields [forEach, forEachOrNull] can be non-null",
+ assertEquals("Only one of the fields [forEach, forEachOrNull, repeat] can be non-null",
violation.getMessage());
assertEquals("select[0]", violation.getPropertyPath().toString());
}
+ @Test
+ void testAtMostOneNonNullWithRepeat() {
+ // Create a SelectClause with both forEach and repeat set
+ final SelectClause invalidSelectClause1 = SelectClause.builder()
+ .forEach("Patient.name")
+ .repeat("item", "answer.item")
+ .column(Column.single("id", "Patient.id"))
+ .build();
+
+ final FhirView fhirView1 = FhirView.ofResource("Patient")
+ .select(invalidSelectClause1)
+ .build();
+
+ final Set> validationResult1 = ValidationUtils.validate(fhirView1);
+ assertEquals(1, validationResult1.size());
+ final ConstraintViolation violation1 = validationResult1.iterator().next();
+ assertEquals("Only one of the fields [forEach, forEachOrNull, repeat] can be non-null",
+ violation1.getMessage());
+ assertEquals("select[0]", violation1.getPropertyPath().toString());
+
+ // Create a SelectClause with both forEachOrNull and repeat set
+ final SelectClause invalidSelectClause2 = SelectClause.builder()
+ .forEachOrNull("Patient.address")
+ .repeat("item", "answer.item")
+ .column(Column.single("id", "Patient.id"))
+ .build();
+
+ final FhirView fhirView2 = FhirView.ofResource("Patient")
+ .select(invalidSelectClause2)
+ .build();
+
+ final Set> validationResult2 = ValidationUtils.validate(fhirView2);
+ assertEquals(1, validationResult2.size());
+ final ConstraintViolation violation2 = validationResult2.iterator().next();
+ assertEquals("Only one of the fields [forEach, forEachOrNull, repeat] can be non-null",
+ violation2.getMessage());
+ assertEquals("select[0]", violation2.getPropertyPath().toString());
+ }
+
@Test
void testDuplicateAnsiTypeTags() {
// Create a column with duplicate ANSI_TYPE_TAG tags
@@ -269,7 +308,16 @@ static Stream recursiveValidationTestCases() {
Arguments.of(
"ForEachOrNullSelect direct validation",
FhirView.ofResource("Patient")
- .select(forEach("Patient.name", invalidColumn))
+ .select(FhirView.forEachOrNull("Patient.name", invalidColumn))
+ .build(),
+ "select[0].column[0].name"
+ ),
+
+ // Test validation in RepeatSelect
+ Arguments.of(
+ "RepeatSelect direct validation",
+ FhirView.ofResource("QuestionnaireResponse")
+ .select(FhirView.repeat(List.of("item", "answer.item"), invalidColumn))
.build(),
"select[0].column[0].name"
),
diff --git a/fhirpath/src/test/resources/fhirpath-js/config.yaml b/fhirpath/src/test/resources/fhirpath-js/config.yaml
index cadd6c1bbf..6b680ad5ed 100644
--- a/fhirpath/src/test/resources/fhirpath-js/config.yaml
+++ b/fhirpath/src/test/resources/fhirpath-js/config.yaml
@@ -227,6 +227,32 @@ excludeSet:
- "not_el in coll"
- "^coll contains el"
- "coll contains not_el"
+ - title: Conversion exclusions
+ glob: "fhirpath-js/cases/5.5_conversion.yaml"
+ exclude:
+ - title: "Null values in collections not properly handled"
+ comment: "Conversion functions on null values within collections return converted values instead of empty. This is a limitation of how null values are represented in arrays."
+ type: wontfix
+ outcome: failure
+ any:
+ - "Functions.collWithNullsAndTrue[0].toBoolean()"
+ - "Functions.collWithNullsAndTrue[0].toInteger()"
+ - "Functions.collWithNullsAndTrue[0].toDecimal()"
+ - "Functions.collWithNullsAndTrue[0].toQuantity()"
+ - title: "Unsupported comparison for indefinite and definite time quantities"
+ comment: "Comparing quantities from toQuantity() calendar durations with UCUM quantity literals is not supported, similar to direct quantity literal comparison (see Comparison exclusions)."
+ type: wontfix
+ outcome: failure
+ any:
+ - "'1 year'.toQuantity() != 1 'a'"
+ - "MedicationRequest.dispenseRequest.expectedSupplyDuration.toQuantity() = 3 days"
+ - title: "UCUM to calendar duration conversions now supported"
+ comment: "Full UCUM unit conversion support has been implemented, including conversions between UCUM time units and calendar duration units (e.g., 'wk' to 'days', 'd' to 'days'). Test expectations were based on limited conversion support."
+ type: feature
+ id: "#2391"
+ outcome: failure
+ any:
+ - '''1 \''wk\''''.toQuantity(''days'')'
- title: Variable exclusions
glob: "fhirpath-js/cases/8_variables.yaml"
exclude:
@@ -267,6 +293,8 @@ excludeSet:
- "** value of extension of extension (using FHIR model data)"
- "** id of extension of extension"
- "** expression with extension() for primitive type (using FHIR model data)"
+ - "Patient.birthDate.extension .where(url = 'http://hl7.org/fhir/StructureDefinition/patient-birthTime') .valueDateTime.toDateTime() = @1974-12-25T14:35:45-05:00"
+ - "Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime') .valueDateTime.toDateTime() = @1974-12-25T14:35:45-05:00"
- title: Factory exclusions
glob: "fhirpath-js/cases/factory.yaml"
exclude:
diff --git a/fhirpath/src/test/resources/viewTests/repeat.json b/fhirpath/src/test/resources/viewTests/repeat.json
new file mode 100644
index 0000000000..cde6137ca7
--- /dev/null
+++ b/fhirpath/src/test/resources/viewTests/repeat.json
@@ -0,0 +1,98 @@
+{
+ "title": "repeat",
+ "description": "Recursive traversal with repeat directive",
+ "fhirVersion": ["5.0.0", "4.0.1", "3.0.2"],
+ "resources": [
+ {
+ "resourceType": "Patient",
+ "id": "pt1",
+ "extension": [
+ {
+ "url": "urn:id1",
+ "extension": [
+ {
+ "url": "urn:id2",
+ "extension": [
+ {
+ "url": "urn:id3",
+ "extension": [
+ {
+ "url": "urn:id4",
+ "valueString": "value4"
+ }
+ ] }
+ ]
+ }
+ ]
+ }
+ ],
+ "name": [
+ {
+ "use": "official",
+ "family": "f1.1",
+ "given": [
+ "g1.1"
+ ]
+ }
+ ]
+ }
+ ],
+ "tests": [
+ {
+ "title": "extension",
+ "tags": ["shareable"],
+ "view": {
+ "resource": "Patient",
+ "status": "active",
+ "select": [
+ {
+ "column": [
+ {
+ "name": "id",
+ "path": "id",
+ "type": "id"
+ }
+ ]
+ },
+ {
+ "repeat": ["extension"],
+ "column": [
+ {
+ "name": "url",
+ "path": "url",
+ "type": "uri"
+ },
+ {
+ "name": "valueString",
+ "path": "value.ofType(string)",
+ "type": "string"
+ }
+ ]
+ }
+ ]
+ },
+ "expect": [
+ {
+ "id": "pt1",
+ "url": "urn:id1",
+ "valueString": null
+ },
+ {
+ "id": "pt1",
+ "url": "urn:id2",
+ "valueString": null
+ },
+ {
+ "id": "pt1",
+ "url": "urn:id3",
+ "valueString": null
+ },
+ {
+ "id": "pt1",
+ "url": "urn:id4",
+ "valueString": "value4"
+ }
+ ]
+ }
+ ]
+}
diff --git a/lib/R/R/context.R b/lib/R/R/context.R
index eb9fffc32b..405866af2b 100644
--- a/lib/R/R/context.R
+++ b/lib/R/R/context.R
@@ -79,6 +79,11 @@ StorageType <- list(
#' expiry when deciding whether to send it with a terminology request
#' @param accept_language The default value of the Accept-Language HTTP header passed to the
#' terminology server
+#' @param explain_queries Setting this option to TRUE will enable additional logging relating
+#' to the query plan used to execute queries
+#' @param max_unbound_traversal_depth Maximum depth for self-referencing structure traversals
+#' in repeat operations. Controls how deeply nested hierarchical data can be flattened
+#' during projection.
#'
#' @return A Pathling context instance initialized with the specified configuration
#'
@@ -124,7 +129,9 @@ pathling_connect <- function(
client_secret = NULL,
scope = NULL,
token_expiry_tolerance = 120,
- accept_language = NULL
+ accept_language = NULL,
+ explain_queries = FALSE,
+ max_unbound_traversal_depth = 10
) {
@@ -202,9 +209,19 @@ pathling_connect <- function(
j_invoke("acceptLanguage", accept_language) %>%
j_invoke("build")
- j_invoke_static(spark, "au.csiro.pathling.library.PathlingContext", "create",
- sparklyr::spark_session(spark),
- encoders_config, terminology_config)
+ query_config <- j_invoke_static(
+ spark, "au.csiro.pathling.config.QueryConfiguration", "builder"
+ ) %>%
+ j_invoke("explainQueries", as.logical(explain_queries)) %>%
+ j_invoke("maxUnboundTraversalDepth", as.integer(max_unbound_traversal_depth)) %>%
+ j_invoke("build")
+
+ j_invoke_static(spark, "au.csiro.pathling.library.PathlingContext", "builder",
+ sparklyr::spark_session(spark)) %>%
+ j_invoke("encodingConfiguration", encoders_config) %>%
+ j_invoke("terminologyConfiguration", terminology_config) %>%
+ j_invoke("queryConfiguration", query_config) %>%
+ j_invoke("build")
}
#' Get the Spark session
diff --git a/lib/R/pom.xml b/lib/R/pom.xml
index 407a525cb4..fcbb724279 100644
--- a/lib/R/pom.xml
+++ b/lib/R/pom.xml
@@ -24,7 +24,7 @@
au.csiro.pathling
pathling
- 9.0.1-SNAPSHOT
+ 9.1.0-SNAPSHOT
../../pom.xml
r
diff --git a/lib/R/src/license/THIRD-PARTY.properties b/lib/R/src/license/THIRD-PARTY.properties
index 672ddaf192..ad2512ba1b 100644
--- a/lib/R/src/license/THIRD-PARTY.properties
+++ b/lib/R/src/license/THIRD-PARTY.properties
@@ -56,6 +56,6 @@
# Please fill the missing licenses for dependencies :
#
#
-#Tue Oct 28 09:09:04 AEST 2025
+#Mon Nov 24 10:19:58 AEST 2025
org.apache.datasketches--datasketches-memory--3.0.2=
oro--oro--2.0.8=
diff --git a/lib/R/tests/testthat/helper_default.R b/lib/R/tests/testthat/helper_default.R
index 3a57619416..392bc47eec 100644
--- a/lib/R/tests/testthat/helper_default.R
+++ b/lib/R/tests/testthat/helper_default.R
@@ -36,7 +36,7 @@ def_pathling_context <- function(spark) {
j_invoke_new("au.csiro.pathling.terminology.mock.MockTerminologyServiceFactory")
spark %>%
- j_invoke_static("au.csiro.pathling.library.PathlingContext", "create",
+ j_invoke_static("au.csiro.pathling.library.PathlingContext", "createInternal",
spark_session(spark), encoders, terminology_service_factory)
}
diff --git a/lib/R/tests/testthat/test-context-configuration.R b/lib/R/tests/testthat/test-context-configuration.R
new file mode 100644
index 0000000000..1aff11f232
--- /dev/null
+++ b/lib/R/tests/testthat/test-context-configuration.R
@@ -0,0 +1,82 @@
+# Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+# Organisation (CSIRO) ABN 41 687 119 230.
+#
+# 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.
+
+
+test_that("default configurations are correct", {
+ # Create PathlingContext with all defaults
+ pc <- pathling_connect()
+ spark <- pathling_spark(pc)
+
+ # Retrieve EncodingConfiguration
+ encoding_config <- pc %>% j_invoke("getEncodingConfiguration")
+
+ # Verify default encoding configuration values
+ expect_equal(encoding_config %>% invoke("getMaxNestingLevel"), 3L)
+ expect_equal(encoding_config %>% invoke("isEnableExtensions"), FALSE)
+
+ # Verify default open types match STANDARD_OPEN_TYPES
+ open_types_set <- encoding_config %>% j_invoke("getOpenTypes")
+ open_types <- invoke(open_types_set, "toArray")
+ expected_types <- c(
+ "boolean", "code", "date", "dateTime", "decimal", "integer",
+ "string", "Coding", "CodeableConcept", "Address", "Identifier", "Reference"
+ )
+ expect_setequal(open_types, expected_types)
+
+ # Retrieve QueryConfiguration
+ query_config <- pc %>% j_invoke("getQueryConfiguration")
+
+ # Verify default query configuration values
+ expect_equal(query_config %>% invoke("isExplainQueries"), FALSE)
+ expect_equal(query_config %>% invoke("getMaxUnboundTraversalDepth"), 10L)
+
+ # Clean up
+ pathling_disconnect(pc)
+})
+
+
+test_that("custom configurations round trip correctly", {
+ # Create PathlingContext with all non-default parameters
+ pc <- pathling_connect(
+ max_nesting_level = 5,
+ enable_extensions = TRUE,
+ enabled_open_types = c("string", "boolean"),
+ explain_queries = TRUE,
+ max_unbound_traversal_depth = 20
+ )
+ spark <- pathling_spark(pc)
+
+ # Retrieve EncodingConfiguration
+ encoding_config <- pc %>% j_invoke("getEncodingConfiguration")
+
+ # Verify custom encoding configuration values
+ expect_equal(encoding_config %>% invoke("getMaxNestingLevel"), 5L)
+ expect_equal(encoding_config %>% invoke("isEnableExtensions"), TRUE)
+
+ # Verify custom open types
+ open_types_set <- encoding_config %>% j_invoke("getOpenTypes")
+ open_types <- invoke(open_types_set, "toArray")
+ expect_setequal(open_types, c("string", "boolean"))
+
+ # Retrieve QueryConfiguration
+ query_config <- pc %>% j_invoke("getQueryConfiguration")
+
+ # Verify custom query configuration values
+ expect_equal(query_config %>% invoke("isExplainQueries"), TRUE)
+ expect_equal(query_config %>% invoke("getMaxUnboundTraversalDepth"), 20L)
+
+ # Clean up
+ pathling_disconnect(pc)
+})
diff --git a/lib/python/LICENSE b/lib/python/LICENSE
index 8be71b8c71..d1dacdbe8e 100644
--- a/lib/python/LICENSE
+++ b/lib/python/LICENSE
@@ -184,9 +184,9 @@ accessing the Software. Other third party software may also be identified in
separate files distributed with the Software.
* (Apache License, Version 2.0) FHIR Bulk Client (au.csiro.fhir:bulk-export:1.0.3 - https://github.com/aehrc/fhir-bulk-java)
-* (Apache License, Version 2.0) HAPI FHIR - Core Library (ca.uhn.hapi.fhir:hapi-fhir-base:8.4.0 - http://jamesagnew.github.io/hapi-fhir/)
-* (Apache License, Version 2.0) HAPI FHIR - Client Framework (ca.uhn.hapi.fhir:hapi-fhir-client:8.4.0 - https://hapifhir.io/hapi-deployable-pom/hapi-fhir-client)
-* (Apache License, Version 2.0) HAPI FHIR Structures - FHIR R4 (ca.uhn.hapi.fhir:hapi-fhir-structures-r4:8.4.0 - https://hapifhir.io/hapi-deployable-pom/hapi-fhir-structures-r4)
+* (Apache License, Version 2.0) HAPI FHIR - Core Library (ca.uhn.hapi.fhir:hapi-fhir-base:8.6.0 - https://hapifhir.io/)
+* (Apache License, Version 2.0) HAPI FHIR - Client Framework (ca.uhn.hapi.fhir:hapi-fhir-client:8.6.0 - https://hapifhir.io/hapi-deployable-pom/hapi-fhir-client)
+* (Apache License, Version 2.0) HAPI FHIR Structures - FHIR R4 (ca.uhn.hapi.fhir:hapi-fhir-structures-r4:8.6.0 - https://hapifhir.io/hapi-deployable-pom/hapi-fhir-structures-r4)
* (Eclipse Public License 1.0) (GNU Lesser General Public License) Logback Classic Module (ch.qos.logback:logback-classic:1.5.18 - http://logback.qos.ch/logback-classic)
* (Apache License, Version 2.0) WireMock (com.github.tomakehurst:wiremock-jre8-standalone:2.35.2 - http://wiremock.org)
* (Apache License, Version 2.0) FindBugs-jsr305 (com.google.code.findbugs:jsr305:3.0.2 - http://findbugs.sourceforge.net/)
@@ -214,14 +214,14 @@ separate files distributed with the Software.
* (Apache License, Version 2.0) Infinispan Component Annotations (org.infinispan:infinispan-component-annotations:15.0.3.Final - http://www.infinispan.org)
* (Apache License, Version 2.0) Infinispan Core (org.infinispan:infinispan-core:15.0.3.Final - http://www.infinispan.org)
* (Public Domain) JSON in Java (org.json:json:20240303 - https://github.com/douglascrockford/JSON-java)
-* (Eclipse Public License v2.0) JUnit Jupiter API (org.junit.jupiter:junit-jupiter-api:5.10.1 - https://junit.org/junit5/)
-* (Eclipse Public License v2.0) JUnit Jupiter Engine (org.junit.jupiter:junit-jupiter-engine:5.10.1 - https://junit.org/junit5/)
-* (Eclipse Public License v2.0) JUnit Jupiter Params (org.junit.jupiter:junit-jupiter-params:5.10.1 - https://junit.org/junit5/)
-* (MIT License) mockito-core (org.mockito:mockito-core:5.17.0 - https://github.com/mockito/mockito)
+* (Eclipse Public License v2.0) JUnit Jupiter API (org.junit.jupiter:junit-jupiter-api:5.11.4 - https://junit.org/junit5/)
+* (Eclipse Public License v2.0) JUnit Jupiter Engine (org.junit.jupiter:junit-jupiter-engine:5.11.4 - https://junit.org/junit5/)
+* (Eclipse Public License v2.0) JUnit Jupiter Params (org.junit.jupiter:junit-jupiter-params:5.11.4 - https://junit.org/junit5/)
+* (MIT License) mockito-core (org.mockito:mockito-core:5.18.0 - https://github.com/mockito/mockito)
* (GNU General Public License (GPL), version 2, with the Classpath exception) JMH Core (org.openjdk.jmh:jmh-core:1.37 - http://openjdk.java.net/projects/code-tools/jmh/jmh-core/)
* (GNU General Public License (GPL), version 2, with the Classpath exception) JMH Generators: Annotation Processors (org.openjdk.jmh:jmh-generator-annprocess:1.37 - http://openjdk.java.net/projects/code-tools/jmh/jmh-generator-annprocess/)
* (MIT License) Project Lombok (org.projectlombok:lombok:1.18.38 - https://projectlombok.org)
* (Apache License, Version 2.0) Scala Library (org.scala-lang:scala-library:2.13.15 - https://www.scala-lang.org/)
* (Apache License, Version 2.0) JSONassert (org.skyscreamer:jsonassert:1.5.1 - https://github.com/skyscreamer/JSONassert)
* (MIT License) SLF4J API Module (org.slf4j:slf4j-api:2.0.17 - http://www.slf4j.org)
-* (Apache License, Version 2.0) spring-boot-starter-test (org.springframework.boot:spring-boot-starter-test:3.3.11 - https://spring.io/projects/spring-boot)
+* (Apache License, Version 2.0) spring-boot-starter-test (org.springframework.boot:spring-boot-starter-test:3.4.11 - https://spring.io/projects/spring-boot)
diff --git a/lib/python/pathling/context.py b/lib/python/pathling/context.py
index 09c54c7690..db766393b5 100644
--- a/lib/python/pathling/context.py
+++ b/lib/python/pathling/context.py
@@ -99,6 +99,8 @@ def create(
scope: Optional[str] = None,
token_expiry_tolerance: Optional[int] = 120,
accept_language: Optional[str] = None,
+ explain_queries: Optional[bool] = False,
+ max_unbound_traversal_depth: Optional[int] = 10,
enable_delta=False,
enable_remote_debugging: Optional[bool] = False,
debug_port: Optional[int] = 5005,
@@ -173,6 +175,11 @@ def create(
the header is not sent. The server can use the header to return the result in the
preferred language if it is able. The actual behaviour may depend on the server
implementation and the code systems used.
+ :param explain_queries: setting this option to `True` will enable additional logging relating
+ to the query plan used to execute queries
+ :param max_unbound_traversal_depth: maximum depth for self-referencing structure traversals
+ in repeat operations. Controls how deeply nested hierarchical data can be flattened
+ during projection.
:param enable_delta: enables the use of Delta for storage of FHIR data.
Only supported when no SparkSession is provided.
:param enable_remote_debugging: enables remote debugging for the JVM process.
@@ -270,8 +277,20 @@ def _new_spark_session():
.build()
)
- jpc: JavaObject = jvm.au.csiro.pathling.library.PathlingContext.create(
- spark._jsparkSession, encoders_config, terminology_config
+ # Build a query configuration object from the provided parameters.
+ query_config = (
+ jvm.au.csiro.pathling.config.QueryConfiguration.builder()
+ .explainQueries(explain_queries)
+ .maxUnboundTraversalDepth(max_unbound_traversal_depth)
+ .build()
+ )
+
+ jpc: JavaObject = (
+ jvm.au.csiro.pathling.library.PathlingContext.builder(spark._jsparkSession)
+ .encodingConfiguration(encoders_config)
+ .terminologyConfiguration(terminology_config)
+ .queryConfiguration(query_config)
+ .build()
)
return PathlingContext(spark, jpc)
diff --git a/lib/python/pom.xml b/lib/python/pom.xml
index 8e662826cc..338be679cc 100644
--- a/lib/python/pom.xml
+++ b/lib/python/pom.xml
@@ -24,7 +24,7 @@
au.csiro.pathling
pathling
- 9.0.1-SNAPSHOT
+ 9.1.0-SNAPSHOT
../../pom.xml
python
diff --git a/lib/python/tests/conftest.py b/lib/python/tests/conftest.py
index 9d73463ada..7bba9c43c5 100644
--- a/lib/python/tests/conftest.py
+++ b/lib/python/tests/conftest.py
@@ -105,7 +105,7 @@ def pathling_ctx(request, temp_warehouse_dir):
terminology_service_factory = (
jvm.au.csiro.pathling.terminology.mock.MockTerminologyServiceFactory()
)
- pathling_context = jvm.au.csiro.pathling.library.PathlingContext.create(
+ pathling_context = jvm.au.csiro.pathling.library.PathlingContext.createInternal(
spark._jsparkSession, encoders, terminology_service_factory
)
return PathlingContext(spark, pathling_context)
diff --git a/lib/python/tests/test_context_configuration.py b/lib/python/tests/test_context_configuration.py
new file mode 100644
index 0000000000..ea5ae846d5
--- /dev/null
+++ b/lib/python/tests/test_context_configuration.py
@@ -0,0 +1,109 @@
+# Copyright © 2018-2025 Commonwealth Scientific and Industrial Research
+# Organisation (CSIRO) ABN 41 687 119 230.
+#
+# 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.
+
+import logging
+import os
+from pyspark.sql import SparkSession
+from pytest import fixture
+from tempfile import mkdtemp
+
+from pathling import PathlingContext
+from pathling._version import __java_version__
+
+PROJECT_DIR = os.path.abspath(
+ os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir)
+)
+
+
+@fixture(scope="module")
+def spark_session(request):
+ """
+ Fixture for creating a Spark Session available for all tests in this
+ testing session.
+ """
+
+ gateway_log = logging.getLogger("java_gateway")
+ gateway_log.setLevel(logging.ERROR)
+
+ # Get the shaded JAR for testing purposes.
+ spark = (
+ SparkSession.builder.appName("pathling-config-test")
+ .master("local[2]")
+ .config(
+ "spark.jars.packages",
+ f"au.csiro.pathling:library-runtime:{__java_version__}",
+ )
+ .config("spark.sql.warehouse.dir", mkdtemp())
+ .config("spark.driver.memory", "4g")
+ .getOrCreate()
+ )
+
+ request.addfinalizer(lambda: spark.stop())
+
+ return spark
+
+
+def test_default_configurations(spark_session):
+ """Test that default configurations can be retrieved correctly."""
+ # Create PathlingContext with all default parameters
+ pc = PathlingContext.create(spark_session)
+
+ # Get Java PathlingContext instance
+ jpc = pc._jpc
+
+ # Retrieve EncodingConfiguration
+ encoding_config = jpc.getEncodingConfiguration()
+ assert encoding_config.getMaxNestingLevel() == 3
+ assert encoding_config.isEnableExtensions() == False
+ # Default open types should match STANDARD_OPEN_TYPES
+ open_types = set(encoding_config.getOpenTypes())
+ expected_types = {
+ "boolean", "code", "date", "dateTime", "decimal", "integer",
+ "string", "Coding", "CodeableConcept", "Address", "Identifier", "Reference"
+ }
+ assert open_types == expected_types
+
+ # Retrieve QueryConfiguration
+ query_config = jpc.getQueryConfiguration()
+ assert query_config.isExplainQueries() == False
+ assert query_config.getMaxUnboundTraversalDepth() == 10
+
+
+def test_custom_configurations(spark_session):
+ """Test that custom configurations round-trip correctly."""
+ # Create PathlingContext with all non-default values
+ pc = PathlingContext.create(
+ spark_session,
+ max_nesting_level=5,
+ enable_extensions=True,
+ enabled_open_types=["string", "boolean"],
+ explain_queries=True,
+ max_unbound_traversal_depth=20
+ )
+
+ # Get Java PathlingContext instance
+ jpc = pc._jpc
+
+ # Retrieve EncodingConfiguration and verify custom values
+ encoding_config = jpc.getEncodingConfiguration()
+ assert encoding_config.getMaxNestingLevel() == 5
+ assert encoding_config.isEnableExtensions() == True
+ open_types = set(encoding_config.getOpenTypes())
+ assert open_types == {"string", "boolean"}
+
+ # Retrieve QueryConfiguration and verify custom values
+ query_config = jpc.getQueryConfiguration()
+ assert query_config.isExplainQueries() == True
+ assert query_config.getMaxUnboundTraversalDepth() == 20
diff --git a/library-api/pom.xml b/library-api/pom.xml
index 172503a04e..3c90dea893 100644
--- a/library-api/pom.xml
+++ b/library-api/pom.xml
@@ -25,7 +25,7 @@
pathling
au.csiro.pathling
- 9.0.1-SNAPSHOT
+ 9.1.0-SNAPSHOT
library-api
jar
diff --git a/library-api/src/license/THIRD-PARTY.properties b/library-api/src/license/THIRD-PARTY.properties
index 8fa50bdd72..ef0102fc74 100644
--- a/library-api/src/license/THIRD-PARTY.properties
+++ b/library-api/src/license/THIRD-PARTY.properties
@@ -59,7 +59,7 @@
# Please fill the missing licenses for dependencies :
#
#
-#Tue Oct 28 09:08:03 AEST 2025
+#Mon Nov 24 10:19:06 AEST 2025
javax.transaction--jta--1.1=
javax.transaction--transaction-api--1.1=
org.apache.datasketches--datasketches-memory--3.0.2=
diff --git a/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java b/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java
index c39dc9b840..4b0b409c19 100644
--- a/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java
+++ b/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java
@@ -21,6 +21,7 @@
import au.csiro.pathling.PathlingVersion;
import au.csiro.pathling.config.EncodingConfiguration;
+import au.csiro.pathling.config.QueryConfiguration;
import au.csiro.pathling.config.TerminologyConfiguration;
import au.csiro.pathling.encoders.EncoderBuilder;
import au.csiro.pathling.encoders.FhirEncoderBuilder;
@@ -93,18 +94,31 @@ public class PathlingContext {
@Getter
private final TerminologyServiceFactory terminologyServiceFactory;
+ @Nonnull
+ @Getter
+ private final QueryConfiguration queryConfiguration;
@Nonnull
@Getter
private final Gson gson;
+ /**
+ * Creates a new PathlingContext with the specified configuration.
+ *
+ * @param spark the Spark session to use
+ * @param fhirEncoders the FHIR encoders to use
+ * @param terminologyServiceFactory the terminology service factory to use
+ * @param queryConfiguration the query configuration to use
+ */
private PathlingContext(@Nonnull final SparkSession spark,
@Nonnull final FhirEncoders fhirEncoders,
- @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+ @Nonnull final TerminologyServiceFactory terminologyServiceFactory,
+ @Nonnull final QueryConfiguration queryConfiguration) {
this.spark = spark;
this.fhirVersion = fhirEncoders.getFhirVersion();
this.fhirEncoders = fhirEncoders;
this.terminologyServiceFactory = terminologyServiceFactory;
+ this.queryConfiguration = queryConfiguration;
TerminologyUdfRegistrar.registerUdfs(spark, terminologyServiceFactory);
PathlingUdfConfigurer.registerUDFs(spark);
gson = buildGson();
@@ -118,6 +132,169 @@ private static Gson buildGson() {
return builder.create();
}
+ /**
+ * Creates a {@link PathlingContext} with advanced configuration for testing purposes. This method
+ * is internal and should not be used by library consumers but it needs to be public to be
+ * accessible from other packages in the module.
+ *
+ * @param spark the Spark session to use
+ * @param fhirEncoders the FHIR encoders to use
+ * @param terminologyServiceFactory the terminology service factory to use
+ * @return a new {@link PathlingContext} instance with default query configuration
+ */
+ @Nonnull
+ public static PathlingContext createInternal(@Nonnull final SparkSession spark,
+ @Nonnull final FhirEncoders fhirEncoders,
+ @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+ return new PathlingContext(spark, fhirEncoders, terminologyServiceFactory,
+ QueryConfiguration.builder().build());
+ }
+
+ /**
+ * Builder for creating {@link PathlingContext} instances with configurable options.
+ */
+ public static class Builder {
+
+ @Nullable
+ private SparkSession spark;
+ @Nullable
+ private EncodingConfiguration encodingConfiguration;
+ @Nullable
+ private TerminologyConfiguration terminologyConfiguration;
+ @Nullable
+ private QueryConfiguration queryConfiguration;
+
+ Builder() {
+ }
+
+ Builder(@Nullable final SparkSession spark) {
+ this.spark = spark;
+ }
+
+ /**
+ * Sets the Spark session for the context.
+ *
+ * @param spark the Spark session to use, or null to use a default Spark session
+ * @return this builder
+ */
+ @Nonnull
+ public Builder spark(@Nullable final SparkSession spark) {
+ this.spark = spark;
+ return this;
+ }
+
+ /**
+ * Sets the encoding configuration for the context.
+ *
+ * @param encodingConfiguration the encoding configuration to use
+ * @return this builder
+ */
+ @Nonnull
+ public Builder encodingConfiguration(
+ @Nonnull final EncodingConfiguration encodingConfiguration) {
+ this.encodingConfiguration = encodingConfiguration;
+ return this;
+ }
+
+ /**
+ * Sets the terminology configuration for the context.
+ *
+ * @param terminologyConfiguration the terminology configuration to use
+ * @return this builder
+ */
+ @Nonnull
+ public Builder terminologyConfiguration(
+ @Nonnull final TerminologyConfiguration terminologyConfiguration) {
+ this.terminologyConfiguration = terminologyConfiguration;
+ return this;
+ }
+
+ /**
+ * Sets the query configuration for the context.
+ *
+ * @param queryConfiguration the query configuration to use
+ * @return this builder
+ */
+ @Nonnull
+ public Builder queryConfiguration(@Nonnull final QueryConfiguration queryConfiguration) {
+ this.queryConfiguration = queryConfiguration;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link PathlingContext} instance with the configured options.
+ *
+ * @return a new {@link PathlingContext} instance
+ */
+ @Nonnull
+ public PathlingContext build() {
+ final SparkSession finalSpark = getOrDefault(spark, PathlingContext::buildDefaultSpark);
+ final EncodingConfiguration finalEncodingConfig = getOrDefault(encodingConfiguration,
+ EncodingConfiguration.builder()::build);
+ final TerminologyConfiguration finalTerminologyConfig = getOrDefault(
+ terminologyConfiguration, TerminologyConfiguration.builder()::build);
+ final QueryConfiguration finalQueryConfig = getOrDefault(queryConfiguration,
+ QueryConfiguration.builder()::build);
+
+ validateConfigurations(finalEncodingConfig, finalTerminologyConfig, finalQueryConfig);
+
+ return createContext(finalSpark, finalEncodingConfig, finalTerminologyConfig,
+ finalQueryConfig);
+ }
+
+ @Nonnull
+ private static T getOrDefault(@Nullable final T value,
+ @Nonnull final java.util.function.Supplier defaultSupplier) {
+ return value != null
+ ? value
+ : defaultSupplier.get();
+ }
+
+ private static void validateConfigurations(
+ @Nonnull final EncodingConfiguration encodingConfig,
+ @Nonnull final TerminologyConfiguration terminologyConfig,
+ @Nonnull final QueryConfiguration queryConfig) {
+ ValidationUtils.ensureValid(terminologyConfig, "Invalid terminology configuration");
+ ValidationUtils.ensureValid(encodingConfig, "Invalid encoding configuration");
+ ValidationUtils.ensureValid(queryConfig, "Invalid query configuration");
+ }
+
+ @Nonnull
+ private static PathlingContext createContext(@Nonnull final SparkSession spark,
+ @Nonnull final EncodingConfiguration encodingConfig,
+ @Nonnull final TerminologyConfiguration terminologyConfig,
+ @Nonnull final QueryConfiguration queryConfig) {
+ final FhirEncoderBuilder encoderBuilder = getEncoderBuilder(encodingConfig);
+ final TerminologyServiceFactory terminologyServiceFactory =
+ getTerminologyServiceFactory(terminologyConfig);
+
+ return new PathlingContext(spark, encoderBuilder.getOrCreate(),
+ terminologyServiceFactory, queryConfig);
+ }
+ }
+
+ /**
+ * Creates a new {@link Builder} for building a {@link PathlingContext}.
+ *
+ * @return a new builder instance
+ */
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Creates a new {@link Builder} for building a {@link PathlingContext} with a pre-configured
+ * Spark session.
+ *
+ * @param spark the Spark session to use, or null to use a default Spark session
+ * @return a new builder instance
+ */
+ @Nonnull
+ public static Builder builder(@Nullable final SparkSession spark) {
+ return new Builder(spark);
+ }
+
/**
* Gets the FhirContext for this instance.
*
@@ -129,21 +306,34 @@ public FhirContext getFhirContext() {
}
/**
- * Creates a new {@link PathlingContext} using pre-configured {@link SparkSession},
- * {@link FhirEncoders} and {@link TerminologyServiceFactory} objects.
+ * Returns the encoding configuration used by this PathlingContext.
+ *
+ * The configuration is constructed on-demand from the current state of the FhirEncoders
+ * instance.
*
- * @param spark the Spark session to use
- * @param fhirEncoders the FHIR encoders to use
- * @param terminologyServiceFactory the terminology service factory to use
- * @return a new {@link PathlingContext} instance
+ * @return the encoding configuration, never null
*/
@Nonnull
- public static PathlingContext create(@Nonnull final SparkSession spark,
- @Nonnull final FhirEncoders fhirEncoders,
- @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
- return new PathlingContext(spark, fhirEncoders, terminologyServiceFactory);
+ public EncodingConfiguration getEncodingConfiguration() {
+ return fhirEncoders.getConfiguration();
+ }
+
+ /**
+ * Returns the terminology configuration used by this PathlingContext.
+ *
+ * The configuration is retrieved from the terminology service factory. Factories that do not
+ * support configuration access will throw {@link IllegalStateException}.
+ *
+ * @return the terminology configuration, never null
+ * @throws IllegalStateException if the terminology service factory does not support configuration
+ * access
+ */
+ @Nonnull
+ public TerminologyConfiguration getTerminologyConfiguration() {
+ return terminologyServiceFactory.getConfiguration();
}
+
/**
* Creates a new {@link PathlingContext} with a default setup for Spark, FHIR encoders, and
* terminology services.
@@ -152,12 +342,7 @@ public static PathlingContext create(@Nonnull final SparkSession spark,
*/
@Nonnull
public static PathlingContext create() {
- final SparkSession spark = SparkSession.builder()
- .appName("Pathling")
- .master("local[*]")
- .getOrCreate();
-
- return create(spark);
+ return builder().build();
}
/**
@@ -168,61 +353,44 @@ public static PathlingContext create() {
*/
@Nonnull
public static PathlingContext create(@Nonnull final SparkSession sparkSession) {
- final EncodingConfiguration encodingConfig = EncodingConfiguration.builder().build();
- return create(sparkSession, encodingConfig);
+ return builder(sparkSession).build();
}
/**
- * Creates a new {@link PathlingContext} using supplied configuration and a pre-configured
- * {@link SparkSession}.
+ * Creates a new {@link PathlingContext} using supplied encoding configuration and a
+ * pre-configured {@link SparkSession}.
+ *
+ * This is a convenience method for case when only encoding functionality of Pathling is needed.
*
* @param sparkSession the Spark session to use
* @param encodingConfig the encoding configuration to use
* @return a new {@link PathlingContext} instance
*/
@Nonnull
- public static PathlingContext create(@Nonnull final SparkSession sparkSession,
+ public static PathlingContext createForEncoding(@Nonnull final SparkSession sparkSession,
@Nonnull final EncodingConfiguration encodingConfig) {
- final TerminologyConfiguration terminologyConfig = TerminologyConfiguration.builder().build();
- return create(sparkSession, encodingConfig, terminologyConfig);
+ return builder(sparkSession)
+ .encodingConfiguration(encodingConfig)
+ .build();
}
/**
- * Creates a new {@link PathlingContext} using supplied configuration and a pre-configured
- * {@link SparkSession}.
+ * Creates a new {@link PathlingContext} using supplied configuration terminology and a
+ * pre-configured {@link SparkSession}.
+ *
+ * This is a convenience method for case when only terminology functionality (terminology UDFs) of
+ * Pathling is needed.
*
* @param sparkSession the Spark session to use
* @param terminologyConfig the terminology configuration to use
* @return a new {@link PathlingContext} instance
*/
@Nonnull
- public static PathlingContext create(@Nonnull final SparkSession sparkSession,
+ public static PathlingContext createForTerminology(@Nonnull final SparkSession sparkSession,
@Nonnull final TerminologyConfiguration terminologyConfig) {
- final EncodingConfiguration encodingConfig = EncodingConfiguration.builder().build();
- return create(sparkSession, encodingConfig, terminologyConfig);
- }
-
- /**
- * Creates a new {@link PathlingContext} using supplied configuration and a pre-configured
- * {@link SparkSession}.
- *
- * @param sparkSession the Spark session to use
- * @param encodingConfiguration the encoding configuration to use
- * @param terminologyConfiguration the terminology configuration to use
- * @return a new {@link PathlingContext} instance
- */
- @Nonnull
- public static PathlingContext create(@Nonnull final SparkSession sparkSession,
- @Nonnull final EncodingConfiguration encodingConfiguration,
- @Nonnull final TerminologyConfiguration terminologyConfiguration) {
-
- ValidationUtils.ensureValid(terminologyConfiguration, "Invalid terminology configuration");
- ValidationUtils.ensureValid(encodingConfiguration, "Invalid encoding configuration");
-
- final FhirEncoderBuilder encoderBuilder = getEncoderBuilder(encodingConfiguration);
- final TerminologyServiceFactory terminologyServiceFactory = getTerminologyServiceFactory(
- terminologyConfiguration);
- return create(sparkSession, encoderBuilder.getOrCreate(), terminologyServiceFactory);
+ return builder(sparkSession)
+ .terminologyConfiguration(terminologyConfig)
+ .build();
}
/**
@@ -476,6 +644,14 @@ public Optional matchSupportedResourceType(@Nonnull final String resourc
return Optional.empty();
}
+ @Nonnull
+ private static SparkSession buildDefaultSpark() {
+ return SparkSession.builder()
+ .appName("Pathling")
+ .master("local[*]")
+ .getOrCreate();
+ }
+
@Nonnull
private static FhirEncoderBuilder getEncoderBuilder(@Nonnull final EncodingConfiguration config) {
return FhirEncoders.forR4()
diff --git a/library-api/src/main/java/au/csiro/pathling/library/io/source/AbstractSource.java b/library-api/src/main/java/au/csiro/pathling/library/io/source/AbstractSource.java
index b76ca7a123..bb92e62172 100644
--- a/library-api/src/main/java/au/csiro/pathling/library/io/source/AbstractSource.java
+++ b/library-api/src/main/java/au/csiro/pathling/library/io/source/AbstractSource.java
@@ -63,7 +63,7 @@ protected AbstractSource(@Nonnull final PathlingContext context) {
private QueryDispatcher buildDispatcher(final @Nonnull PathlingContext context,
final DataSource dataSource) {
final FhirViewExecutor viewExecutor = new FhirViewExecutor(context.getFhirContext(),
- context.getSpark(), dataSource
+ context.getSpark(), dataSource, context.getQueryConfiguration()
);
// Build the dispatcher using the executors.
diff --git a/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java b/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java
index 7f86f54df0..42b53e8ba1 100644
--- a/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java
+++ b/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java
@@ -22,6 +22,7 @@
import static java.util.function.Predicate.not;
import static org.apache.spark.sql.functions.col;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -33,8 +34,10 @@
import au.csiro.pathling.config.HttpClientCachingConfiguration;
import au.csiro.pathling.config.HttpClientCachingStorageType;
import au.csiro.pathling.config.HttpClientConfiguration;
+import au.csiro.pathling.config.QueryConfiguration;
import au.csiro.pathling.config.TerminologyAuthConfiguration;
import au.csiro.pathling.config.TerminologyConfiguration;
+import au.csiro.pathling.encoders.FhirEncoders;
import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
import au.csiro.pathling.terminology.TerminologyService;
import au.csiro.pathling.terminology.TerminologyServiceFactory;
@@ -242,8 +245,9 @@ void testEncoderOptions() {
.enableExtensions(false)
.maxNestingLevel(1)
.build();
- final Row rowWithNesting = PathlingContext.create(spark, encodingConfig1)
- .encode(jsonResourcesDF, "Questionnaire").head();
+ final PathlingContext context1 = PathlingContext.createForEncoding(spark, encodingConfig1);
+
+ final Row rowWithNesting = context1.encode(jsonResourcesDF, "Questionnaire").head();
assertFieldNotPresent("_extension", rowWithNesting.schema());
// Test item nesting
final Row itemWithNesting = (Row) rowWithNesting
@@ -253,14 +257,19 @@ void testEncoderOptions() {
.getList(itemWithNesting.fieldIndex("item")).getFirst();
assertFieldNotPresent("item", nestedItem.schema());
+ // Verify encoding configuration can be retrieved
+ final EncodingConfiguration retrievedConfig1 = context1.getEncodingConfiguration();
+ assertFalse(retrievedConfig1.isEnableExtensions());
+ assertEquals(1, retrievedConfig1.getMaxNestingLevel());
+
// Test explicit options
// Extensions and open types
final EncodingConfiguration encodingConfig2 = EncodingConfiguration.builder()
.enableExtensions(true)
.openTypes(Set.of("boolean", "string", "Address"))
.build();
- final Row rowWithExtensions = PathlingContext.create(spark, encodingConfig2)
- .encode(jsonResourcesDF, "Patient").head();
+ final PathlingContext context2 = PathlingContext.createForEncoding(spark, encodingConfig2);
+ final Row rowWithExtensions = context2.encode(jsonResourcesDF, "Patient").head();
assertFieldPresent("_extension", rowWithExtensions.schema());
final Map> extensions = rowWithExtensions
@@ -272,6 +281,11 @@ void testEncoderOptions() {
assertFieldPresent("valueAddress", extension.schema());
assertFieldPresent("valueBoolean", extension.schema());
assertFieldNotPresent("valueInteger", extension.schema());
+
+ // Verify encoding configuration can be retrieved
+ final EncodingConfiguration retrievedConfig2 = context2.getEncodingConfiguration();
+ assertTrue(retrievedConfig2.isEnableExtensions());
+ assertEquals(Set.of("boolean", "string", "Address"), retrievedConfig2.getOpenTypes());
}
@Test
@@ -279,7 +293,7 @@ void testEncodeResourceStream() throws Exception {
final EncodingConfiguration encodingConfig = EncodingConfiguration.builder()
.enableExtensions(true)
.build();
- final PathlingContext pathling = PathlingContext.create(spark, encodingConfig);
+ final PathlingContext pathling = PathlingContext.createForEncoding(spark, encodingConfig);
final Dataset jsonResources = spark.readStream()
.text(TEST_DATA_URL + "/resources/R4/json");
@@ -323,7 +337,8 @@ void testBuildContextWithTerminologyDefaults() {
final TerminologyConfiguration terminologyConfig = TerminologyConfiguration.builder()
.serverUrl(terminologyServerUrl)
.build();
- final PathlingContext pathlingContext = PathlingContext.create(spark, terminologyConfig);
+ final PathlingContext pathlingContext = PathlingContext.createForTerminology(spark, terminologyConfig);
+
assertNotNull(pathlingContext);
final DefaultTerminologyServiceFactory expectedFactory = new DefaultTerminologyServiceFactory(
FhirVersionEnum.R4, terminologyConfig);
@@ -332,6 +347,12 @@ void testBuildContextWithTerminologyDefaults() {
assertEquals(expectedFactory, actualServiceFactory);
final TerminologyService actualService = actualServiceFactory.build();
assertNotNull(actualService);
+
+ // Verify terminology configuration can be retrieved
+ final TerminologyConfiguration retrievedConfig = pathlingContext.getTerminologyConfiguration();
+ assertNotNull(retrievedConfig);
+ assertEquals(terminologyServerUrl, retrievedConfig.getServerUrl());
+ assertTrue(retrievedConfig.isEnabled());
}
@Test
@@ -345,7 +366,8 @@ void testBuildContextWithTerminologyNoCache() {
.serverUrl(terminologyServerUrl)
.cache(cacheConfig)
.build();
- final PathlingContext pathlingContext = PathlingContext.create(spark, terminologyConfig);
+ final PathlingContext pathlingContext = PathlingContext.createForTerminology(spark,
+ terminologyConfig);
assertNotNull(pathlingContext);
final TerminologyServiceFactory expectedFactory = new DefaultTerminologyServiceFactory(
FhirVersionEnum.R4, terminologyConfig);
@@ -400,7 +422,9 @@ void testBuildContextWithCustomizedTerminology() throws IOException {
.authentication(authConfig)
.build();
- final PathlingContext pathlingContext = PathlingContext.create(spark, terminologyConfig);
+ final PathlingContext pathlingContext = PathlingContext.createForTerminology(spark,
+ terminologyConfig);
+
assertNotNull(pathlingContext);
final TerminologyServiceFactory expectedFactory = new DefaultTerminologyServiceFactory(
FhirVersionEnum.R4, terminologyConfig);
@@ -409,6 +433,24 @@ void testBuildContextWithCustomizedTerminology() throws IOException {
assertEquals(expectedFactory, actualServiceFactory);
final TerminologyService actualService = actualServiceFactory.build();
assertNotNull(actualService);
+
+ // Verify terminology configuration can be retrieved with all custom values
+ final TerminologyConfiguration retrievedConfig = pathlingContext.getTerminologyConfiguration();
+ assertNotNull(retrievedConfig);
+ assertEquals(terminologyServerUrl, retrievedConfig.getServerUrl());
+ assertTrue(retrievedConfig.isVerboseLogging());
+ assertEquals(maxConnectionsTotal, retrievedConfig.getClient().getMaxConnectionsTotal());
+ assertEquals(maxConnectionsPerRoute, retrievedConfig.getClient().getMaxConnectionsPerRoute());
+ assertEquals(socketTimeout, retrievedConfig.getClient().getSocketTimeout());
+ assertEquals(cacheMaxEntries, retrievedConfig.getCache().getMaxEntries());
+ assertEquals(cacheStorageType, retrievedConfig.getCache().getStorageType());
+ assertEquals(cacheStoragePath, retrievedConfig.getCache().getStoragePath());
+ assertEquals(tokenEndpoint, retrievedConfig.getAuthentication().getTokenEndpoint());
+ assertEquals(clientId, retrievedConfig.getAuthentication().getClientId());
+ assertEquals(clientSecret, retrievedConfig.getAuthentication().getClientSecret());
+ assertEquals(scope, retrievedConfig.getAuthentication().getScope());
+ assertEquals(tokenExpiryTolerance,
+ retrievedConfig.getAuthentication().getTokenExpiryTolerance());
}
@Test
@@ -422,8 +464,11 @@ void failsOnInvalidTerminologyConfiguration() {
.build())
.build();
+ final PathlingContext.Builder builder = PathlingContext.builder(spark)
+ .terminologyConfiguration(invalidTerminologyConfig);
+
final ConstraintViolationException ex = assertThrows(ConstraintViolationException.class,
- () -> PathlingContext.create(spark, invalidTerminologyConfig));
+ builder::build);
assertEquals("Invalid terminology configuration:"
+ " cache: If the storage type is disk, then a storage path must be supplied.,"
@@ -442,8 +487,12 @@ void failsOnInvalidEncodingConfiguration() {
.openTypes(null)
.build();
+ final PathlingContext.Builder builder = PathlingContext.builder(spark)
+ .encodingConfiguration(invalidEncodersConfiguration)
+ .terminologyConfiguration(terminologyConfig);
+
final ConstraintViolationException ex = assertThrows(ConstraintViolationException.class,
- () -> PathlingContext.create(spark, invalidEncodersConfiguration, terminologyConfig));
+ builder::build);
assertEquals("Invalid encoding configuration:"
+ " maxNestingLevel: must be greater than or equal to 0,"
@@ -451,4 +500,158 @@ void failsOnInvalidEncodingConfiguration() {
ex.getMessage());
}
+ @Test
+ void testBuildContextWithQueryConfiguration() {
+ final QueryConfiguration queryConfig = QueryConfiguration.builder()
+ .explainQueries(true)
+ .maxUnboundTraversalDepth(20)
+ .build();
+
+ final PathlingContext pathlingContext = PathlingContext.builder(spark)
+ .queryConfiguration(queryConfig)
+ .build();
+
+ assertNotNull(pathlingContext);
+ assertNotNull(pathlingContext.getQueryConfiguration());
+ assertTrue(pathlingContext.getQueryConfiguration().isExplainQueries());
+ assertEquals(20, pathlingContext.getQueryConfiguration().getMaxUnboundTraversalDepth());
+
+ // Verify configuration can be retrieved
+ final QueryConfiguration retrievedConfig = pathlingContext.getQueryConfiguration();
+ assertEquals(queryConfig, retrievedConfig);
+ }
+
+ @Test
+ void testBuildContextWithDefaultQueryConfiguration() {
+ final PathlingContext pathlingContext = PathlingContext.builder(spark).build();
+
+ assertNotNull(pathlingContext);
+ assertNotNull(pathlingContext.getQueryConfiguration());
+ assertFalse(pathlingContext.getQueryConfiguration().isExplainQueries());
+ assertEquals(10, pathlingContext.getQueryConfiguration().getMaxUnboundTraversalDepth());
+ }
+
+ @Test
+ void failsOnInvalidQueryConfiguration() {
+ // Test with negative maxUnboundTraversalDepth
+ final QueryConfiguration invalidQueryConfig = QueryConfiguration.builder()
+ .maxUnboundTraversalDepth(-5)
+ .build();
+
+ final PathlingContext.Builder builder = PathlingContext.builder(spark)
+ .queryConfiguration(invalidQueryConfig);
+
+ final ConstraintViolationException ex = assertThrows(ConstraintViolationException.class,
+ builder::build);
+
+ final String message = ex.getMessage();
+ assertTrue(message.contains("maxUnboundTraversalDepth: must be greater than or equal to 0"),
+ "Expected error message to contain 'maxUnboundTraversalDepth: must be greater than or equal to 0', but was: "
+ + message);
+ }
+
+ @Test
+ void testBuilderWithNullSpark() {
+ final PathlingContext pathlingContext = PathlingContext.builder()
+ .spark(null)
+ .build();
+
+ assertNotNull(pathlingContext);
+ assertNotNull(pathlingContext.getSpark());
+ }
+
+ @Test
+ void testBuilderWithAllConfigurations() {
+ final EncodingConfiguration encodingConfig = EncodingConfiguration.builder()
+ .maxNestingLevel(5)
+ .enableExtensions(false)
+ .build();
+
+ final TerminologyConfiguration terminologyConfig = TerminologyConfiguration.builder()
+ .build();
+
+ final QueryConfiguration queryConfig = QueryConfiguration.builder()
+ .explainQueries(true)
+ .maxUnboundTraversalDepth(15)
+ .build();
+
+ final PathlingContext pathlingContext = PathlingContext.builder(spark)
+ .encodingConfiguration(encodingConfig)
+ .terminologyConfiguration(terminologyConfig)
+ .queryConfiguration(queryConfig)
+ .build();
+
+ assertNotNull(pathlingContext);
+ assertNotNull(pathlingContext.getQueryConfiguration());
+ assertTrue(pathlingContext.getQueryConfiguration().isExplainQueries());
+ assertEquals(15, pathlingContext.getQueryConfiguration().getMaxUnboundTraversalDepth());
+ }
+
+ @Test
+ void testConfigurationRoundTrip() {
+ // Create specific configurations
+ final EncodingConfiguration encodingConfig = EncodingConfiguration.builder()
+ .maxNestingLevel(7)
+ .enableExtensions(true)
+ .openTypes(Set.of("string", "Coding"))
+ .build();
+
+ final TerminologyConfiguration terminologyConfig = TerminologyConfiguration.builder()
+ .serverUrl("https://test.server.com/fhir")
+ .verboseLogging(true)
+ .build();
+
+ final QueryConfiguration queryConfig = QueryConfiguration.builder()
+ .explainQueries(true)
+ .maxUnboundTraversalDepth(25)
+ .build();
+
+ // Build context with configurations
+ final PathlingContext pathlingContext = PathlingContext.builder(spark)
+ .encodingConfiguration(encodingConfig)
+ .terminologyConfiguration(terminologyConfig)
+ .queryConfiguration(queryConfig)
+ .build();
+
+ // Retrieve configurations
+ final EncodingConfiguration retrievedEncodingConfig = pathlingContext.getEncodingConfiguration();
+ final TerminologyConfiguration retrievedTerminologyConfig = pathlingContext.getTerminologyConfiguration();
+ final QueryConfiguration retrievedQueryConfig = pathlingContext.getQueryConfiguration();
+
+ // Verify encoding configuration matches
+ assertEquals(7, retrievedEncodingConfig.getMaxNestingLevel());
+ assertTrue(retrievedEncodingConfig.isEnableExtensions());
+ assertEquals(Set.of("string", "Coding"), retrievedEncodingConfig.getOpenTypes());
+
+ // Verify terminology configuration matches
+ assertEquals("https://test.server.com/fhir", retrievedTerminologyConfig.getServerUrl());
+ assertTrue(retrievedTerminologyConfig.isVerboseLogging());
+
+ // Verify query configuration matches
+ assertEquals(queryConfig, retrievedQueryConfig);
+ assertTrue(retrievedQueryConfig.isExplainQueries());
+ assertEquals(25, retrievedQueryConfig.getMaxUnboundTraversalDepth());
+ }
+
+ @Test
+ void testTerminologyConfigurationWithNonConfigurableFactory() {
+ // Create a mock terminology service factory that uses default implementation
+ final TerminologyServiceFactory mockFactory = mock(
+ TerminologyServiceFactory.class, withSettings().serializable());
+
+ // Mock uses default method which throws IllegalStateException
+ when(mockFactory.getConfiguration()).thenCallRealMethod();
+
+ // Create context using createInternal (which bypasses builder validations)
+ final PathlingContext pathlingContext = PathlingContext.createInternal(
+ spark, FhirEncoders.forR4().getOrCreate(), mockFactory);
+
+ // Verify default implementation throws expected exception
+ final IllegalStateException ex = assertThrows(IllegalStateException.class,
+ pathlingContext::getTerminologyConfiguration);
+
+ assertTrue(ex.getMessage().contains("does not support configuration access"),
+ "Expected error message to contain 'does not support configuration access', but was: "
+ + ex.getMessage());
+ }
}
diff --git a/library-api/src/test/java/au/csiro/pathling/library/TestHelpers.java b/library-api/src/test/java/au/csiro/pathling/library/TestHelpers.java
index 9138632062..c5a9d431dd 100644
--- a/library-api/src/test/java/au/csiro/pathling/library/TestHelpers.java
+++ b/library-api/src/test/java/au/csiro/pathling/library/TestHelpers.java
@@ -23,6 +23,10 @@
public class TestHelpers {
+ private TestHelpers() {
+ // Utility class - prevent instantiation
+ }
+
@Nonnull
public static SparkSession spark() {
return sparkBuilder()
diff --git a/library-api/src/test/java/au/csiro/pathling/library/io/DataSourcesTest.java b/library-api/src/test/java/au/csiro/pathling/library/io/DataSourcesTest.java
index 03c2f4064f..78d7f18c66 100644
--- a/library-api/src/test/java/au/csiro/pathling/library/io/DataSourcesTest.java
+++ b/library-api/src/test/java/au/csiro/pathling/library/io/DataSourcesTest.java
@@ -103,7 +103,7 @@ static void setupContext() throws IOException {
TerminologyServiceFactory.class, withSettings().serializable());
// Create the Pathling context.
- pathlingContext = PathlingContext.create(spark, FhirEncoders.forR4().getOrCreate(),
+ pathlingContext = PathlingContext.createInternal(spark, FhirEncoders.forR4().getOrCreate(),
terminologyServiceFactory);
}
diff --git a/library-runtime/pom.xml b/library-runtime/pom.xml
index a551f8d3b4..cdef0f2d01 100644
--- a/library-runtime/pom.xml
+++ b/library-runtime/pom.xml
@@ -25,7 +25,7 @@
pathling
au.csiro.pathling
- 9.0.1-SNAPSHOT
+ 9.1.0-SNAPSHOT
library-runtime
jar
diff --git a/licenses/apache-2.0 - license-2.0.html b/licenses/apache-2.0 - license-2.0.html
index fd2be6350e..9ed4739d58 100644
--- a/licenses/apache-2.0 - license-2.0.html
+++ b/licenses/apache-2.0 - license-2.0.html
@@ -186,7 +186,7 @@
- Sponsor
+ Donate
Search
diff --git a/licenses/epl 2.0 - epl-2.0.html b/licenses/epl 2.0 - epl-2.0.html
index e237ce3bd9..06869a9e11 100644
--- a/licenses/epl 2.0 - epl-2.0.html
+++ b/licenses/epl 2.0 - epl-2.0.html
@@ -1,5 +1,5 @@
Eclipse Public License 2.0 (EPL) | The Eclipse Foundation
-Skip to main content