Repository: FHIR/sushi (ts)

Issue:
SUSHI errors when an instance adds an extension on meta.profile without explicitly setting the profile
Given FSH like the following:
```
Instance: PatientExample
InstanceOf: MyPatient
Description: "An example of a patient with a license to krill."
* meta.profile.extension[MyExtension].valueString = "Howdy"
* name
  * given[0] = "James"
  * family = "Pond"
```
SUSHI will emit the following error: `Cannot read properties of undefined (reading 'startsWith')`

This is because the InstanceExporter is trying to check if the `meta.profile` value starts with a particular string -- but in this case, although `meta.profile` is not null, it does not yet have an assigned value. In fact, the code where this is happening is the code that is intended to _set_ the `meta.profile` value based on the `InstanceOf`. Oops.

Pull request:
Improve handling of child elements on meta.profile
Fixes #1330 and completes task [CIMPL-1159](https://standardhealthrecord.atlassian.net/browse/CIMPL-1159).

An Instance may have extensions or other child elements defined on meta.profile. When these elements exist, they should not interfere with checking for the InstanceOf within the profile array. Additionally, if values are set on meta.profile and on child elements, keep those values at aligned indices when adding InstanceOf to meta.profile.

It sounds sort of complicated when written out, but if you take a look at the test cases I added, I think it makes sense. Please let me know if you think that the behavior I've implemented will cause problems. I ran a 1-year 100-count regression and nothing changed, and I'll go for a bigger set tonight.

Patch:
diff --git a/src/export/InstanceExporter.ts b/src/export/InstanceExporter.ts
index 13d991a..18dcb6d 100644
--- a/src/export/InstanceExporter.ts
+++ b/src/export/InstanceExporter.ts
@@ -28,7 +28,23 @@ import { InstanceOfNotDefinedError } from '../errors/InstanceOfNotDefinedError';
 import { AbstractInstanceOfError } from '../errors/AbstractInstanceOfError';
 import { MismatchedTypeError } from '../errors/MismatchedTypeError';
 import { Package } from '.';
-import { at, cloneDeep, isEmpty, isEqual, isMatch, merge, padEnd, uniq, upperFirst } from 'lodash';
+import {
+  at,
+  cloneDeep,
+  differenceWith,
+  isArray,
+  isEmpty,
+  isEqual,
+  isMatch,
+  merge,
+  mergeWith,
+  padEnd,
+  uniq,
+  uniqWith,
+  unzip,
+  upperFirst,
+  zip
+} from 'lodash';
 import { AssignmentRule, AssignmentValueType, PathRule } from '../fshtypes/rules';
 import chalk from 'chalk';
 
@@ -796,10 +812,10 @@ export class InstanceExporter implements Fishable {
       // so give the parameter a union type to handle both cases.
       if (
         !instanceDef.meta?.profile?.some((profile: string | { assignedValue: string }) => {
-          const profileUrl = typeof profile === 'object' ? profile.assignedValue : profile;
+          const profileUrl = typeof profile === 'object' ? profile?.assignedValue : profile;
           return (
             profileUrl === instanceOfStructureDefinition.url ||
-            profileUrl.startsWith(`${instanceOfStructureDefinition.url}|`)
+            profileUrl?.startsWith(`${instanceOfStructureDefinition.url}|`)
           );
         })
       ) {
@@ -809,7 +825,17 @@ export class InstanceExporter implements Fishable {
         } else if (instanceDef.meta.profile == null) {
           instanceDef.meta.profile = [instanceOfStructureDefinition.url];
         } else {
-          instanceDef.meta.profile.unshift(instanceOfStructureDefinition.url);
+          // if instanceDef.meta._profile exists, we need to be careful.
+          if (instanceDef.meta._profile?.length > 0) {
+            if (isEmpty(instanceDef.meta.profile[0])) {
+              instanceDef.meta.profile[0] = instanceOfStructureDefinition.url;
+            } else {
+              instanceDef.meta.profile.unshift(instanceOfStructureDefinition.url);
+              instanceDef.meta._profile.unshift(null);
+            }
+          } else {
+            instanceDef.meta.profile.push(instanceOfStructureDefinition.url);
+          }
         }
       }
     }
