Skip to content

Commit

Permalink
Extract rules for Invariants. Use keyword for Invariant description, …
Browse files Browse the repository at this point in the history
…rules for everything else (#251)

* Add rules to Invariants

When extracting an Invariant, create assignment rules for properties
that are not represented using keywords. Use the same functions as the
InstanceProcessor for this. Add the paths for these elements to the
returned list of paths so that caret rules will not be created for these
elements.

* Use keyword for description, rules for everything else

When producing FSH for an Invariant, use the Description keyword for the
description element. For all other metadata elements, use assignment
rules instead. These rules will always come before any other rules on
the Invariant.

* Remove metadataToFSH override from ExportableInvariant
  • Loading branch information
mint-thompson authored Jan 12, 2024
1 parent 225d702 commit a8eee62
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 27 deletions.
4 changes: 3 additions & 1 deletion src/exportable/ExportableInvariant.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
63 changes: 50 additions & 13 deletions src/extractor/InvariantExtractor.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down
13 changes: 10 additions & 3 deletions src/processor/StructureDefinitionProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 30 additions & 1 deletion test/exportable/ExportableInvariant.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EOL } from 'os';
import { fshtypes } from 'fsh-sushi';
import { ExportableInvariant } from '../../src/exportable';
import { ExportableAssignmentRule, ExportableInvariant } from '../../src/exportable';

describe('ExportableInvariant', () => {
it('should export the simplest invariant', () => {
Expand Down Expand Up @@ -46,4 +46,33 @@ describe('ExportableInvariant', () => {
const result = input.toFSH();
expect(result).toBe(expectedResult);
});

it('should produce FSH for an invariant with additional rules', () => {
const input = new ExportableInvariant('inv-4');
input.description = 'This is an important condition.';
input.severity = new fshtypes.FshCode('error');
input.expression = 'requirement.exists()';
input.xpath = 'f:requirement';

const requirements = new ExportableAssignmentRule('requirements');
requirements.value = 'This is necessary because it is important.';
const extensionUrl = new ExportableAssignmentRule('human.extension[0].url');
extensionUrl.value = 'http://example.org/SomeExtension';
const extensionValue = new ExportableAssignmentRule('human.extension[0].valueString');
extensionValue.value = 'ExtensionValue';
input.rules.push(requirements, extensionUrl, extensionValue);

const expectedResult = [
'Invariant: inv-4',
'Description: "This is an important condition."',
'* severity = #error',
'* expression = "requirement.exists()"',
'* xpath = "f:requirement"',
'* requirements = "This is necessary because it is important."',
'* human.extension[0].url = "http://example.org/SomeExtension"',
'* human.extension[0].valueString = "ExtensionValue"'
].join(EOL);
const result = input.toFSH();
expect(result).toBe(expectedResult);
});
});
37 changes: 30 additions & 7 deletions test/extractor/InvariantExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@ 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()
);
});

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.';
Expand All @@ -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.';
Expand All @@ -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');
Expand All @@ -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);
});
Expand All @@ -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');
Expand All @@ -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);
});
Expand Down
15 changes: 15 additions & 0 deletions test/extractor/fixtures/obeys-profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand Down
4 changes: 2 additions & 2 deletions test/processor/StructureDefinitionProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down

0 comments on commit a8eee62

Please sign in to comment.