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 c363f97b6d8..49708447990 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 @@ -26,10 +26,12 @@ 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 org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.tuple.Triple; import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseBackboneElement; +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; @@ -93,6 +95,8 @@ public final class TerserUtil { private static final Logger ourLog = getLogger(TerserUtil.class); 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 TerserUtil() {} @@ -266,6 +270,15 @@ public final class TerserUtil { 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; + } + /** * Merges all fields on the provided instance. theTo will contain a union of all values from theFrom * instance and theTo instance. @@ -695,24 +708,36 @@ public final class TerserUtil { BaseRuntimeChildDefinition childDefinition, List theFromFieldValues, List theToFieldValues) { - for (IBase theFromFieldValue : theFromFieldValues) { - if (contains(theFromFieldValue, theToFieldValues)) { + if (!theFromFieldValues.isEmpty() && theToFieldValues.stream().anyMatch(TerserUtil::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; } - IBase newFieldValue = newElement(theTerser, childDefinition, theFromFieldValue, null); - if (theFromFieldValue instanceof IPrimitiveType) { + 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(theFromFieldValue, "copy"); + Method copyMethod = getMethod(fromFieldValue, "copy"); if (copyMethod != null) { - newFieldValue = (IBase) copyMethod.invoke(theFromFieldValue, new Object[] {}); + newFieldValue = (IBase) copyMethod.invoke(fromFieldValue, new Object[] {}); } } catch (Throwable t) { - ((IPrimitiveType) newFieldValue) - .setValueAsString(((IPrimitiveType) theFromFieldValue).getValueAsString()); + ((IPrimitiveType) newFieldValue) + .setValueAsString(((IPrimitiveType) fromFieldValue).getValueAsString()); } } else { - theTerser.cloneInto(theFromFieldValue, newFieldValue, true); + theTerser.cloneInto(fromFieldValue, newFieldValue, true); } try { @@ -724,6 +749,21 @@ public final class TerserUtil { } } + 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); + } + /** * Clones the specified resource. * diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6370-data-absent-merge.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6370-data-absent-merge.yaml new file mode 100644 index 00000000000..255a4f18eb2 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6370-data-absent-merge.yaml @@ -0,0 +1,6 @@ +--- +type: add +issue: 6370 +title: "When using the FHIR `TerserUtil` to merge two resource, if one resource has real data in a particular field, +and the other resource has a `data-absent-reason` extension in the same field, the real data will be given +precedence in the merged resource, and the extension will be ignored." diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java index b20a996cfda..d5a0713a068 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/TerserUtilTest.java @@ -2,6 +2,7 @@ package ca.uhn.fhir.util; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.RuntimeResourceDefinition; +import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseBackboneElement; import org.hl7.fhir.r4.model.Address; @@ -10,10 +11,12 @@ import org.hl7.fhir.r4.model.Claim; import org.hl7.fhir.r4.model.Condition; import org.hl7.fhir.r4.model.DateTimeType; import org.hl7.fhir.r4.model.DateType; +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.HumanName; import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Observation; import org.hl7.fhir.r4.model.Organization; import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.Practitioner; @@ -21,9 +24,14 @@ import org.hl7.fhir.r4.model.PrimitiveType; import org.hl7.fhir.r4.model.Reference; import org.hl7.fhir.r4.model.StringType; 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.Date; +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; @@ -118,6 +126,7 @@ class TerserUtilTest { ] } """; + public static final String DATA_ABSENT_REASON_EXTENSION_URI = "http://hl7.org/fhir/StructureDefinition/data-absent-reason"; @Test void cloneIdentifierIntoResource() { @@ -417,6 +426,102 @@ class TerserUtilTest { 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); + + TerserUtil.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); + + TerserUtil.mergeField(ourFhirContext, "identifier", fromObservation, toObservation); + + assertThat(toObservation.getIdentifier()).hasSize(theExpectedIdentifiers.size()); + assertThat(toObservation.getIdentifier()).allMatch(t -> { + if (t.hasValue()) { + return theExpectedIdentifiers.stream().anyMatch(s -> StringUtils.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; + } + @Test void testCloneWithDuplicateNonPrimitives() { Patient p1 = new Patient();