Repository: FHIR/sushi
Pull request #1432 (2024-03-04 17:17:33)

Combine included concepts when exporting ValueSet
ValueSet concepts with the same system and version may appear as separate rules. This happens most often when RuleSets are used. Included concepts with the same system and version are added to the same concept list.

A full regression for this PR is in progress. Results will be added to this post when the regression is complete.


Patch:
diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts
index e70d4cb..bb34ae4 100644
--- a/src/export/ValueSetExporter.ts
+++ b/src/export/ValueSetExporter.ts
@@ -26,7 +26,7 @@ import {
   setImpliedPropertiesOnInstance
 } from '../fhirtypes/common';
 import { isUri } from 'valid-url';
-import { flatMap, partition } from 'lodash';
+import { flatMap, partition, xor } from 'lodash';
 
 export class ValueSetExporter {
   constructor(
@@ -169,13 +169,13 @@ export class ValueSetExporter {
               }
             );
             if (composeElement.concept.length > 0) {
-              valueSet.compose.include.push(composeElement);
+              this.addConceptComposeElement(composeElement, valueSet.compose.include);
             }
           } else {
             valueSet.compose.include.push(composeElement);
           }
         } else {
-          valueSet.compose.exclude.push(composeElement);
+          this.addConceptComposeElement(composeElement, valueSet.compose.exclude);
         }
       });
       if (valueSet.compose.exclude.length == 0) {
@@ -184,6 +184,29 @@ export class ValueSetExporter {
     }
   }
 
