Skip to content

Commit 7465d04

Browse files
committed
Support Nested Lists
closes #499
1 parent 9ad7a69 commit 7465d04

34 files changed

+1151
-846
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package de.medizininformatikinitiative.torch.model.crtdl;
2+
3+
import de.medizininformatikinitiative.torch.model.crtdl.annotated.AnnotatedAttribute;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
8+
import static java.util.Objects.requireNonNull;
9+
10+
public record FieldCondition(String fieldName, String condition) {
11+
12+
public FieldCondition {
13+
requireNonNull(fieldName, "fieldName must not be null");
14+
requireNonNull(condition, "condition must not be null");
15+
}
16+
17+
18+
public String fhirPath() {
19+
return fieldName + condition;
20+
}
21+
22+
23+
/**
24+
* Splits a FHIR path segment into a field and a condition (e.g., `.where(...)`, `.ofType(...)` or `.type(...)`).
25+
* <p>
26+
* If the segment contains a function call, the field will be everything before the first function,
27+
* and the condition will include the function call itself (including parentheses and nested content).
28+
*
29+
* @param segment the FHIR path segment to parse
30+
* @return a {@link FieldCondition} object with field and condition separated
31+
*/
32+
private static FieldCondition parseSegment(String segment) {
33+
String[] functions = {"where(", "ofType(", "type("};
34+
35+
int firstFuncIndex = -1;
36+
37+
for (String func : functions) {
38+
int idx = segment.indexOf("." + func);
39+
if (idx >= 0 && (firstFuncIndex == -1 || idx < firstFuncIndex)) {
40+
firstFuncIndex = idx;
41+
}
42+
}
43+
44+
if (firstFuncIndex >= 0) {
45+
// Field is everything before the first function
46+
String field = segment.substring(0, firstFuncIndex).trim();
47+
// Condition is everything from the first function onwards
48+
String cond = segment.substring(firstFuncIndex).trim();
49+
return new FieldCondition(field, cond);
50+
} else {
51+
return new FieldCondition(segment.trim(), "");
52+
}
53+
}
54+
55+
/**
56+
* Splits a FHIRPath expression into sequential segments as {@link FieldCondition} objects.
57+
* <p>
58+
* Rules for splitting:
59+
* <ul>
60+
* <li>Splits after each {@code where} clause, keeping the clause as part of the condition.</li>
61+
* <li>Splits at each {@code .} (dot) when not inside parentheses and not immediately followed by a function call like {@code where()} or {@code ofType()}.</li>
62+
* <li>Keeps nested function calls intact.</li>
63+
* </ul>
64+
* Example:
65+
* <pre>
66+
* Input: "Observation.value.ofType(Quantity).where(value &gt; 5).unit"
67+
* Output: [
68+
* FieldCondition(field="Observation", condition=""),
69+
* FieldCondition(field="value", condition=".ofType(Quantity)"),
70+
* FieldCondition(field="", condition=".where(value &gt; 5)"),
71+
* FieldCondition(field="unit", condition="")
72+
* ]
73+
* </pre>
74+
*
75+
* @return a non-null {@link List} of {@link FieldCondition} objects representing each segment of the FHIRPath
76+
*/
77+
public static List<FieldCondition> splitFhirPath(AnnotatedAttribute attr) {
78+
String fhirPath = attr.fhirPath();
79+
List<FieldCondition> result = new ArrayList<>();
80+
int start = 0;
81+
int parenDepth = 0;
82+
83+
for (int i = 0; i < fhirPath.length(); i++) {
84+
char c = fhirPath.charAt(i);
85+
if (c == '(') parenDepth++;
86+
else if (c == ')') parenDepth--;
87+
else if (c == '.' && parenDepth == 0) {
88+
// Look ahead to check for function calls: where(), ofType(), type()
89+
String remaining = fhirPath.substring(i + 1);
90+
if (remaining.startsWith("where(") || remaining.startsWith("ofType(") || remaining.startsWith("type(")) {
91+
continue; // skip splitting for this dot
92+
}
93+
result.add(parseSegment(fhirPath.substring(start, i)));
94+
start = i + 1;
95+
}
96+
}
97+
98+
if (start < fhirPath.length()) {
99+
result.add(parseSegment(fhirPath.substring(start)));
100+
}
101+
102+
return result;
103+
}
104+
}

src/main/java/de/medizininformatikinitiative/torch/model/crtdl/annotated/AnnotatedAttribute.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,12 @@
88

