3131import org .hl7 .fhir .r4 .model .Parameters ;
3232import org .hl7 .fhir .r4 .model .Parameters .ParametersParameterComponent ;
3333import org .hl7 .fhir .r4 .model .PlanDefinition ;
34+ import org .hl7 .fhir .r4 .model .Reference ;
3435import org .hl7 .fhir .r4 .model .RelatedArtifact ;
3536import org .hl7 .fhir .r4 .model .StringType ;
3637import 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 ();
0 commit comments