Repository: FHIR/sushi
Pull request #1538 (2024-11-22 18:53:39)

Support "position" slicing discriminator
**Description:**
FHIR R5 added "position" as an allowed slice discriminator, where slices are differentiated by their order within a list element. Because a consistent order is necessary for this slicing to be meaningful, emit an error if "ordered" is not set to true for the slicing. Previous versions of FHIR may not use this discriminator.

Minor changes to npm-shrinkwrap.json due to issues detected by `npm audit`.

**Testing Instructions:**
Confirm that new tests adequately cover this new feature. Try building projects with R4 and R5 resources using SUSHI, observing that the "position" discriminator can't be used with R4, but can be used with R5. There is also a new error message, so please let me know if you have suggestions for its wording.

**Related Issue:**
Partially addresses #1391, but does _not_ validate Instances to confirm that slices are provided in the required order when position-based slicing is used.


Patch:
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
index 323d2a1..e30d0b1 100644
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -848,9 +848,9 @@
       }
     },
     "node_modules/@eslint/plugin-kit": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz",
-      "integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==",
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz",
+      "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -2648,10 +2648,11 @@
       "dev": true
     },
     "node_modules/cross-spawn": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
-      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "path-key": "^3.1.0",
         "shebang-command": "^2.0.0",
diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts
index e81601a..3764f6d 100644
--- a/src/fhirtypes/ElementDefinition.ts
+++ b/src/fhirtypes/ElementDefinition.ts
@@ -386,13 +386,27 @@ export class ElementDefinition {
     validationErrors.push(
       this.validateIncludes(slicing.rules, ALLOWED_SLICING_RULES, 'slicing.rules')
     );
-
+    const isR4 = this.structDef.fhirVersion.startsWith('4.');
     slicing.discriminator?.forEach((d, i) => {
       const discriminatorPath = `slicing.discriminator[${i}]`;
       validationErrors.push(this.validateRequired(d.type, `${discriminatorPath}.type`));
-      validationErrors.push(
-        this.validateIncludes(d.type, ALLOWED_DISCRIMINATOR_TYPES, `${discriminatorPath}.type`)
-      );
+      if (isR4) {
+        validationErrors.push(
+          this.validateIncludes(d.type, ALLOWED_DISCRIMINATOR_TYPES, `${discriminatorPath}.type`)
+        );
+      } else {
+        validationErrors.push(
+          this.validateIncludes(d.type, ALLOWED_DISCRIMINATOR_TYPES_R5, `${discriminatorPath}.type`)
+        );
+        if (d.type === 'position' && slicing.ordered !== true) {
+          validationErrors.push(
+            new ValidationError(
+              'Slicing ordering must be true when a position discriminator is used.',
+              'slicing.ordered'
+            )
+          );
+        }
+      }
       validationErrors.push(this.validateRequired(d.path, `${discriminatorPath}.path`));
     });
     return validationErrors.filter(e => e);
@@ -3076,6 +3090,15 @@ export type ElementDefinitionSlicingDiscriminator = {
 // Cannot constrain ElementDefinitionSlicingDiscriminator to have these values as a type
 // since we want to process other string values, but log an error
 const ALLOWED_DISCRIMINATOR_TYPES = ['value', 'exists', 'pattern', 'type', 'profile'];
+// R5 introduced the 'position' discriminator
+const ALLOWED_DISCRIMINATOR_TYPES_R5 = [
+  'value',
+  'exists',
+  'pattern',
+  'type',
+  'profile',
+  'position'
+];
 
 export type ElementDefinitionBase = {
   path: string;


Test patch:
diff --git a/test/fhirtypes/ElementDefinition.test.ts b/test/fhirtypes/ElementDefinition.test.ts
index 556fd0a..9fbcb38 100644
--- a/test/fhirtypes/ElementDefinition.test.ts
+++ b/test/fhirtypes/ElementDefinition.test.ts
@@ -1112,5 +1112,69 @@ describe('ElementDefinition', () => {
         /slicing.discriminator\[0\].type: Invalid value: #foo. Value must be selected from one of the following: #value, #exists, #pattern, #type, #profile/
       );
     });
+
+    it('should be invalid when an element has a position discriminator, ordered is true, and the FHIR version is older than R5', () => {
+      const clone = valueX.clone(false);
+      clone.slicing = {
+        rules: 'open',
+        ordered: true,
+        discriminator: [{ path: '$this', type: 'position' }]
+      };
+      const validationErrors = clone.validate();
+      expect(validationErrors).toHaveLength(1);
+      expect(validationErrors[0].message).toMatch(
+        /slicing.discriminator\[0\].type: Invalid value: #position. Value must be selected from one of the following: #value, #exists, #pattern, #type, #profile/
+      );
+    });
+  });
+});
+
+describe('ElementDefinition R5', () => {
+  let defs: FHIRDefinitions;
+  let jsonObservation: any;
+  let jsonValueX: any;
+  let observation: StructureDefinition;
+  let valueX: ElementDefinition;
+  let fisher: TestFisher;
+
+  beforeAll(() => {
+    defs = new FHIRDefinitions();
+    loadFromPath(path.join(__dirname, '..', 'testhelpers', 'testdefs'), 'r5-definitions', defs);
+    fisher = new TestFisher().withFHIR(defs);
+    // resolve observation once to ensure it is present in defs
+    observation = fisher.fishForStructureDefinition('Observation');
+    jsonObservation = defs.fishForFHIR('Observation', Type.Resource);
+    jsonValueX = jsonObservation.snapshot.element[21];
+  });
+
+  beforeEach(() => {
+    observation = StructureDefinition.fromJSON(jsonObservation);
+    valueX = ElementDefinition.fromJSON(jsonValueX);
+    valueX.structDef = observation;
+  });
+
+  it('should be valid when an element has a position discriminator, ordered is true, and the FHIR version is R5 is newer', () => {
+    const clone = valueX.clone(false);
+    clone.slicing = {
+      rules: 'open',
+      ordered: true,
+      discriminator: [{ path: '$this', type: 'position' }]
+    };
+    const validationErrors = clone.validate();
+    expect(validationErrors).toHaveLength(0);
+  });
+
+  it('should be invalid when an element has a position discriminator, ordered is not true, and the FHIR version is R5 or newer', () => {
+    const clone = valueX.clone(false);
+    clone.structDef.fhirVersion = '5.0.0';
+    clone.slicing = {
+      rules: 'open',
+      discriminator: [{ path: '$this', type: 'position' }]
+    };
+    const validationErrors = clone.validate();
+    expect(validationErrors).toHaveLength(1);
+    expect(validationErrors[0].message).toMatch(
+      /slicing\.ordered: Slicing ordering must be true when a position discriminator is used/
+    );
   });
 });