Repository: FHIR/sushi
Pull request #90 (2020-01-14 17:40:40)

Import CodeSystem
This pull request adds functionality to create `CodeSystem` instances from FSH. CodeSystems are relatively simple entities that hold a list of `FshConcept` instances, but there are two conditions that will result in error messages being emitted:
- If a listed concept includes a system, an error message is produced. Specifying a system isn't necessary, because the concept necessarily belongs to the CodeSystem being defined. The concept is still added to the CodeSystem.
- If a concept is listed more than once, only the first instance is used. This enforces `csd-1` on http://hl7.org/fhir/codesystem.html#invs.

Test cases exist to demonstrate each of these scenarios.

This pull request addresses https://standardhealthrecord.atlassian.net/browse/CIMPL-197.


Patch:
diff --git a/src/errors/CodeSystemDuplicateCodeError.ts b/src/errors/CodeSystemDuplicateCodeError.ts
new file mode 100644
index 0000000..bff4f2b
--- /dev/null
+++ b/src/errors/CodeSystemDuplicateCodeError.ts
@@ -0,0 +1,8 @@
+import { Annotated } from './Annotated';
+
+export class CodeSystemDuplicateCodeError extends Error implements Annotated {
+  specReferences = ['http://hl7.org/fhir/codesystem.html#invs'];
+  constructor(system: string, code: string) {
+    super(`CodeSystem ${system} already contains code ${code}.`);
+  }
+}
diff --git a/src/errors/index.ts b/src/errors/index.ts
index 377ec0a..0bec755 100644
--- a/src/errors/index.ts
+++ b/src/errors/index.ts
@@ -6,6 +6,7 @@ export * from './NoSingleTypeError';
 export * from './MismatchedTypeError';
 export * from './CodeAndSystemMismatchError';
 export * from './CodedTypeNotFoundError';
+export * from './CodeSystemDuplicateCodeError';
 export * from './DisableFlagError';
 export * from './InvalidCardinalityError';
 export * from './InvalidDateTimeError';
diff --git a/src/fshtypes/CodeSystem.ts b/src/fshtypes/CodeSystem.ts
index c2531e3..8260301 100644
--- a/src/fshtypes/CodeSystem.ts
+++ b/src/fshtypes/CodeSystem.ts
@@ -1,5 +1,6 @@
 import { FshEntity } from './FshEntity';
 import { FshConcept } from './FshConcept';
