Skip to content

Commit

Permalink
Support code caret rules in ValueSets (#249)
Browse files Browse the repository at this point in the history
* Support code caret rules in ValueSets

A ValueSet can use caret rules with a code path to set values on
included or excluded concepts. The typical use of this is to set a
designation, but other elements may also appear. Elements outside of a
concept are still unsupported and require the creation of an Instance.

* update jest-extended dependency to avoid downstream problems

* ValueSet concept caret rules include system in path array

The SUSHI dependency is updated to 3.6.0, which supports including the
system in the path array. This is needed for rules on ValueSets. Add the
system to the path array when extracting caret rules on ValueSet concept
components. Caret rules on CodeSystem concepts don't need a system, but
still need the leading # as a separator.

Add optimizer that reorders rules on ValueSets so that concept rules are
grouped with their corresponding caret rules.

Update other tests for Invariants, ValueSets, and ConceptRules based on
other changes included in SUSHI 3.6.0.

* Resolve URLs in ValueSet caret rule concept paths

Resolve URLs on caret rules after resolving URLs on ValueSet components
so that the caret rules will be able to use aliases that were created
when resolving the components.

Create new arrays to assign to path array when extracting caret rules on
concepts. This avoids the possibility of inadvertantly modifying
multiple rules with a single operation that mutates the path array.

* Add concepts without caret rules to optimizer test
  • Loading branch information
mint-thompson authored Jan 8, 2024
1 parent 8668264 commit 225d702
Show file tree
Hide file tree
Showing 22 changed files with 592 additions and 474 deletions.
506 changes: 100 additions & 406 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"eslint": "^8.5.0",
"eslint-config-prettier": "^6.10.1",
"jest": "^28.1.3",
"jest-extended": "^1.2.0",
"jest-extended": "^3.0.2",
"opener": "^1.5.1",
"prettier": "^2.0.2",
"ts-jest": "^28.0.7",
Expand All @@ -76,7 +76,7 @@
"fhir-package-loader": "^0.5.0",
"flat": "^5.0.2",
"fs-extra": "^9.0.1",
"fsh-sushi": "^3.5.0",
"fsh-sushi": "^3.6.0",
"ini": "^1.3.8",
"lodash": "^4.17.21",
"readline-sync": "^1.4.10",
Expand Down
13 changes: 7 additions & 6 deletions src/extractor/CaretValueRuleExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,9 @@ export class CaretValueRuleExtractor {

static processConcept(
input: fhirtypes.CodeSystemConcept,
conceptHierarchy: string[],
codeSystemName: string,
pathArray: string[],
entityName: string,
entityType: 'CodeSystem' | 'ValueSet',
fisher: utils.Fishable
): ExportableCaretValueRule[] {
const caretValueRules: ExportableCaretValueRule[] = [];
Expand All @@ -258,12 +259,12 @@ export class CaretValueRuleExtractor {
caretValueRule.caretPath = key;
caretValueRule.value = getFSHValue(i, flatArray, 'Concept', fisher);
caretValueRule.isCodeCaretRule = true;
caretValueRule.pathArray = conceptHierarchy;
caretValueRule.pathArray = [...pathArray];
if (isFSHValueEmpty(caretValueRule.value)) {
logger.error(
`Value in CodeSytem ${codeSystemName} at concept ${conceptHierarchy.join(
'.'
)} for element ${caretValueRule.caretPath} is empty. No caret value rule will be created.`
`Value in ${entityType} ${entityName} at concept ${pathArray.join('.')} for element ${
caretValueRule.caretPath
} is empty. No caret value rule will be created.`
);
} else {
caretValueRules.push(caretValueRule);
Expand Down
29 changes: 29 additions & 0 deletions src/optimizer/plugins/ResolveValueSetCaretRuleURLsOptimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { utils } from 'fsh-sushi';
import { OptimizerPlugin } from '../OptimizerPlugin';
import { optimizeURL } from '../utils';
import { Package } from '../../processor';
import { MasterFisher, ProcessingOptions } from '../../utils';
import { ExportableCaretValueRule } from '../../exportable';

export default {
name: 'resolve_value_set_caret_rule_urls',
description: 'Replace URLs in value set caret rules with their names or aliases',
runAfter: ['resolve_value_set_component_rule_urls'],
optimize(pkg: Package, fisher: MasterFisher, options: ProcessingOptions = {}): void {
pkg.valueSets.forEach(vs => {
vs.rules.forEach(rule => {
if (rule instanceof ExportableCaretValueRule && rule.pathArray.length > 0) {
const [system, ...code] = rule.pathArray[0].split('#');
const resolvedSystem = optimizeURL(
system,
pkg.aliases,
[utils.Type.CodeSystem],
fisher,
options.alias ?? true
);
rule.pathArray[0] = [resolvedSystem, code.join('#')].join('#');
}
});
});
}
} as OptimizerPlugin;
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {

export default {
name: 'resolve_value_set_component_rule_urls',
description: 'Replace URLs in value set rules with their names or aliases',
description: 'Replace URLs in value set component rules with their names or aliases',

optimize(pkg: Package, fisher: MasterFisher, options: ProcessingOptions = {}): void {
pkg.valueSets.forEach(vs => {
Expand Down
71 changes: 71 additions & 0 deletions src/optimizer/plugins/SeparateConceptsWithCaretRulesOptimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { isEmpty, isEqual } from 'lodash';
import { ExportableCaretValueRule, ExportableValueSetConceptComponentRule } from '../../exportable';
import { Package } from '../../processor';
import { OptimizerPlugin } from '../OptimizerPlugin';

// a ValueSetConceptComponentRule will print as multiple consecutive rules
// if there is a system, but no valuesets.
// normally, this is fine, but if more than one of those concepts has caret rules,
// split them manually so that the caret rules appear immediately after the concept. for example:
// * #BEAR from system http://example.org/zoo
// * #BEAR ^designation.value = "ourse"
// * #BEAR ^designation.language = #fr
// * #PEL from system http://example.org/zoo
// * #PEL ^designation.value = "pelícano"
// * #PEL ^designation.language = #es
export default {
name: 'separate_concepts_with_caret_rules',
description: 'Separate concepts in ValueSets from the same system if they also have caret rules.',
runBefore: ['resolve_value_set_component_rule_urls'],
optimize(pkg: Package): void {
pkg.valueSets.forEach(vs => {
const systemRulesToCheck = vs.rules.filter(rule => {
return (
rule instanceof ExportableValueSetConceptComponentRule &&
rule.from.system != null &&
isEmpty(rule.from.valueSets) &&
rule.concepts.length > 1
);
}) as ExportableValueSetConceptComponentRule[];
const allCodeCaretRules = vs.rules.filter(rule => {
return rule instanceof ExportableCaretValueRule && rule.pathArray.length > 0;
}) as ExportableCaretValueRule[];
if (allCodeCaretRules.length > 0) {
systemRulesToCheck.forEach(conceptRule => {
// for each concept in the rule, see if there are any caret value rules.
const caretRulesForSystem = new Map<string, ExportableCaretValueRule[]>();
conceptRule.concepts.forEach(concept => {
caretRulesForSystem.set(
concept.code,
allCodeCaretRules.filter(caretRule =>
isEqual(caretRule.pathArray, [`${conceptRule.from.system ?? ''}#${concept.code}`])
)
);
});
if (caretRulesForSystem.size > 1) {
// split apart the codes so that the ones with caret rules can be next to their concept rule
const reorganizedRules: (
| ExportableValueSetConceptComponentRule
| ExportableCaretValueRule
)[] = [];
for (const concept of conceptRule.concepts) {
const singleConceptRule = new ExportableValueSetConceptComponentRule(
conceptRule.inclusion
);
singleConceptRule.from.system = conceptRule.from.system;
singleConceptRule.concepts = [concept];
// don't need to copy indent since it will always be 0
reorganizedRules.push(singleConceptRule);
for (const caretRule of caretRulesForSystem.get(concept.code)) {
reorganizedRules.push(caretRule);
vs.rules.splice(vs.rules.indexOf(caretRule), 1);
}
}
const originalConceptRuleIndex = vs.rules.indexOf(conceptRule);
vs.rules.splice(originalConceptRuleIndex, 1, ...reorganizedRules);
}
});
}
});
}
} as OptimizerPlugin;
4 changes: 4 additions & 0 deletions src/optimizer/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import ResolveOnlyRuleURLsOptimizer from './ResolveOnlyRuleURLsOptimizer';
import ResolveParentURLsOptimizer from './ResolveParentURLsOptimizer';
import ResolveReferenceAssignmentsOptimizer from './ResolveReferenceAssignmentsOptimizer';
import ResolveValueRuleURLsOptimizer from './ResolveValueRuleURLsOptimizer';
import ResolveValueSetCaretRuleURLsOptimizer from './ResolveValueSetCaretRuleURLsOptimizer';
import ResolveValueSetComponentRuleURLsOptimizer from './ResolveValueSetComponentRuleURLsOptimizer';
import SeparateConceptsWithCaretRulesOptimizer from './SeparateConceptsWithCaretRulesOptimizer';
import SimplifyArrayIndexingOptimizer from './SimplifyArrayIndexingOptimizer';
import SimplifyInstanceNameOptimizer from './SimplifyInstanceNameOptimizer';
import SimplifyMappingNamesOptimizer from './SimplifyMappingNamesOptimizer';
Expand Down Expand Up @@ -52,7 +54,9 @@ export {
ResolveParentURLsOptimizer,
ResolveReferenceAssignmentsOptimizer,
ResolveValueRuleURLsOptimizer,
ResolveValueSetCaretRuleURLsOptimizer,
ResolveValueSetComponentRuleURLsOptimizer,
SeparateConceptsWithCaretRulesOptimizer,
SimplifyArrayIndexingOptimizer,
SimplifyInstanceNameOptimizer,
SimplifyMappingNamesOptimizer,
Expand Down
3 changes: 2 additions & 1 deletion src/processor/CodeSystemProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ export class CodeSystemProcessor {
newConceptRule,
...CaretValueRuleExtractor.processConcept(
concept,
[...newConceptRule.hierarchy, concept.code],
[...newConceptRule.hierarchy, concept.code].map(code => `#${code}`),
codeSystemName,
'CodeSystem',
fisher
)
);
Expand Down
28 changes: 24 additions & 4 deletions src/processor/ValueSetProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ const SUPPORTED_COMPONENT_PATHS = [
'system',
'version',
'concept',
'concept.code',
'concept.display',
'filter',
'filter.property',
'filter.op',
Expand Down Expand Up @@ -46,13 +44,33 @@ export class ValueSetProcessor {
...CaretValueRuleExtractor.processResource(input, fisher, input.resourceType, config)
);
if (input.compose) {
input.compose.include?.forEach((vsComponent: any) => {
input.compose.include?.forEach((vsComponent: fhirtypes.ValueSetComposeIncludeOrExclude) => {
newRules.push(ValueSetFilterComponentRuleExtractor.process(vsComponent, input, true));
newRules.push(ValueSetConceptComponentRuleExtractor.process(vsComponent, true));
vsComponent.concept?.forEach(includedConcept => {
const conceptCaretRules = CaretValueRuleExtractor.processConcept(
includedConcept,
[`${vsComponent.system ?? ''}#${includedConcept.code}`],
target.name,
'ValueSet',
fisher
);
newRules.push(...conceptCaretRules);
});
});
input.compose.exclude?.forEach((vsComponent: any) => {
input.compose.exclude?.forEach((vsComponent: fhirtypes.ValueSetComposeIncludeOrExclude) => {
newRules.push(ValueSetFilterComponentRuleExtractor.process(vsComponent, input, false));
newRules.push(ValueSetConceptComponentRuleExtractor.process(vsComponent, false));
vsComponent.concept?.forEach(excludedConcept => {
const conceptCaretRules = CaretValueRuleExtractor.processConcept(
excludedConcept,
[`${vsComponent.system ?? ''}#${excludedConcept.code}`],
target.name,
'ValueSet',
fisher
);
newRules.push(...conceptCaretRules);
});
});
}
target.rules = compact(newRules);
Expand Down Expand Up @@ -100,6 +118,8 @@ export class ValueSetProcessor {
.filter(k => isNaN(parseInt(k)))
.join('.');
});
// any path that starts with "concept." is okay, since those can use code caret rules
flatPaths = flatPaths.filter(p => !p.startsWith('concept.'));
// Check if there are any paths that are not a supported path
return difference(flatPaths, SUPPORTED_COMPONENT_PATHS).length === 0;
}
Expand Down
25 changes: 25 additions & 0 deletions test/exportable/ExportableCaretValueRule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,31 @@ describe('ExportableCaretValueRule', () => {
expect(rule.toFSH()).toBe('* . ^short = "Another important summary."');
});

it('should export a code caret rule with a code path', () => {
// this type of rule appears on CodeSystems
const rule = new ExportableCaretValueRule('');
rule.isCodeCaretRule = true;
rule.caretPath = 'designation.value';
rule.pathArray = ['#bear', '#brown bear'];
rule.value = 'Brown Bear';

expect(rule.toFSH()).toBe('* #bear #"brown bear" ^designation.value = "Brown Bear"');
});

it('should export a code caret rule with a code and system path', () => {
// this type of rule appears on ValueSets
const rule = new ExportableCaretValueRule('');
rule.isCodeCaretRule = true;
rule.caretPath = 'designation.value';
rule.pathArray = ['http://example.org/zoo#brown bear'];
// rule.fromSystem = 'http://example.org/zoo';
rule.value = 'Brown Bear';

expect(rule.toFSH()).toBe(
'* http://example.org/zoo#"brown bear" ^designation.value = "Brown Bear"'
);
});

it('should export a caret rule assigning a boolean', () => {
const rule = new ExportableCaretValueRule('');
rule.caretPath = 'abstract';
Expand Down
2 changes: 1 addition & 1 deletion test/exportable/ExportableConceptRule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('ExportableConceptRule', () => {
rule.display = 'bar';
rule.definition = 'baz';

const expectedResult = '* #"foo\twith\ta\ttab" "bar" "baz"';
const expectedResult = '* #"foo\\twith\\ta\\ttab" "bar" "baz"';
const result = rule.toFSH();
expect(result).toBe(expectedResult);
});
Expand Down
12 changes: 6 additions & 6 deletions test/exportable/ExportableInvariant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ describe('ExportableInvariant', () => {
const expectedResult = [
'Invariant: inv-2',
'Description: "This is an important condition."',
'Severity: #error',
'Expression: "requirement.exists()"',
'XPath: "f:requirement"'
'* severity = #error',
'* expression = "requirement.exists()"',
'* xpath = "f:requirement"'
].join(EOL);
const result = input.toFSH();
expect(result).toBe(expectedResult);
Expand All @@ -39,9 +39,9 @@ describe('ExportableInvariant', () => {
const expectedResult = [
'Invariant: inv-3',
'Description: """Please do this.\nPlease always do this with a \\ character."""',
'Severity: #warning',
'Expression: "requirement.contains(\\"\\\\\\")"',
'XPath: "f:requirement"'
'* severity = #warning',
'* expression = "requirement.contains(\\"\\\\\\")"',
'* xpath = "f:requirement"'
].join(EOL);
const result = input.toFSH();
expect(result).toBe(expectedResult);
Expand Down
39 changes: 6 additions & 33 deletions test/exportable/ExportableValueSetComponentRule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('ExportableValueSetConceptComponentRule', () => {
rule.concepts.push(new fshtypes.FshCode('foo', 'bar'));
rule.from.valueSets = ['someValueSet'];

expect(rule.toFSH()).toBe('* include bar#foo from valueset someValueSet');
expect(rule.toFSH()).toBe('* bar#foo from valueset someValueSet');
});

it('should export a ValueSetConceptComponentRule with a concept excluded from a valueset', () => {
Expand All @@ -76,7 +76,7 @@ describe('ExportableValueSetConceptComponentRule', () => {
rule.concepts.push(new fshtypes.FshCode('foo', 'bar'));
rule.from.valueSets = ['someValueSet', 'otherValueSet'];

expect(rule.toFSH()).toBe('* include bar#foo from valueset someValueSet and otherValueSet');
expect(rule.toFSH()).toBe('* bar#foo from valueset someValueSet and otherValueSet');
});

it('should export a ValueSetConceptComponentRule with a concept excluded from several valuesets', () => {
Expand All @@ -89,23 +89,21 @@ describe('ExportableValueSetConceptComponentRule', () => {

it('should export a ValueSetConceptComponentRule with a concept included from a system and several valuesets', () => {
const rule = new ExportableValueSetConceptComponentRule(true);
rule.concepts.push(new fshtypes.FshCode('foo'));
rule.concepts.push(new fshtypes.FshCode('foo', 'someSystem'));
rule.from.system = 'someSystem';
rule.from.valueSets = ['someValueSet', 'otherValueSet'];

expect(rule.toFSH()).toBe(
'* include #foo from system someSystem and valueset someValueSet and otherValueSet'
);
expect(rule.toFSH()).toBe('* someSystem#foo from valueset someValueSet and otherValueSet');
});

it('should export a ValueSetConceptComponentRule with a concept excluded from a system and several valuesets', () => {
const rule = new ExportableValueSetConceptComponentRule(false);
rule.concepts.push(new fshtypes.FshCode('foo'));
rule.concepts.push(new fshtypes.FshCode('foo', 'someSystem'));
rule.from.system = 'someSystem';
rule.from.valueSets = ['someValueSet', 'otherValueSet'];

expect(rule.toFSH()).toBe(
'* exclude #foo from system someSystem and valueset someValueSet and otherValueSet'
'* exclude someSystem#foo from valueset someValueSet and otherValueSet'
);
});
});
Expand Down Expand Up @@ -273,31 +271,6 @@ describe('ExportableValueSetFilterComponentRule', () => {
);
});

it('should format a long ValueSetConceptComponentRule to take up multiple lines', () => {
const rule = new ExportableValueSetConceptComponentRule(true);
rule.concepts = [
new FshCode('cookies', undefined, 'Cookies'),
new FshCode('candy', undefined, 'Candy'),
new FshCode('chips', undefined, 'Chips'),
new FshCode('cakes', undefined, 'Cakes'),
new FshCode('verylargecakes', undefined, 'Very Large Cakes')
];
rule.from.system = 'http://fhir.food-pyramid.org/FoodPyramidGuide/CodeSystems/FoodGroupsCS';
rule.from.valueSets = ['http://fhir.food-pyramid.org/FoodPyramidGuide/ValueSets/DeliciousVS'];

const result = rule.toFSH();
const expectedResult = [
'* include #cookies "Cookies" and',
' #candy "Candy" and',
' #chips "Chips" and',
' #cakes "Cakes" and',
' #verylargecakes "Very Large Cakes"',
' from system http://fhir.food-pyramid.org/FoodPyramidGuide/CodeSystems/FoodGroupsCS and',
' valueset http://fhir.food-pyramid.org/FoodPyramidGuide/ValueSets/DeliciousVS'
].join(EOL);
expect(result).toEqual(expectedResult);
});

it('should format a long ValueSetFilterComponentRule to take up multiple lines', () => {
const rule = new ExportableValueSetFilterComponentRule(false);
rule.from.system = 'http://fhir.example.org/myImplementationGuide/CodeSystem/AppleCS';
Expand Down
Loading

0 comments on commit 225d702

Please sign in to comment.