Repository: FHIR/sushi
Pull request #1120 (2022-07-19 15:02:52)

Allow cardinality constraints to be applied to connected elements
Completes task [CIMPL-986](https://standardhealthrecord.atlassian.net/browse/CIMPL-986).

When constraining cardinality on an element, if there are connected elements that are the child of a slice, it may be possible to apply the cardinality constraint to those elements. As long as no cardinality is widened, applying the constraints is allowed. This may mean that only the new min or max are applied. If the new cardinality has no overlap with the connected element's existing cardinality, the cardinality constraint can't be applied.

jafeltra: Oh I also ran a full regression again, and there were no differences besides that one sliced element that has the `MS` flag properly applied (which was discussed in Slack).

Patch:
diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts
index c9d22f0..61970fc 100644
--- a/src/fhirtypes/ElementDefinition.ts
+++ b/src/fhirtypes/ElementDefinition.ts
@@ -754,27 +754,23 @@ export class ElementDefinition {
       }
     }
 
-    const connectedElements = this.findConnectedElements();
-    if (connectedElements.length > 0) {
-      // check to see if the card constraint would actually be a problem for the connected element
-      // that is to say, if the new card is narrower than the connected card
-      connectedElements
-        .filter(ce => !(ce.path === this.path && ce.id.startsWith(this.id)))
-        // Filter out elements that are directly slices of this, since they may have min < this.min
-        // Children of slices however must have min >= this.min
-        .forEach(ce => {
-          if (ce.min != null && ce.min < min) {
-            throw new NarrowingRootCardinalityError(
-              this.path,
-              ce.id,
-              min,
-              max,
-              ce.min,
-              ce.max ?? '*'
-            );
-          }
-        });
-    }
+    const connectedElements = this.findConnectedElements().filter(
+      ce => !(ce.path === this.path && ce.id.startsWith(this.id))
+    );
+    // check to see if the card constraint would actually be a problem for the connected element
+    // that is to say, if the new card is incompatible with the connected card
+    // Filter out elements that are directly slices of this, since they may have min < this.min
+    connectedElements.forEach(ce => {
+      // the cardinality is incompatible if:
+      // the new min is greater than the connected element's max, or
+      // the new max is less than the connected element's min
+      if (
+        (ce.max != null && ce.max !== '*' && min > parseInt(ce.max)) ||
+        (ce.min != null && !isUnbounded && maxInt < ce.min)
+      ) {
+        throw new NarrowingRootCardinalityError(this.path, ce.id, min, max, ce.min, ce.max ?? '*');
+      }
+    });
 
     // If element is a slice
     const slicedElement = this.slicedElement();
@@ -800,6 +796,17 @@ export class ElementDefinition {
       }
     }
 
+    // apply the cardinality to connected elements, but don't try to widen cardinalities
+    connectedElements.forEach(ce => {
+      const newMin = Math.max(min, ce.min);
+      let newMax = max;
+      if (isUnbounded) {
+        newMax = ce.max;
+      } else if (ce.max !== '*') {
+        newMax = `${Math.min(maxInt, parseInt(ce.max))}`;
+      }
+      ce.constrainCardinality(newMin, newMax);
+    });
     [this.min, this.max] = [min, max];
   }
 
