Repository: FHIR/sushi
Pull request #814 (2021-05-10 18:28:24)

Export CodeSystems with hierarchical codes
Completes task [CIMPL-697](https://standardhealthrecord.atlassian.net/browse/CIMPL-697).

Concepts within a code system are arranged hierarchically based on the ConceptRule's specified hierarchy. The path to a hierarchical code must exist in order to be added, and this is checked when adding the ConceptRule to the FshCodeSystem.

Additionally, a CodeCaretValueRule may be used to specify values on a concept anywhere in the hierarchy. The codePath on this rule is traversed after all the concepts are added so that the correct element in the CodeSystem can be located. Just like for CaretValueRules, the value to set is validated before being assigned.


Patch:
diff --git a/src/export/CodeSystemExporter.ts b/src/export/CodeSystemExporter.ts
index 95bf863..3e16141 100644
--- a/src/export/CodeSystemExporter.ts
+++ b/src/export/CodeSystemExporter.ts
@@ -2,10 +2,11 @@ import { FSHTank } from '../import/FSHTank';
 import { CodeSystem, CodeSystemConcept, StructureDefinition } from '../fhirtypes';
 import { setPropertyOnInstance, applyInsertRules } from '../fhirtypes/common';
 import { FshCodeSystem } from '../fshtypes';
-import { CaretValueRule, ConceptRule } from '../fshtypes/rules';
+import { CaretValueRule, ConceptRule, CodeCaretValueRule } from '../fshtypes/rules';
 import { logger } from '../utils/FSHLogger';
 import { MasterFisher, Type, resolveSoftIndexing } from '../utils';
 import { Package } from '.';
+import { CannotResolvePathError } from '../errors';
 
 export class CodeSystemExporter {
   constructor(
@@ -26,19 +27,43 @@ export class CodeSystemExporter {
 
   private setConcepts(codeSystem: CodeSystem, concepts: ConceptRule[]): void {
     if (concepts.length > 0) {
-      codeSystem.concept = concepts.map(concept => {
-        const codeSystemConcept: CodeSystemConcept = { code: concept.code };
-        if (concept.display) codeSystemConcept.display = concept.display;
-        if (concept.definition) codeSystemConcept.definition = concept.definition;
-        return codeSystemConcept;
+      codeSystem.concept = [];
+      concepts.forEach(concept => {
+        let conceptContainer = codeSystem.concept;
+        const newConcept: CodeSystemConcept = { code: concept.code };
+        if (concept.display) {
+          newConcept.display = concept.display;
+        }
+        if (concept.definition) {
+          newConcept.definition = concept.definition;
+        }
+        for (const ancestorCode of concept.hierarchy) {
+          const ancestorConcept = conceptContainer.find(
+            ancestorConcept => ancestorConcept.code === ancestorCode
+          );
+          if (ancestorConcept) {
+            if (!ancestorConcept.concept) {
+              ancestorConcept.concept = [];
+            }
+            conceptContainer = ancestorConcept.concept;
+          } else {
+            logger.error(
+              `Could not find ${ancestorCode} in concept hierarchy to use as ancestor of ${concept.code}.`,
+              concept.sourceInfo
+            );
+            return;
+          }
+        }
+        conceptContainer.push(newConcept);
       });
     }
   }
 
-  private setCaretRules(codeSystem: CodeSystem, rules: CaretValueRule[]): void {
-    const csStructureDefinition = StructureDefinition.fromJSON(
-      this.fisher.fishForFHIR('CodeSystem', Type.Resource)
-    );
+  private setCaretRules(
+    codeSystem: CodeSystem,
+    csStructureDefinition: StructureDefinition,
+    rules: CaretValueRule[]
+  ): void {
     resolveSoftIndexing(rules);
     for (const rule of rules) {
       try {
@@ -54,10 +79,57 @@ export class CodeSystemExporter {
     }
   }
 
+  private setCodeCaretRules(
+    codeSystem: CodeSystem,
+    csStructureDefinition: StructureDefinition,
+    rules: CodeCaretValueRule[]
+  ) {
+    for (const rule of rules) {
+      try {
+        const conceptPath = this.findConceptPath(codeSystem, rule.codePath);
+        const { assignedValue, pathParts } = csStructureDefinition.validateValueAtPath(
+          `${conceptPath}.${rule.caretPath}`,
+          rule.value,
+          this.fisher
+        );
+        setPropertyOnInstance(codeSystem, pathParts, assignedValue, this.fisher);
+      } catch (e) {
+        logger.error(e.message, rule.sourceInfo);
+      }
+    }
+  }
+
+  private findConceptPath(codeSystem: CodeSystem, codePath: string[]): string {
+    const conceptIndices: number[] = [];
+    let conceptList = codeSystem.concept ?? [];
+    for (const codeStep of codePath) {
+      const stepIndex = conceptList.findIndex(concept => concept.code === codeStep);
+      if (stepIndex === -1) {
+        throw new CannotResolvePathError(codePath.map(code => `#${code}`).join(' '));
+      }
+      conceptIndices.push(stepIndex);
+      conceptList = conceptList[stepIndex].concept ?? [];
+    }
+    return conceptIndices.map(conceptIndex => `concept[${conceptIndex}]`).join('.');
+  }
+
+  private countConcepts(concepts: CodeSystemConcept[]): number {
+    if (concepts) {
+      return (
+        concepts.length +
+        concepts
+          .map(concept => this.countConcepts(concept.concept))
+          .reduce((sum, next) => sum + next, 0)
+      );
+    } else {
+      return 0;
+    }
+  }
+
   private updateCount(codeSystem: CodeSystem, fshDefinition: FshCodeSystem): void {
     // We can only derive a true count if the content is #complete
     if (codeSystem.content === 'complete') {
-      const actualCount = codeSystem.concept?.length;
+      const actualCount = this.countConcepts(codeSystem.concept) || undefined;
       if (codeSystem.count == null && actualCount != null) {
         codeSystem.count = actualCount;
       } else if (codeSystem.count !== actualCount) {
@@ -85,14 +157,23 @@ export class CodeSystemExporter {
     this.setMetadata(codeSystem, fshDefinition);
     // fshDefinition.rules may include insert rules, which must be expanded before applying other rules
     applyInsertRules(fshDefinition, this.tank);
+    const csStructureDefinition = StructureDefinition.fromJSON(
+      this.fisher.fishForFHIR('CodeSystem', Type.Resource)
+    );
     this.setCaretRules(
       codeSystem,
+      csStructureDefinition,
       fshDefinition.rules.filter(rule => rule instanceof CaretValueRule) as CaretValueRule[]
     );
     this.setConcepts(
       codeSystem,
       fshDefinition.rules.filter(rule => rule instanceof ConceptRule) as ConceptRule[]
     );
+    this.setCodeCaretRules(
+      codeSystem,
+      csStructureDefinition,
+      fshDefinition.rules.filter(rule => rule instanceof CodeCaretValueRule) as CodeCaretValueRule[]
+    );
 
     // check for another code system with the same id
     // see https://www.hl7.org/fhir/resource.html#id
diff --git a/src/utils/PathUtils.ts b/src/utils/PathUtils.ts
index ff787cc..25bc2c5 100644
--- a/src/utils/PathUtils.ts
+++ b/src/utils/PathUtils.ts
@@ -1,6 +1,6 @@
 import { PathPart } from '../fhirtypes';
 import { splitOnPathPeriods } from '../fhirtypes/common';
-import { CaretValueRule, Rule } from '../fshtypes/rules';
+import { CaretValueRule, Rule, CodeCaretValueRule } from '../fshtypes/rules';
 import { logger } from './FSHLogger';
 
 /**
@@ -119,8 +119,8 @@ export function resolveSoftIndexing(rules: Array<Rule | CaretValueRule>): void {
     const parsedPath: { path: PathPart[]; caretPath?: PathPart[] } = {
       path: parseFSHPath(rule.path)
     };
-    // If we have a CaretValueRule, we'll need a second round of parsing for the caret path
-    if (rule instanceof CaretValueRule) {
+    // If we have a CaretValueRule or CodeCaretValueRule, we'll need a second round of parsing for the caret path
+    if (rule instanceof CaretValueRule || rule instanceof CodeCaretValueRule) {
       parsedPath.caretPath = parseFSHPath(rule.caretPath);
     }
     return parsedPath;


Test patch:
diff --git a/test/export/CodeSystemExporter.test.ts b/test/export/CodeSystemExporter.test.ts
index 469d90e..9eca72a 100644
--- a/test/export/CodeSystemExporter.test.ts
+++ b/test/export/CodeSystemExporter.test.ts
@@ -1,7 +1,13 @@
 import { CodeSystemExporter, Package } from '../../src/export';
 import { FSHDocument, FSHTank } from '../../src/import';
 import { FshCodeSystem, FshCode, RuleSet } from '../../src/fshtypes';
-import { CaretValueRule, InsertRule, AssignmentRule, ConceptRule } from '../../src/fshtypes/rules';
+import {
+  CaretValueRule,
+  InsertRule,
+  AssignmentRule,
+  ConceptRule,
+  CodeCaretValueRule
+} from '../../src/fshtypes/rules';
 import { TestFisher } from '../testhelpers';
 import { loggerSpy } from '../testhelpers';
 import { FHIRDefinitions, loadFromPath } from '../../src/fhirdefs';
@@ -139,6 +145,85 @@ describe('CodeSystemExporter', () => {
     });
   });
 
+  it('should export a code system with hierarchical codes', () => {
+    // The concept hierarchy looks like this:
+    // topCode
+    //   middleCode
+    //     bottomCode
+    //   otherMiddle
+    // unrelatedCode
+    const codeSystem = new FshCodeSystem('HierarchicalCodeSystem');
+    const topCode = new ConceptRule('topCode', 'Top Code', 'This is at the top of the hierarchy.');
+    const middleCode = new ConceptRule(
+      'middleCode',
+      'Middle Code',
+      'This is in the middle of the hierarchy.'
+    );
+    middleCode.hierarchy = ['topCode'];
+    const bottomCode = new ConceptRule(
+      'bottomCode',
+      'Bottom Code',
+      'This is at the bottom of the hierarchy.'
+    );
+    bottomCode.hierarchy = ['topCode', 'middleCode'];
+    const otherMiddle = new ConceptRule(
+      'otherMiddle',
+      'Other Middle',
+      'This is another middle code.'
+    );
+    otherMiddle.hierarchy = ['topCode'];
+    const unrelatedCode = new ConceptRule(
+      'unrelatedCode',
+      'Unrelated Code',
+      'This is not related to the hierarchy.'
+    );
+    codeSystem.rules.push(topCode, middleCode, bottomCode, otherMiddle, unrelatedCode);
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
+      name: 'HierarchicalCodeSystem',
+      id: 'HierarchicalCodeSystem',
+      status: 'active',
+      content: 'complete',
+      url: 'http://hl7.org/fhir/us/minimal/CodeSystem/HierarchicalCodeSystem',
+      version: '1.0.0',
+      count: 5,
+      concept: [
+        {
+          code: 'topCode',
+          display: 'Top Code',
+          definition: 'This is at the top of the hierarchy.',
+          concept: [
+            {
+              code: 'middleCode',
+              display: 'Middle Code',
+              definition: 'This is in the middle of the hierarchy.',
+              concept: [
+                {
+                  code: 'bottomCode',
+                  display: 'Bottom Code',
+                  definition: 'This is at the bottom of the hierarchy.'
+                }
+              ]
+            },
+            {
+              code: 'otherMiddle',
+              display: 'Other Middle',
+              definition: 'This is another middle code.'
+            }
+          ]
+        },
+        {
+          code: 'unrelatedCode',
+          display: 'Unrelated Code',
+          definition: 'This is not related to the hierarchy.'
+        }
+      ]
+    });
+  });
+
   it('should log a message when the code system has an invalid id', () => {
     const codeSystem = new FshCodeSystem('StrangeSystem')
       .withFile('Strange.fsh')
@@ -238,6 +323,80 @@ describe('CodeSystemExporter', () => {
     });
   });
 
+  it('should apply a CodeCaretValueRule on a top-level concept', () => {
+    const codeSystem = new FshCodeSystem('CaretCodeSystem');
+    const someCode = new ConceptRule('someCode', 'Some Code');
+    const someCaret = new CodeCaretValueRule(['someCode']);
+    someCaret.caretPath = 'designation[0].value';
+    someCaret.value = 'Designated value';
+    codeSystem.rules.push(someCode, someCaret);
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
+      id: 'CaretCodeSystem',
+      name: 'CaretCodeSystem',
+      content: 'complete',
+      url: 'http://hl7.org/fhir/us/minimal/CodeSystem/CaretCodeSystem',
+      version: '1.0.0',
+      count: 1,
+      status: 'active',
+      concept: [
+        {
+          code: 'someCode',
+          display: 'Some Code',
+          designation: [
+            {
+              value: 'Designated value'
+            }
+          ]
+        }
+      ]
+    });
+  });
+
+  it('should apply a CodeCaretValueRule on a concept within a hierarchy', () => {
+    const codeSystem = new FshCodeSystem('CaretCodeSystem');
+    const someCode = new ConceptRule('someCode', 'Some Code');
+    const otherCode = new ConceptRule('otherCode', 'Other Code');
+    otherCode.hierarchy = ['someCode'];
+    const someCaret = new CodeCaretValueRule(['someCode', 'otherCode']);
+    someCaret.caretPath = 'designation[0].value';
+    someCaret.value = 'Other designated value';
+    codeSystem.rules.push(someCode, otherCode, someCaret);
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
+      id: 'CaretCodeSystem',
+      name: 'CaretCodeSystem',
+      content: 'complete',
+      url: 'http://hl7.org/fhir/us/minimal/CodeSystem/CaretCodeSystem',
+      version: '1.0.0',
+      count: 2,
+      status: 'active',
+      concept: [
+        {
+          code: 'someCode',
+          display: 'Some Code',
+          concept: [
+            {
+              code: 'otherCode',
+              display: 'Other Code',
+              designation: [
+                {
+                  value: 'Other designated value'
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    });
+  });
+
   it('should resolve soft indexing when applying Caret Value rules', () => {
     const codeSystem = new FshCodeSystem('CaretCodeSystem');
     const contactRule1 = new CaretValueRule('');
@@ -347,6 +506,23 @@ describe('CodeSystemExporter', () => {
     expect(exported[0].count).toBeUndefined();
   });
 
+  it('should log a message when applying an invalid ConceptRule', () => {
+    const codeSystem = new FshCodeSystem('MyCodeSystem');
+    const topCode = new ConceptRule('top');
+    const bottomCode = new ConceptRule('bottom');
+    bottomCode.hierarchy = ['top'];
+    const mistakeCode = new ConceptRule('mistake')
+      .withFile('InvalidHierarchy.fsh')
+      .withLocation([8, 3, 8, 25]);
+    mistakeCode.hierarchy = ['bottom']; // This is an incomplete hierarchy, and will generate an error.
+    codeSystem.rules.push(topCode, bottomCode, mistakeCode);
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0].count).toBe(2); // top and bottom were added, but not mistake
+    expect(loggerSpy.getLastMessage('error')).toMatch(/File: InvalidHierarchy\.fsh.*Line: 8\D*/s);
+  });
+
   it('should log a message when applying invalid CaretValueRule', () => {
     const codeSystem = new FshCodeSystem('CaretCodeSystem');
     const rule = new CaretValueRule('').withFile('InvalidValue.fsh').withLocation([6, 3, 6, 12]);
@@ -368,6 +544,45 @@ describe('CodeSystemExporter', () => {
     expect(loggerSpy.getLastMessage('error')).toMatch(/File: InvalidValue\.fsh.*Line: 6\D*/s);
   });
 
+  it('should log a message when applying an invalid CodeCaretValueRule', () => {
+    const codeSystem = new FshCodeSystem('CaretCodeSystem');
+    const someCode = new ConceptRule('someCode', 'Some Code');
+    const otherCode = new ConceptRule('otherCode', 'Other Code');
+    otherCode.hierarchy = ['someCode'];
+    const someCaret = new CodeCaretValueRule(['someCode', 'wrongCode'])
+      .withFile('InvalidValue.fsh')
+      .withLocation([8, 5, 8, 25]);
+    someCaret.caretPath = 'designation[0].value';
+    someCaret.value = 'Other designated value';
+    codeSystem.rules.push(someCode, otherCode, someCaret);
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
+      id: 'CaretCodeSystem',
+      name: 'CaretCodeSystem',
+      content: 'complete',
+      url: 'http://hl7.org/fhir/us/minimal/CodeSystem/CaretCodeSystem',
+      version: '1.0.0',
+      count: 2,
+      status: 'active',
+      concept: [
+        {
+          code: 'someCode',
+          display: 'Some Code',
+          concept: [
+            {
+              code: 'otherCode',
+              display: 'Other Code'
+            }
+          ]
+        }
+      ]
+    });
+    expect(loggerSpy.getLastMessage('error')).toMatch(/File: InvalidValue\.fsh.*Line: 8\D*/s);
+  });
+
   describe('#insertRules', () => {
     let cs: FshCodeSystem;
     let ruleSet: RuleSet;