diff --git a/src/exportable/ExportableInvariant.ts b/src/exportable/ExportableInvariant.ts index ef622501..ea417149 100644 --- a/src/exportable/ExportableInvariant.ts +++ b/src/exportable/ExportableInvariant.ts @@ -1,7 +1,9 @@ import { fshtypes } from 'fsh-sushi'; -import { Exportable } from '.'; +import { Exportable, ExportableAssignmentRule, ExportableInsertRule } from '.'; export class ExportableInvariant extends fshtypes.Invariant implements Exportable { + rules: (ExportableAssignmentRule | ExportableInsertRule)[]; + constructor(name: string) { super(name); } diff --git a/src/extractor/InvariantExtractor.ts b/src/extractor/InvariantExtractor.ts index 2f64597b..89788fc1 100644 --- a/src/extractor/InvariantExtractor.ts +++ b/src/extractor/InvariantExtractor.ts @@ -1,47 +1,84 @@ -import { fshtypes } from 'fsh-sushi'; -import { isEqual } from 'lodash'; +import { fshtypes, utils } from 'fsh-sushi'; +import { cloneDeep, isEqual, toPairs } from 'lodash'; import { ExportableInvariant } from '../exportable/ExportableInvariant'; -import { ProcessableElementDefinition, ProcessableStructureDefinition } from '../processor'; +import { + ProcessableElementDefinition, + ProcessableStructureDefinition, + switchQuantityRules +} from '../processor'; +import { getFSHValue, getPathValuePairs, isFSHValueEmpty } from '../utils'; +import { ExportableAssignmentRule } from '../exportable'; export class InvariantExtractor { static process( input: ProcessableElementDefinition, structDef: ProcessableStructureDefinition, - existingInvariants: ExportableInvariant[] + existingInvariants: ExportableInvariant[], + fisher: utils.Fishable ): ExportableInvariant[] { const invariants: ExportableInvariant[] = []; if (input.constraint?.length > 0) { input.constraint.forEach((constraint, i) => { + // clone the constraint so we can take it apart as we work with it + const workingConstraint = cloneDeep(constraint); const constraintPaths: string[] = []; // required: key, human, severity - const invariant = new ExportableInvariant(constraint.key); - invariant.description = constraint.human; - invariant.severity = new fshtypes.FshCode(constraint.severity); + const invariant = new ExportableInvariant(workingConstraint.key); + invariant.description = workingConstraint.human; + invariant.severity = new fshtypes.FshCode(workingConstraint.severity); constraintPaths.push( `constraint[${i}].key`, `constraint[${i}].human`, `constraint[${i}].severity` ); + delete workingConstraint.key; + delete workingConstraint.human; + delete workingConstraint.severity; // optional: expression, xpath - if (constraint.expression) { - invariant.expression = constraint.expression; + if (workingConstraint.expression) { + invariant.expression = workingConstraint.expression; constraintPaths.push(`constraint[${i}].expression`); + delete workingConstraint.expression; } - if (constraint.xpath) { - invariant.xpath = constraint.xpath; + if (workingConstraint.xpath) { + invariant.xpath = workingConstraint.xpath; constraintPaths.push(`constraint[${i}].xpath`); + delete workingConstraint.xpath; } // SUSHI autopopulates source to the current SD URL, so as long as it matches, mark that path as processed - if (constraint.source == null || constraint.source === structDef.url) { + if (workingConstraint.source == null || workingConstraint.source === structDef.url) { constraintPaths.push(`constraint[${i}].source`); + delete workingConstraint.source; } + // other properties are created with rules on the invariant + // since we're already inside the ElementDefinition, we have to manually prepend "constraint" to the path + // so that we can get the FSH value correctly. + // but, we want the original path for the rule itself. + const flatPropertyArray = toPairs( + getPathValuePairs(workingConstraint, x => `constraint.${x}`) + ); + flatPropertyArray.forEach(([path], propertyIdx) => { + const originalPath = path.replace('constraint.', ''); + const assignmentRule = new ExportableAssignmentRule(originalPath); + assignmentRule.value = getFSHValue( + propertyIdx, + flatPropertyArray, + 'ElementDefinition', + fisher + ); + if (!isFSHValueEmpty(assignmentRule.value)) { + invariant.rules.push(assignmentRule); + } + constraintPaths.push(`constraint[${i}].${originalPath}`); + }); + switchQuantityRules(invariant.rules); // if an invariant with this key already exists, don't make a new invariant with the same key. // if the new invariant would be an exact match of the existing invariant, mark the paths as // processed so an ObeysRule is created and no CaretValueRules are created. // if the new invariant has a key match but isn't an exact match, it will be created using CaretValueRules. const matchingKeyInvariant = [...existingInvariants, ...invariants].find( - inv => inv.name === constraint.key + inv => inv.name === invariant.name ); if (matchingKeyInvariant) { if (isEqual(matchingKeyInvariant, invariant)) { diff --git a/src/processor/StructureDefinitionProcessor.ts b/src/processor/StructureDefinitionProcessor.ts index 8941aba8..394a718e 100644 --- a/src/processor/StructureDefinitionProcessor.ts +++ b/src/processor/StructureDefinitionProcessor.ts @@ -73,7 +73,8 @@ export class StructureDefinitionProcessor { const invariants = StructureDefinitionProcessor.extractInvariants( input, elements, - existingInvariants + existingInvariants, + fisher ); const mappings = StructureDefinitionProcessor.extractMappings(elements, input, fisher); StructureDefinitionProcessor.extractRules(input, elements, sd, fisher, config); @@ -239,12 +240,18 @@ export class StructureDefinitionProcessor { static extractInvariants( input: ProcessableStructureDefinition, elements: ProcessableElementDefinition[], - existingInvariants: ExportableInvariant[] + existingInvariants: ExportableInvariant[], + fisher: utils.Fishable ): ExportableInvariant[] { const invariants: ExportableInvariant[] = []; elements.forEach(element => { invariants.push( - ...InvariantExtractor.process(element, input, [...existingInvariants, ...invariants]) + ...InvariantExtractor.process( + element, + input, + [...existingInvariants, ...invariants], + fisher + ) ); }); return invariants; diff --git a/test/extractor/InvariantExtractor.test.ts b/test/extractor/InvariantExtractor.test.ts index 17147816..4557cec3 100644 --- a/test/extractor/InvariantExtractor.test.ts +++ b/test/extractor/InvariantExtractor.test.ts @@ -2,14 +2,18 @@ import path from 'path'; import fs from 'fs-extra'; import { ProcessableElementDefinition, ProcessableStructureDefinition } from '../../src/processor'; import { InvariantExtractor } from '../../src/extractor'; -import { ExportableInvariant } from '../../src/exportable'; +import { ExportableAssignmentRule, ExportableInvariant } from '../../src/exportable'; import { fshtypes } from 'fsh-sushi'; +import { FHIRDefinitions } from '../../src/utils'; +import { loadTestDefinitions } from '../helpers/loadTestDefinitions'; const { FshCode } = fshtypes; describe('InvariantExtractor', () => { + let defs: FHIRDefinitions; let looseSD: ProcessableStructureDefinition; beforeAll(() => { + defs = loadTestDefinitions(); looseSD = JSON.parse( fs.readFileSync(path.join(__dirname, 'fixtures', 'obeys-profile.json'), 'utf-8').trim() ); @@ -17,7 +21,7 @@ describe('InvariantExtractor', () => { it('should extract invariants from an element with one constraint', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); - const invariants = InvariantExtractor.process(element, looseSD, []); + const invariants = InvariantExtractor.process(element, looseSD, [], defs); const rootInvariant = new ExportableInvariant('zig-1'); rootInvariant.severity = new FshCode('warning'); rootInvariant.description = 'This is a constraint on the root element.'; @@ -32,7 +36,7 @@ describe('InvariantExtractor', () => { it('should extract invariants from an element with multiple constraints', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[1]); - const invariants = InvariantExtractor.process(element, looseSD, []); + const invariants = InvariantExtractor.process(element, looseSD, [], defs); const expressionInvariant = new ExportableInvariant('zig-2'); expressionInvariant.severity = new FshCode('error'); expressionInvariant.description = 'This constraint has an expression.'; @@ -46,11 +50,23 @@ describe('InvariantExtractor', () => { bothInvariant.description = 'This constraint has an expression and an xpath.'; bothInvariant.expression = 'category.double.exists()'; bothInvariant.xpath = 'f:category/double'; + const complexInvariant = new ExportableInvariant('zig-5'); + complexInvariant.severity = new FshCode('warning'); + complexInvariant.description = 'This constraint has some extra rules.'; + complexInvariant.expression = 'category.triple.exists()'; + const invExtensionUrl = new ExportableAssignmentRule('human.extension[0].url'); + invExtensionUrl.value = 'http://example.org/SomeExtension'; + const invExtensionValue = new ExportableAssignmentRule('human.extension[0].valueString'); + invExtensionValue.value = 'ExtensionValue'; + const invRequirements = new ExportableAssignmentRule('requirements'); + invRequirements.value = 'This is an additional requirement'; + complexInvariant.rules.push(invExtensionUrl, invExtensionValue, invRequirements); - expect(invariants).toHaveLength(3); + expect(invariants).toHaveLength(4); expect(invariants).toContainEqual(expressionInvariant); expect(invariants).toContainEqual(xpathInvariant); expect(invariants).toContainEqual(bothInvariant); + expect(invariants).toContainEqual(complexInvariant); expect(element.processedPaths).toContain('constraint[0].key'); expect(element.processedPaths).toContain('constraint[0].severity'); expect(element.processedPaths).toContain('constraint[0].human'); @@ -67,11 +83,18 @@ describe('InvariantExtractor', () => { expect(element.processedPaths).toContain('constraint[2].expression'); expect(element.processedPaths).toContain('constraint[2].xpath'); expect(element.processedPaths).toContain('constraint[2].source'); + expect(element.processedPaths).toContain('constraint[3].key'); + expect(element.processedPaths).toContain('constraint[3].severity'); + expect(element.processedPaths).toContain('constraint[3].human'); + expect(element.processedPaths).toContain('constraint[3].human.extension[0].url'); + expect(element.processedPaths).toContain('constraint[3].human.extension[0].valueString'); + expect(element.processedPaths).toContain('constraint[3].expression'); + expect(element.processedPaths).toContain('constraint[3].requirements'); }); it('should extract no invariants from an element with no constraints', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[2]); - const invariants = InvariantExtractor.process(element, looseSD, []); + const invariants = InvariantExtractor.process(element, looseSD, [], defs); expect(invariants).toHaveLength(0); expect(element.processedPaths).toHaveLength(0); }); @@ -81,7 +104,7 @@ describe('InvariantExtractor', () => { existingInvariant.severity = new FshCode('warning'); existingInvariant.description = 'This is a constraint on the root element.'; const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); - const invariants = InvariantExtractor.process(element, looseSD, [existingInvariant]); + const invariants = InvariantExtractor.process(element, looseSD, [existingInvariant], defs); expect(invariants).toHaveLength(0); expect(element.processedPaths).toHaveLength(4); expect(element.processedPaths).toContainEqual('constraint[0].key'); @@ -93,7 +116,7 @@ describe('InvariantExtractor', () => { it('should not extract an invariant nor process paths if a non-equal invariant with a matching key already exists', () => { const existingInvariant = new ExportableInvariant('zig-1'); const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); - const invariants = InvariantExtractor.process(element, looseSD, [existingInvariant]); + const invariants = InvariantExtractor.process(element, looseSD, [existingInvariant], defs); expect(invariants).toHaveLength(0); expect(element.processedPaths).toHaveLength(0); }); diff --git a/test/extractor/fixtures/obeys-profile.json b/test/extractor/fixtures/obeys-profile.json index 8fefe363..c01ce747 100644 --- a/test/extractor/fixtures/obeys-profile.json +++ b/test/extractor/fixtures/obeys-profile.json @@ -41,6 +41,21 @@ "human": "This constraint has an expression and an xpath.", "expression": "category.double.exists()", "xpath": "f:category/double" + }, + { + "key": "zig-5", + "severity": "warning", + "human": "This constraint has some extra rules.", + "_human": { + "extension": [ + { + "url": "http://example.org/SomeExtension", + "valueString": "ExtensionValue" + } + ] + }, + "expression": "category.triple.exists()", + "requirements": "This is an additional requirement" } ] }, diff --git a/test/processor/StructureDefinitionProcessor.test.ts b/test/processor/StructureDefinitionProcessor.test.ts index a64f923a..643381e2 100644 --- a/test/processor/StructureDefinitionProcessor.test.ts +++ b/test/processor/StructureDefinitionProcessor.test.ts @@ -743,9 +743,9 @@ describe('StructureDefinitionProcessor', () => { input.differential?.element?.map(rawElement => { return ProcessableElementDefinition.fromJSON(rawElement, false); }) ?? []; - const result = StructureDefinitionProcessor.extractInvariants(input, elements, []); + const result = StructureDefinitionProcessor.extractInvariants(input, elements, [], defs); - expect(result).toHaveLength(4); + expect(result).toHaveLength(5); }); });