Skip to content

Commit 1c4ccbb

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

28 files changed

+503
-186
lines changed

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

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@
22

33
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
44

5+
import java.util.ArrayList;
56
import java.util.List;
67

78
import static java.util.Objects.requireNonNull;
89

910
@JsonIgnoreProperties(ignoreUnknown = true)
1011
public record AnnotatedAttribute(String attributeRef, String fhirPath,
11-
String terserPath,
1212
boolean mustHave,
1313
List<String> linkedGroups) {
1414

1515
public AnnotatedAttribute {
1616
requireNonNull(attributeRef);
1717
requireNonNull(fhirPath);
18-
requireNonNull(terserPath);
1918
linkedGroups = linkedGroups == null ? List.of() : List.copyOf(linkedGroups);
2019
}
2120

@@ -24,9 +23,54 @@ public record AnnotatedAttribute(String attributeRef, String fhirPath,
2423
*/
2524
public AnnotatedAttribute(String attributeRef,
2625
String fhirPath,
27-
String terserPath,
2826
boolean mustHave
2927
) {
30-
this(attributeRef, fhirPath, terserPath, mustHave, List.of()); // Default value for includeReferenceOnly
28+
this(attributeRef, fhirPath, mustHave, List.of()); // Default value for includeReferenceOnly
3129
}
30+
31+
/**
32+
* Splits the FHIR path into sub-paths according to the following rules:
33+
* Splits after each {@code where} clause (including the where clause itself)
34+
* Splits at each dot ({@code .}) when there is no where clause
35+
* This method is useful for processing complex FHIR path expressions by breaking them
36+
* into manageable segments that can be evaluated sequentially.
37+
*
38+
* @return a list of sub-paths in the order they appear in the original FHIR path.
39+
* The list is never {@code null} and contains at least one element.
40+
*/
41+
public List<String> splitFhirPath() {
42+
List<String> parts = new ArrayList<>();
43+
int start = 0;
44+
int parenDepth = 0;
45+
String path = fhirPath;
46+
47+
for (int i = 0; i < path.length(); i++) {
48+
char c = path.charAt(i);
49+
50+
if (c == '(') {
51+
parenDepth++;
52+
} else if (c == ')') {
53+
parenDepth--;
54+
} else if (c == '.' && parenDepth == 0) {
55+
// Split at dot unless the next segment is "where("
56+
if (path.startsWith("where(", i + 1)) {
57+
continue; // do not split; attach where(...) to previous segment
58+
}
59+
if (start < i) {
60+
parts.add(path.substring(start, i));
61+
}
62+
start = i + 1;
63+
}
64+
}
65+
66+
// Add last segment
67+
if (start < path.length()) {
68+
parts.add(path.substring(start));
69+
}
70+
71+
return parts;
72+
}
73+
3274
}
75+
76+

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import de.medizininformatikinitiative.torch.model.crtdl.Filter;
55
import de.medizininformatikinitiative.torch.model.fhir.Query;
66
import de.medizininformatikinitiative.torch.model.fhir.QueryParams;
7+
import de.medizininformatikinitiative.torch.model.management.CopyTreeNode;
78
import de.medizininformatikinitiative.torch.model.mapping.DseMappingTreeBase;
89
import org.hl7.fhir.r4.model.Resource;
910

@@ -105,4 +106,17 @@ public List<AnnotatedAttribute> refAttributes() {
105106
return attributes.stream().filter(annotatedAttribute -> !annotatedAttribute.linkedGroups().isEmpty()).toList();
106107
}
107108

109+
public CopyTreeNode buildTree() {
110+
CopyTreeNode root = new CopyTreeNode(resourceType());
111+
112+
for (AnnotatedAttribute attr : attributes) {
113+
List<String> parts = attr.splitFhirPath();
114+
CopyTreeNode current = root;
115+
for (int i = 1; i < parts.size(); i++) {
116+
current = current.getOrCreateChild(parts.get(i));
117+
}
118+
}
119+
return root;
120+
}
121+
108122
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package de.medizininformatikinitiative.torch.model.management;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.Objects;
6+
import java.util.Optional;
7+
8+
import static de.medizininformatikinitiative.torch.util.CopyUtils.baseName;
9+
10+
public record CopyTreeNode(String subFhirPath, List<CopyTreeNode> children) {
11+
12+
public CopyTreeNode(String subFhirPath) {
13+
this(subFhirPath, new ArrayList<>());
14+
}
15+
16+
public CopyTreeNode {
17+
Objects.requireNonNull(subFhirPath);
18+
children = new ArrayList<>(children); // keep mutable for adding children
19+
}
20+
21+
/**
22+
* Returns the child node with the given FHIRPath segment if it already exists,
23+
* or creates a new child node with that segment and adds it to this node's children.
24+
* <p>
25+
* Each segment of a FHIRPath, including conditional nodes (e.g., {@code .where(...)}),
26+
* is treated as a separate child. This allows multiple FHIRPaths to be merged
27+
* into the tree while preserving the hierarchy and separating conditional from
28+
* unconditional nodes.
29+
*
30+
* @param subPath the FHIRPath segment for the child node
31+
* @return the existing child node if present, otherwise a newly created child node
32+
*/
33+
public CopyTreeNode getOrCreateChild(String subPath) {
34+
return getChild(subPath).orElseGet(() -> {
35+
CopyTreeNode child = new CopyTreeNode(subPath);
36+
children.add(child);
37+
return child;
38+
});
39+
}
40+
41+
public Optional<CopyTreeNode> getChild(String subPath) {
42+
return children.stream()
43+
.filter(c -> c.subFhirPath.equals(subPath))
44+
.findFirst();
45+
}
46+
47+
public CopyTreeNode merged(CopyTreeNode other) {
48+
CopyTreeNode result = new CopyTreeNode(this.subFhirPath, new ArrayList<>(this.children));
49+
50+
for (CopyTreeNode otherChild : other.children()) {
51+
String otherBase = baseName(otherChild.subFhirPath());
52+
53+
// Find any existing child with the same base name
54+
Optional<CopyTreeNode> matching = result.children().stream()
55+
.filter(c -> baseName(c.subFhirPath).equals(otherBase))
56+
.findFirst();
57+
58+
if (matching.isPresent()) {
59+
CopyTreeNode existingChild = matching.get();
60+
61+
if (existingChild.children().isEmpty()) {
62+
// Unconditional node with no children covers everything, skip conditional entirely
63+
continue;
64+
}
65+
66+
// Determine which children are already covered
67+
List<CopyTreeNode> uncovered = otherChild.children().stream()
68+
.filter(c -> existingChild.children().stream()
69+
.noneMatch(e -> e.subFhirPath.equals(c.subFhirPath)))
70+
.toList();
71+
72+
if (!uncovered.isEmpty()) {
73+
// Add conditional node with only uncovered children
74+
CopyTreeNode conditionalNode = new CopyTreeNode(otherChild.subFhirPath, uncovered);
75+
result.children().add(conditionalNode);
76+
}
77+
// Else all children already covered, do not add
78+
} else {
79+
// No matching base, add otherChild as-is
80+
result.children().add(otherChild);
81+
}
82+
}
83+
84+
return result;
85+
}
86+
87+
88+
}

src/main/java/de/medizininformatikinitiative/torch/service/BatchCopierRedacter.java

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,8 @@
55
import de.medizininformatikinitiative.torch.model.consent.PatientBatchWithConsent;
66
import de.medizininformatikinitiative.torch.model.crtdl.annotated.AnnotatedAttribute;
77
import de.medizininformatikinitiative.torch.model.crtdl.annotated.AnnotatedAttributeGroup;
8-
import de.medizininformatikinitiative.torch.model.management.ExtractionRedactionWrapper;
9-
import de.medizininformatikinitiative.torch.model.management.PatientResourceBundle;
10-
import de.medizininformatikinitiative.torch.model.management.ProfileAttributeCollection;
8+
import de.medizininformatikinitiative.torch.model.management.*;
119
import de.medizininformatikinitiative.torch.model.management.ResourceBundle;
12-
import de.medizininformatikinitiative.torch.model.management.ResourceGroup;
1310
import de.medizininformatikinitiative.torch.util.ElementCopier;
1411
import de.medizininformatikinitiative.torch.util.Redaction;
1512
import de.medizininformatikinitiative.torch.util.ResourceUtils;
@@ -18,11 +15,7 @@
1815
import org.slf4j.Logger;
1916
import org.slf4j.LoggerFactory;
2017

21-
import java.util.HashMap;
22-
import java.util.HashSet;
23-
import java.util.Map;
24-
import java.util.Optional;
25-
import java.util.Set;
18+
import java.util.*;
2619
import java.util.stream.Collectors;
2720

2821
import static java.util.Objects.requireNonNull;
@@ -128,7 +121,7 @@ public PatientResourceBundle transform(PatientResourceBundle patientResourceBund
128121

129122
Resource transformed = transform(wrapper);
130123

131-
bundle.remove(ResourceUtils.getRelativeURL(transformed));
124+
bundle.remove(resourceId);
132125
bundle.put(transformed);
133126

134127

src/main/java/de/medizininformatikinitiative/torch/service/CrtdlValidatorService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ private AnnotatedAttributeGroup annotateGroup(AttributeGroup attributeGroup, Com
104104
}
105105

106106
String[] fhirTerser = FhirPathBuilder.handleSlicingForFhirPath(attribute.attributeRef(), definition);
107-
annotatedAttributes.add(new AnnotatedAttribute(attribute.attributeRef(), fhirTerser[0], fhirTerser[1], attribute.mustHave(), attribute.linkedGroups()));
107+
annotatedAttributes.add(new AnnotatedAttribute(attribute.attributeRef(), fhirTerser[0], attribute.mustHave(), attribute.linkedGroups()));
108108
}
109109

110110
AnnotatedAttributeGroup group = attributeGenerator

src/main/java/de/medizininformatikinitiative/torch/service/StandardAttributeGenerator.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,16 @@ public AnnotatedAttributeGroup generate(AttributeGroup attributeGroup, String pa
4242

4343
String resourceType = definition.type();
4444
String id = resourceType + ".id";
45-
tempAttributes.add(new AnnotatedAttribute(id, id, id, false));
45+
tempAttributes.add(new AnnotatedAttribute(id, id, false));
4646
String profile = resourceType + ".meta.profile";
47-
tempAttributes.add(new AnnotatedAttribute(profile, profile, profile, false));
47+
tempAttributes.add(new AnnotatedAttribute(profile, profile, false));
4848

4949

5050
if (compartmentManager.isInCompartment(resourceType)) {
5151
for (String field : patientRefFields) {
5252
String fieldString = resourceType + "." + field;
5353
if (definition.elementDefinitionById(fieldString).isPresent()) {
54-
tempAttributes.add(new AnnotatedAttribute(fieldString, fieldString, fieldString, false, List.of(patientGroupId)));
54+
tempAttributes.add(new AnnotatedAttribute(fieldString, fieldString, false, List.of(patientGroupId)));
5555
}
5656
}
5757
}

src/main/java/de/medizininformatikinitiative/torch/util/CopyUtils.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,13 @@ public static String capitalizeFirstLetter(String str) {
5555
return firstChar + restOfString;
5656
}
5757

58+
59+
/**
60+
* Returns the base element name, stripping any ".where(...)" suffix
61+
*/
62+
public static String baseName(String name) {
63+
int idx = name.indexOf(".where(");
64+
return idx == -1 ? name : name.substring(0, idx);
65+
}
66+
5867
}

src/main/java/de/medizininformatikinitiative/torch/util/ElementCopier.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ca.uhn.fhir.util.TerserUtil;
66
import de.medizininformatikinitiative.torch.exceptions.MustHaveViolatedException;
77
import de.medizininformatikinitiative.torch.model.crtdl.annotated.AnnotatedAttribute;
8+
import de.medizininformatikinitiative.torch.model.management.CopyTreeNode;
89
import org.hl7.fhir.instance.model.api.IBase;
910
import org.hl7.fhir.r4.model.Base;
1011
import org.hl7.fhir.r4.model.DomainResource;
@@ -14,6 +15,7 @@
1415
import java.lang.reflect.Method;
1516
import java.util.List;
1617

18+
import static de.medizininformatikinitiative.torch.util.CopyUtils.baseName;
1719
import static de.medizininformatikinitiative.torch.util.CopyUtils.capitalizeFirstLetter;
1820

1921
/**
@@ -38,6 +40,30 @@ public ElementCopier(FhirContext ctx) {
3840
}
3941

4042

43+
/**
44+
* Copies elements from the source resource to the target resource according to the copy tree.
45+
*
46+
* @param src Source Resource to copy from
47+
* @param tgt Target Resource to copy to
48+
* @param copyTree Attribute tree describing which elements to copy
49+
* @throws MustHaveViolatedException if a mandatory element is missing
50+
*/
51+
public void copy(Base src, Base tgt, CopyTreeNode copyTree) {
52+
53+
copyTree.children().stream().forEach(child -> {
54+
List<Base> elements;
55+
elements = fhirPathEngine.evaluate(src, child.subFhirPath(), Base.class);
56+
elements.stream().forEach(element -> {
57+
Base extractedElement = tgt.addChild(baseName(child.subFhirPath()));
58+
copy(element, extractedElement, child);
59+
extractedElement.copyValues(element);
60+
});
61+
});
62+
63+
64+
}
65+
66+
4167
/**
4268
* @param src Source Resource to copy from
4369
* @param tgt Target Resource to copy to
@@ -60,7 +86,7 @@ public <T extends DomainResource> void copy(T src, T tgt, AnnotatedAttribute att
6086
}
6187
} else {
6288

63-
String terserFHIRPATH = attribute.terserPath();
89+
String terserFHIRPATH = attribute.fhirPath();
6490
logger.trace("Terser FhirPath {}", terserFHIRPATH);
6591
if (elements.size() == 1) {
6692

0 commit comments

Comments
 (0)