From 50a8632e5d0706d5ea149d126291ed0415c7c010 Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Mon, 24 Nov 2025 15:21:20 -0500 Subject: [PATCH 1/8] pull merge code out of terserutil --- .../java/ca/uhn/fhir/util/ResourceUtil.java | 239 ++++++++++++++++++ .../java/ca/uhn/fhir/util/TerserUtil.java | 8 + .../ca/uhn/fhir/util/ResourceUtilTest.java | 235 +++++++++++++++++ 3 files changed, 482 insertions(+) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java index 69bc11cf722b..6969192bbb99 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java @@ -21,21 +21,41 @@ import ca.uhn.fhir.context.BaseRuntimeChildDefinition; import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; +import ca.uhn.fhir.context.BaseRuntimeElementDefinition; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeChildChoiceDefinition; +import ca.uhn.fhir.context.RuntimeResourceDefinition; +import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.rest.api.EncodingEnum; import jakarta.annotation.Nonnull; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; +import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IBaseHasExtensions; import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IPrimitiveType; import java.io.IOException; +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Predicate; public class ResourceUtil { private static final String ENCODING = "ENCODING_TYPE"; private static final String RAW_ = "RAW_"; + private static final String EQUALS_DEEP = "equalsDeep"; + public static final String DATA_ABSENT_REASON_EXTENSION_URI = + "http://hl7.org/fhir/StructureDefinition/data-absent-reason"; private ResourceUtil() {} + /** + * Exclusion predicate for keeping all fields. + */ + public static final Predicate INCLUDE_ALL = s -> true; + /** * This method removes the narrative from the resource, or if the resource is a bundle, removes the narrative from * all of the resources in the bundle @@ -79,4 +99,223 @@ public static String getRawStringFromResourceOrNull(@Nonnull IBaseResource theRe private static String getRawUserDataKey(EncodingEnum theEncodingEnum) { return RAW_ + theEncodingEnum.name(); } + + /** + * Merges all fields on the provided instance. theTo will contain a union of all values from theFrom + * instance and theTo instance. + * + * @param theFhirContext Context holding resource definition + * @param theFrom The resource to merge the fields from + * @param theTo The resource to merge the fields into + */ + public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { + mergeFields(theFhirContext, theFrom, theTo, INCLUDE_ALL); + } + + /** + * Merges values of all field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + * @param inclusionStrategy Predicate to test which fields should be merged + */ + public static void mergeFields( + FhirContext theFhirContext, + IBaseResource theFrom, + IBaseResource theTo, + Predicate inclusionStrategy) { + FhirTerser terser = theFhirContext.newTerser(); + + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); + for (BaseRuntimeChildDefinition childDefinition : definition.getChildrenAndExtension()) { + if (!inclusionStrategy.test(childDefinition.getElementName())) { + continue; + } + + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + + mergeFields(terser, theTo, childDefinition, theFromFieldValues, theToFieldValues); + } + } + + /** + * Merges value of the specified field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theFieldName Name of the child filed to merge + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + */ + public static void mergeField( + FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); + } + + /** + * Merges value of the specified field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theTerser Terser to be used when cloning the field values + * @param theFieldName Name of the child filed to merge + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + */ + public static void mergeField( + FhirContext theFhirContext, + FhirTerser theTerser, + String theFieldName, + IBaseResource theFrom, + IBaseResource theTo) { + BaseRuntimeChildDefinition childDefinition = + getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom); + + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + + mergeFields(theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues); + } + + private static void mergeFields( + FhirTerser theTerser, + IBaseResource theTo, + BaseRuntimeChildDefinition childDefinition, + List theFromFieldValues, + List theToFieldValues) { + if (!theFromFieldValues.isEmpty() && theToFieldValues.stream().anyMatch(ResourceUtil::hasDataAbsentReason)) { + // If the to resource has a data absent reason, and there is potentially real data incoming + // in the from resource, we should clear the data absent reason because it won't be absent anymore. + theToFieldValues = removeDataAbsentReason(theTo, childDefinition, theToFieldValues); + } + + for (IBase fromFieldValue : theFromFieldValues) { + if (contains(fromFieldValue, theToFieldValues)) { + continue; + } + + if (hasDataAbsentReason(fromFieldValue) && !theToFieldValues.isEmpty()) { + // if the from field value asserts a reason the field isn't populated, but the to field is populated, + // we don't want to overwrite real data with the extension + continue; + } + + IBase newFieldValue = newElement(theTerser, childDefinition, fromFieldValue, null); + if (fromFieldValue instanceof IPrimitiveType) { + try { + Method copyMethod = getMethod(fromFieldValue, "copy"); + if (copyMethod != null) { + newFieldValue = (IBase) copyMethod.invoke(fromFieldValue, new Object[] {}); + } + } catch (Throwable t) { + ((IPrimitiveType) newFieldValue) + .setValueAsString(((IPrimitiveType) fromFieldValue).getValueAsString()); + } + } else { + theTerser.cloneInto(fromFieldValue, newFieldValue, true); + } + + try { + theToFieldValues.add(newFieldValue); + } catch (UnsupportedOperationException e) { + childDefinition.getMutator().setValue(theTo, newFieldValue); + theToFieldValues = childDefinition.getAccessor().getValues(theTo); + } + } + } + + private static BaseRuntimeChildDefinition getBaseRuntimeChildDefinition( + FhirContext theFhirContext, String theFieldName, IBaseResource theFrom) { + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); + BaseRuntimeChildDefinition childDefinition = definition.getChildByName(theFieldName); + Validate.notNull(childDefinition); + return childDefinition; + } + + private static Method getMethod(IBase theBase, String theMethodName) { + Method method = null; + for (Method m : theBase.getClass().getDeclaredMethods()) { + if (m.getName().equals(theMethodName)) { + method = m; + break; + } + } + return method; + } + + private static boolean equals(IBase theItem1, IBase theItem2, Method theMethod) { + if (theMethod != null) { + try { + return (Boolean) theMethod.invoke(theItem1, theItem2); + } catch (Exception e) { + throw new RuntimeException( + Msg.code(1746) + String.format("Unable to compare equality via %s", EQUALS_DEEP), e); + } + } + return theItem1.equals(theItem2); + } + + private static boolean contains(IBase theItem, List theItems) { + final Method method = getMethod(theItem, EQUALS_DEEP); + return theItems.stream().anyMatch(i -> equals(i, theItem, method)); + } + + private static boolean hasDataAbsentReason(IBase theItem) { + if (theItem instanceof IBaseHasExtensions) { + IBaseHasExtensions hasExtensions = (IBaseHasExtensions) theItem; + return hasExtensions.getExtension().stream() + .anyMatch(t -> StringUtils.equals(t.getUrl(), DATA_ABSENT_REASON_EXTENSION_URI)); + } + return false; + } + + private static List removeDataAbsentReason( + IBaseResource theResource, BaseRuntimeChildDefinition theFieldDefinition, List theFieldValues) { + for (int i = 0; i < theFieldValues.size(); i++) { + if (hasDataAbsentReason(theFieldValues.get(i))) { + try { + theFieldDefinition.getMutator().remove(theResource, i); + } catch (UnsupportedOperationException e) { + // the field must be single-valued, just clear it + theFieldDefinition.getMutator().setValue(theResource, null); + } + } + } + return theFieldDefinition.getAccessor().getValues(theResource); + } + + /** + * Creates a new element taking into consideration elements with choice that are not directly retrievable by element + * name + * + * @param theFhirTerser + * @param theChildDefinition Child to create a new instance for + * @param theFromFieldValue The base parent field + * @param theConstructorParam Optional constructor param + * @return Returns the new element with the given value if configured + */ + private static IBase newElement( + FhirTerser theFhirTerser, + BaseRuntimeChildDefinition theChildDefinition, + IBase theFromFieldValue, + Object theConstructorParam) { + BaseRuntimeElementDefinition runtimeElementDefinition; + if (theChildDefinition instanceof RuntimeChildChoiceDefinition) { + runtimeElementDefinition = + theChildDefinition.getChildElementDefinitionByDatatype(theFromFieldValue.getClass()); + } else { + runtimeElementDefinition = theChildDefinition.getChildByName(theChildDefinition.getElementName()); + } + if ("contained".equals(runtimeElementDefinition.getName())) { + IBaseResource sourceResource = (IBaseResource) theFromFieldValue; + return theFhirTerser.clone(sourceResource); + } else if (theConstructorParam == null) { + return runtimeElementDefinition.newInstance(); + } else { + return runtimeElementDefinition.newInstance(theConstructorParam); + } + } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java index 8adc307c7495..011173a94196 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java @@ -286,7 +286,9 @@ private static boolean hasDataAbsentReason(IBase theItem) { * @param theFhirContext Context holding resource definition * @param theFrom The resource to merge the fields from * @param theTo The resource to merge the fields into + * @deprecated Use {@link ResourceUtil#mergeAllFields(FhirContext, IBaseResource, IBaseResource)} */ + @Deprecated(since = "8.7.0") public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { mergeFields(theFhirContext, theFrom, theTo, INCLUDE_ALL); } @@ -602,7 +604,9 @@ public static void mergeFieldsExceptIdAndMeta( * @param theFrom Resource to merge the specified field from * @param theTo Resource to merge the specified field into * @param inclusionStrategy Predicate to test which fields should be merged + * @deprecated Use {@link ResourceUtil#mergeFields(FhirContext, IBaseResource, IBaseResource, Predicate)} */ + @Deprecated(since = "8.7.0") public static void mergeFields( FhirContext theFhirContext, IBaseResource theFrom, @@ -631,7 +635,9 @@ public static void mergeFields( * @param theFieldName Name of the child filed to merge * @param theFrom Resource to merge the specified field from * @param theTo Resource to merge the specified field into + * @deprecated Use {@link ResourceUtil#mergeField(FhirContext, String, IBaseResource, IBaseResource)} */ + @Deprecated(since = "8.7.0") public static void mergeField( FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); @@ -646,7 +652,9 @@ public static void mergeField( * @param theFieldName Name of the child filed to merge * @param theFrom Resource to merge the specified field from * @param theTo Resource to merge the specified field into + * @deprecated Use {@link ResourceUtil#mergeField(FhirContext, FhirTerser, String, IBaseResource, IBaseResource)} */ + @Deprecated(since = "8.7.0") public static void mergeField( FhirContext theFhirContext, FhirTerser theTerser, diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java index ce57f7f3b067..88429ff72469 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java @@ -1,14 +1,40 @@ package ca.uhn.fhir.util; import ca.uhn.fhir.context.FhirContext; +import org.apache.commons.lang3.Strings; +import org.hl7.fhir.r4.model.BooleanType; import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.Condition; +import org.hl7.fhir.r4.model.DateTimeType; +import org.hl7.fhir.r4.model.Enumeration; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Extension; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Observation; import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Practitioner; +import org.hl7.fhir.r4.model.Reference; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class ResourceUtilTest { + public static final String DATA_ABSENT_REASON_EXTENSION_URI = + "http://hl7.org/fhir/StructureDefinition/data-absent-reason"; + private final FhirContext ourFhirContext = FhirContext.forR4(); + @Test public void testRemoveNarrative() { Bundle bundle = new Bundle(); @@ -26,4 +52,213 @@ public void testRemoveNarrative() { assertNull(((Patient) bundle.getEntry().get(0).getResource()).getText().getDiv().getValueAsString()); } + @Test + void testMergeBooleanField() { + Patient p1 = new Patient(); + p1.setDeceased(new BooleanType(true)); + + Patient p2 = new Patient(); + ResourceUtil.mergeAllFields(ourFhirContext, p1, p2); + + assertTrue(p2.hasDeceased()); + assertEquals("true", p2.getDeceased().primitiveValue()); + } + + @Test + void testMergeExtensions() { + Patient p1 = new Patient(); + p1.addExtension( + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + new Coding().setCode("X").setSystem("MyInternalRace").setDisplay("Eks")); + p1.addExtension( + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity'", + new Coding().setSystem("MyInternalEthnicity").setDisplay("NNN")); + + Patient p2 = new Patient(); + ResourceUtil.mergeAllFields(ourFhirContext, p1, p2); + + assertThat(p2.getExtension()).hasSize(2); + } + + @Test + void testMergeForAddressWithExtensions() { + Extension ext = new Extension(); + ext.setUrl("http://hapifhir.io/extensions/address#create-timestamp"); + ext.setValue(new DateTimeType("2021-01-02T11:13:15")); + + Patient p1 = new Patient(); + p1.addAddress() + .addLine("10 Main Street") + .setCity("Hamilton") + .setState("ON") + .setPostalCode("Z0Z0Z0") + .setCountry("Canada") + .addExtension(ext); + + Patient p2 = new Patient(); + p2.addAddress().addLine("10 Lenin Street").setCity("Severodvinsk").setCountry("Russia"); + + ResourceUtil.mergeField(ourFhirContext, "address", p1, p2); + + assertThat(p2.getAddress()).hasSize(2); + assertEquals("[10 Lenin Street]", p2.getAddress().get(0).getLine().toString()); + assertEquals("[10 Main Street]", p2.getAddress().get(1).getLine().toString()); + assertTrue(p2.getAddress().get(1).hasExtension()); + + p1 = new Patient(); + p1.addAddress().addLine("10 Main Street").addExtension(ext); + p2 = new Patient(); + p2.addAddress().addLine("10 Main Street").addExtension( + new Extension("demo", new DateTimeType("2021-01-02"))); + + ResourceUtil.mergeField(ourFhirContext, "address", p1, p2); + assertThat(p2.getAddress()).hasSize(2); + assertTrue(p2.getAddress().get(0).hasExtension()); + assertTrue(p2.getAddress().get(1).hasExtension()); + + } + + @Test + void testMergeForSimilarAddresses() { + Extension ext = new Extension(); + ext.setUrl("http://hapifhir.io/extensions/address#create-timestamp"); + ext.setValue(new DateTimeType("2021-01-02T11:13:15")); + + Patient p1 = new Patient(); + p1.addAddress() + .addLine("10 Main Street") + .setCity("Hamilton") + .setState("ON") + .setPostalCode("Z0Z0Z0") + .setCountry("Canada") + .addExtension(ext); + + Patient p2 = new Patient(); + p2.addAddress() + .addLine("10 Main Street") + .setCity("Hamilton") + .setState("ON") + .setPostalCode("Z0Z0Z1") + .setCountry("Canada") + .addExtension(ext); + + ResourceUtil.mergeField(ourFhirContext, "address", p1, p2); + + assertThat(p2.getAddress()).hasSize(2); + assertEquals("[10 Main Street]", p2.getAddress().get(0).getLine().toString()); + assertEquals("[10 Main Street]", p2.getAddress().get(1).getLine().toString()); + assertTrue(p2.getAddress().get(1).hasExtension()); + } + + @Test + public void testMergeWithReference() { + Practitioner practitioner = new Practitioner(); + practitioner.setId(UUID.randomUUID().toString()); + practitioner.addName().setFamily("Smith").addGiven("Jane"); + + Condition c1 = new Condition(); + c1.setRecorder(new Reference(practitioner)); + + Condition c2 = new Condition(); + + ResourceUtil.mergeField(ourFhirContext, "recorder", c1, c2); + + assertThat(c2.getRecorder().getResource()).isSameAs(practitioner); + } + + @ParameterizedTest + @MethodSource("singleCardinalityArguments") + public void testMergeWithDataAbsentReason_singleCardinality( + Enumeration theFromStatus, + Enumeration theToStatus, + Enumeration theExpectedStatus) { + Observation fromObservation = new Observation(); + fromObservation.setStatusElement(theFromStatus); + + Observation toObservation = new Observation(); + toObservation.setStatusElement(theToStatus); + + ResourceUtil.mergeField(ourFhirContext, "status", fromObservation, toObservation); + + if (theExpectedStatus == null) { + assertThat(toObservation.hasStatus()).isFalse(); + } else { + assertThat(toObservation.getStatusElement().getCode()).isEqualTo(theExpectedStatus.getCode()); + } + } + + private static Stream singleCardinalityArguments() { + return Stream.of( + Arguments.of(null, null, null), + Arguments.of(statusFromEnum(Observation.ObservationStatus.FINAL), null, statusFromEnum(Observation.ObservationStatus.FINAL)), + Arguments.of(null, statusFromEnum(Observation.ObservationStatus.FINAL), statusFromEnum(Observation.ObservationStatus.FINAL)), + Arguments.of(statusFromEnum(Observation.ObservationStatus.FINAL), statusFromEnum(Observation.ObservationStatus.PRELIMINARY), statusFromEnum(Observation.ObservationStatus.FINAL)), + Arguments.of(statusWithDataAbsentReason(), null, statusWithDataAbsentReason()), + Arguments.of(null, statusWithDataAbsentReason(), statusWithDataAbsentReason()), + Arguments.of(statusWithDataAbsentReason(), statusWithDataAbsentReason(), statusWithDataAbsentReason()), + Arguments.of(statusFromEnum(Observation.ObservationStatus.FINAL), statusWithDataAbsentReason(), statusFromEnum(Observation.ObservationStatus.FINAL)), + Arguments.of(statusWithDataAbsentReason(), statusFromEnum(Observation.ObservationStatus.FINAL), statusFromEnum(Observation.ObservationStatus.FINAL)) + ); + } + + private static Enumeration statusFromEnum(Observation.ObservationStatus theStatus) { + return new Enumeration<>(new Observation.ObservationStatusEnumFactory(), theStatus); + } + + private static Enumeration statusWithDataAbsentReason() { + Enumeration enumeration = new Enumeration<>(new Observation.ObservationStatusEnumFactory()); + Enumeration extension = new Enumeration<>(new Enumerations.DataAbsentReasonEnumFactory(), Enumerations.DataAbsentReason.UNKNOWN); + enumeration.addExtension(DATA_ABSENT_REASON_EXTENSION_URI, extension); + return enumeration; + } + + @ParameterizedTest + @MethodSource("multipleCardinalityArguments") + public void testMergeWithDataAbsentReason_multipleCardinality( + List theFromIdentifiers, List theToIdentifiers, List theExpectedIdentifiers) { + Observation fromObservation = new Observation(); + theFromIdentifiers.forEach(fromObservation::addIdentifier); + + Observation toObservation = new Observation(); + theToIdentifiers.forEach(toObservation::addIdentifier); + + ResourceUtil.mergeField(ourFhirContext, "identifier", fromObservation, toObservation); + + assertThat(toObservation.getIdentifier()).hasSize(theExpectedIdentifiers.size()); + assertThat(toObservation.getIdentifier()).allMatch(t -> { + if (t.hasValue()) { + return theExpectedIdentifiers.stream().anyMatch(s -> Strings.CS.equals(t.getValue(), s.getValue())); + } else if (t.hasExtension(DATA_ABSENT_REASON_EXTENSION_URI)) { + return theExpectedIdentifiers.stream().anyMatch(s -> s.hasExtension(DATA_ABSENT_REASON_EXTENSION_URI)); + } + return false; + }); + } + + private static Stream multipleCardinalityArguments() { + return Stream.of( + Arguments.of(List.of(), List.of(), List.of()), + Arguments.of(List.of(identifierFromValue("identifier1")), List.of(), List.of(identifierFromValue("identifier1"))), + Arguments.of(List.of(), List.of(identifierFromValue("identifier1")), List.of(identifierFromValue("identifier1"))), + Arguments.of(List.of(identifierFromValue("identifier1")), List.of(identifierFromValue("identifier2")), List.of(identifierFromValue("identifier1"), identifierFromValue("identifier2"))), + Arguments.of(List.of(identifierWithDataAbsentReason()), List.of(), List.of(identifierWithDataAbsentReason())), + Arguments.of(List.of(), List.of(identifierWithDataAbsentReason()), List.of(identifierWithDataAbsentReason())), + Arguments.of(List.of(identifierWithDataAbsentReason()), List.of(identifierWithDataAbsentReason()), List.of(identifierWithDataAbsentReason())), + Arguments.of(List.of(identifierFromValue("identifier1")), List.of(identifierWithDataAbsentReason()), List.of(identifierFromValue("identifier1"))), + Arguments.of(List.of(identifierWithDataAbsentReason()), List.of(identifierFromValue("identifier1")), List.of(identifierFromValue("identifier1"))), + Arguments.of(List.of(identifierFromValue("identifier1"), identifierFromValue("identifier2")), List.of(identifierWithDataAbsentReason()), List.of(identifierFromValue("identifier1"), identifierFromValue("identifier2"))), + Arguments.of(List.of(identifierWithDataAbsentReason()), List.of(identifierFromValue("identifier1"), identifierFromValue("identifier2")), List.of(identifierFromValue("identifier1"), identifierFromValue("identifier2"))) + ); + } + + private static Identifier identifierFromValue(String theValue) { + return new Identifier().setValue(theValue); + } + + private static Identifier identifierWithDataAbsentReason() { + Identifier identifier = new Identifier(); + Enumeration extension = new Enumeration<>(new Enumerations.DataAbsentReasonEnumFactory(), Enumerations.DataAbsentReason.UNKNOWN); + identifier.addExtension(DATA_ABSENT_REASON_EXTENSION_URI, extension); + return identifier; + } } From 3e4591b0c392ae0794c774bef43ce0886a06ac2c Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Mon, 24 Nov 2025 16:04:13 -0500 Subject: [PATCH 2/8] clean up sonar warnings in copied code --- .../java/ca/uhn/fhir/util/ResourceUtil.java | 66 +++++++++---------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java index 6969192bbb99..2a4b69d143e4 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java @@ -28,17 +28,18 @@ import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.rest.api.EncodingEnum; import jakarta.annotation.Nonnull; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.Validate; +import org.apache.commons.lang3.Strings; import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseHasExtensions; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IPrimitiveType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.io.IOException; import java.lang.reflect.Method; import java.util.List; +import java.util.Objects; import java.util.function.Predicate; public class ResourceUtil { @@ -49,6 +50,8 @@ public class ResourceUtil { public static final String DATA_ABSENT_REASON_EXTENSION_URI = "http://hl7.org/fhir/StructureDefinition/data-absent-reason"; + private static final Logger ourLog = LoggerFactory.getLogger(ResourceUtil.class); + private ResourceUtil() {} /** @@ -78,8 +81,7 @@ public static void removeNarrative(FhirContext theContext, IBaseResource theInpu } public static void addRawDataToResource( - @Nonnull IBaseResource theResource, @Nonnull EncodingEnum theEncodingType, String theSerializedData) - throws IOException { + @Nonnull IBaseResource theResource, @Nonnull EncodingEnum theEncodingType, String theSerializedData) { theResource.setUserData(getRawUserDataKey(theEncodingType), theSerializedData); theResource.setUserData(ENCODING, theEncodingType); } @@ -186,31 +188,24 @@ private static void mergeFields( BaseRuntimeChildDefinition childDefinition, List theFromFieldValues, List theToFieldValues) { + if (!theFromFieldValues.isEmpty() && theToFieldValues.stream().anyMatch(ResourceUtil::hasDataAbsentReason)) { // If the to resource has a data absent reason, and there is potentially real data incoming // in the from resource, we should clear the data absent reason because it won't be absent anymore. theToFieldValues = removeDataAbsentReason(theTo, childDefinition, theToFieldValues); } - for (IBase fromFieldValue : theFromFieldValues) { - if (contains(fromFieldValue, theToFieldValues)) { - continue; - } + List filteredFromFieldValues = filterFromFields(theFromFieldValues, theToFieldValues); - if (hasDataAbsentReason(fromFieldValue) && !theToFieldValues.isEmpty()) { - // if the from field value asserts a reason the field isn't populated, but the to field is populated, - // we don't want to overwrite real data with the extension - continue; - } - - IBase newFieldValue = newElement(theTerser, childDefinition, fromFieldValue, null); + for (IBase fromFieldValue : filteredFromFieldValues) { + IBase newFieldValue = newElement(theTerser, childDefinition, fromFieldValue); if (fromFieldValue instanceof IPrimitiveType) { try { Method copyMethod = getMethod(fromFieldValue, "copy"); if (copyMethod != null) { - newFieldValue = (IBase) copyMethod.invoke(fromFieldValue, new Object[] {}); + newFieldValue = (IBase) copyMethod.invoke(fromFieldValue); } - } catch (Throwable t) { + } catch (Exception t) { ((IPrimitiveType) newFieldValue) .setValueAsString(((IPrimitiveType) fromFieldValue).getValueAsString()); } @@ -227,11 +222,18 @@ private static void mergeFields( } } + private static List filterFromFields(List theFromFieldValues, List theToFieldValues) { + return theFromFieldValues.stream() + .filter(v -> !contains(v, theToFieldValues)) + .filter(v -> !hasDataAbsentReason(v) || theToFieldValues.isEmpty()) + .toList(); + } + private static BaseRuntimeChildDefinition getBaseRuntimeChildDefinition( FhirContext theFhirContext, String theFieldName, IBaseResource theFrom) { RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); BaseRuntimeChildDefinition childDefinition = definition.getChildByName(theFieldName); - Validate.notNull(childDefinition); + Objects.requireNonNull(childDefinition); return childDefinition; } @@ -251,8 +253,7 @@ private static boolean equals(IBase theItem1, IBase theItem2, Method theMethod) try { return (Boolean) theMethod.invoke(theItem1, theItem2); } catch (Exception e) { - throw new RuntimeException( - Msg.code(1746) + String.format("Unable to compare equality via %s", EQUALS_DEEP), e); + ourLog.debug("{} Unable to compare equality via {}", Msg.code(2821), EQUALS_DEEP, e); } } return theItem1.equals(theItem2); @@ -264,10 +265,9 @@ private static boolean contains(IBase theItem, List theItems) { } private static boolean hasDataAbsentReason(IBase theItem) { - if (theItem instanceof IBaseHasExtensions) { - IBaseHasExtensions hasExtensions = (IBaseHasExtensions) theItem; + if (theItem instanceof IBaseHasExtensions hasExtensions) { return hasExtensions.getExtension().stream() - .anyMatch(t -> StringUtils.equals(t.getUrl(), DATA_ABSENT_REASON_EXTENSION_URI)); + .anyMatch(t -> Strings.CS.equals(t.getUrl(), DATA_ABSENT_REASON_EXTENSION_URI)); } return false; } @@ -291,18 +291,14 @@ private static List removeDataAbsentReason( * Creates a new element taking into consideration elements with choice that are not directly retrievable by element * name * - * @param theFhirTerser - * @param theChildDefinition Child to create a new instance for - * @param theFromFieldValue The base parent field - * @param theConstructorParam Optional constructor param + * @param theFhirTerser A terser instance for the FHIR release + * @param theChildDefinition Child to create a new instance for + * @param theFromFieldValue The base parent field * @return Returns the new element with the given value if configured */ private static IBase newElement( - FhirTerser theFhirTerser, - BaseRuntimeChildDefinition theChildDefinition, - IBase theFromFieldValue, - Object theConstructorParam) { - BaseRuntimeElementDefinition runtimeElementDefinition; + FhirTerser theFhirTerser, BaseRuntimeChildDefinition theChildDefinition, IBase theFromFieldValue) { + BaseRuntimeElementDefinition runtimeElementDefinition; if (theChildDefinition instanceof RuntimeChildChoiceDefinition) { runtimeElementDefinition = theChildDefinition.getChildElementDefinitionByDatatype(theFromFieldValue.getClass()); @@ -312,10 +308,8 @@ private static IBase newElement( if ("contained".equals(runtimeElementDefinition.getName())) { IBaseResource sourceResource = (IBaseResource) theFromFieldValue; return theFhirTerser.clone(sourceResource); - } else if (theConstructorParam == null) { - return runtimeElementDefinition.newInstance(); } else { - return runtimeElementDefinition.newInstance(theConstructorParam); + return runtimeElementDefinition.newInstance(); } } } From e47832b01d6cf0d2ec920118c98207aacbcafe26 Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Tue, 25 Nov 2025 15:35:52 -0500 Subject: [PATCH 3/8] ignore coding order when matching CodeableConcepts --- .../java/ca/uhn/fhir/util/ResourceUtil.java | 137 ++++++++++++++++-- .../ca/uhn/fhir/util/ResourceUtilTest.java | 71 +++++++++ 2 files changed, 196 insertions(+), 12 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java index 2a4b69d143e4..b9ddb8bfd09c 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java @@ -38,6 +38,7 @@ import org.slf4j.LoggerFactory; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.function.Predicate; @@ -52,6 +53,18 @@ public class ResourceUtil { private static final Logger ourLog = LoggerFactory.getLogger(ResourceUtil.class); + public static class MergeControlParameters { + private boolean ignoreCodeableConceptCodingOrder; + + public boolean isIgnoreCodeableConceptCodingOrder() { + return ignoreCodeableConceptCodingOrder; + } + + public void setIgnoreCodeableConceptCodingOrder(boolean theIgnoreCodeableConceptCodingOrder) { + ignoreCodeableConceptCodingOrder = theIgnoreCodeableConceptCodingOrder; + } + } + private ResourceUtil() {} /** @@ -157,6 +170,25 @@ public static void mergeField( mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); } + /** + * Merges value of the specified field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theFieldName Name of the child filed to merge + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + * @param theMergeControlParameters Parameters to provide fine-grained control over the behaviour of the merge + */ + public static void mergeField( + FhirContext theFhirContext, + String theFieldName, + IBaseResource theFrom, + IBaseResource theTo, + MergeControlParameters theMergeControlParameters) { + mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo, theMergeControlParameters); + } + /** * Merges value of the specified field from theFrom resource to theTo resource. Fields * values are compared via the equalsDeep method, or via object identity if this method is not available. @@ -173,13 +205,34 @@ public static void mergeField( String theFieldName, IBaseResource theFrom, IBaseResource theTo) { + mergeField(theFhirContext, theTerser, theFieldName, theFrom, theTo, new MergeControlParameters()); + } + + /** + * Merges value of the specified field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theTerser Terser to be used when cloning the field values + * @param theFieldName Name of the child filed to merge + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + * @param theMergeControlParameters Parameters to provide fine-grained control over the behaviour of the merge + */ + public static void mergeField( + FhirContext theFhirContext, + FhirTerser theTerser, + String theFieldName, + IBaseResource theFrom, + IBaseResource theTo, + MergeControlParameters theMergeControlParameters) { BaseRuntimeChildDefinition childDefinition = getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom); List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); List theToFieldValues = childDefinition.getAccessor().getValues(theTo); - mergeFields(theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues); + mergeFields(theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues, theMergeControlParameters); } private static void mergeFields( @@ -188,14 +241,42 @@ private static void mergeFields( BaseRuntimeChildDefinition childDefinition, List theFromFieldValues, List theToFieldValues) { + mergeFields( + theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues, new MergeControlParameters()); + } + private static void mergeFields( + FhirTerser theTerser, + IBaseResource theTo, + BaseRuntimeChildDefinition childDefinition, + List theFromFieldValues, + List theToFieldValues, + MergeControlParameters theMergeControlParameters) { if (!theFromFieldValues.isEmpty() && theToFieldValues.stream().anyMatch(ResourceUtil::hasDataAbsentReason)) { // If the to resource has a data absent reason, and there is potentially real data incoming // in the from resource, we should clear the data absent reason because it won't be absent anymore. theToFieldValues = removeDataAbsentReason(theTo, childDefinition, theToFieldValues); } - List filteredFromFieldValues = filterFromFields(theFromFieldValues, theToFieldValues); + List filteredFromFieldValues = new ArrayList<>(); + for (IBase fromFieldValue : theFromFieldValues) { + if (theToFieldValues.isEmpty()) { + // if the target field is unpopulated, accept any value from the source field + filteredFromFieldValues.add(fromFieldValue); + } else if (!hasDataAbsentReason(fromFieldValue)) { + // if the value from the source field does not have a data absent reason extension, + // evaluate its suitability for inclusion + if (Strings.CI.equals(fromFieldValue.fhirType(), "codeableConcept")) { + if (!containsCodeableConcept( + fromFieldValue, theToFieldValues, theTerser, theMergeControlParameters)) { + filteredFromFieldValues.add(fromFieldValue); + } + } else if (!contains(fromFieldValue, theToFieldValues)) { + // include it if the target list doesn't already contain an exact match + filteredFromFieldValues.add(fromFieldValue); + } + } + } for (IBase fromFieldValue : filteredFromFieldValues) { IBase newFieldValue = newElement(theTerser, childDefinition, fromFieldValue); @@ -222,13 +303,6 @@ private static void mergeFields( } } - private static List filterFromFields(List theFromFieldValues, List theToFieldValues) { - return theFromFieldValues.stream() - .filter(v -> !contains(v, theToFieldValues)) - .filter(v -> !hasDataAbsentReason(v) || theToFieldValues.isEmpty()) - .toList(); - } - private static BaseRuntimeChildDefinition getBaseRuntimeChildDefinition( FhirContext theFhirContext, String theFieldName, IBaseResource theFrom) { RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); @@ -248,12 +322,12 @@ private static Method getMethod(IBase theBase, String theMethodName) { return method; } - private static boolean equals(IBase theItem1, IBase theItem2, Method theMethod) { + private static boolean evaluateEquality(IBase theItem1, IBase theItem2, Method theMethod) { if (theMethod != null) { try { return (Boolean) theMethod.invoke(theItem1, theItem2); } catch (Exception e) { - ourLog.debug("{} Unable to compare equality via {}", Msg.code(2821), EQUALS_DEEP, e); + ourLog.debug("{} Unable to compare equality via {}", Msg.code(2821), theMethod.getName(), e); } } return theItem1.equals(theItem2); @@ -261,7 +335,46 @@ private static boolean equals(IBase theItem1, IBase theItem2, Method theMethod) private static boolean contains(IBase theItem, List theItems) { final Method method = getMethod(theItem, EQUALS_DEEP); - return theItems.stream().anyMatch(i -> equals(i, theItem, method)); + return theItems.stream().anyMatch(i -> evaluateEquality(i, theItem, method)); + } + + + private static boolean containsCodeableConcept( + IBase theSourceItem, + List theTargetItems, + FhirTerser theTerser, + MergeControlParameters theMergeControlParameters) { + Method shallowEquals = getMethod(theSourceItem, "equalsShallow"); + List shallowMatches = theTargetItems.stream() + .filter(targetItem -> evaluateEquality(targetItem, theSourceItem, shallowEquals)) + .toList(); + + if (theMergeControlParameters.isIgnoreCodeableConceptCodingOrder()) { + return shallowMatches.stream().anyMatch(targetItem -> { + List sourceCodings = theTerser.getValues(theSourceItem, "coding"); + List targetCodings = theTerser.getValues(targetItem, "coding"); + return sourceCodings.stream().allMatch(sourceCoding -> { + Method deepEquals = getMethod(sourceCoding, EQUALS_DEEP); + return targetCodings.stream() + .anyMatch(targetCoding -> evaluateEquality(sourceCoding, targetCoding, deepEquals)); + }); + }); + } else { + return shallowMatches.stream().anyMatch(targetItem -> { + boolean match = true; + List sourceCodings = theTerser.getValues(theSourceItem, "coding"); + List targetCodings = theTerser.getValues(targetItem, "coding"); + if (sourceCodings.size() == targetCodings.size()) { + for (int i = 0; i < sourceCodings.size(); i++) { + Method deepEquals = getMethod(sourceCodings.get(i), EQUALS_DEEP); + match &= evaluateEquality(sourceCodings.get(i), targetCodings.get(i), deepEquals); + } + } else { + match = false; + } + return match; + }); + } } private static boolean hasDataAbsentReason(IBase theItem) { diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java index 88429ff72469..439b73e636cf 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java @@ -4,6 +4,7 @@ import org.apache.commons.lang3.Strings; import org.hl7.fhir.r4.model.BooleanType; import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.CodeableConcept; import org.hl7.fhir.r4.model.Coding; import org.hl7.fhir.r4.model.Condition; import org.hl7.fhir.r4.model.DateTimeType; @@ -261,4 +262,74 @@ private static Identifier identifierWithDataAbsentReason() { identifier.addExtension(DATA_ABSENT_REASON_EXTENSION_URI, extension); return identifier; } + + /* + * Ensure that codeable concepts with entirely disjoint codings are treated as discrete + */ + @Test + public void testMerge_discreteCodeableConcepts_doesNotMerge() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("vital-signs"); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2); + + // validate + assertThat(o2.getCategory()).hasSize(2); + } + + /* + * The default behaviour for the merge operation requires an exact match between elements. + * Therefore, two CodeableConcepts where the order of the Codings differs will be considered + * distinct elements, and both will be included in the merged Resource. + */ + @Test + public void testMerge_codingOrder_doesNotMerge() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2); + + // validate + assertThat(o2.getCategory()).hasSize(2); + } + + @Test + public void testMerge_ignoreCodingOrder() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setIgnoreCodeableConceptCodingOrder(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCategory()).hasSize(1); + } } From fa76487ee923a5d596865ce0a48b87a1ed656cba Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Thu, 27 Nov 2025 11:47:16 -0500 Subject: [PATCH 4/8] merge lists of codings --- .../java/ca/uhn/fhir/util/ResourceUtil.java | 292 ++++++++++++------ .../java/ca/uhn/fhir/util/TerserUtil.java | 6 +- .../ca/uhn/fhir/util/ResourceUtilTest.java | 145 +++++++++ 3 files changed, 341 insertions(+), 102 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java index b9ddb8bfd09c..fb0d0a9bd2d8 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java @@ -41,6 +41,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.function.Predicate; public class ResourceUtil { @@ -54,14 +55,23 @@ public class ResourceUtil { private static final Logger ourLog = LoggerFactory.getLogger(ResourceUtil.class); public static class MergeControlParameters { - private boolean ignoreCodeableConceptCodingOrder; + private boolean myIgnoreCodeableConceptCodingOrder; + private boolean myMergeCodings; public boolean isIgnoreCodeableConceptCodingOrder() { - return ignoreCodeableConceptCodingOrder; + return myIgnoreCodeableConceptCodingOrder; } public void setIgnoreCodeableConceptCodingOrder(boolean theIgnoreCodeableConceptCodingOrder) { - ignoreCodeableConceptCodingOrder = theIgnoreCodeableConceptCodingOrder; + myIgnoreCodeableConceptCodingOrder = theIgnoreCodeableConceptCodingOrder; + } + + public boolean isMergeCodings() { + return myMergeCodings; + } + + public void setMergeCodings(boolean theMergeCodings) { + myMergeCodings = theMergeCodings; } } @@ -123,7 +133,7 @@ private static String getRawUserDataKey(EncodingEnum theEncodingEnum) { * @param theFrom The resource to merge the fields from * @param theTo The resource to merge the fields into */ - public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { + public static void mergeAllFields(FhirContext theFhirContext, IBase theFrom, IBase theTo) { mergeFields(theFhirContext, theFrom, theTo, INCLUDE_ALL); } @@ -137,22 +147,19 @@ public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theF * @param inclusionStrategy Predicate to test which fields should be merged */ public static void mergeFields( - FhirContext theFhirContext, - IBaseResource theFrom, - IBaseResource theTo, - Predicate inclusionStrategy) { - FhirTerser terser = theFhirContext.newTerser(); - - RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); - for (BaseRuntimeChildDefinition childDefinition : definition.getChildrenAndExtension()) { - if (!inclusionStrategy.test(childDefinition.getElementName())) { - continue; - } + FhirContext theFhirContext, IBase theFrom, IBase theTo, Predicate inclusionStrategy) { + BaseRuntimeElementDefinition definition = theFhirContext.getElementDefinition(theFrom.getClass()); + if (definition instanceof BaseRuntimeElementCompositeDefinition compositeDefinition) { + for (BaseRuntimeChildDefinition childDefinition : compositeDefinition.getChildrenAndExtension()) { + if (!inclusionStrategy.test(childDefinition.getElementName())) { + continue; + } - List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); - List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); - mergeFields(terser, theTo, childDefinition, theFromFieldValues, theToFieldValues); + mergeFields(theFhirContext, theTo, childDefinition, theFromFieldValues, theToFieldValues); + } } } @@ -167,7 +174,7 @@ public static void mergeFields( */ public static void mergeField( FhirContext theFhirContext, String theFieldName, IBaseResource theFrom, IBaseResource theTo) { - mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo); + mergeField(theFhirContext, theFieldName, theFrom, theTo, new MergeControlParameters()); } /** @@ -186,78 +193,105 @@ public static void mergeField( IBaseResource theFrom, IBaseResource theTo, MergeControlParameters theMergeControlParameters) { - mergeField(theFhirContext, theFhirContext.newTerser(), theFieldName, theFrom, theTo, theMergeControlParameters); - } - - /** - * Merges value of the specified field from theFrom resource to theTo resource. Fields - * values are compared via the equalsDeep method, or via object identity if this method is not available. - * - * @param theFhirContext Context holding resource definition - * @param theTerser Terser to be used when cloning the field values - * @param theFieldName Name of the child filed to merge - * @param theFrom Resource to merge the specified field from - * @param theTo Resource to merge the specified field into - */ - public static void mergeField( - FhirContext theFhirContext, - FhirTerser theTerser, - String theFieldName, - IBaseResource theFrom, - IBaseResource theTo) { - mergeField(theFhirContext, theTerser, theFieldName, theFrom, theTo, new MergeControlParameters()); - } - - /** - * Merges value of the specified field from theFrom resource to theTo resource. Fields - * values are compared via the equalsDeep method, or via object identity if this method is not available. - * - * @param theFhirContext Context holding resource definition - * @param theTerser Terser to be used when cloning the field values - * @param theFieldName Name of the child filed to merge - * @param theFrom Resource to merge the specified field from - * @param theTo Resource to merge the specified field into - * @param theMergeControlParameters Parameters to provide fine-grained control over the behaviour of the merge - */ - public static void mergeField( - FhirContext theFhirContext, - FhirTerser theTerser, - String theFieldName, - IBaseResource theFrom, - IBaseResource theTo, - MergeControlParameters theMergeControlParameters) { BaseRuntimeChildDefinition childDefinition = getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom); List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); List theToFieldValues = childDefinition.getAccessor().getValues(theTo); - mergeFields(theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues, theMergeControlParameters); + mergeFields( + theFhirContext, + theTo, + childDefinition, + theFromFieldValues, + theToFieldValues, + theMergeControlParameters); } private static void mergeFields( - FhirTerser theTerser, - IBaseResource theTo, + FhirContext theFhirContext, + IBase theTo, BaseRuntimeChildDefinition childDefinition, List theFromFieldValues, List theToFieldValues) { mergeFields( - theTerser, theTo, childDefinition, theFromFieldValues, theToFieldValues, new MergeControlParameters()); + theFhirContext, + theTo, + childDefinition, + theFromFieldValues, + theToFieldValues, + new MergeControlParameters()); } private static void mergeFields( - FhirTerser theTerser, - IBaseResource theTo, + FhirContext theFhirContext, + IBase theTarget, BaseRuntimeChildDefinition childDefinition, - List theFromFieldValues, - List theToFieldValues, + List theSourceFieldValues, + List theTargetFieldValues, MergeControlParameters theMergeControlParameters) { - if (!theFromFieldValues.isEmpty() && theToFieldValues.stream().anyMatch(ResourceUtil::hasDataAbsentReason)) { - // If the to resource has a data absent reason, and there is potentially real data incoming - // in the from resource, we should clear the data absent reason because it won't be absent anymore. - theToFieldValues = removeDataAbsentReason(theTo, childDefinition, theToFieldValues); + FhirTerser terser = theFhirContext.newTerser(); + + if (!theSourceFieldValues.isEmpty() && theTargetFieldValues.stream().anyMatch(ResourceUtil::hasDataAbsentReason)) { + // If the target resource has a data absent reason, and there is potentially real data incoming + // in the source resource, we should clear the data absent reason because it won't be absent anymore. + theTargetFieldValues = removeDataAbsentReason(theTarget, childDefinition, theTargetFieldValues); } + List filteredFromFieldValues = filterValuesThatAlreadyExistInTarget( + terser, theSourceFieldValues, theTargetFieldValues, theMergeControlParameters); + + for (IBase fromFieldValue : filteredFromFieldValues) { + IBase newFieldValue = null; + if (Strings.CI.equals(fromFieldValue.fhirType(), "codeableConcept")) { + Optional matchedTargetValue = theTargetFieldValues.stream() + .filter(targetValue -> + isMergeCandidate(fromFieldValue, targetValue, terser, theMergeControlParameters)) + .findFirst(); + if (matchedTargetValue.isPresent()) { + mergeAllFields(theFhirContext, fromFieldValue, matchedTargetValue.get()); + } else { + newFieldValue = createNewElement(terser, childDefinition, fromFieldValue); + } + } else { + newFieldValue = createNewElement(terser, childDefinition, fromFieldValue); + } + + if (newFieldValue != null) { + try { + theTargetFieldValues.add(newFieldValue); + } catch (UnsupportedOperationException e) { + childDefinition.getMutator().setValue(theTarget, newFieldValue); + theTargetFieldValues = childDefinition.getAccessor().getValues(theTarget); + } + } + } + } + + private static IBase createNewElement( + FhirTerser theTerser, BaseRuntimeChildDefinition theChildDefinition, IBase theFromFieldValue) { + IBase newFieldValue = newElement(theTerser, theChildDefinition, theFromFieldValue); + if (theFromFieldValue instanceof IPrimitiveType) { + try { + Method copyMethod = getMethod(theFromFieldValue, "copy"); + if (copyMethod != null) { + newFieldValue = (IBase) copyMethod.invoke(theFromFieldValue); + } + } catch (Exception t) { + ((IPrimitiveType) newFieldValue) + .setValueAsString(((IPrimitiveType) theFromFieldValue).getValueAsString()); + } + } else { + theTerser.cloneInto(theFromFieldValue, newFieldValue, true); + } + return newFieldValue; + } + + private static List filterValuesThatAlreadyExistInTarget( + FhirTerser theTerser, + List theFromFieldValues, + List theToFieldValues, + MergeControlParameters theMergeControlParameters) { List filteredFromFieldValues = new ArrayList<>(); for (IBase fromFieldValue : theFromFieldValues) { if (theToFieldValues.isEmpty()) { @@ -277,30 +311,7 @@ private static void mergeFields( } } } - - for (IBase fromFieldValue : filteredFromFieldValues) { - IBase newFieldValue = newElement(theTerser, childDefinition, fromFieldValue); - if (fromFieldValue instanceof IPrimitiveType) { - try { - Method copyMethod = getMethod(fromFieldValue, "copy"); - if (copyMethod != null) { - newFieldValue = (IBase) copyMethod.invoke(fromFieldValue); - } - } catch (Exception t) { - ((IPrimitiveType) newFieldValue) - .setValueAsString(((IPrimitiveType) fromFieldValue).getValueAsString()); - } - } else { - theTerser.cloneInto(fromFieldValue, newFieldValue, true); - } - - try { - theToFieldValues.add(newFieldValue); - } catch (UnsupportedOperationException e) { - childDefinition.getMutator().setValue(theTo, newFieldValue); - theToFieldValues = childDefinition.getAccessor().getValues(theTo); - } - } + return filteredFromFieldValues; } private static BaseRuntimeChildDefinition getBaseRuntimeChildDefinition( @@ -338,7 +349,90 @@ private static boolean contains(IBase theItem, List theItems) { return theItems.stream().anyMatch(i -> evaluateEquality(i, theItem, method)); } + /** + * Evaluates whether a given source CodeableConcept can be merged into a target + * CodeableConcept + * + * @param theSourceItem the source item + * @param theTargetItem the target item + * @param theTerser a terser for introspecting the items + * @param theMergeControlParameters parameters providing fine-grained control over the merge operation + * @return true if the source item can be merged into the target item + */ + private static boolean isMergeCandidate( + IBase theSourceItem, + IBase theTargetItem, + FhirTerser theTerser, + MergeControlParameters theMergeControlParameters) { + // First, compare the shallow fields of the CodeableConcepts. + Method shallowEquals = getMethod(theSourceItem, "equalsShallow"); + boolean isMergeCandidate = evaluateEquality(theSourceItem, theTargetItem, shallowEquals); + + // if the shallow fields match, we proceed to compare the lists of Codings + if (isMergeCandidate) { + List sourceCodings = theTerser.getValues(theSourceItem, "coding"); + List targetCodings = theTerser.getValues(theTargetItem, "coding"); + if (theMergeControlParameters.isIgnoreCodeableConceptCodingOrder()) { + if (theMergeControlParameters.isMergeCodings()) { + if (sourceCodings.size() < targetCodings.size()) { + isMergeCandidate = sourceCodings.stream().allMatch(sourceCoding -> { + Method deepEquals = getMethod(sourceCoding, EQUALS_DEEP); + return targetCodings.stream() + .anyMatch(targetCoding -> evaluateEquality(sourceCoding, targetCoding, deepEquals)); + }); + } else { + isMergeCandidate = targetCodings.stream().allMatch(targetCoding -> { + Method deepEquals = getMethod(targetCoding, EQUALS_DEEP); + return sourceCodings.stream() + .anyMatch(sourceCoding -> evaluateEquality(sourceCoding, targetCoding, deepEquals)); + }); + } + } else { + isMergeCandidate = sourceCodings.size() == targetCodings.size() + && sourceCodings.stream().allMatch(sourceCoding -> { + Method deepEquals = getMethod(sourceCoding, EQUALS_DEEP); + return targetCodings.stream() + .anyMatch(targetCoding -> + evaluateEquality(sourceCoding, targetCoding, deepEquals)); + }); + } + } else { + if (theMergeControlParameters.isMergeCodings()) { + int prefixLength = Math.min(sourceCodings.size(), targetCodings.size()); + for (int i = 0; i < prefixLength; i++) { + Method deepEquals = getMethod(sourceCodings.get(i), EQUALS_DEEP); + isMergeCandidate &= evaluateEquality(sourceCodings.get(i), targetCodings.get(i), deepEquals); + } + } else { + if (sourceCodings.size() == targetCodings.size()) { + for (int i = 0; i < sourceCodings.size(); i++) { + Method deepEquals = getMethod(sourceCodings.get(i), EQUALS_DEEP); + isMergeCandidate &= + evaluateEquality(sourceCodings.get(i), targetCodings.get(i), deepEquals); + } + } else { + isMergeCandidate = false; + } + } + } + } + + return isMergeCandidate; + } + /** + * Evaluates whether a list of target CodeableConcepts already contains a given source + * CodeableConcept. The order of Codings may or may not matter, depending on the + * configuration parameters, but otherwise we evaluate equivalence in the strictest + * available sense, since values filtered out by this method will not be candidates + * for subsequent merge operations. + * + * @param theSourceItem The source value + * @param theTargetItems The list of target values + * @param theTerser A terser to use to inspect the values + * @param theMergeControlParameters A set of parameters to control the operation + * @return true if the source item already exists in the list of target items + */ private static boolean containsCodeableConcept( IBase theSourceItem, List theTargetItems, @@ -386,18 +480,18 @@ private static boolean hasDataAbsentReason(IBase theItem) { } private static List removeDataAbsentReason( - IBaseResource theResource, BaseRuntimeChildDefinition theFieldDefinition, List theFieldValues) { + IBase theFhirElement, BaseRuntimeChildDefinition theFieldDefinition, List theFieldValues) { for (int i = 0; i < theFieldValues.size(); i++) { if (hasDataAbsentReason(theFieldValues.get(i))) { try { - theFieldDefinition.getMutator().remove(theResource, i); + theFieldDefinition.getMutator().remove(theFhirElement, i); } catch (UnsupportedOperationException e) { // the field must be single-valued, just clear it - theFieldDefinition.getMutator().setValue(theResource, null); + theFieldDefinition.getMutator().setValue(theFhirElement, null); } } } - return theFieldDefinition.getAccessor().getValues(theResource); + return theFieldDefinition.getAccessor().getValues(theFhirElement); } /** diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java index 011173a94196..e1329418dad3 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/TerserUtil.java @@ -286,7 +286,7 @@ private static boolean hasDataAbsentReason(IBase theItem) { * @param theFhirContext Context holding resource definition * @param theFrom The resource to merge the fields from * @param theTo The resource to merge the fields into - * @deprecated Use {@link ResourceUtil#mergeAllFields(FhirContext, IBaseResource, IBaseResource)} + * @deprecated Use {@link ResourceUtil#mergeAllFields(FhirContext, IBase, IBase)} */ @Deprecated(since = "8.7.0") public static void mergeAllFields(FhirContext theFhirContext, IBaseResource theFrom, IBaseResource theTo) { @@ -604,7 +604,7 @@ public static void mergeFieldsExceptIdAndMeta( * @param theFrom Resource to merge the specified field from * @param theTo Resource to merge the specified field into * @param inclusionStrategy Predicate to test which fields should be merged - * @deprecated Use {@link ResourceUtil#mergeFields(FhirContext, IBaseResource, IBaseResource, Predicate)} + * @deprecated Use {@link ResourceUtil#mergeFields(FhirContext, IBase, IBase, Predicate)} */ @Deprecated(since = "8.7.0") public static void mergeFields( @@ -652,7 +652,7 @@ public static void mergeField( * @param theFieldName Name of the child filed to merge * @param theFrom Resource to merge the specified field from * @param theTo Resource to merge the specified field into - * @deprecated Use {@link ResourceUtil#mergeField(FhirContext, FhirTerser, String, IBaseResource, IBaseResource)} + * @deprecated Use {@link ResourceUtil#mergeField(FhirContext, String, IBaseResource, IBaseResource)} */ @Deprecated(since = "8.7.0") public static void mergeField( diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java index 439b73e636cf..6c7e1524ed63 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java @@ -332,4 +332,149 @@ public void testMerge_ignoreCodingOrder() { // validate assertThat(o2.getCategory()).hasSize(1); } + + /* + * The default behaviour is to simply overwrite the field in the target resource + * with the value from the source resource + */ + @Test + public void testMerge_singleton_conceptsDoNotMatch_doNotMerge() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10834-0"); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(1); + assertThat(o2.getCode().getCodingFirstRep().getSystem()).isEqualTo("http://loinc.org"); + assertThat(o2.getCode().getCodingFirstRep().getCode()).isEqualTo("10836-5"); + assertThat(o2.getCode().getCodingFirstRep().getDisplay()).isEqualTo("Niacin [Mass/volume] in Blood"); + } + + @Test + public void testMerge_singleton_mergeCodingsDisabled_doNotMerge() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + o2.getCode().addCoding().setSystem("http://customlocalcodesystem.org").setCode("ABC").setDisplay("Niacin"); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(1); + assertThat(o2.getCode().getCodingFirstRep().getSystem()).isEqualTo("http://loinc.org"); + } + + @Test + public void testMerge_singleton_mergeCodingsEnabled_mergeCodingsFromSource() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + o1.getCode().addCoding().setSystem("http://customlocalcodesystem.org").setCode("ABC").setDisplay("Niacin"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(2); + assertThat(o2.getCode().getCoding().get(0).getSystem()).isEqualTo("http://loinc.org"); + assertThat(o2.getCode().getCoding().get(1).getSystem()).isEqualTo("http://customlocalcodesystem.org"); + } + + @Test + public void testMerge_singleton_mergeCodingsEnabled_mergeCodingsFromTarget() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + o2.getCode().addCoding().setSystem("http://customlocalcodesystem.org").setCode("ABC").setDisplay("Niacin"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(2); + assertThat(o2.getCode().getCoding().get(0).getSystem()).isEqualTo("http://loinc.org"); + assertThat(o2.getCode().getCoding().get(1).getSystem()).isEqualTo("http://customlocalcodesystem.org"); + } + + @Test + public void testMerge_singleton_conceptsMatch_doNotMergeCodingFields() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2); + + // validate + assertThat(o2.getCode().getCodingFirstRep().hasDisplay()).isFalse(); + } + + /* + * The default behaviour should be retained if the codings do not match + */ + @Test + public void testMerge_singleton_conceptsDoNotMatch_mergeCodingEnabled_noEffect() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10834-0"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(1); + assertThat(o2.getCode().getCodingFirstRep().getSystem()).isEqualTo("http://loinc.org"); + assertThat(o2.getCode().getCodingFirstRep().getCode()).isEqualTo("10836-5"); + assertThat(o2.getCode().getCodingFirstRep().getDisplay()).isEqualTo("Niacin [Mass/volume] in Blood"); + } + + @Test + public void testMerge_singleton_conceptsMatch_mergeCodingEnabled() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCodingFirstRep().getDisplay()).isEqualTo("Niacin [Mass/volume] in Blood"); + } } From 7c5fdf1340ec7b461c99b03c21364dcecfcec693 Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Thu, 27 Nov 2025 12:21:03 -0500 Subject: [PATCH 5/8] more singleton tests --- .../java/ca/uhn/fhir/util/ResourceUtil.java | 3 +- .../ca/uhn/fhir/util/ResourceUtilTest.java | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java index fb0d0a9bd2d8..72e7392d8dfa 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java @@ -232,7 +232,8 @@ private static void mergeFields( MergeControlParameters theMergeControlParameters) { FhirTerser terser = theFhirContext.newTerser(); - if (!theSourceFieldValues.isEmpty() && theTargetFieldValues.stream().anyMatch(ResourceUtil::hasDataAbsentReason)) { + if (!theSourceFieldValues.isEmpty() + && theTargetFieldValues.stream().anyMatch(ResourceUtil::hasDataAbsentReason)) { // If the target resource has a data absent reason, and there is potentially real data incoming // in the source resource, we should clear the data absent reason because it won't be absent anymore. theTargetFieldValues = removeDataAbsentReason(theTarget, childDefinition, theTargetFieldValues); diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java index 6c7e1524ed63..f87dea59464b 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java @@ -418,6 +418,55 @@ public void testMerge_singleton_mergeCodingsEnabled_mergeCodingsFromTarget() { assertThat(o2.getCode().getCoding().get(1).getSystem()).isEqualTo("http://customlocalcodesystem.org"); } + @Test + public void testMerge_singleton_ignoreOrderEnabled_mergeCodingsEnabled_mergeCodings() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + o1.getCode().addCoding().setSystem("http://customlocalcodesystem.org").setCode("ABC").setDisplay("Niacin"); + o1.getCode().addCoding().setSystem("http://anothersystem.org").setCode("123").setDisplay("Niacin"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://anothersystem.org").setCode("123").setDisplay("Niacin"); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setIgnoreCodeableConceptCodingOrder(true); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(3); + assertThat(o2.getCode().getCoding().get(0).getSystem()).isEqualTo("http://anothersystem.org"); + assertThat(o2.getCode().getCoding().get(1).getSystem()).isEqualTo("http://loinc.org"); + assertThat(o2.getCode().getCoding().get(2).getSystem()).isEqualTo("http://customlocalcodesystem.org"); + } + + @Test + public void testMerge_singleton_mergeCodingsEnabled_overlapingCodings_doNotMerge() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + o1.getCode().addCoding().setSystem("http://anothersystem.org").setCode("123").setDisplay("Niacin"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + o2.getCode().addCoding().setSystem("http://customlocalcodesystem.org").setCode("ABC").setDisplay("Niacin"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(2); + assertThat(o2.getCode().getCoding().get(0).getSystem()).isEqualTo("http://loinc.org"); + assertThat(o2.getCode().getCoding().get(1).getSystem()).isEqualTo("http://anothersystem.org"); + } + @Test public void testMerge_singleton_conceptsMatch_doNotMergeCodingFields() { // set up From 22b08bbab1be24e3b4db7adb3ee59b25649140db Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Thu, 27 Nov 2025 14:01:44 -0500 Subject: [PATCH 6/8] add collection test cases --- .../ca/uhn/fhir/util/ResourceUtilTest.java | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java index f87dea59464b..98eab3554099 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java @@ -467,6 +467,160 @@ public void testMerge_singleton_mergeCodingsEnabled_overlapingCodings_doNotMerge assertThat(o2.getCode().getCoding().get(1).getSystem()).isEqualTo("http://anothersystem.org"); } + /* + * The default behaviour is to simply overwrite the field in the target resource + * with the value from the source resource + */ + @Test + public void testMerge_collection_conceptsDoNotMatch_doNotMerge() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2); + + // validate + assertThat(o2.getCategory()).hasSize(2); + assertThat(o2.getCategory().get(0).getCoding()).hasSize(1); + assertThat(o2.getCategory().get(0).getCodingFirstRep().getCode()).isEqualTo("survey"); + assertThat(o2.getCategory().get(1).getCoding()).hasSize(1); + assertThat(o2.getCategory().get(1).getCodingFirstRep().getCode()).isEqualTo("social-history"); + } + + @Test + public void testMerge_collection_mergeCodingsDisabled_doNotMerge() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2); + + // validate + assertThat(o2.getCategory()).hasSize(2); + assertThat(o2.getCategory().get(0).getCoding()).hasSize(2); + assertThat(o2.getCategory().get(0).getCoding().get(0).getCode()).isEqualTo("social-history"); + assertThat(o2.getCategory().get(0).getCoding().get(1).getCode()).isEqualTo("survey"); + assertThat(o2.getCategory().get(1).getCoding()).hasSize(1); + assertThat(o2.getCategory().get(1).getCodingFirstRep().getCode()).isEqualTo("social-history"); + } + + @Test + public void testMerge_collection_mergeCodingsEnabled_mergeCodingsFromSource() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCategory()).hasSize(1); + assertThat(o2.getCategory().get(0).getCoding()).hasSize(2); + assertThat(o2.getCategory().get(0).getCoding().get(0).getCode()).isEqualTo("social-history"); + assertThat(o2.getCategory().get(0).getCoding().get(1).getCode()).isEqualTo("survey"); + } + + @Test + public void testMerge_collection_mergeCodingsEnabled_mergeCodingsFromTarget() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCategory()).hasSize(1); + assertThat(o2.getCategory().get(0).getCoding()).hasSize(2); + assertThat(o2.getCategory().get(0).getCoding().get(0).getCode()).isEqualTo("social-history"); + assertThat(o2.getCategory().get(0).getCoding().get(1).getCode()).isEqualTo("survey"); + } + + @Test + public void testMerge_collection_ignoreOrderEnabled_mergeCodingsEnabled_mergeCodings() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("therapy"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("therapy"); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setIgnoreCodeableConceptCodingOrder(true); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCategory()).hasSize(1); + assertThat(o2.getCategory().get(0).getCoding()).hasSize(3); + assertThat(o2.getCategory().get(0).getCoding().get(0).getCode()).isEqualTo("therapy"); + assertThat(o2.getCategory().get(0).getCoding().get(1).getCode()).isEqualTo("social-history"); + assertThat(o2.getCategory().get(0).getCoding().get(2).getCode()).isEqualTo("survey"); + } + + @Test + public void testMerge_collection_mergeCodingsEnabled_overlappingCodings_doNotMerge() { + // set up + Observation o1 = new Observation(); + CodeableConcept category1 = o1.addCategory(); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category1.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("survey"); + + Observation o2 = new Observation(); + CodeableConcept category2 = o2.addCategory(); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("social-history"); + category2.addCoding().setSystem("http://terminology.hl7.org/CodeSystem/observation-category").setCode("therapy"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodings(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "category", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCategory()).hasSize(2); + assertThat(o2.getCategory().get(0).getCoding().get(1).getCode()).isEqualTo("therapy"); + assertThat(o2.getCategory().get(1).getCoding().get(1).getCode()).isEqualTo("survey"); + } + @Test public void testMerge_singleton_conceptsMatch_doNotMergeCodingFields() { // set up From ff75a698d0b5b3d8dd9f0e4125b2704badbc2000 Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Thu, 27 Nov 2025 15:01:56 -0500 Subject: [PATCH 7/8] initial support for merging matching codings --- .../java/ca/uhn/fhir/util/ResourceUtil.java | 79 ++++++++++++++----- .../ca/uhn/fhir/util/ResourceUtilTest.java | 2 +- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java index 72e7392d8dfa..83225901566e 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java @@ -57,6 +57,7 @@ public class ResourceUtil { public static class MergeControlParameters { private boolean myIgnoreCodeableConceptCodingOrder; private boolean myMergeCodings; + private boolean myMergeCodingDetails; public boolean isIgnoreCodeableConceptCodingOrder() { return myIgnoreCodeableConceptCodingOrder; @@ -73,6 +74,14 @@ public boolean isMergeCodings() { public void setMergeCodings(boolean theMergeCodings) { myMergeCodings = theMergeCodings; } + + public boolean isMergeCodingDetails() { + return myMergeCodingDetails; + } + + public void setMergeCodingDetails(boolean theMergeCodingDetails) { + myMergeCodingDetails = theMergeCodingDetails; + } } private ResourceUtil() {} @@ -376,40 +385,32 @@ private static boolean isMergeCandidate( if (theMergeControlParameters.isIgnoreCodeableConceptCodingOrder()) { if (theMergeControlParameters.isMergeCodings()) { if (sourceCodings.size() < targetCodings.size()) { - isMergeCandidate = sourceCodings.stream().allMatch(sourceCoding -> { - Method deepEquals = getMethod(sourceCoding, EQUALS_DEEP); - return targetCodings.stream() - .anyMatch(targetCoding -> evaluateEquality(sourceCoding, targetCoding, deepEquals)); - }); + isMergeCandidate = sourceCodings.stream().allMatch(sourceCoding -> targetCodings.stream() + .anyMatch(targetCoding -> isCodingMatches( + theTerser, sourceCoding, targetCoding, theMergeControlParameters))); } else { - isMergeCandidate = targetCodings.stream().allMatch(targetCoding -> { - Method deepEquals = getMethod(targetCoding, EQUALS_DEEP); - return sourceCodings.stream() - .anyMatch(sourceCoding -> evaluateEquality(sourceCoding, targetCoding, deepEquals)); - }); + isMergeCandidate = targetCodings.stream().allMatch(targetCoding -> sourceCodings.stream() + .anyMatch(sourceCoding -> isCodingMatches( + theTerser, sourceCoding, targetCoding, theMergeControlParameters))); } } else { isMergeCandidate = sourceCodings.size() == targetCodings.size() - && sourceCodings.stream().allMatch(sourceCoding -> { - Method deepEquals = getMethod(sourceCoding, EQUALS_DEEP); - return targetCodings.stream() - .anyMatch(targetCoding -> - evaluateEquality(sourceCoding, targetCoding, deepEquals)); - }); + && sourceCodings.stream().allMatch(sourceCoding -> targetCodings.stream() + .anyMatch(targetCoding -> isCodingMatches( + theTerser, sourceCoding, targetCoding, theMergeControlParameters))); } } else { if (theMergeControlParameters.isMergeCodings()) { int prefixLength = Math.min(sourceCodings.size(), targetCodings.size()); for (int i = 0; i < prefixLength; i++) { - Method deepEquals = getMethod(sourceCodings.get(i), EQUALS_DEEP); - isMergeCandidate &= evaluateEquality(sourceCodings.get(i), targetCodings.get(i), deepEquals); + isMergeCandidate &= isCodingMatches( + theTerser, sourceCodings.get(i), targetCodings.get(i), theMergeControlParameters); } } else { if (sourceCodings.size() == targetCodings.size()) { for (int i = 0; i < sourceCodings.size(); i++) { - Method deepEquals = getMethod(sourceCodings.get(i), EQUALS_DEEP); - isMergeCandidate &= - evaluateEquality(sourceCodings.get(i), targetCodings.get(i), deepEquals); + isMergeCandidate &= isCodingMatches( + theTerser, sourceCodings.get(i), targetCodings.get(i), theMergeControlParameters); } } else { isMergeCandidate = false; @@ -421,6 +422,42 @@ private static boolean isMergeCandidate( return isMergeCandidate; } + @SuppressWarnings("rawtypes") + private static boolean isCodingMatches( + FhirTerser theTerser, + IBase theSourceCoding, + IBase theTargetCoding, + MergeControlParameters theMergeControlParameters) { + boolean codingMatches; + if (theMergeControlParameters.isMergeCodingDetails()) { + // Use the tuple (system,code) as a business key on Coding + Optional sourceSystem = + theTerser.getSingleValue(theSourceCoding, "system", IPrimitiveType.class); + Optional sourceCode = + theTerser.getSingleValue(theSourceCoding, "code", IPrimitiveType.class); + Optional targetSystem = + theTerser.getSingleValue(theTargetCoding, "system", IPrimitiveType.class); + Optional targetCode = + theTerser.getSingleValue(theTargetCoding, "code", IPrimitiveType.class); + boolean systemMatches = sourceSystem.isPresent() + && targetSystem.isPresent() + && Strings.CS.equals( + sourceSystem.get().getValueAsString(), + targetSystem.get().getValueAsString()); + boolean codeMatches = sourceCode.isPresent() + && targetCode.isPresent() + && Strings.CS.equals( + sourceCode.get().getValueAsString(), + targetCode.get().getValueAsString()); + codingMatches = systemMatches && codeMatches; + } else { + // require an exact match on every field + Method deepEquals = getMethod(theSourceCoding, EQUALS_DEEP); + codingMatches = evaluateEquality(theSourceCoding, theTargetCoding, deepEquals); + } + return codingMatches; + } + /** * Evaluates whether a list of target CodeableConcepts already contains a given source * CodeableConcept. The order of Codings may or may not matter, depending on the diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java index 98eab3554099..2aa48aa11fa6 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java @@ -672,7 +672,7 @@ public void testMerge_singleton_conceptsMatch_mergeCodingEnabled() { o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); - mergeControlParameters.setMergeCodings(true); + mergeControlParameters.setMergeCodingDetails(true); // execute ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); From a035960cea6f847b95aa8d02d200b09524638877 Mon Sep 17 00:00:00 2001 From: Jason Roberts Date: Fri, 28 Nov 2025 14:18:29 -0500 Subject: [PATCH 8/8] merge matching codings --- .../java/ca/uhn/fhir/util/ResourceUtil.java | 72 ++++++++++++++----- .../ca/uhn/fhir/util/ResourceUtilTest.java | 44 +++++++++++- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java index 83225901566e..32311d08055c 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ResourceUtil.java @@ -143,7 +143,21 @@ private static String getRawUserDataKey(EncodingEnum theEncodingEnum) { * @param theTo The resource to merge the fields into */ public static void mergeAllFields(FhirContext theFhirContext, IBase theFrom, IBase theTo) { - mergeFields(theFhirContext, theFrom, theTo, INCLUDE_ALL); + mergeAllFields(theFhirContext, theFrom, theTo, new MergeControlParameters()); + } + + /** + * Merges all fields on the provided instance. theTo will contain a union of all values from theFrom + * instance and theTo instance. + * + * @param theFhirContext Context holding resource definition + * @param theFrom The resource to merge the fields from + * @param theTo The resource to merge the fields into + * @param theMergeControlParameters + */ + public static void mergeAllFields( + FhirContext theFhirContext, IBase theFrom, IBase theTo, MergeControlParameters theMergeControlParameters) { + mergeFields(theFhirContext, theFrom, theTo, INCLUDE_ALL, theMergeControlParameters); } /** @@ -157,6 +171,25 @@ public static void mergeAllFields(FhirContext theFhirContext, IBase theFrom, IBa */ public static void mergeFields( FhirContext theFhirContext, IBase theFrom, IBase theTo, Predicate inclusionStrategy) { + mergeFields(theFhirContext, theFrom, theTo, inclusionStrategy, new MergeControlParameters()); + } + + /** + * Merges values of all field from theFrom resource to theTo resource. Fields + * values are compared via the equalsDeep method, or via object identity if this method is not available. + * + * @param theFhirContext Context holding resource definition + * @param theFrom Resource to merge the specified field from + * @param theTo Resource to merge the specified field into + * @param inclusionStrategy Predicate to test which fields should be merged + * @param theMergeControlParameters + */ + public static void mergeFields( + FhirContext theFhirContext, + IBase theFrom, + IBase theTo, + Predicate inclusionStrategy, + MergeControlParameters theMergeControlParameters) { BaseRuntimeElementDefinition definition = theFhirContext.getElementDefinition(theFrom.getClass()); if (definition instanceof BaseRuntimeElementCompositeDefinition compositeDefinition) { for (BaseRuntimeChildDefinition childDefinition : compositeDefinition.getChildrenAndExtension()) { @@ -167,7 +200,13 @@ public static void mergeFields( List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); List theToFieldValues = childDefinition.getAccessor().getValues(theTo); - mergeFields(theFhirContext, theTo, childDefinition, theFromFieldValues, theToFieldValues); + mergeFields( + theFhirContext, + theTo, + childDefinition, + theFromFieldValues, + theToFieldValues, + theMergeControlParameters); } } } @@ -217,21 +256,6 @@ public static void mergeField( theMergeControlParameters); } - private static void mergeFields( - FhirContext theFhirContext, - IBase theTo, - BaseRuntimeChildDefinition childDefinition, - List theFromFieldValues, - List theToFieldValues) { - mergeFields( - theFhirContext, - theTo, - childDefinition, - theFromFieldValues, - theToFieldValues, - new MergeControlParameters()); - } - private static void mergeFields( FhirContext theFhirContext, IBase theTarget, @@ -253,13 +277,23 @@ private static void mergeFields( for (IBase fromFieldValue : filteredFromFieldValues) { IBase newFieldValue = null; - if (Strings.CI.equals(fromFieldValue.fhirType(), "codeableConcept")) { + if (Strings.CI.equals(fromFieldValue.fhirType(), "CodeableConcept")) { Optional matchedTargetValue = theTargetFieldValues.stream() .filter(targetValue -> isMergeCandidate(fromFieldValue, targetValue, terser, theMergeControlParameters)) .findFirst(); if (matchedTargetValue.isPresent()) { - mergeAllFields(theFhirContext, fromFieldValue, matchedTargetValue.get()); + mergeAllFields(theFhirContext, fromFieldValue, matchedTargetValue.get(), theMergeControlParameters); + } else { + newFieldValue = createNewElement(terser, childDefinition, fromFieldValue); + } + } else if (Strings.CI.equals(fromFieldValue.fhirType(), "Coding")) { + Optional matchedTargetValue = theTargetFieldValues.stream() + .filter(targetValue -> + isCodingMatches(terser, fromFieldValue, targetValue, theMergeControlParameters)) + .findFirst(); + if (matchedTargetValue.isPresent()) { + mergeAllFields(theFhirContext, fromFieldValue, matchedTargetValue.get(), theMergeControlParameters); } else { newFieldValue = createNewElement(terser, childDefinition, fromFieldValue); } diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java index 2aa48aa11fa6..c3376189dda9 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/ResourceUtilTest.java @@ -663,7 +663,7 @@ public void testMerge_singleton_conceptsDoNotMatch_mergeCodingEnabled_noEffect() } @Test - public void testMerge_singleton_conceptsMatch_mergeCodingEnabled() { + public void testMerge_singleton_conceptsMatch_mergeCodingDetailsFromTarget() { // set up Observation o1 = new Observation(); o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5"); @@ -678,6 +678,48 @@ public void testMerge_singleton_conceptsMatch_mergeCodingEnabled() { ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); // validate + assertThat(o2.getCode().getCoding()).hasSize(1); + assertThat(o2.getCode().getCodingFirstRep().getDisplay()).isEqualTo("Niacin [Mass/volume] in Blood"); + } + + @Test + public void testMerge_singleton_conceptsMatch_mergeCodingDetailsFromSource() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodingDetails(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(1); + assertThat(o2.getCode().getCodingFirstRep().getDisplay()).isEqualTo("Niacin [Mass/volume] in Blood"); + } + + @Test + public void testMerge_singleton_conceptsMatch_mergeCodingDetailsFromBoth() { + // set up + Observation o1 = new Observation(); + o1.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setDisplay("Niacin [Mass/volume] in Blood"); + + Observation o2 = new Observation(); + o2.getCode().addCoding().setSystem("http://loinc.org").setCode("10836-5").setVersion("4.0.1"); + + ResourceUtil.MergeControlParameters mergeControlParameters = new ResourceUtil.MergeControlParameters(); + mergeControlParameters.setMergeCodingDetails(true); + + // execute + ResourceUtil.mergeField(ourFhirContext, "code", o1, o2, mergeControlParameters); + + // validate + assertThat(o2.getCode().getCoding()).hasSize(1); assertThat(o2.getCode().getCodingFirstRep().getDisplay()).isEqualTo("Niacin [Mass/volume] in Blood"); + assertThat(o2.getCode().getCodingFirstRep().getVersion()).isEqualTo("4.0.1"); } }