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 69bc11cf722..32311d08055 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,76 @@ 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.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.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +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 static final Logger ourLog = LoggerFactory.getLogger(ResourceUtil.class); + + public static class MergeControlParameters { + private boolean myIgnoreCodeableConceptCodingOrder; + private boolean myMergeCodings; + private boolean myMergeCodingDetails; + + public boolean isIgnoreCodeableConceptCodingOrder() { + return myIgnoreCodeableConceptCodingOrder; + } + + public void setIgnoreCodeableConceptCodingOrder(boolean theIgnoreCodeableConceptCodingOrder) { + myIgnoreCodeableConceptCodingOrder = theIgnoreCodeableConceptCodingOrder; + } + + public boolean isMergeCodings() { + return myMergeCodings; + } + + public void setMergeCodings(boolean theMergeCodings) { + myMergeCodings = theMergeCodings; + } + + public boolean isMergeCodingDetails() { + return myMergeCodingDetails; + } + + public void setMergeCodingDetails(boolean theMergeCodingDetails) { + myMergeCodingDetails = theMergeCodingDetails; + } + } 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 @@ -58,8 +113,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); } @@ -79,4 +133,462 @@ 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, IBase theFrom, IBase theTo) { + 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); + } + + /** + * 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, 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()) { + if (!inclusionStrategy.test(childDefinition.getElementName())) { + continue; + } + + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + + mergeFields( + theFhirContext, + theTo, + childDefinition, + theFromFieldValues, + theToFieldValues, + 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 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, 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 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) { + BaseRuntimeChildDefinition childDefinition = + getBaseRuntimeChildDefinition(theFhirContext, theFieldName, theFrom); + + List theFromFieldValues = childDefinition.getAccessor().getValues(theFrom); + List theToFieldValues = childDefinition.getAccessor().getValues(theTo); + + mergeFields( + theFhirContext, + theTo, + childDefinition, + theFromFieldValues, + theToFieldValues, + theMergeControlParameters); + } + + private static void mergeFields( + FhirContext theFhirContext, + IBase theTarget, + BaseRuntimeChildDefinition childDefinition, + List theSourceFieldValues, + List theTargetFieldValues, + MergeControlParameters theMergeControlParameters) { + 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(), 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); + } + } 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()) { + // 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); + } + } + } + return filteredFromFieldValues; + } + + private static BaseRuntimeChildDefinition getBaseRuntimeChildDefinition( + FhirContext theFhirContext, String theFieldName, IBaseResource theFrom) { + RuntimeResourceDefinition definition = theFhirContext.getResourceDefinition(theFrom); + BaseRuntimeChildDefinition childDefinition = definition.getChildByName(theFieldName); + Objects.requireNonNull(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 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), theMethod.getName(), 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 -> 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 -> targetCodings.stream() + .anyMatch(targetCoding -> isCodingMatches( + theTerser, sourceCoding, targetCoding, theMergeControlParameters))); + } else { + isMergeCandidate = targetCodings.stream().allMatch(targetCoding -> sourceCodings.stream() + .anyMatch(sourceCoding -> isCodingMatches( + theTerser, sourceCoding, targetCoding, theMergeControlParameters))); + } + } else { + isMergeCandidate = sourceCodings.size() == targetCodings.size() + && 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++) { + isMergeCandidate &= isCodingMatches( + theTerser, sourceCodings.get(i), targetCodings.get(i), theMergeControlParameters); + } + } else { + if (sourceCodings.size() == targetCodings.size()) { + for (int i = 0; i < sourceCodings.size(); i++) { + isMergeCandidate &= isCodingMatches( + theTerser, sourceCodings.get(i), targetCodings.get(i), theMergeControlParameters); + } + } else { + isMergeCandidate = false; + } + } + } + } + + 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 + * 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, + 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) { + if (theItem instanceof IBaseHasExtensions hasExtensions) { + return hasExtensions.getExtension().stream() + .anyMatch(t -> Strings.CS.equals(t.getUrl(), DATA_ABSENT_REASON_EXTENSION_URI)); + } + return false; + } + + private static List removeDataAbsentReason( + IBase theFhirElement, BaseRuntimeChildDefinition theFieldDefinition, List theFieldValues) { + for (int i = 0; i < theFieldValues.size(); i++) { + if (hasDataAbsentReason(theFieldValues.get(i))) { + try { + theFieldDefinition.getMutator().remove(theFhirElement, i); + } catch (UnsupportedOperationException e) { + // the field must be single-valued, just clear it + theFieldDefinition.getMutator().setValue(theFhirElement, null); + } + } + } + return theFieldDefinition.getAccessor().getValues(theFhirElement); + } + + /** + * Creates a new element taking into consideration elements with choice that are not directly retrievable by element + * name + * + * @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) { + 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 { + return runtimeElementDefinition.newInstance(); + } + } } 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 8adc307c749..e1329418dad 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, IBase, IBase)} */ + @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, IBase, IBase, 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, 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 ce57f7f3b06..c3376189dda 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,41 @@ 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.CodeableConcept; +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 +53,673 @@ 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; + } + + /* + * 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); + } + + /* + * 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_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"); + } + + /* + * 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 + 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_mergeCodingDetailsFromTarget() { + // 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.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_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"); + } }