99
@JsonIgnoreProperties(ignoreUnknown = true)
1010
public record AnnotatedAttribute(String attributeRef, String fhirPath,
11-
String terserPath,
1211
boolean mustHave,
1312
List<String> linkedGroups) {
1413

1514
public AnnotatedAttribute {
1615
requireNonNull(attributeRef);
1716
requireNonNull(fhirPath);
18-
requireNonNull(terserPath);
1917
linkedGroups = linkedGroups == null ? List.of() : List.copyOf(linkedGroups);
2018
}
2119

@@ -24,9 +22,10 @@ public record AnnotatedAttribute(String attributeRef, String fhirPath,
2422
*/
2523
public AnnotatedAttribute(String attributeRef,
2624
String fhirPath,
27-
String terserPath,
2825
boolean mustHave
2926
) {
30-
this(attributeRef, fhirPath, terserPath, mustHave, List.of()); // Default value for includeReferenceOnly
27+
this(attributeRef, fhirPath, mustHave, List.of()); // Default value for includeReferenceOnly
3128
}
3229
}
30+
31+

src/main/java/de/medizininformatikinitiative/torch/model/crtdl/annotated/AnnotatedAttributeGroup.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package de.medizininformatikinitiative.torch.model.crtdl.annotated;
22

33

4+
import de.medizininformatikinitiative.torch.model.crtdl.FieldCondition;
45
import de.medizininformatikinitiative.torch.model.crtdl.Filter;
56
import de.medizininformatikinitiative.torch.model.fhir.Query;
67
import de.medizininformatikinitiative.torch.model.fhir.QueryParams;
8+
import de.medizininformatikinitiative.torch.model.management.CopyTreeNode;
79
import de.medizininformatikinitiative.torch.model.mapping.DseMappingTreeBase;
810
import org.hl7.fhir.r4.model.Resource;
911

@@ -105,4 +107,17 @@ public List<AnnotatedAttribute> refAttributes() {
105107
return attributes.stream().filter(annotatedAttribute -> !annotatedAttribute.linkedGroups().isEmpty()).toList();
106108
}
107109

110+
public CopyTreeNode buildTree() {
111+
CopyTreeNode root = new CopyTreeNode(resourceType());
112+
113+
for (AnnotatedAttribute attr : attributes) {
114+
List<FieldCondition> parts = FieldCondition.splitFhirPath(attr);
115+
CopyTreeNode current = root;
116+
for (int i = 1; i < parts.size(); i++) {
117+
current = current.getOrCreateChild(parts.get(i));
118+
}
119+
}
120+
return root;
121+
}
122+
108123
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package de.medizininformatikinitiative.torch.model.management;
2+
3+
4+
import de.medizininformatikinitiative.torch.model.crtdl.FieldCondition;
5+
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.Objects;
9+
import java.util.Optional;
10+
import java.util.stream.Stream;
11+
12+
public record CopyTreeNode(FieldCondition fieldCondition,
13+
List<CopyTreeNode> children) {
14+
15+
public CopyTreeNode(String fieldName) {
16+
this(new FieldCondition(fieldName, ""), new ArrayList<>());
17+
}
18+
19+
public CopyTreeNode(String fieldName, String condition, List<CopyTreeNode> children) {
20+
this(new FieldCondition(fieldName, condition), children);
21+
}
22+
23+
public CopyTreeNode(FieldCondition fieldCondition) {
24+
this(fieldCondition, new ArrayList<>());
25+
}
26+
27+
public CopyTreeNode {
28+
Objects.requireNonNull(fieldCondition);
29+
children = new ArrayList<>(children); // mutable copy
30+
}
31+
32+
public boolean isConditional() {
33+
return !fieldCondition.condition().isEmpty();
34+
}
35+
36+
public String fhirPath() {
37+
return fieldCondition.fhirPath();
38+
}
39+
40+
public String condition() {
41+
return fieldCondition.condition();
42+
}
43+
44+
public String fieldName() {
45+
return fieldCondition.fieldName();
46+
}
47+
48+
public CopyTreeNode getOrCreateChild(FieldCondition fieldCondition) {
49+
return getChild(fieldCondition).orElseGet(() -> {
50+
CopyTreeNode child = new CopyTreeNode(fieldCondition);
51+
children.add(child);
52+
return child;
53+
});
54+
}
55+
56+
public Optional<CopyTreeNode> getChild(FieldCondition fieldCondition) {
57+
return children.stream()
58+
.filter(c -> c.fieldCondition.equals(fieldCondition))
59+
.findFirst();
60+
}
61+
62+
/**
63+
* Merges this {@code CopyTreeNode} with another node, combining children while respecting
64+
* unconditional and conditional branches.
65+
* <p>
66+
* Merge rules:
67+
* <ul>
68+
* <li>Unconditional children (condition == "") are merged recursively.</li>
69+
* <li>Conditional children (condition != "") are kept unless fully covered by an
70+
* unconditional child with empty children.</li>
71+
* <li>Conditional children that are partially covered by an unconditional child
72+
* will keep only the remaining, uncovered children.</li>
73+
* <li>This merge is applied recursively for all child nodes.</li>
74+
* </ul>
75+
*
76+
* <p>Example:</p>
77+
* <pre>
78+
* Node a: identifier -> [] (unconditional, empty children)
79+
* Node b: identifier.where(type='official') -> [value]
80+
* Merged result: identifier -> [] (conditional branch removed)
81+
* </pre>
82+
*
83+
* @param other the other {@code CopyTreeNode} to merge with
84+
* @return a new {@code CopyTreeNode} containing the merged structure
85+
*/
86+
public CopyTreeNode merged(CopyTreeNode other) {
87+
// Step 0: combine unconditional children first
88+
List<CopyTreeNode> allChildren = new ArrayList<>();
89+
90+
// Collect all unconditional children from both trees
91+
List<CopyTreeNode> uncondThis = this.children().stream()
92+
.filter(c -> c.fieldCondition.condition().isEmpty())
93+
.toList();
94+
List<CopyTreeNode> uncondOther = other.children().stream()
95+
.filter(c -> c.fieldCondition.condition().isEmpty())
96+
.toList();
97+
98+
// Merge unconditional children by fieldName recursively
99+
List<CopyTreeNode> mergedUncond = new ArrayList<>();
100+
for (CopyTreeNode c : uncondThis) {
101+
CopyTreeNode match = uncondOther.stream()
102+
.filter(o -> o.fieldCondition.fieldName().equals(c.fieldCondition.fieldName()))
103+
.findFirst()
104+
.orElse(null);
105+
106+
if (match != null) {
107+
mergedUncond.add(c.merged(match));
108+
} else {
109+
mergedUncond.add(c);
110+
}
111+
}
112+
113+
// Add unconditional children from other that were not in this
114+
uncondOther.stream()
115+
.filter(o -> mergedUncond.stream().noneMatch(m -> m.fieldCondition.fieldName().equals(o.fieldCondition.fieldName())))
116+
.forEach(mergedUncond::add);
117+
118+
allChildren.addAll(mergedUncond);
119+
120+
// Step 1: handle conditional children
121+
List<CopyTreeNode> condChildren = Stream.concat(
122+
this.children().stream().filter(c -> !c.fieldCondition.condition().isEmpty()),
123+
other.children().stream().filter(c -> !c.fieldCondition.condition().isEmpty())
124+
).toList();
125+
126+
for (CopyTreeNode c : condChildren) {
127+
// Find corresponding unconditional child with same fieldName
128+
CopyTreeNode uncondMatch = mergedUncond.stream()
129+
.filter(u -> u.fieldCondition.fieldName().equals(c.fieldCondition.fieldName()))
130+
.findFirst().orElse(null);
131+
132+
List<CopyTreeNode> remainingChildren;
133+
if (uncondMatch != null) {
134+
// Remove any children that are already in the unconditional branch
135+
remainingChildren = c.children().stream()
136+
.filter(ch -> uncondMatch.children().stream().noneMatch(uCh -> uCh.equals(ch)))
137+
.toList();
138+
} else {
139+
remainingChildren = new ArrayList<>(c.children());
140+
}
141+
142+
if (!remainingChildren.isEmpty()) {
143+
allChildren.add(new CopyTreeNode(c.fieldCondition, remainingChildren));
144+
}
145+
}
146+
147+
return new CopyTreeNode(this.fieldCondition, allChildren);
148+
}
149+
150+
151+
}

src/main/java/de/medizininformatikinitiative/torch/model/management/ExtractionRedactionWrapper.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,21 @@
1212
* @param resource Resource to be processed
1313
* @param profiles profiles of structure definitions of the applied groups
1414
* @param references map from elementid to reference string
15-
* @param attributes merged attributes for the extraction
15+
* @param copyTree merged attributes for the extraction
1616
*/
1717
public record ExtractionRedactionWrapper(DomainResource resource, Set<String> profiles,
1818
Map<String, Set<String>> references,
19-
Set<de.medizininformatikinitiative.torch.model.crtdl.annotated.AnnotatedAttribute> attributes) {
19+
CopyTreeNode copyTree) {
2020

2121
public ExtractionRedactionWrapper {
2222
Objects.requireNonNull(resource);
23+
Objects.requireNonNull(copyTree);
2324
profiles = Set.copyOf(profiles);
24-
attributes = Set.copyOf(attributes);
2525
references = Map.copyOf(references);
2626
}
2727

2828
public ExtractionRedactionWrapper updateWithResource(DomainResource resource) {
29-
return new ExtractionRedactionWrapper(resource, profiles, references, attributes);
29+
return new ExtractionRedactionWrapper(resource, profiles, references, copyTree);
3030
}
3131

3232
}

src/main/java/de/medizininformatikinitiative/torch/model/management/ProfileAttributeCollection.java

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)