Skip to content

Commit 5bac08f

Browse files
authored
Harden $create-changelog and improve artifact-diff fidelity (#1039)
* Harden $create-changelog and improve artifact-diff fidelity * Addressing minor sonarqube issue
1 parent 146ffe8 commit 5bac08f

5 files changed

Lines changed: 460 additions & 29 deletions

File tree

cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/common/HapiArtifactDiffProcessor.java

Lines changed: 148 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.hl7.fhir.r4.model.Parameters;
3232
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
3333
import org.hl7.fhir.r4.model.PlanDefinition;
34+
import org.hl7.fhir.r4.model.Reference;
3435
import org.hl7.fhir.r4.model.RelatedArtifact;
3536
import org.hl7.fhir.r4.model.StringType;
3637
import org.hl7.fhir.r4.model.Type;
@@ -192,8 +193,14 @@ private static void handleSourceMatches(
192193
combinedReferenceList.getSourceMatches().get(i).getResource();
193194
var targetCanonical =
194195
combinedReferenceList.getTargetMatches().get(i).getResource();
196+
// Identical version-pinned canonicals reference the same immutable artifact — there
197+
// is nothing to diff, so skip the resolve/recurse entirely. This avoids both wasted
198+
// fetches and spurious "could not be retrieved" entries for unchanged dependencies.
199+
if (Objects.equals(sourceCanonical, targetCanonical)) {
200+
continue;
201+
}
195202
boolean diffNotAlreadyComputedAndPresent =
196-
baseDiff.getParameter(Canonicals.getUrl(targetCanonical)) == null;
203+
!hasParameterNamed(baseDiff, Canonicals.getUrl(targetCanonical));
197204
if (diffNotAlreadyComputedAndPresent) {
198205
var source = checkOrUpdateResourceCache(
199206
sourceCanonical, cache, repository, true, ctx, terminologyEndpoint, compareExecutable);
@@ -227,14 +234,19 @@ private static void handleSourceMatches(
227234
terminologyEndpoint);
228235
},
229236
() -> {
237+
// A side couldn't be resolved (commonly external code systems
238+
// like LOINC/SNOMED that aren't stored as full resources). There
239+
// is no sub-diff to add, and any reference-level change is already
240+
// captured in the relatedArtifact operations — so log rather than
241+
// emitting a diagnostic string into the diff payload.
230242
if (target == null) {
231-
var component = baseDiff.addParameter();
232-
component.setName(Canonicals.getUrl(sourceCanonical));
233-
component.setValue(new StringType("Target could not be retrieved"));
243+
logger.debug(
244+
"Target resource could not be retrieved for {}; skipping sub-diff",
245+
sourceCanonical);
234246
} else if (source == null) {
235-
var component = baseDiff.addParameter();
236-
component.setName(Canonicals.getUrl(targetCanonical));
237-
component.setValue(new StringType("Source could not be retrieved"));
247+
logger.debug(
248+
"Source resource could not be retrieved for {}; skipping sub-diff",
249+
targetCanonical);
238250
}
239251
});
240252
}
@@ -255,7 +267,7 @@ private static void handleInsertions(
255267
for (var addition : combinedReferenceList.getInsertions()) {
256268
if (addition.hasResource()) {
257269
boolean diffNotAlreadyComputedAndPresent =
258-
baseDiff.getParameter(Canonicals.getUrl(addition.getResource())) == null;
270+
!hasParameterNamed(baseDiff, Canonicals.getUrl(addition.getResource()));
259271
if (diffNotAlreadyComputedAndPresent) {
260272
var targetResource = checkOrUpdateResourceCache(
261273
addition.getResource(),
@@ -297,7 +309,7 @@ private static void handleDeletions(
297309
for (var deletion : combinedReferenceList.getDeletions()) {
298310
if (deletion.hasResource()) {
299311
boolean diffNotAlreadyComputedAndPresent =
300-
baseDiff.getParameter(Canonicals.getUrl(deletion.getResource())) == null;
312+
!hasParameterNamed(baseDiff, Canonicals.getUrl(deletion.getResource()));
301313
if (diffNotAlreadyComputedAndPresent) {
302314
var sourceResource = checkOrUpdateResourceCache(
303315
deletion.getResource(),
@@ -554,11 +566,42 @@ private static <T> boolean compareSourceAndTarget(T targetObj, T sourceObj) {
554566
return valueSetContainsEquals(sourceVSECC, targetVSECC);
555567
} else if (sourceObj instanceof Extension sourceExt && targetObj instanceof Extension targetExt) {
556568
return extensionEquals(sourceExt, targetExt);
569+
} else if (sourceObj instanceof ParametersParameterComponent sourcePpc
570+
&& targetObj instanceof ParametersParameterComponent targetPpc) {
571+
return parametersParameterEquals(sourcePpc, targetPpc);
557572
} else {
558573
return false;
559574
}
560575
}
561576

577+
/**
578+
* Identity match for expansion-parameter entries (contained Parameters.parameter[]). Two entries
579+
* are the "same" entry when they share a name and — for canonical-valued params like
580+
* system-version / canonicalVersion — the same version-stripped URL. This lets reordering produce
581+
* no operations while a genuine version bump on the same system surfaces as a single replace. For
582+
* non-canonical-valued params (booleans, codes, language tags) it falls back to deep equality.
583+
*/
584+
private static boolean parametersParameterEquals(
585+
ParametersParameterComponent source, ParametersParameterComponent target) {
586+
if (!Objects.equals(source.getName(), target.getName())) {
587+
return false;
588+
}
589+
var sourceUrl = canonicalValueUrl(source);
590+
var targetUrl = canonicalValueUrl(target);
591+
if (sourceUrl != null && targetUrl != null) {
592+
return sourceUrl.equals(targetUrl);
593+
}
594+
return source.equalsDeep(target);
595+
}
596+
597+
private static String canonicalValueUrl(ParametersParameterComponent p) {
598+
if (p.getValue() instanceof IPrimitiveType<?> primitive) {
599+
var value = primitive.getValueAsString();
600+
return value == null ? null : Canonicals.getUrl(value);
601+
}
602+
return null;
603+
}
604+
562605
private static boolean relatedArtifactEquals(RelatedArtifact ref1, RelatedArtifact ref2) {
563606
return Objects.equals(Canonicals.getUrl(ref1.getResource()), Canonicals.getUrl(ref2.getResource()))
564607
&& ref1.getType() == ref2.getType();
@@ -590,23 +633,47 @@ private static boolean valueSetContainsEquals(
590633
return ref1.getSystem().equals(ref2.getSystem()) && ref1.getCode().equals(ref2.getCode());
591634
}
592635

636+
/**
637+
* Null-safe variant of {@link Parameters#getParameter(String)} membership check. HAPI's
638+
* built-in iterates components and calls {@code component.getName().equals(name)} which NPEs
639+
* if any component in the list has a null name — a state that occurs here whenever a malformed
640+
* relatedArtifact URL passes through {@link Canonicals#getUrl(String)} and the resulting null
641+
* is stored on a component (see e.g. {@code handleSourceMatches} fall-through paths).
642+
*/
643+
private static boolean hasParameterNamed(Parameters params, String name) {
644+
if (params == null || name == null) {
645+
return false;
646+
}
647+
return params.getParameter().stream()
648+
.filter(ParametersParameterComponent::hasName)
649+
.anyMatch(p -> name.equals(p.getName()));
650+
}
651+
593652
private static boolean extensionEquals(Extension source, Extension target) {
594-
if (!source.getValue().getClass().equals(target.getValue().getClass())) {
653+
var sourceValue = source.getValue();
654+
var targetValue = target.getValue();
655+
// Complex extensions (e.g. package-source) carry no top-level value — only
656+
// nested sub-extensions. Fall back to deep equality (which compares URLs +
657+
// nested extensions) rather than dereferencing a null value.
658+
if (sourceValue == null || targetValue == null) {
659+
return sourceValue == null && targetValue == null && source.equalsDeep(target);
660+
}
661+
if (!sourceValue.getClass().equals(targetValue.getClass())) {
595662
return false;
596663
}
597-
if (source.getValue() instanceof IPrimitiveType<?> sourcePrimitive) {
598-
return (sourcePrimitive.getValue()).equals((((IPrimitiveType<?>) target.getValue()).getValue()));
599-
} else if (source.getValue() instanceof CodeableConcept sourceCodableConcept) {
664+
if (sourceValue instanceof IPrimitiveType<?> sourcePrimitive) {
665+
return (sourcePrimitive.getValue()).equals(((IPrimitiveType<?>) targetValue).getValue());
666+
} else if (sourceValue instanceof CodeableConcept sourceCodableConcept) {
600667
return sourceCodableConcept
601668
.getCodingFirstRep()
602669
.getSystem()
603-
.equals(((CodeableConcept) target.getValue())
670+
.equals(((CodeableConcept) targetValue)
604671
.getCodingFirstRep()
605672
.getSystem())
606-
&& ((CodeableConcept) source.getValue())
673+
&& sourceCodableConcept
607674
.getCodingFirstRep()
608675
.getCode()
609-
.equals(((CodeableConcept) target.getValue())
676+
.equals(((CodeableConcept) targetValue)
610677
.getCodingFirstRep()
611678
.getCode());
612679
} else {
@@ -651,6 +718,13 @@ private static Parameters diffWithExtensions(
651718
updateSource.setRelatedArtifact(processedRelatedArtifacts.getSourceMatches());
652719
updateTarget.setRelatedArtifact(processedRelatedArtifacts.getTargetMatches());
653720

721+
// Align order-independent contained expansion parameters before the positional diff, so a
722+
// reordered-but-unchanged parameter doesn't surface as a spurious replace. Matched entries
723+
// end up at the same indices on both sides; source-only entries (deletions) and target-only
724+
// entries (insertions) are appended last.
725+
reorderContainedExpansionParameters(
726+
(MetadataResource) updateSource.get(), (MetadataResource) updateTarget.get());
727+
654728
// get the diff of the RelatedArtifacts without Extensions
655729
var updateOperations = (Parameters) patch.diff(
656730
removeRelatedArtifactExtensions(updateSource), removeRelatedArtifactExtensions(updateTarget));
@@ -685,7 +759,7 @@ private static void fixRelatedArtifactExtensionDiffPaths(
685759
for (ParametersParameterComponent parameter : parameters) {
686760
Optional<ParametersParameterComponent> path = parameter.getPart().stream()
687761
.filter(ParametersParameterComponent::hasName)
688-
.filter(part -> part.getName().equals("path"))
762+
.filter(part -> "path".equals(part.getName()))
689763
.findFirst();
690764
if (path.isPresent()) {
691765
var pathString = ((StringType) path.get().getValue()).getValue();
@@ -696,6 +770,59 @@ private static void fixRelatedArtifactExtensionDiffPaths(
696770
}
697771
}
698772

773+
/**
774+
* Reorders the contained expansion-parameters ({@code Parameters.parameter[]}) on both sides so
775+
* matched entries occupy the same indices before the positional {@code patch.diff}. Without this,
776+
* a manifest whose expansion parameters are merely reordered yields a cascade of meaningless
777+
* replace operations pairing unrelated entries by position. Mutates the working copies in place,
778+
* consistent with the existing relatedArtifact reordering.
779+
*/
780+
private static void reorderContainedExpansionParameters(
781+
MetadataResource sourceLibrary, MetadataResource targetLibrary) {
782+
var sourceParams = getContainedExpansionParameters(sourceLibrary);
783+
var targetParams = getContainedExpansionParameters(targetLibrary);
784+
if (sourceParams == null || targetParams == null) {
785+
return;
786+
}
787+
var processed = extractAdditionsAndDeletions(
788+
sourceParams.getParameter(), targetParams.getParameter(), ParametersParameterComponent.class);
789+
sourceParams.setParameter(
790+
new ArrayList<>(Stream.concat(processed.getSourceMatches().stream(), processed.getDeletions().stream())
791+
.toList()));
792+
targetParams.setParameter(
793+
new ArrayList<>(Stream.concat(processed.getTargetMatches().stream(), processed.getInsertions().stream())
794+
.toList()));
795+
}
796+
797+
/**
798+
* Resolves the contained Parameters carrying expansion parameters. Prefers the contained resource
799+
* referenced by the {@code cqf-expansionParameters} extension; falls back to the first contained
800+
* Parameters. Returns null when there is none.
801+
*/
802+
private static Parameters getContainedExpansionParameters(MetadataResource resource) {
803+
if (resource == null) {
804+
return null;
805+
}
806+
var containedParams = resource.getContained().stream()
807+
.filter(Parameters.class::isInstance)
808+
.map(Parameters.class::cast)
809+
.toList();
810+
if (containedParams.isEmpty()) {
811+
return null;
812+
}
813+
var ext = resource.getExtensionByUrl(Constants.CQF_EXPANSION_PARAMETERS);
814+
if (ext != null && ext.getValue() instanceof Reference reference && reference.getReference() != null) {
815+
var localId = reference.getReference().replace("#", "");
816+
var match = containedParams.stream()
817+
.filter(p -> localId.equals(p.getIdPart()))
818+
.findFirst();
819+
if (match.isPresent()) {
820+
return match.get();
821+
}
822+
}
823+
return containedParams.get(0);
824+
}
825+
699826
private static IDomainResource removeRelatedArtifactExtensions(IKnowledgeArtifactAdapter resource) {
700827
var retval = resource.copy();
701828
IAdapterFactory.forFhirVersion(FhirVersionEnum.R4)
@@ -885,7 +1012,7 @@ private static void fixDeletePathIndexesAndAddValues(
8851012
ParametersParameterComponent parameter = parameters.get(i);
8861013
Optional<ParametersParameterComponent> path = parameter.getPart().stream()
8871014
.filter(ParametersParameterComponent::hasName)
888-
.filter(part -> part.getName().equals("path"))
1015+
.filter(part -> "path".equals(part.getName()))
8891016
.findFirst();
8901017
if (path.isPresent()) {
8911018

@@ -947,13 +1074,13 @@ private static void fixInsertPathIndexes(List<ParametersParameterComponent> para
9471074
int opCounter = 0;
9481075
for (ParametersParameterComponent parameter : parameters) {
9491076
Optional<ParametersParameterComponent> index = parameter.getPart().stream()
950-
.filter(part -> part.getName().equals("index"))
1077+
.filter(part -> "index".equals(part.getName()))
9511078
.findFirst();
9521079
Optional<ParametersParameterComponent> value = parameter.getPart().stream()
953-
.filter(part -> part.getName().equals("value"))
1080+
.filter(part -> "value".equals(part.getName()))
9541081
.findFirst();
9551082
Optional<ParametersParameterComponent> path = parameter.getPart().stream()
956-
.filter(part -> part.getName().equals("path"))
1083+
.filter(part -> "path".equals(part.getName()))
9571084
.findFirst();
9581085
if (path.isPresent()) {
9591086
var pathString = ((StringType) path.get().getValue()).getValue();

cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/common/HapiCreateChangelogProcessor.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,12 @@ private void processChange(
222222
MetadataResource sourceResource,
223223
Page<?> page) {
224224
if (change.hasName()
225-
&& !change.getName().equals("operation")
225+
&& !"operation".equals(change.getName())
226226
&& change.hasResource()
227227
&& change.getResource() instanceof Parameters parameters) {
228228
// Nested Parameters objects get recursively processed
229229
processChanges(parameters.getParameter(), changelog, cache, change.getName());
230-
} else if (change.getName().equals("operation")) {
230+
} else if ("operation".equals(change.getName())) {
231231
// 1) For each operation get the relevant parameters
232232
var type = getStringParameter(change, "type")
233233
.orElseThrow(() -> new UnprocessableEntityException(
@@ -267,7 +267,7 @@ private String removeBase(EncodeContextPath path) {
267267

268268
private Optional<String> getStringParameter(Parameters.ParametersParameterComponent part, String name) {
269269
return part.getPart().stream()
270-
.filter(p -> p.getName().equalsIgnoreCase(name))
270+
.filter(p -> name.equalsIgnoreCase(p.getName()))
271271
.filter(p -> p.getValue() instanceof IPrimitiveType)
272272
.map(p -> (IPrimitiveType<?>) p.getValue())
273273
.map(s -> (String) s.getValue())
@@ -276,7 +276,7 @@ private Optional<String> getStringParameter(Parameters.ParametersParameterCompon
276276

277277
private Optional<IBase> getParameter(Parameters.ParametersParameterComponent part, String name) {
278278
return part.getPart().stream()
279-
.filter(p -> p.getName().equalsIgnoreCase(name))
279+
.filter(p -> name.equalsIgnoreCase(p.getName()))
280280
.filter(ParametersParameterComponent::hasValue)
281281
.map(p -> (IBase) p.getValue())
282282
.findAny();

0 commit comments

Comments
 (0)