@@ -825,7 +832,7 @@ export class ElementDefinition {
       })
       .filter(e => e);
     if (this.parent()) {
-      const [parentPath] = splitOnPathPeriods(this.path).slice(-1);
+      const [parentPath] = splitOnPathPeriods(this.id).slice(-1);
       return connectedElements.concat(
         this.parent().findConnectedElements(`.${parentPath}${postPath}`)
       );
@@ -1403,11 +1410,13 @@ export class ElementDefinition {
       }
 
       this.mustSupport = mustSupport;
-      // MS only gets applied to connected elements that are not themselves slices
+      // MS only gets applied to connected elements that are not themselves slices,
+      // unless they're the same slice name as this.
       // For example, Observation.component.interpretation MS implies Observation.component:Lab.interpretation MS
+      // And Observation.component.extension:Sequel MS implies Observation.component:Lab.extension:Sequel
       // But Observation.component MS does not imply Observation.component:Lab MS
       connectedElements
-        .filter(ce => ce.sliceName == null)
+        .filter(ce => ce.sliceName == null || ce.sliceName == this.sliceName)
         .forEach(ce => (ce.mustSupport = mustSupport || ce.mustSupport));
     }
     if (summary === true) {


Test patch:
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index fde4d4c..8140b1d 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -6743,27 +6743,142 @@ describe('StructureDefinitionExporter R4', () => {
       expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
     });
 
-    it('should not apply a CardRule that would make the cardinality of the child of a slice too small', () => {
-      loggerSpy.reset();
-      // * component[Lab].interpretation 0..4
-      // * component.interpretation 1..5 // this rule is invalid!
+    it('should apply a CardRule that would increase the minimum cardinality of a child of a slice', () => {
+      // * component[Lab].interpretation 1..5
+      // * component.interpretation 2..6
+      const labCard = new CardRule('component[Lab].interpretation');
+      labCard.min = 1;
+      labCard.max = '5';
+      const rootCard = new CardRule('component.interpretation');
+      rootCard.min = 2;
+      rootCard.max = '6';
+
+      observationWithSlice.rules.push(labCard, rootCard);
+      doc.profiles.set(observationWithSlice.name, observationWithSlice);
+      exporter.export();
+      const sd = pkg.profiles[0];
+      const rootInterpretation = sd.findElement('Observation.component.interpretation');
+      const labInterpretation = sd.findElement('Observation.component:Lab.interpretation');
+      expect(rootInterpretation.min).toBe(2);
+      expect(rootInterpretation.max).toBe('6');
+      expect(labInterpretation.min).toBe(2);
+      expect(labInterpretation.max).toBe('5');
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
+    it('should apply a CardRule that would decrease the maximum cardinality of a child of a slice', () => {
+      // * component[Lab].interpretation 1..5
+      // * component.interpretation 0..3
+      const labCard = new CardRule('component[Lab].interpretation');
+      labCard.min = 1;
+      labCard.max = '5';
+      const rootCard = new CardRule('component.interpretation');
+      rootCard.min = 0;
+      rootCard.max = '3';
+
+      observationWithSlice.rules.push(labCard, rootCard);
+      doc.profiles.set(observationWithSlice.name, observationWithSlice);
+      exporter.export();
+      const sd = pkg.profiles[0];
+      const rootInterpretation = sd.findElement('Observation.component.interpretation');
+      const labInterpretation = sd.findElement('Observation.component:Lab.interpretation');
+      expect(rootInterpretation.min).toBe(0);
+      expect(rootInterpretation.max).toBe('3');
+      expect(labInterpretation.min).toBe(1);
+      expect(labInterpretation.max).toBe('3');
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
+    it('should apply a CardRule that would increase the minimum cardinality and decrease the maximum cardinality of a child of a slice', () => {
+      // * component[Lab].interpretation 1..5
+      // * component.interpretation 2..2
+      const labCard = new CardRule('component[Lab].interpretation');
+      labCard.min = 1;
+      labCard.max = '5';
+      const rootCard = new CardRule('component.interpretation');
+      rootCard.min = 2;
+      rootCard.max = '2';
+
+      observationWithSlice.rules.push(labCard, rootCard);
+      doc.profiles.set(observationWithSlice.name, observationWithSlice);
+      exporter.export();
+      const sd = pkg.profiles[0];
+      const rootInterpretation = sd.findElement('Observation.component.interpretation');
+      const labInterpretation = sd.findElement('Observation.component:Lab.interpretation');
+      expect(rootInterpretation.min).toBe(2);
+      expect(rootInterpretation.max).toBe('2');
+      expect(labInterpretation.min).toBe(2);
+      expect(labInterpretation.max).toBe('2');
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
+    it('should not apply a CardRule that is incompatible with the existing cardinality of a child of a slice', () => {
+      // * component[Lab].interpretation 1..5
+      // * component.interpretation 6..10
       const labCard = new CardRule('component[Lab].interpretation');
-      labCard.max = '4';
+      labCard.min = 1;
+      labCard.max = '5';
       const rootCard = new CardRule('component.interpretation')
-        .withFile('Narrower.fsh')
-        .withLocation([7, 9, 7, 23]);
-      rootCard.min = 1;
-      rootCard.max = '5';
+        .withFile('Incompatible.fsh')
+        .withLocation([8, 3, 8, 25]);
+      rootCard.min = 6;
+      rootCard.max = '10';
 
       observationWithSlice.rules.push(labCard, rootCard);
       doc.profiles.set(observationWithSlice.name, observationWithSlice);
       exporter.export();
       const sd = pkg.profiles[0];
-      expect(sd.findElement('Observation.component.interpretation').max).toBe('*');
-      expect(sd.findElement('Observation.component.interpretation').min).toBe(0);
-      expect(sd.findElement('Observation.component:Lab.interpretation').max).toBe('4');
-      expect(loggerSpy.getLastMessage('error')).toMatch(/cannot be narrowed/s);
-      expect(loggerSpy.getLastMessage('error')).toMatch(/File: Narrower\.fsh.*Line: 7\D*/s);
+      const rootInterpretation = sd.findElement('Observation.component.interpretation');
+      const labInterpretation = sd.findElement('Observation.component:Lab.interpretation');
+      expect(rootInterpretation.min).toBe(0);
+      expect(rootInterpretation.max).toBe('*');
+      expect(labInterpretation.min).toBe(1);
+      expect(labInterpretation.max).toBe('5');
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        'Cardinality on Observation.component.interpretation cannot be narrowed'
+      );
+      expect(loggerSpy.getLastMessage('error')).toMatch(/File: Incompatible\.fsh.*Line: 8\D*/s);
+    });
+
+    it('should not apply a CardRule that is incompatible with the existing cardinality on some of the children of slices', () => {
+      // * component contains Field 0..1
+      // * component[Lab].interpretation 1..5
+      // * component[Field].interpretation 0..2
+      // * component.interpretation 6..10
+      const containsField = new ContainsRule('component');
+      containsField.items = [{ name: 'Field' }];
+      const containsCard = new CardRule('component[Field]');
+      containsCard.min = 0;
+      containsCard.max = '1';
+      const labCard = new CardRule('component[Lab].interpretation');
+      labCard.min = 1;
+      labCard.max = '5';
+      const fieldCard = new CardRule('component[Field].interpretation');
+      fieldCard.min = 0;
+      fieldCard.max = '2';
+      const rootCard = new CardRule('component.interpretation')
+        .withFile('Incompatible.fsh')
+        .withLocation([9, 3, 9, 23]);
+      rootCard.min = 6;
+      rootCard.max = '10';
+
+      observationWithSlice.rules.push(containsField, containsCard, labCard, fieldCard, rootCard);
+      doc.profiles.set(observationWithSlice.name, observationWithSlice);
+      exporter.export();
+      const sd = pkg.profiles[0];
+      const rootInterpretation = sd.findElement('Observation.component.interpretation');
+      const labInterpretation = sd.findElement('Observation.component:Lab.interpretation');
+      const fieldInterpretation = sd.findElement('Observation.component:Field.interpretation');
+      expect(rootInterpretation.min).toBe(0);
+      expect(rootInterpretation.max).toBe('*');
+      expect(labInterpretation.min).toBe(1);
+      expect(labInterpretation.max).toBe('5');
+      expect(fieldInterpretation.min).toBe(0);
+      expect(fieldInterpretation.max).toBe('2');
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        'Cardinality on Observation.component.interpretation cannot be narrowed'
+      );
+      expect(loggerSpy.getLastMessage('error')).toMatch(/File: Incompatible\.fsh.*Line: 9\D*/s);
     });
 
     it('should apply a FlagRule on a sliced element that updates the flags on its slices', () => {
diff --git a/test/fhirtypes/ElementDefinition.applyFlags.test.ts b/test/fhirtypes/ElementDefinition.applyFlags.test.ts
index 90efffa..b2fb545 100644
--- a/test/fhirtypes/ElementDefinition.applyFlags.test.ts
+++ b/test/fhirtypes/ElementDefinition.applyFlags.test.ts
@@ -110,5 +110,50 @@ describe('ElementDefinition', () => {
         note.applyFlags(true, undefined, undefined, undefined, undefined, undefined)
       ).toThrow(InvalidMustSupportError);
     });
+
+    it('should set the mustSupport flag on connected elements', () => {
+      // MS only gets applied to connected elements that are not themselves slices
+      // first, let's slice component
+      const component = observation.findElement('Observation.component');
+      component.sliceIt('pattern', 'interpretation');
+      component.addSlice('Lab');
+      // then apply MS to Observation.component.interpretation, which implies Observation.component:Lab.interpretation MS
+      const componentInterpretation = observation.findElement(
+        'Observation.component.interpretation'
+      );
+      componentInterpretation.applyFlags(
+        true,
+        undefined,
+        undefined,
+        undefined,
+        undefined,
+        undefined
+      );
+      const labInterpretation = observation.findElementByPath(
+        'component[Lab].interpretation',
+        fisher
+      );
+      expect(labInterpretation.mustSupport).toBeTrue();
+    });
+
+    it('should not set the mustSupport flag on an element with the same path as the rule, but where the rule path ends with a slice and the other element does not', () => {
+      // first, let's slice component
+      const component = observation.findElement('Observation.component');
+      component.sliceIt('pattern', 'interpretation');
+      component.addSlice('Lab');
+      // then, let's slice component.extension
+      const componentExtension = observation.findElement('Observation.component.extension');
+      componentExtension.sliceIt('value', 'url');
+      componentExtension.addSlice('Sequel');
+      // find the elements now to force unfolding, so they will exist when applying the flags
+      const sequel = observation.findElementByPath('component.extension[Sequel]', fisher);
+      const labSequel = observation.findElementByPath('component[Lab].extension[Sequel]', fisher);
+      const labExtension = observation.findElementByPath('component[Lab].extension', fisher);
+      sequel.applyFlags(true, undefined, undefined, undefined, undefined, undefined);
+      // this should set mustSupport on component[Lab].extension[Sequel]
+      expect(labSequel.mustSupport).toBeTrue();
+      // but it should _not_ set mustSupport on component[Lab].extension
+      expect(labExtension.mustSupport).toBeUndefined();
+    });
   });
 });