+import { CodeSystemDuplicateCodeError } from '../errors/CodeSystemDuplicateCodeError';
 
 /**
  * For more information about a CodeSystem in FHIR,
@@ -17,7 +18,10 @@ export class CodeSystem extends FshEntity {
     this.concepts = [];
   }
 
-  addConcept(code: string, display?: string, definition?: string) {
-    this.concepts.push(new FshConcept(code, display, definition));
+  addConcept(newConcept: FshConcept) {
+    if (this.concepts.find(existingConcept => existingConcept.code == newConcept.code)) {
+      throw new CodeSystemDuplicateCodeError(this.id, newConcept.code);
+    }
+    this.concepts.push(newConcept);
   }
 }
diff --git a/src/fshtypes/index.ts b/src/fshtypes/index.ts
index b361950..9494ddd 100644
--- a/src/fshtypes/index.ts
+++ b/src/fshtypes/index.ts
@@ -3,6 +3,7 @@ export * from './Config';
 export * from './Extension';
 export * from './Instance';
 export * from './FshCode';
+export * from './FshConcept';
 export * from './FshEntity';
 export * from './FshQuantity';
 export * from './FshRatio';
diff --git a/src/import/FSHDocument.ts b/src/import/FSHDocument.ts
index b1b54db..f768fdc 100644
--- a/src/import/FSHDocument.ts
+++ b/src/import/FSHDocument.ts
@@ -1,4 +1,4 @@
-import { Profile, Extension, Instance, FshValueSet } from '../fshtypes';
+import { Profile, Extension, Instance, FshValueSet, CodeSystem } from '../fshtypes';
 
 export class FSHDocument {
   readonly aliases: Map<string, string>;
@@ -6,6 +6,7 @@ export class FSHDocument {
   readonly extensions: Map<string, Extension>;
   readonly instances: Map<string, Instance>;
   readonly valueSets: Map<string, FshValueSet>;
+  readonly codeSystems: Map<string, CodeSystem>;
 
   constructor(public readonly file: string) {
     this.aliases = new Map();
@@ -13,5 +14,6 @@ export class FSHDocument {
     this.extensions = new Map();
     this.instances = new Map();
     this.valueSets = new Map();
+    this.codeSystems = new Map();
   }
 }
diff --git a/src/import/FSHImporter.ts b/src/import/FSHImporter.ts
index 41886d7..3fae053 100644
--- a/src/import/FSHImporter.ts
+++ b/src/import/FSHImporter.ts
@@ -9,6 +9,7 @@ import {
   Profile,
   Extension,
   FshCode,
+  FshConcept,
   FshQuantity,
   FshRatio,
   FshReference,
@@ -21,7 +22,8 @@ import {
   ValueSetComponentFrom,
   ValueSetFilter,
   VsOperator,
-  ValueSetFilterValue
+  ValueSetFilterValue,
+  CodeSystem
 } from '../fshtypes';
 import {
   Rule,
@@ -68,6 +70,13 @@ enum VsMetadataKey {
   Unknown = 'Unknown'
 }
 
+enum CsMetadataKey {
+  Id = 'Id',
+  Title = 'Title',
+  Description = 'Description',
+  Unknown = 'Unknown'
+}
+
 enum Flag {
   MustSupport,
   Summary,
@@ -157,6 +166,10 @@ export class FSHImporter extends FSHVisitor {
     if (ctx.valueSet()) {
       this.visitValueSet(ctx.valueSet());
     }
+
+    if (ctx.codeSystem()) {
+      this.visitCodeSystem(ctx.codeSystem());
+    }
   }
 
   visitAlias(ctx: pc.AliasContext): void {
@@ -324,6 +337,54 @@ export class FSHImporter extends FSHVisitor {
       });
   }
 
+  visitCodeSystem(ctx: pc.CodeSystemContext) {
+    const codeSystem = new CodeSystem(ctx.SEQUENCE().getText())
+      .withLocation(this.extractStartStop(ctx))
+      .withFile(this.currentFile);
+    this.parseCodeSystem(codeSystem, ctx.csMetadata(), ctx.concept());
+    this.currentDoc.codeSystems.set(codeSystem.name, codeSystem);
+  }
+
+  private parseCodeSystem(
+    codeSystem: CodeSystem,
+    metaCtx: pc.CsMetadataContext[] = [],
+    conceptCtx: pc.ConceptContext[] = []
+  ) {
+    const seenPairs: Map<CsMetadataKey, string> = new Map();
+    metaCtx
+      .map(csMetadata => ({
+        ...this.visitCsMetadata(csMetadata),
+        context: csMetadata
+      }))
+      .forEach(pair => {
+        if (seenPairs.has(pair.key)) {
+          logger.error(
+            `Metadata field '${pair.key}' already declared with value '${seenPairs.get(
+              pair.key
+            )}'.`,
+            { file: this.currentFile, location: this.extractStartStop(pair.context) }
+          );
+          return;
+        }
+        seenPairs.set(pair.key, pair.value);
+        if (pair.key === CsMetadataKey.Id) {
+          codeSystem.id = pair.value;
+        } else if (pair.key === CsMetadataKey.Title) {
+          codeSystem.title = pair.value;
+        } else if (pair.key === CsMetadataKey.Description) {
+          codeSystem.description = pair.value;
+        }
+      });
+    conceptCtx.forEach(conceptCtx => {
+      const newConcept = this.visitConcept(conceptCtx);
+      try {
+        codeSystem.addConcept(newConcept);
+      } catch (e) {
+        logger.error(e.message, newConcept.sourceInfo);
+      }
+    });
+  }
+
   visitSdMetadata(ctx: pc.SdMetadataContext): { key: SdMetadataKey; value: string } {
     if (ctx.id()) {
       return { key: SdMetadataKey.Id, value: this.visitId(ctx.id()) };
@@ -359,6 +420,17 @@ export class FSHImporter extends FSHVisitor {
     return { key: VsMetadataKey.Unknown, value: ctx.getText() };
   }
 
+  visitCsMetadata(ctx: pc.CsMetadataContext): { key: CsMetadataKey; value: string } {
+    if (ctx.id()) {
+      return { key: CsMetadataKey.Id, value: this.visitId(ctx.id()) };
+    } else if (ctx.title()) {
+      return { key: CsMetadataKey.Title, value: this.visitTitle(ctx.title()) };
+    } else if (ctx.description()) {
+      return { key: CsMetadataKey.Description, value: this.visitDescription(ctx.description()) };
+    }
+    return { key: CsMetadataKey.Unknown, value: ctx.getText() };
+  }
+
   visitId(ctx: pc.IdContext): string {
     return ctx.SEQUENCE().getText();
   }
@@ -589,6 +661,23 @@ export class FSHImporter extends FSHVisitor {
     return concept;
   }
 
+  visitConcept(ctx: pc.ConceptContext): FshConcept {
+    const codePart = this.visitCode(ctx.code());
+    const concept = new FshConcept(codePart.code, codePart.display)
+      .withLocation(this.extractStartStop(ctx))
+      .withFile(this.currentFile);
+    if (codePart.system) {
+      logger.error(
+        'Do not include the system when listing concepts for a code system.',
+        concept.sourceInfo
+      );
+    }
+    if (ctx.STRING()) {
+      concept.definition = this.extractString(ctx.STRING());
+    }
+    return concept;
+  }
+
   visitQuantity(ctx: pc.QuantityContext): FshQuantity {
     const value = parseFloat(ctx.NUMBER().getText());
     const delimitedUnit = ctx.UNIT().getText(); // e.g., 'mm'


Test patch:
diff --git a/test/import/FSHImporter.CodeSystem.test.ts b/test/import/FSHImporter.CodeSystem.test.ts
new file mode 100644
index 0000000..deafc79
--- /dev/null
+++ b/test/import/FSHImporter.CodeSystem.test.ts
@@ -0,0 +1,274 @@
+import { importSingleText } from '../testhelpers/importSingleText';
+import { loggerSpy } from '../testhelpers/loggerSpy';
+
+describe('FSHImporter', () => {
+  describe('CodeSystem', () => {
+    describe('#csMetadata', () => {
+      it('should parse the simplest possible code system', () => {
+        const input = `
+        CodeSystem: ZOO
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.id).toBe('ZOO');
+        expect(codeSystem.concepts).toEqual([]);
+        expect(codeSystem.sourceInfo.location).toEqual({
+          startLine: 2,
+          startColumn: 9,
+          endLine: 2,
+          endColumn: 23
+        });
+        expect(codeSystem.sourceInfo.file).toBe('Zoo.fsh');
+      });
+
+      it('should parse a code system with additional metadata', () => {
+        const input = `
+        CodeSystem: ZOO
+        Id: zoo-codes
+        Title: "Zoo Animals"
+        Description: "Animals and cryptids that may be at a zoo."
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.id).toBe('zoo-codes');
+        expect(codeSystem.title).toBe('Zoo Animals');
+        expect(codeSystem.description).toBe('Animals and cryptids that may be at a zoo.');
+        expect(codeSystem.concepts).toEqual([]);
+        expect(codeSystem.sourceInfo.location).toEqual({
+          startLine: 2,
+          startColumn: 9,
+          endLine: 5,
+          endColumn: 65
+        });
+      });
+
+      it('should parse a code system with a multi-line description', () => {
+        const input = `
+        CodeSystem: ZOO
+        Id: zoo-codes
+        Title: "Zoo Animals"
+        Description: """
+        Animals that may be present at the zoo. This includes
+        animals that have been classified by biologists, as
+        well as certain cryptids, such as:
+        * quadrapedal cryptids
+          * jackalope
+          * hodag
+        * bipedal cryptids
+          * swamp ape
+          * hopkinsville goblin
+        """
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        const expectedDescription = [
+          'Animals that may be present at the zoo. This includes',
+          'animals that have been classified by biologists, as',
+          'well as certain cryptids, such as:',
+          '* quadrapedal cryptids',
+          '  * jackalope',
+          '  * hodag',
+          '* bipedal cryptids',
+          '  * swamp ape',
+          '  * hopkinsville goblin'
+        ].join('\n');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.id).toBe('zoo-codes');
+        expect(codeSystem.title).toBe('Zoo Animals');
+        expect(codeSystem.description).toBe(expectedDescription);
+        expect(codeSystem.concepts).toEqual([]);
+      });
+
+      it('should only apply each metadata attribute the first time it is declared', () => {
+        const input = `
+        CodeSystem: ZOO
+        Id: zoo-codes
+        Title: "Zoo Animals"
+        Description: "Animals and cryptids that may be at a zoo."
+        Title: "Duplicate Animals"
+        Id: zoo-codes-again
+        Description: "Lions and tigers and bears!"
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.id).toBe('zoo-codes');
+        expect(codeSystem.title).toBe('Zoo Animals');
+        expect(codeSystem.description).toBe('Animals and cryptids that may be at a zoo.');
+        expect(codeSystem.concepts).toEqual([]);
+        expect(codeSystem.sourceInfo.location).toEqual({
+          startLine: 2,
+          startColumn: 9,
+          endLine: 8,
+          endColumn: 50
+        });
+      });
+
+      it('should log an error when encountering a duplicate metadata attribute', () => {
+        const input = `
+        CodeSystem: ZOO
+        Id: zoo-codes
+        Title: "Zoo Animals"
+        Description: "Animals and cryptids that may be at a zoo."
+        Title: "Duplicate Animals"
+        Id: zoo-codes-again
+        Description: "Lions and tigers and bears!"
+        `;
+        importSingleText(input, 'Zoo.fsh');
+        expect(loggerSpy.getMessageAtIndex(-3)).toMatch(/File: Zoo\.fsh.*Line: 6\D/s);
+        expect(loggerSpy.getMessageAtIndex(-2)).toMatch(/File: Zoo\.fsh.*Line: 7\D/s);
+        expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 8\D/s);
+      });
+    });
+    describe('#concept', () => {
+      it('should parse a code system with one concept', () => {
+        const input = `
+        CodeSystem: ZOO
+        #lion
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.concepts.length).toBe(1);
+        expect(codeSystem.concepts[0].code).toBe('lion');
+        expect(codeSystem.concepts[0].display).toBeUndefined();
+        expect(codeSystem.concepts[0].definition).toBeUndefined();
+        expect(codeSystem.concepts[0].sourceInfo.location).toEqual({
+          startLine: 3,
+          startColumn: 9,
+          endLine: 3,
+          endColumn: 13
+        });
+        expect(codeSystem.concepts[0].sourceInfo.file).toBe('Zoo.fsh');
+      });
+
+      it('should parse a code system with one concept with a display string', () => {
+        const input = `
+        CodeSystem: ZOO
+        #tiger "Tiger"
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.concepts.length).toBe(1);
+        expect(codeSystem.concepts[0].code).toBe('tiger');
+        expect(codeSystem.concepts[0].display).toBe('Tiger');
+        expect(codeSystem.concepts[0].definition).toBeUndefined();
+        expect(codeSystem.concepts[0].sourceInfo.location).toEqual({
+          startLine: 3,
+          startColumn: 9,
+          endLine: 3,
+          endColumn: 22
+        });
+        expect(codeSystem.concepts[0].sourceInfo.file).toBe('Zoo.fsh');
+      });
+
+      it('should parse a code system with one concept with display and definition strings', () => {
+        const input = `
+        CodeSystem: ZOO
+        #bear "Bear" "A member of family Ursidae."
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.concepts.length).toBe(1);
+        expect(codeSystem.concepts[0].code).toBe('bear');
+        expect(codeSystem.concepts[0].display).toBe('Bear');
+        expect(codeSystem.concepts[0].definition).toBe('A member of family Ursidae.');
+        expect(codeSystem.concepts[0].sourceInfo.location).toEqual({
+          startLine: 3,
+          startColumn: 9,
+          endLine: 3,
+          endColumn: 50
+        });
+        expect(codeSystem.concepts[0].sourceInfo.file).toBe('Zoo.fsh');
+      });
+
+      it('should parse a code system with more than one concept', () => {
+        const input = `
+        CodeSystem: ZOO
+        #lion
+        #tiger "Tiger"
+        #bear "Bear" "A member of family Ursidae."
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.concepts.length).toBe(3);
+        expect(codeSystem.concepts[0].code).toBe('lion');
+        expect(codeSystem.concepts[0].display).toBeUndefined();
+        expect(codeSystem.concepts[0].definition).toBeUndefined();
+        expect(codeSystem.concepts[0].sourceInfo.location).toEqual({
+          startLine: 3,
+          startColumn: 9,
+          endLine: 3,
+          endColumn: 13
+        });
+        expect(codeSystem.concepts[0].sourceInfo.file).toBe('Zoo.fsh');
+        expect(codeSystem.concepts[1].code).toBe('tiger');
+        expect(codeSystem.concepts[1].display).toBe('Tiger');
+        expect(codeSystem.concepts[1].definition).toBeUndefined();
+        expect(codeSystem.concepts[1].sourceInfo.location).toEqual({
+          startLine: 4,
+          startColumn: 9,
+          endLine: 4,
+          endColumn: 22
+        });
+        expect(codeSystem.concepts[1].sourceInfo.file).toBe('Zoo.fsh');
+        expect(codeSystem.concepts[2].code).toBe('bear');
+        expect(codeSystem.concepts[2].display).toBe('Bear');
+        expect(codeSystem.concepts[2].definition).toBe('A member of family Ursidae.');
+        expect(codeSystem.concepts[2].sourceInfo.location).toEqual({
+          startLine: 5,
+          startColumn: 9,
+          endLine: 5,
+          endColumn: 50
+        });
+        expect(codeSystem.concepts[2].sourceInfo.file).toBe('Zoo.fsh');
+      });
+
+      it('should log an error when encountering a duplicate code', () => {
+        const input = `
+        CodeSystem: ZOO
+        #goat
+        #goat
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.concepts.length).toBe(1);
+        expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 4\D/s);
+      });
+
+      it('should log an error when a concept includes a system declaration', () => {
+        const input = `
+        CodeSystem: ZOO
+        #goat
+        ZOO#bat
+        CRYPTID#jackalope
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.codeSystems.size).toBe(1);
+        const codeSystem = result.codeSystems.get('ZOO');
+        expect(codeSystem.name).toBe('ZOO');
+        expect(codeSystem.concepts.length).toBe(3);
+        expect(codeSystem.concepts[0].code).toBe('goat');
+        expect(codeSystem.concepts[1].code).toBe('bat');
+        expect(codeSystem.concepts[2].code).toBe('jackalope');
+        expect(loggerSpy.getMessageAtIndex(-2)).toMatch(/File: Zoo\.fsh.*Line: 4\D/s);
+        expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 5\D/s);
+      });
+    });
+  });
+});