Repository: FHIR/sushi (ts)

Issue:
SUSHI should replace ElementDefinition.type.aggregation array when modified in FSH
Some ElementDefinition array properties are additive (you should only add to them) while others should be fully replaced when edited (and perhaps there are other options too). I asked for more information on this when I filed [FHIR-31405](https://jira.hl7.org/browse/FHIR-31405) back in 2021.

The `ElementDefinition.type.aggregation` array is an array property that should be replaced in the snapshot by whatever is in the differential. To support this, however, SUSHI needs to perform special handling of how it generates the differential. More background can be seen in [this Zulip conversation](https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/restricting.20aggregation.20type/near/413120070).

Note that we will need to support things like this at a broader level, so any solution in FSH/SUSHI should be easy to apply to other element properties as well.

Pull request:
Replace type.aggregation on ElementDefinition when modified
Fixes #1411 and completes task [CIMPL-1229](https://standardhealthrecord.atlassian.net/browse/CIMPL-1229).

If a rule modifies an element or child element of the type.aggregation array, any values on type.aggregation inherited from the parent are completely replaced. Properties of ElementDefinition with this behavior are defined in REPLACEMENT_PROPS.

No changes appeared in the regression output for this code change.

Patch:
diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts
index 6b8b2b8..c585fdc 100644
--- a/src/fhirtypes/ElementDefinition.ts
+++ b/src/fhirtypes/ElementDefinition.ts
@@ -11,7 +11,7 @@ import {
 } from 'lodash';
 import { minify } from 'html-minifier-terser';
 import { isUri } from 'valid-url';
-import { StructureDefinition } from './StructureDefinition';
+import { PathPart, StructureDefinition } from './StructureDefinition';
 import {
   CodeableConcept,
   CodeableReference,
@@ -66,7 +66,8 @@ import {
   setPropertyOnDefinitionInstance,
   splitOnPathPeriods,
   isReferenceType,
-  isModifierExtension
+  isModifierExtension,
+  getArrayIndex
 } from './common';
 import {
   Fishable,
@@ -284,6 +285,7 @@ export class ElementDefinition {
   structDef: StructureDefinition;
   private _original: ElementDefinition;
   private _edStructureDefinition: StructureDefinition;
+  private _replacementProps: string[][];
 
   /**
    * Constructs a new ElementDefinition with the given ID.
@@ -291,6 +293,7 @@ export class ElementDefinition {
    */
   constructor(id = '') {
     this.id = id;
+    this._replacementProps = cloneDeep(REPLACEMENT_PROPS);
   }
 
   get id(): string {
@@ -472,6 +475,54 @@ export class ElementDefinition {
     this._original = undefined;
   }
 
+  clearOriginalProperty(pathParts: PathPart[]): void {
+    let currentOriginalElement: any = this._original;
+    // eslint-disable-next-line
+    let currentActualElement: any = this;
+    const clearPath = this.calculateClearPath(pathParts);
+    for (const [i, pathPart] of clearPath.entries()) {
+      if (i < clearPath.length - 1) {
+        const key = pathPart.primitive ? `_${pathPart.base}` : pathPart.base;
+        currentOriginalElement = currentOriginalElement?.[key];
+        currentActualElement = currentActualElement?.[key];
+        const currentIndex = getArrayIndex(pathPart);
+        if (currentIndex != null) {
+          if (Array.isArray(currentOriginalElement)) {
+            currentOriginalElement = currentOriginalElement[currentIndex];
+          }
+          if (Array.isArray(currentActualElement)) {
+            currentActualElement = currentActualElement[currentIndex];
+          }
+        }
+      } else {
+        if (currentOriginalElement?.[pathPart.base] != null) {
+          currentOriginalElement[pathPart.base] = undefined;
+          currentActualElement[pathPart.base] = undefined;
+        }
+        if (pathPart.primitive && currentOriginalElement?.[`_${pathPart.base}`] != null) {
+          currentOriginalElement[`_${pathPart.base}`] = undefined;
+          currentActualElement[`_${pathPart.base}`] = undefined;
+        }
+      }
+    }
+  }
+
+  private calculateClearPath(pathParts: PathPart[]): PathPart[] {
+    const replacementIndex = this._replacementProps.findIndex(
+      replacementPath =>
+        replacementPath.length <= pathParts.length &&
+        replacementPath.every((rp, index) => rp === pathParts[index].base)
+    );
+    if (replacementIndex >= 0) {
+      // Build the clearPath from the actual pathParts, since we want to know about primitive types.
+      const clearPath = pathParts.slice(0, this._replacementProps[replacementIndex].length);
+      // each replacement property only has to get cleared once, so remove it from the list.
+      this._replacementProps.splice(replacementIndex, 1);
+      return clearPath;
+    }
+    return [];
+  }
+
   /**
    * Determines if the state of the current element differs from the stored "original".
    * @returns {boolean} true if the state of the current element differs from the stored "original", false otherwise
@@ -3136,3 +3187,13 @@ const PROPS_AND_UNDERPROPS: string[] = PROPS.reduce((collect: string[], prop) =>
  * See http://hl7.org/fhir/elementdefinition.html#interpretation.
  */
 const ADDITIVE_PROPS = ['mapping', 'constraint'];
+
+/**
+ * These list properties are replaced in child profiles. If they are modified
+ * in a profile, the snapshot should contain only entries in that profile,
+ * and not in the parent profile. Each property is given as a list of
+ * path parts.
+ * For more context and a specific example, see this Zulip thread:
+ * https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/restricting.20aggregation.20type/near/413120070
+ */
+const REPLACEMENT_PROPS = [['type', 'aggregation']];
diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts
index f2638f9..c7b73fc 100644
--- a/src/fhirtypes/common.ts
+++ b/src/fhirtypes/common.ts
@@ -91,6 +91,9 @@ export function setPropertyOnDefinitionInstance(
 ): void {
   const instanceSD = instance.getOwnStructureDefinition(fisher);
   const { assignedValue, pathParts } = instanceSD.validateValueAtPath(path, value, fisher);
+  if (instance instanceof ElementDefinition) {
+    instance.clearOriginalProperty(pathParts);
+  }
   if (!(instance instanceof CodeSystem || instance instanceof ValueSet)) {
     const knownSlices = determineKnownSlices(instanceSD, new Map([[path, { pathParts }]]), fisher);
     setImpliedPropertiesOnInstance(instance, instanceSD, [path], [], fisher, knownSlices);


Test patch:
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index 78edecc..a511b71 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -8189,6 +8189,229 @@ describe('StructureDefinitionExporter R4', () => {
       expect(ed.type[0]).toEqual(expectedType);
       expect(loggerSpy.getAllMessages()).toHaveLength(0);
     });
+
+    it('should apply CaretValueRules on the aggregation of a type and replace the parent values', () => {
+      // Profile: ParentObservation
+      // Parent: Observation
+      // * component ^type.aggregation[0] = #contained
+      // * component ^type.aggregation[1] = #referenced
+      const parentObservation = new Profile('ParentObservation');
+      parentObservation.parent = 'Observation';
+      const parentAggregationContained = new CaretValueRule('component');
+      parentAggregationContained.caretPath = 'type.aggregation[0]';
+      parentAggregationContained.value = new FshCode('contained');
+      const parentAggregationReferenced = new CaretValueRule('component');
+      parentAggregationReferenced.caretPath = 'type.aggregation[1]';
+      parentAggregationReferenced.value = new FshCode('referenced');
+      parentObservation.rules.push(parentAggregationContained, parentAggregationReferenced);
+      doc.profiles.set(parentObservation.name, parentObservation);
+      // Profile: ChildObservation
+      // Parent: ParentObservation
+      // * component ^type.aggregation[0] = #contained
+      const childObservation = new Profile('ChildObservation');
+      childObservation.parent = 'ParentObservation';
+      const childAggregationContained = new CaretValueRule('component');
+      childAggregationContained.caretPath = 'type.aggregation[0]';
+      childAggregationContained.value = new FshCode('contained');
+      childObservation.rules.push(childAggregationContained);
+      doc.profiles.set(childObservation.name, childObservation);
+
+      exporter.export();
+      const parentSd = pkg.profiles[0];
+      const parentComponent = parentSd.elements.find(el => el.id === 'Observation.component');
+      expect(parentComponent.type[0].aggregation).toEqual(['contained', 'referenced']);
+      const childSd = pkg.profiles[1];
+      const childComponent = childSd.elements.find(el => el.id === 'Observation.component');
+      expect(childComponent.type[0].aggregation).toEqual(['contained']);
+      expect(childComponent.calculateDiff().type[0].aggregation).toEqual(['contained']);
+    });
+
+    it('should apply CaretValueRules on elements within the aggregation of a type and replace the parent values', () => {
+      // Profile: ParentObservation
+      // Parent: Observation
+      // * component ^type.aggregation[0] = #contained
+      // * component ^type.aggregation[1] = #referenced
+      // * component ^type.aggregation[1].extension[0].url = "http://example.org"
+      // * component ^type.aggregation[1].extension[0].valueString = "parent value"
+      const parentObservation = new Profile('ParentObservation');
+      parentObservation.parent = 'Observation';
+      const parentAggregationContained = new CaretValueRule('component');
+      parentAggregationContained.caretPath = 'type.aggregation[0]';
+      parentAggregationContained.value = new FshCode('contained');
+      const parentAggregationReferenced = new CaretValueRule('component');
+      parentAggregationReferenced.caretPath = 'type.aggregation[1]';
+      parentAggregationReferenced.value = new FshCode('referenced');
+      const parentExtensionUrl = new CaretValueRule('component');
+      parentExtensionUrl.caretPath = 'type.aggregation[1].extension[0].url';
+      parentExtensionUrl.value = 'http://example.org';
+      const parentExtensionValue = new CaretValueRule('component');
+      parentExtensionValue.caretPath = 'type.aggregation[1].extension[0].valueString';
+      parentExtensionValue.value = 'parent value';
+      parentObservation.rules.push(
+        parentAggregationContained,
+        parentAggregationReferenced,
+        parentExtensionUrl,
+        parentExtensionValue
+      );
+      doc.profiles.set(parentObservation.name, parentObservation);
+      // Profile: ChildObservation
+      // Parent: ParentObservation
+      // * component ^type.aggregation[0].extension[0].url = "http://example.org"
+      // * component ^type.aggregation[0].extension[0].valueString = "child value"
+      // * component ^type.aggregation[0] = #contained
+      // * component ^type.aggregation[1] = #bundled
+      const childObservation = new Profile('ChildObservation');
+      childObservation.parent = 'ParentObservation';
+      const childExtensionUrl = new CaretValueRule('component');
+      childExtensionUrl.caretPath = 'type.aggregation[0].extension[0].url';
+      childExtensionUrl.value = 'http://example.org';
+      const childExtensionValue = new CaretValueRule('component');
+      childExtensionValue.caretPath = 'type.aggregation[0].extension[0].valueString';
+      childExtensionValue.value = 'child value';
+      const childAggregationContained = new CaretValueRule('component');
+      childAggregationContained.caretPath = 'type.aggregation[0]';
+      childAggregationContained.value = new FshCode('contained');
+      const childAggregationBundled = new CaretValueRule('component');
+      childAggregationBundled.caretPath = 'type.aggregation[1]';
+      childAggregationBundled.value = new FshCode('bundled');
+      childObservation.rules.push(
+        childExtensionUrl,
+        childExtensionValue,
+        childAggregationContained,
+        childAggregationBundled
+      );
+      doc.profiles.set(childObservation.name, childObservation);
+
+      exporter.export();
+      const parentSd = pkg.profiles[0];
+      const parentComponent = parentSd.elements.find(el => el.id === 'Observation.component');
+      expect(parentComponent.type[0].aggregation).toEqual(['contained', 'referenced']);
+      expect(parentComponent.type[0]._aggregation).toEqual([
+        null,
+        {
+          extension: [
+            {
+              url: 'http://example.org',
+              valueString: 'parent value'
+            }
+          ]
+        }
+      ]);
+      const childSd = pkg.profiles[1];
+      const childComponent = childSd.elements.find(el => el.id === 'Observation.component');
+      expect(childComponent.type[0].aggregation).toEqual(['contained', 'bundled']);
+      expect(childComponent.type[0]._aggregation).toEqual([
+        {
+          extension: [
+            {
+              url: 'http://example.org',
+              valueString: 'child value'
+            }
+          ]
+        },
+        null
+      ]);
+      const childComponentDiff = childComponent.calculateDiff();
+      expect(childComponentDiff.type[0].aggregation).toEqual(['contained', 'bundled']);
+      expect(childComponentDiff.type[0]._aggregation).toEqual([
+        {
+          extension: [
+            {
+              url: 'http://example.org',
+              valueString: 'child value'
+            }
+          ]
+        },
+        null
+      ]);
+    });
+
+    it('should apply CaretValueRules on elements within the aggregation of a type and replace the children of parent values when there is no parent value', () => {
+      // Profile: ParentObservation
+      // Parent: Observation
+      // * component ^type.aggregation[1].extension[0].url = "http://example.org"
+      // * component ^type.aggregation[1].extension[0].valueString = "parent value"
+      const parentObservation = new Profile('ParentObservation');
+      parentObservation.parent = 'Observation';
+      const parentExtensionUrl = new CaretValueRule('component');
+      parentExtensionUrl.caretPath = 'type.aggregation[1].extension[0].url';
+      parentExtensionUrl.value = 'http://example.org';
+      const parentExtensionValue = new CaretValueRule('component');
+      parentExtensionValue.caretPath = 'type.aggregation[1].extension[0].valueString';
+      parentExtensionValue.value = 'parent value';
+      parentObservation.rules.push(parentExtensionUrl, parentExtensionValue);
+      doc.profiles.set(parentObservation.name, parentObservation);
+      // Profile: ChildObservation
+      // Parent: ParentObservation
+      // * component ^type.aggregation[0].extension[0].url = "http://example.org"
+      // * component ^type.aggregation[0].extension[0].valueString = "child value"
+      // * component ^type.aggregation[0] = #contained
+      // * component ^type.aggregation[1] = #bundled
+      const childObservation = new Profile('ChildObservation');
+      childObservation.parent = 'ParentObservation';
+      const childExtensionUrl = new CaretValueRule('component');
+      childExtensionUrl.caretPath = 'type.aggregation[0].extension[0].url';
+      childExtensionUrl.value = 'http://example.org';
+      const childExtensionValue = new CaretValueRule('component');
+      childExtensionValue.caretPath = 'type.aggregation[0].extension[0].valueString';
+      childExtensionValue.value = 'child value';
+      const childAggregationContained = new CaretValueRule('component');
+      childAggregationContained.caretPath = 'type.aggregation[0]';
+      childAggregationContained.value = new FshCode('contained');
+      const childAggregationBundled = new CaretValueRule('component');
+      childAggregationBundled.caretPath = 'type.aggregation[1]';
+      childAggregationBundled.value = new FshCode('bundled');
+      childObservation.rules.push(
+        childExtensionUrl,
+        childExtensionValue,
+        childAggregationContained,
+        childAggregationBundled
+      );
+      doc.profiles.set(childObservation.name, childObservation);
+
+      exporter.export();
+      const parentSd = pkg.profiles[0];
+      const parentComponent = parentSd.elements.find(el => el.id === 'Observation.component');
+      expect(parentComponent.type[0].aggregation).toBeUndefined();
+      expect(parentComponent.type[0]._aggregation).toEqual([
+        null,
+        {
+          extension: [
+            {
+              url: 'http://example.org',
+              valueString: 'parent value'
+            }
+          ]
+        }
+      ]);
+      const childSd = pkg.profiles[1];
+      const childComponent = childSd.elements.find(el => el.id === 'Observation.component');
+      expect(childComponent.type[0].aggregation).toEqual(['contained', 'bundled']);
+      expect(childComponent.type[0]._aggregation).toEqual([
+        {
+          extension: [
+            {
+              url: 'http://example.org',
+              valueString: 'child value'
+            }
+          ]
+        },
+        null
+      ]);
+      const childComponentDiff = childComponent.calculateDiff();
+      expect(childComponentDiff.type[0].aggregation).toEqual(['contained', 'bundled']);
+      expect(childComponentDiff.type[0]._aggregation).toEqual([
+        {
+          extension: [
+            {
+              url: 'http://example.org',
+              valueString: 'child value'
+            }
+          ]
+        },
+        null
+      ]);
+    });
   });
 
   describe('#ObeysRule', () => {