@@ -826,7 +852,31 @@ export class InstanceExporter implements Fishable {
     this.pkg.instances.push(instanceDef);
 
     // Once all rules are set, we should ensure that we did not add a duplicate profile URL anywhere
-    if (instanceDef.meta?.profile) instanceDef.meta.profile = uniq(instanceDef.meta.profile);
+    // if any of the duplicates have child elements, such as extension, merge them
+    if (instanceDef.meta?.profile) {
+      if (instanceDef.meta._profile) {
+        const complexProfiles = zip(instanceDef.meta.profile, instanceDef.meta._profile);
+        const uniqueProfiles = uniqWith(complexProfiles, (a, b) => {
+          return a[0] != null && a[0] === b[0];
+        });
+        const removedProfiles = differenceWith(complexProfiles, uniqueProfiles, isEqual);
+        removedProfiles.forEach(rp => {
+          const homeIndex = uniqueProfiles.findIndex(up => up[0] === rp[0]);
+          mergeWith(uniqueProfiles[homeIndex][1], rp[1], (a, b) => {
+            // arrays are concatenated, primitives prefer the existing value if both values are non-null
+            // this makes use of the fact that typeof null === 'object'
+            if (isArray(a) && isArray(b)) {
+              return a.concat(b);
+            } else if (typeof a !== 'object' || typeof b !== 'object') {
+              return a ?? b;
+            }
+          });
+        });
+        [instanceDef.meta.profile, instanceDef.meta._profile] = unzip(uniqueProfiles);
+      } else {
+        instanceDef.meta.profile = uniq(instanceDef.meta.profile);
+      }
+    }
 
     // check for another instance of the same type with the same id
     // see https://www.hl7.org/fhir/resource.html#id
diff --git a/src/fhirtypes/specialTypes.ts b/src/fhirtypes/specialTypes.ts
index 8756b13..d6e6601 100644
--- a/src/fhirtypes/specialTypes.ts
+++ b/src/fhirtypes/specialTypes.ts
@@ -12,6 +12,7 @@ export type Meta = {
   lastUpdated?: string;
   source?: string;
   profile?: string[];
+  _profile?: any[];
   security?: Coding[];
   tag?: Coding[];
   extension?: Extension[];


Test patch:
diff --git a/test/export/InstanceExporter.test.ts b/test/export/InstanceExporter.test.ts
index 5577b5b..0752335 100644
--- a/test/export/InstanceExporter.test.ts
+++ b/test/export/InstanceExporter.test.ts
@@ -766,6 +766,233 @@ describe('InstanceExporter', () => {
       expect(exported.url).toBeUndefined;
     });
 
+    it('should set an extension on meta.profile when no rules set values on meta.profile', () => {
+      // Extension: SimpleExt
+      const simpleExt = new Extension('SimpleExt');
+      doc.extensions.set(simpleExt.name, simpleExt);
+      // * meta.profile.extension[SimpleExt].valueString = "simple string"
+      const metaProfileExtension = new AssignmentRule(
+        'meta.profile.extension[SimpleExt].valueString'
+      );
+      metaProfileExtension.value = 'simple string';
+      patientInstance.rules.push(metaProfileExtension);
+      const exported = exportInstance(patientInstance);
+      // since meta.profile[0] has child elements but no value, the best guess is that
+      // the user expects us to fill in that value using instanceOf, and that they want
+      // those child elements to be at the same index as the instanceOf value.
+      expect(exported.meta.profile).toEqual([
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient'
+      ]);
+      expect(exported.meta._profile[0].extension).toEqual([
+        {
+          url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+          valueString: 'simple string'
+        }
+      ]);
+    });
+
+    it('should set an extension on meta.profile when a rule sets the InstanceOf url on meta.profile', () => {
+      // Extension: SimpleExt
+      const simpleExt = new Extension('SimpleExt');
+      doc.extensions.set(simpleExt.name, simpleExt);
+      // * meta.profile = "http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient"
+      // * meta.profile.extension[SimpleExt].valueString = "simple string"
+      const metaProfileValue = new AssignmentRule('meta.profile');
+      metaProfileValue.value = 'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient';
+      const metaProfileExtension = new AssignmentRule(
+        'meta.profile.extension[SimpleExt].valueString'
+      );
+      metaProfileExtension.value = 'simple string';
+      patientInstance.rules.push(metaProfileValue, metaProfileExtension);
+      const exported = exportInstance(patientInstance);
+      // since meta.profile already contains a value of instanceOf, nothing changes in either array.
+      expect(exported.meta.profile).toEqual([
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient'
+      ]);
+      expect(exported.meta._profile[0].extension).toEqual([
+        {
+          url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+          valueString: 'simple string'
+        }
+      ]);
+    });
+
+    it('should set an extension on meta.profile when a rule sets a non-InstanceOf url on meta.profile', () => {
+      // Extension: SimpleExt
+      const simpleExt = new Extension('SimpleExt');
+      doc.extensions.set(simpleExt.name, simpleExt);
+      // * meta.profile = "http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient"
+      // * meta.profile.extension[SimpleExt].valueString = "simple string"
+      const metaProfileValue = new AssignmentRule('meta.profile');
+      metaProfileValue.value =
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient';
+      const metaProfileExtension = new AssignmentRule(
+        'meta.profile.extension[SimpleExt].valueString'
+      );
+      metaProfileExtension.value = 'simple string';
+      patientInstance.rules.push(metaProfileValue, metaProfileExtension);
+      const exported = exportInstance(patientInstance);
+      // in this case, since profile[0] has both a value and a child element before receiving instanceOf,
+      // keep those aligned.
+      expect(exported.meta.profile).toEqual([
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient',
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient'
+      ]);
+      expect(exported.meta._profile).toEqual([
+        null,
+        {
+          extension: [
+            {
+              url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+              valueString: 'simple string'
+            }
+          ]
+        }
+      ]);
+    });
+
+    it('should set a non-InstanceOf url and an extension on meta.profile at the same non-zero index', () => {
+      // Extension: SimpleExt
+      const simpleExt = new Extension('SimpleExt');
+      doc.extensions.set(simpleExt.name, simpleExt);
+      // * meta.profile[1] = "http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient"
+      // * meta.profile[1].extension[SimpleExt].valueString = "simple string"
+      const metaProfileValue = new AssignmentRule('meta.profile[1]');
+      metaProfileValue.value =
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient';
+      const metaProfileExtension = new AssignmentRule(
+        'meta.profile[1].extension[SimpleExt].valueString'
+      );
+      metaProfileExtension.value = 'simple string';
+      patientInstance.rules.push(metaProfileValue, metaProfileExtension);
+      const exported = exportInstance(patientInstance);
+      // in this case, since profile[1] has both a value and a child element before receiving instanceOf,
+      // keep those aligned, and put instanceOf at profile[0]
+      expect(exported.meta.profile).toEqual([
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient',
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient'
+      ]);
+      expect(exported.meta._profile).toEqual([
+        null,
+        {
+          extension: [
+            {
+              url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+              valueString: 'simple string'
+            }
+          ]
+        }
+      ]);
+    });
+
+    it('should set InstanceOf and non-InstanceOf urls in meta.profile alongside extensions', () => {
+      // Extension: SimpleExt
+      const simpleExt = new Extension('SimpleExt');
+      doc.extensions.set(simpleExt.name, simpleExt);
+      // * meta.profile[0] = "http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient"
+      // * meta.profile[0].extension[SimpleExt].valueString = "simple string"
+      // * meta.profile[1] = "http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient"
+      // * meta.profile[1].extension[SimpleExt].valueString = "less simple string"
+      const metaProfileZero = new AssignmentRule('meta.profile[0]');
+      metaProfileZero.value = 'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient';
+      const metaExtensionZero = new AssignmentRule(
+        'meta.profile[0].extension[SimpleExt].valueString'
+      );
+      metaExtensionZero.value = 'simple string';
+      const metaProfileOne = new AssignmentRule('meta.profile[1]');
+      metaProfileOne.value = 'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient';
+      const metaExtensionOne = new AssignmentRule(
+        'meta.profile[1].extension[SimpleExt].valueString'
+      );
+      metaExtensionOne.value = 'less simple string';
+      patientInstance.rules.push(
+        metaProfileZero,
+        metaExtensionZero,
+        metaProfileOne,
+        metaExtensionOne
+      );
+      const exported = exportInstance(patientInstance);
+      // since a rule sets the InstanceOf url, we don't expect anything to change after the rules are applied.
+      expect(exported.meta.profile).toEqual([
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient',
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient'
+      ]);
+      expect(exported.meta._profile).toEqual([
+        {
+          extension: [
+            {
+              url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+              valueString: 'simple string'
+            }
+          ]
+        },
+        {
+          extension: [
+            {
+              url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+              valueString: 'less simple string'
+            }
+          ]
+        }
+      ]);
+    });
+
+    it('should keep meta.profile and child elements of meta.profile aligned when removing duplicates from meta.profile', () => {
+      // Extension: SimpleExt
+      const simpleExt = new Extension('SimpleExt');
+      doc.extensions.set(simpleExt.name, simpleExt);
+      // * meta.profile[0] = "http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient"
+      // * meta.profile[0].extension[SimpleExt].valueString = "this will be first"
+      // * meta.profile[1] = "http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient"
+      // * meta.profile[1].extension[SimpleExt].valueString = "this will be second"
+      // * meta.profile[1].id = "use-this"
+      const metaProfileZero = new AssignmentRule('meta.profile[0]');
+      metaProfileZero.value = 'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient';
+      const metaExtensionZero = new AssignmentRule(
+        'meta.profile[0].extension[SimpleExt].valueString'
+      );
+      metaExtensionZero.value = 'this will be first';
+      const metaProfileOne = new AssignmentRule('meta.profile[1]');
+      metaProfileOne.value = 'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient';
+      const metaExtensionOne = new AssignmentRule(
+        'meta.profile[1].extension[SimpleExt].valueString'
+      );
+      metaExtensionOne.value = 'this will be second';
+      const metaIdOne = new AssignmentRule('meta.profile[1].id');
+      metaIdOne.value = 'use-this';
+      patientInstance.rules.push(
+        metaProfileZero,
+        metaExtensionZero,
+        metaProfileOne,
+        metaExtensionOne,
+        metaIdOne
+      );
+      const exported = exportInstance(patientInstance);
+      // we expect the duplicate DifferentPatient to be removed.
+      // since the extensions are different, we take the union
+      // see https://hl7.org/fhir/R5/resource.html#profile-tags
+      expect(exported.meta.profile).toEqual([
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/TestPatient',
+        'http://hl7.org/fhir/us/minimal/StructureDefinition/DifferentPatient'
+      ]);
+      expect(exported.meta._profile).toEqual([
+        null,
+        {
+          extension: [
+            {
+              url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+              valueString: 'this will be first'
+            },
+            {
+              url: 'http://hl7.org/fhir/us/minimal/StructureDefinition/SimpleExt',
+              valueString: 'this will be second'
+            }
+          ],
+          id: 'use-this'
+        }
+      ]);
+    });
+
     // Setting instance id
     it('should set id to instance name by default', () => {
       const myExamplePatient = new Instance('MyExample');