Skip to content

Commit ec957f9

Browse files
piotrszulclaude
andcommitted
fix: Preserve extension map in ResourceCollection.copyWith()
ResourceCollection.copyWith() was re-deriving the extension map from the new column representation, which fails for non-resource structs (like Extension) that lack the _extension field. This caused repeatAll(extension) to only return top-level extensions, missing nested sub-extensions. Added a private constructor that accepts an explicit extension map column, and updated copyWith() to use it. Also consolidated RepeatAllFunctionDslTest from 13 methods to 7 by chaining groups that share the same resource setup, and refined the delta spec for same-type recursion depth scenarios. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e6fb1d0 commit ec957f9

File tree

3 files changed

+177
-82
lines changed

3 files changed

+177
-82
lines changed

fhirpath/src/main/java/au/csiro/pathling/fhirpath/collection/ResourceCollection.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import jakarta.annotation.Nonnull;
3232
import java.util.Optional;
3333
import lombok.Getter;
34+
import org.apache.spark.sql.Column;
3435
import org.hl7.fhir.exceptions.FHIRException;
3536
import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
3637
import org.hl7.fhir.r4.model.Enumerations.ResourceType;
@@ -47,7 +48,7 @@ public class ResourceCollection extends Collection {
4748
@Nonnull private final ResourceDefinition resourceDefinition;
4849

4950
/**
50-
* Creates a new ResourceCollection.
51+
* Creates a new ResourceCollection, deriving the extension map from the column representation.
5152
*
5253
* @param columnRepresentation the column representation
5354
* @param type the FhirPath type
@@ -71,6 +72,29 @@ protected ResourceCollection(
7172
this.resourceDefinition = resourceDefinition;
7273
}
7374

75+
/**
76+
* Creates a new ResourceCollection with an explicit extension map column. This is used by {@link
77+
* #copyWith(ColumnRepresentation)} to preserve the resource-level extension map when creating
78+
* copies with a different column representation (e.g. during repeatAll traversal).
79+
*
80+
* @param columnRepresentation the column representation
81+
* @param type the FhirPath type
82+
* @param fhirType the FHIR type
83+
* @param definition the node definition
84+
* @param resourceDefinition the resource definition
85+
* @param extensionMapColumn the extension map column to preserve
86+
*/
87+
private ResourceCollection(
88+
@Nonnull final ColumnRepresentation columnRepresentation,
89+
@Nonnull final Optional<FhirPathType> type,
90+
@Nonnull final Optional<FHIRDefinedType> fhirType,
91+
@Nonnull final Optional<? extends NodeDefinition> definition,
92+
@Nonnull final ResourceDefinition resourceDefinition,
93+
@Nonnull final Optional<Column> extensionMapColumn) {
94+
super(columnRepresentation, type, fhirType, definition, extensionMapColumn);
95+
this.resourceDefinition = resourceDefinition;
96+
}
97+
7498
@Nonnull
7599
private static Optional<FHIRDefinedType> getFhirType(@Nonnull final ResourceType resourceType) {
76100
return getFhirType(resourceType.toCode());
@@ -175,7 +199,12 @@ protected ColumnRepresentation getFid() {
175199
@Override
176200
public Collection copyWith(@Nonnull final ColumnRepresentation newValue) {
177201
return new ResourceCollection(
178-
newValue, getType(), getFhirType(), getDefinition(), resourceDefinition);
202+
newValue,
203+
getType(),
204+
getFhirType(),
205+
getDefinition(),
206+
resourceDefinition,
207+
getExtensionMapColumn());
179208
}
180209

181210
/**

fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/RepeatAllFunctionDslTest.java

Lines changed: 86 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,47 @@ private static Questionnaire createEmptyQuestionnaire() {
120120
return questionnaire;
121121
}
122122

123+
/**
124+
* Creates a Patient with all fields needed by the patient projection tests: two names, gender,
125+
* marital status, and nested extensions.
126+
*
127+
* <p>Extension structure:
128+
*
129+
* <pre>
130+
* Patient
131+
* name: Smith, Jones
132+
* gender: MALE
133+
* maritalStatus: M (Married)
134+
* extension "http://example.com/simple" = "simple-value"
135+
* extension "http://example.com/parent"
136+
* extension "http://example.com/child" = "child-value"
137+
* </pre>
138+
*
139+
* @return a Patient resource with all fields populated
140+
*/
141+
private static Patient createPatient() {
142+
final Patient patient = new Patient();
143+
patient.setId("test-patient");
144+
patient.addName().setFamily("Smith");
145+
patient.addName().setFamily("Jones");
146+
patient.setGender(AdministrativeGender.MALE);
147+
patient.setMaritalStatus(
148+
new CodeableConcept(new Coding("http://example.com/cs", "M", "Married")));
149+
150+
// Simple leaf extension.
151+
patient.addExtension(
152+
new Extension("http://example.com/simple", new StringType("simple-value")));
153+
154+
// Complex extension containing a nested sub-extension.
155+
final Extension parent = new Extension("http://example.com/parent");
156+
parent.addExtension(new Extension("http://example.com/child", new StringType("child-value")));
157+
patient.addExtension(parent);
158+
159+
return patient;
160+
}
161+
123162
@FhirPathTest
124-
public Stream<DynamicTest> testRepeatAllBasicTraversal() {
163+
public Stream<DynamicTest> testRepeatAllQuestionnaireTraversal() {
125164
return builder()
126165
.withResource(createQuestionnaire())
127166
.group("repeatAll() basic traversal")
@@ -133,18 +172,54 @@ public Stream<DynamicTest> testRepeatAllBasicTraversal() {
133172
4,
134173
"repeatAll(item).count()",
135174
"repeatAll(item).count() returns total items across all levels")
175+
.group("repeatAll() with filtering")
176+
.testEquals(
177+
List.of("1", "1.1"),
178+
"repeatAll(item).where(type = 'group').linkId",
179+
"repeatAll(item).where(type = 'group') filters items from all nesting levels")
180+
.group("repeatAll() $index not defined")
181+
.testError(
182+
"repeatAll($index)", "$index is not available within repeatAll projection expression")
136183
.build();
137184
}
138185

139186
@FhirPathTest
140-
public Stream<DynamicTest> testRepeatAllFiltering() {
187+
public Stream<DynamicTest> testRepeatAllPatientProjections() {
188+
// repeatAll(first()) produces the same SQL DataType at every level (ArrayType(HumanName)),
189+
// triggering same-type depth limiting. With a depth limit of 10, the initial level (where
190+
// parentType is None and does not consume budget) plus 10 same-type levels produce 11 total
191+
// copies of the first element before traversal stops.
141192
return builder()
142-
.withResource(createQuestionnaire())
143-
.group("repeatAll() with filtering")
193+
.withResource(createPatient())
194+
.group("repeatAll() non-recursive projection")
144195
.testEquals(
145-
List.of("1", "1.1"),
146-
"repeatAll(item).where(type = 'group').linkId",
147-
"repeatAll(item).where(type = 'group') filters items from all nesting levels")
196+
List.of("Smith", "Jones"),
197+
"repeatAll(name).family",
198+
"repeatAll(name).family returns same result as select() for non-recursive fields")
199+
.group("repeatAll() singular primitive projection")
200+
.testEquals(
201+
"male",
202+
"repeatAll(gender)",
203+
"repeatAll(gender) returns a singular primitive value like select()")
204+
.group("repeatAll() singular complex projection")
205+
.testEquals(
206+
"M",
207+
"repeatAll(maritalStatus).coding.code",
208+
"repeatAll(maritalStatus) returns a singular complex value with sub-elements intact")
209+
.group("repeatAll() identity-like same-type depth limiting")
210+
.testEquals(
211+
11,
212+
"name.repeatAll(first()).count()",
213+
"repeatAll(first()) stops after same-type depth limit and returns bounded results")
214+
.group("repeatAll() extension traversal")
215+
.testEquals(
216+
List.of(
217+
"http://example.com/simple",
218+
"http://example.com/parent",
219+
"http://example.com/child"),
220+
"repeatAll(extension).url",
221+
"repeatAll(extension) recursively collects all extensions including nested"
222+
+ " sub-extensions")
148223
.build();
149224
}
150225

@@ -157,6 +232,10 @@ public Stream<DynamicTest> testRepeatAllEmptyInput() {
157232
"repeatAll(item)", "repeatAll() on a resource with no items returns empty collection")
158233
.testEquals(
159234
0, "repeatAll(item).count()", "repeatAll(item).count() returns 0 for empty input")
235+
.group("repeatAll() empty literal")
236+
.testEmpty(
237+
"{}.repeatAll($this)",
238+
"repeatAll() on the FHIRPath empty literal returns empty collection")
160239
.build();
161240
}
162241

@@ -231,56 +310,6 @@ public Stream<DynamicTest> testRepeatAllDuplicatesPreserved() {
231310
.build();
232311
}
233312

234-
@FhirPathTest
235-
public Stream<DynamicTest> testRepeatAllNonRecursiveProjection() {
236-
final Patient patient = new Patient();
237-
patient.setId("test-patient");
238-
patient.addName().setFamily("Smith");
239-
patient.addName().setFamily("Jones");
240-
241-
return builder()
242-
.withResource(patient)
243-
.group("repeatAll() non-recursive projection")
244-
.testEquals(
245-
List.of("Smith", "Jones"),
246-
"repeatAll(name).family",
247-
"repeatAll(name).family returns same result as select() for non-recursive fields")
248-
.build();
249-
}
250-
251-
@FhirPathTest
252-
public Stream<DynamicTest> testRepeatAllSingularPrimitive() {
253-
final Patient patient = new Patient();
254-
patient.setId("singular-patient");
255-
patient.setGender(AdministrativeGender.MALE);
256-
257-
return builder()
258-
.withResource(patient)
259-
.group("repeatAll() singular primitive projection")
260-
.testEquals(
261-
"male",
262-
"repeatAll(gender)",
263-
"repeatAll(gender) returns a singular primitive value like select()")
264-
.build();
265-
}
266-
267-
@FhirPathTest
268-
public Stream<DynamicTest> testRepeatAllSingularComplex() {
269-
final Patient patient = new Patient();
270-
patient.setId("complex-patient");
271-
patient.setMaritalStatus(
272-
new CodeableConcept(new Coding("http://example.com/cs", "M", "Married")));
273-
274-
return builder()
275-
.withResource(patient)
276-
.group("repeatAll() singular complex projection")
277-
.testEquals(
278-
"M",
279-
"repeatAll(maritalStatus).coding.code",
280-
"repeatAll(maritalStatus) returns a singular complex value with sub-elements intact")
281-
.build();
282-
}
283-
284313
/**
285314
* Creates a QuestionnaireResponse with items nested through answer elements, to verify that
286315
* repeatAll can traverse through intermediate types (Item -> Answer -> Item).

openspec/changes/repeat-all-function/specs/fhirpath-repeat-all/spec.md

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,21 @@ across all current items produces an empty collection (no new results).
6868

6969
### Requirement: repeatAll function handles empty input
7070

71-
If the input collection is empty, the result SHALL be empty.
71+
If the input collection is empty, the result SHALL be empty. This SHALL hold
72+
both when the input is a typed collection that evaluates to empty at runtime
73+
(e.g., a resource where the traversed field is absent) and when the input is
74+
the FHIRPath empty literal `{}`.
7275

73-
#### Scenario: Empty input collection
76+
#### Scenario: Empty collection from absent field
7477

75-
- **WHEN** `repeatAll` is called on an empty collection
78+
- **WHEN** `repeatAll` is called on a resource where the traversed field is
79+
absent (e.g., a Questionnaire with no items)
80+
- **THEN** the result SHALL be an empty collection
81+
82+
#### Scenario: Empty collection literal
83+
84+
- **WHEN** `repeatAll` is called on the FHIRPath empty literal `{}` (e.g.,
85+
`{}.repeatAll(item)`)
7686
- **THEN** the result SHALL be an empty collection
7787

7888
### Requirement: repeatAll function supports chained expressions
@@ -104,26 +114,53 @@ subsequent FHIRPath operations such as path navigation, `where()`, `count()`,
104114

105115
### Requirement: repeatAll function bounds same-type recursion depth
106116

107-
The function SHALL always navigate to the deepest level possible regardless of
108-
the encoding's maximum nesting level. Recursion depth SHALL only be limited when
109-
the traversal expression is self-referential (i.e. navigates to a node of the
110-
same type), in which case a hardcoded depth limit of 10 SHALL prevent infinite
111-
loops. This matches the approach used by the SQL on FHIR `repeat` clause
112-
implementation.
113-
114-
#### Scenario: Same-type recursion bounded by depth limit
115-
116-
- **WHEN** `repeatAll` is evaluated and the traversal expression navigates to a
117-
node of the same type (e.g., `item` navigating from Item to Item)
118-
- **THEN** the function SHALL stop same-type traversal after the hardcoded depth
119-
limit and return results collected up to that point
120-
121-
#### Scenario: Cross-type traversal not limited by depth
122-
123-
- **WHEN** `repeatAll` is evaluated and the traversal expression crosses
124-
different types before recurring (e.g., Item → Answer → Item)
125-
- **THEN** the cross-type steps SHALL NOT consume depth budget, allowing deeper
126-
traversal than the same-type limit alone would permit
117+
The function delegates recursive traversal to `UnresolvedTransformTree`, which
118+
limits recursion depth based on Spark SQL `DataType` equality. The depth counter
119+
only decrements when the traversal expression produces a node whose resolved SQL
120+
`DataType` is structurally identical to its parent's. When the counter reaches
121+
zero, traversal stops and returns results collected up to that point. The
122+
hardcoded same-type depth limit is 10, matching the default
123+
`maxUnboundTraversalDepth` used by the SQL on FHIR `repeat` clause.
124+
125+
For traversals through schema-truncated structures (e.g.,
126+
`Questionnaire.repeatAll(item)`), each nesting level has a progressively smaller
127+
`StructType` due to the encoding's `maxNestingLevel` truncation. These are NOT
128+
same-type traversals — the depth counter is never decremented, and traversal
129+
terminates naturally when a field is no longer present in the truncated schema
130+
(`FIELD_NOT_FOUND`).
131+
132+
Same-type recursion occurs when the traversal produces an element with an
133+
identical SQL `DataType` at every level. Known cases include:
134+
135+
- **Extension traversal**: Extension structs have a fixed flat schema at all
136+
nesting levels (extensions use a global `_extension` map rather than recursive
137+
struct fields), so `repeatAll(extension)` produces same-type nodes.
138+
- **Identity-like traversals**: Functions such as `first()` that return an
139+
element of the same type as the input, causing the SQL `DataType` to remain
140+
unchanged across levels.
141+
142+
#### Scenario: Extension traversal bounded by actual depth or limit
143+
144+
- **WHEN** `repeatAll(extension)` is evaluated on a resource whose elements
145+
carry nested extensions
146+
- **THEN** the function SHALL traverse extensions up to the actual nesting depth
147+
or the same-type depth limit (10), whichever is reached first, and return all
148+
collected extensions
149+
150+
#### Scenario: Identity-like traversal bounded by depth limit
151+
152+
- **WHEN** `repeatAll` is evaluated with a traversal that always produces the
153+
same SQL `DataType` (e.g., `repeatAll(first())` on a complex-typed
154+
collection)
155+
- **THEN** the function SHALL stop after the same-type depth limit and return
156+
results collected up to that point
157+
158+
#### Scenario: Schema-truncated traversal terminates naturally
159+
160+
- **WHEN** `repeatAll(item)` is evaluated on a Questionnaire with nested items
161+
- **THEN** traversal SHALL terminate naturally when the schema no longer
162+
contains the `item` field (due to `maxNestingLevel` truncation during the
163+
encoding), without consuming same-type depth budget
127164

128165
### Requirement: repeatAll function does not define $index
129166

0 commit comments

Comments
 (0)