+  private addConceptComposeElement(
+    freshElement: ValueSetComposeIncludeOrExclude,
+    composeList: ValueSetComposeIncludeOrExclude[]
+  ): void {
+    if (freshElement.concept?.length > 0) {
+      const matchingFromIndex = composeList.findIndex(compose => {
+        return (
+          compose.system === freshElement.system &&
+          compose.version === freshElement.version &&
+          compose.concept?.length > 0 &&
+          xor(compose.valueSet ?? [], freshElement.valueSet ?? []).length === 0
+        );
+      });
+      if (matchingFromIndex > -1) {
+        composeList[matchingFromIndex].concept.push(...freshElement.concept);
+      } else {
+        composeList.push(freshElement);
+      }
+    } else {
+      composeList.push(freshElement);
+    }
+  }
+
   private setCaretRules(valueSet: ValueSet, rules: CaretValueRule[]) {
     resolveSoftIndexing(rules);
 
diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts
index e1a9fc0..f2638f9 100644
--- a/src/fhirtypes/common.ts
+++ b/src/fhirtypes/common.ts
@@ -1236,6 +1236,7 @@ export function applyInsertRules(
             );
             ruleSetRule = new ValueSetConceptComponentRule(true);
             (ruleSetRule as ValueSetConceptComponentRule).concepts = [relatedCode];
+            (ruleSetRule as ValueSetConceptComponentRule).from.system = relatedCode.system;
           } else if (fshDefinition instanceof FshCodeSystem) {
             logger.error(
               'Do not include the system when listing concepts for a code system.',


Test patch:
diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts
index 5d72b5c..d4e1483 100644
--- a/test/export/ValueSetExporter.test.ts
+++ b/test/export/ValueSetExporter.test.ts
@@ -1517,19 +1517,14 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     const inclusions = exported[0].compose.include;
-    expect(inclusions.length).toBe(4);
+    expect(inclusions.length).toBe(2);
     expect(inclusions[0]).toEqual({
       system: 'http://food.org/food',
       concept: [
         {
           code: 'Pizza',
           display: 'Delicious pizza to share.'
-        }
-      ]
-    });
-    expect(inclusions[1]).toEqual({
-      system: 'http://food.org/food',
-      concept: [
+        },
         {
           code: 'Salad',
           display: 'Plenty of fresh vegetables.'
@@ -1539,19 +1534,13 @@ describe('ValueSetExporter', () => {
         }
       ]
     });
-    expect(inclusions[2]).toEqual({
+    expect(inclusions[1]).toEqual({
       system: 'http://food.org/food',
       version: '2.0.1',
       concept: [
         {
           code: 'Toast'
-        }
-      ]
-    });
-    expect(inclusions[3]).toEqual({
-      system: 'http://food.org/food',
-      version: '2.0.1',
-      concept: [
+        },
         {
           code: 'Waffles'
         }
@@ -2319,6 +2308,191 @@ describe('ValueSetExporter', () => {
       ]);
     });
 
+    it('should apply concept-creating rules from a rule set and combine concepts from the same system', () => {
+      // RuleSet: Bar
+      // * http://food.org/food#bread "bread"
+      // * #granola from system http://food.org/food
+      // * http://food.org/food#toast "toast"
+      //
+      // ValueSet: Foo
+      // * insert Bar
+
+      // due to rule precedence, a RuleSet will sometimes create a ConceptRule,
+      // even when the eventual use is a VsConceptComponentRule.
+      // that's fine, though. we can handle that.
+      const breadRule = new ConceptRule('bread', 'bread');
+      breadRule.system = 'http://food.org/food';
+      const granolaRule = new ValueSetConceptComponentRule(true);
+      granolaRule.from.system = 'http://food.org/food';
+      granolaRule.concepts.push(new FshCode('granola', 'http://food.org/food'));
+      const toastRule = new ConceptRule('toast', 'toast');
+      toastRule.system = 'http://food.org/food';
+      ruleSet.rules.push(breadRule, granolaRule, toastRule);
+
+      const breakfastInsert = new InsertRule('');
+      breakfastInsert.ruleSet = 'Bar';
+      vs.rules.push(breakfastInsert);
+
+      exporter.applyInsertRules();
+      const exported = exporter.exportValueSet(vs);
+      const inclusions = exported.compose.include;
+      expect(inclusions).toHaveLength(1);
+      expect(inclusions[0]).toEqual({
+        system: 'http://food.org/food',
+        concept: [
+          {
+            code: 'bread',
+            display: 'bread'
+          },
+          {
+            code: 'granola'
+          },
+          {
+            code: 'toast',
+            display: 'toast'
+          }
+        ]
+      });
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
+    it('should apply concept-creating rules from a rule set and combine concepts from the same system and valuesets', () => {
+      // RuleSet: Bar
+      // * http://food.org/food#bread from valueset http://food.org/BakeryVS
+      // * #granola from system http://food.org/food and valueset http://food.org/CerealVS
+      // * http://food.org/food#toast from valueset http://food.org/BakeryVS
+      // * #oatmeal from system http://food.org/food and valueset http://food.org/CerealVS and http://food.org/BakeryVS
+      // * #porridge from system http://food.org/food and valueset http://food.org/BakeryVS and http://food.org/CerealVS
+      //
+      // ValueSet: Foo
+      // * insert Bar
+      const breadRule = new ValueSetConceptComponentRule(true);
+      breadRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/BakeryVS']
+      };
+      breadRule.concepts.push(new FshCode('bread', 'http://food.org/food'));
+      const granolaRule = new ValueSetConceptComponentRule(true);
+      granolaRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/CerealVS']
+      };
+      granolaRule.concepts.push(new FshCode('granola', 'http://food.org/food'));
+      const toastRule = new ValueSetConceptComponentRule(true);
+      toastRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/BakeryVS']
+      };
+      toastRule.concepts.push(new FshCode('toast', 'http://food.org/food'));
+      const oatmealRule = new ValueSetConceptComponentRule(true);
+      oatmealRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/CerealVS', 'http://food.org/BakeryVS']
+      };
+      oatmealRule.concepts.push(new FshCode('oatmeal', 'http://food.org/food'));
+      const porridgeRule = new ValueSetConceptComponentRule(true);
+      porridgeRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/BakeryVS', 'http://food.org/CerealVS']
+      };
+      porridgeRule.concepts.push(new FshCode('porridge', 'http://food.org/food'));
+      ruleSet.rules.push(breadRule, granolaRule, toastRule, oatmealRule, porridgeRule);
+
+      const breakfastInsert = new InsertRule('');
+      breakfastInsert.ruleSet = 'Bar';
+      vs.rules.push(breakfastInsert);
+
+      exporter.applyInsertRules();
+      const exported = exporter.exportValueSet(vs);
+      const inclusions = exported.compose.include;
+      expect(inclusions).toHaveLength(3);
+      expect(inclusions[0]).toEqual({
+        system: 'http://food.org/food',
+        valueSet: ['http://food.org/BakeryVS'],
+        concept: [{ code: 'bread' }, { code: 'toast' }]
+      });
+      expect(inclusions[1]).toEqual({
+        system: 'http://food.org/food',
+        valueSet: ['http://food.org/CerealVS'],
+        concept: [{ code: 'granola' }]
+      });
+      expect(inclusions[2]).toEqual({
+        system: 'http://food.org/food',
+        valueSet: ['http://food.org/CerealVS', 'http://food.org/BakeryVS'],
+        concept: [{ code: 'oatmeal' }, { code: 'porridge' }]
+      });
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+    });
+
+    it('should apply concept-creating rules from a rule set and combine excluded concepts from the same system and valuesets', () => {
+      // RuleSet: Bar
+      // * http://food.org/food#bread from valueset http://food.org/BakeryVS
+      // * #granola from system http://food.org/food and valueset http://food.org/CerealVS
+      // * http://food.org/food#toast from valueset http://food.org/BakeryVS
+      // * exclude #oatmeal from system http://food.org/food and valueset http://food.org/CerealVS and http://food.org/BakeryVS
+      // * exclude #porridge from system http://food.org/food and valueset http://food.org/BakeryVS and http://food.org/CerealVS
+      //
+      // ValueSet: Foo
+      // * insert Bar
+      const breadRule = new ValueSetConceptComponentRule(true);
+      breadRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/BakeryVS']
+      };
+      breadRule.concepts.push(new FshCode('bread', 'http://food.org/food'));
+      const granolaRule = new ValueSetConceptComponentRule(true);
+      granolaRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/CerealVS']
+      };
+      granolaRule.concepts.push(new FshCode('granola', 'http://food.org/food'));
+      const toastRule = new ValueSetConceptComponentRule(true);
+      toastRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/BakeryVS']
+      };
+      toastRule.concepts.push(new FshCode('toast', 'http://food.org/food'));
+      const oatmealRule = new ValueSetConceptComponentRule(false);
+      oatmealRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/CerealVS', 'http://food.org/BakeryVS']
+      };
+      oatmealRule.concepts.push(new FshCode('oatmeal', 'http://food.org/food'));
+      const porridgeRule = new ValueSetConceptComponentRule(false);
+      porridgeRule.from = {
+        system: 'http://food.org/food',
+        valueSets: ['http://food.org/BakeryVS', 'http://food.org/CerealVS']
+      };
+      porridgeRule.concepts.push(new FshCode('porridge', 'http://food.org/food'));
+      ruleSet.rules.push(breadRule, granolaRule, toastRule, oatmealRule, porridgeRule);
+
+      const breakfastInsert = new InsertRule('');
+      breakfastInsert.ruleSet = 'Bar';
+      vs.rules.push(breakfastInsert);
+
+      exporter.applyInsertRules();
+      const exported = exporter.exportValueSet(vs);
+      const inclusions = exported.compose.include;
+      expect(inclusions).toHaveLength(2);
+      expect(inclusions[0]).toEqual({
+        system: 'http://food.org/food',
+        valueSet: ['http://food.org/BakeryVS'],
+        concept: [{ code: 'bread' }, { code: 'toast' }]
+      });
+      expect(inclusions[1]).toEqual({
+        system: 'http://food.org/food',
+        valueSet: ['http://food.org/CerealVS'],
+        concept: [{ code: 'granola' }]
+      });
+      const exclusions = exported.compose.exclude;
+      expect(exclusions).toHaveLength(1);
+      expect(exclusions[0]).toEqual({
+        system: 'http://food.org/food',
+        valueSet: ['http://food.org/CerealVS', 'http://food.org/BakeryVS'],
+        concept: [{ code: 'oatmeal' }, { code: 'porridge' }]
+      });
+    });
+
     it('should log an error and not apply rules from an invalid insert rule', () => {
       // RuleSet: Bar
       // * experimental = true
diff --git a/test/fhirtypes/applyInsertRules.test.ts b/test/fhirtypes/applyInsertRules.test.ts
index a85e5a7..1ed8353 100644
--- a/test/fhirtypes/applyInsertRules.test.ts
+++ b/test/fhirtypes/applyInsertRules.test.ts
@@ -390,7 +390,7 @@ describe('applyInsertRules', () => {
     expect(vs.rules).toHaveLength(1);
     assertValueSetConceptComponent(
       vs.rules[0],
-      undefined,
+      'ZOO',
       undefined,
       [new FshCode('bear', 'ZOO', 'brown bear')],
       true