Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract extension contexts to keyword #244

Merged
merged 2 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/extractor/CaretValueRuleExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ export class CaretValueRuleExtractor {
parent = cloneDeep(sd);
}

// if this is an Extension, ignore context, since it was covered by a keyword
if (sd.derivation === 'constraint' && sd.type === 'Extension') {
delete sd.context;
delete parent.context;
}

// Remove properties that are covered by other extractors or keywords
RESOURCE_IGNORED_PROPERTIES['StructureDefinition'].forEach(prop => {
delete sd[prop];
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooooh. This optimizer is way nicer now!

Original file line number Diff line number Diff line change
@@ -1,37 +1,22 @@
import { OptimizerPlugin } from '../OptimizerPlugin';
import { Package } from '../../processor';
import { ExportableCaretValueRule } from '../../exportable';
import { isEqual, pullAt } from 'lodash';
import { fshtypes } from 'fsh-sushi';
const { FshCode } = fshtypes;
import { isEqual } from 'lodash';

export default {
name: 'remove_default_extension_context_rules',
description: 'Remove extension contexts matching the default context that SUSHI generates',

optimize(pkg: Package): void {
// * ^context[0].type = #element
const DEFAULT_TYPE = new ExportableCaretValueRule('');
DEFAULT_TYPE.caretPath = 'context[0].type';
DEFAULT_TYPE.value = new FshCode('element');
// * ^context[0].expression = "Element"
const DEFAULT_EXPRESSION = new ExportableCaretValueRule('');
DEFAULT_EXPRESSION.caretPath = 'context[0].expression';
DEFAULT_EXPRESSION.value = 'Element';
// Loop through extensions looking for the default context type (and removing it)
pkg.extensions.forEach(sd => {
const numContexts = sd.rules.filter(
r =>
r instanceof ExportableCaretValueRule &&
r.path === '' &&
/^context\[\d+]\.type$/.test(r.caretPath)
).length;
if (numContexts === 1) {
const typeRuleIdx = sd.rules.findIndex(r => isEqual(r, DEFAULT_TYPE));
const expressionRuleIdx = sd.rules.findIndex(r => isEqual(r, DEFAULT_EXPRESSION));
if (typeRuleIdx !== -1 && expressionRuleIdx !== -1) {
pullAt(sd.rules, [typeRuleIdx, expressionRuleIdx]);
}
if (
sd.contexts?.length === 1 &&
isEqual(sd.contexts[0], {
value: 'Element',
isQuoted: false
})
) {
sd.contexts = [];
}
});
}
Expand Down
59 changes: 59 additions & 0 deletions src/optimizer/plugins/ResolveContextURLsOptimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { utils } from 'fsh-sushi';
import { OptimizerPlugin } from '../OptimizerPlugin';
import { optimizeURL } from '../utils';
import { Package } from '../../processor';
import { MasterFisher, ProcessingOptions } from '../../utils';
import { isUri } from 'valid-url';

const FISHER_TYPES = [
utils.Type.Resource,
utils.Type.Type,
utils.Type.Profile,
utils.Type.Extension,
utils.Type.Logical
];

export default {
name: 'resolve_context_urls',
description: 'Replace declared extension context URLs with their names or aliases',

optimize(pkg: Package, fisher: MasterFisher, options: ProcessingOptions = {}): void {
for (const extension of pkg.extensions) {
if (extension.contexts) {
extension.contexts.forEach(context => {
if (!context.isQuoted) {
// if the context is an extension, value is just a url without a #
// if the context is an element of a non-core resource, value is a url, #, and a path
if (context.value.indexOf('#') > -1) {
const [url, path] = context.value.split('#');
const newUrl = optimizeURL(
url,
pkg.aliases,
FISHER_TYPES,
fisher,
options.alias ?? true
);
if (newUrl !== url) {
let separator: string;
if (newUrl.startsWith('$')) {
separator = '#';
} else {
separator = '.';
}
context.value = `${newUrl}${separator}${path}`;
}
} else if (isUri(context.value)) {
context.value = optimizeURL(
context.value,
pkg.aliases,
FISHER_TYPES,
fisher,
options.alias ?? true
);
}
}
});
}
}
}
} as OptimizerPlugin;
2 changes: 2 additions & 0 deletions src/optimizer/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import RemoveGeneratedTextRulesOptimizer from './RemoveGeneratedTextRulesOptimiz
import RemoveImpliedZeroZeroCardRulesOptimize from './RemoveImpliedZeroZeroCardRulesOptimizer';
import RemovePublisherDerivedDateRulesOptimizer from './RemovePublisherDerivedDateRulesOptimizer';
import ResolveBindingRuleURLsOptimizer from './ResolveBindingRuleURLsOptimizer';
import ResolveContextURLsOptimizer from './ResolveContextURLsOptimizer';
import ResolveInstanceOfURLsOptimizer from './ResolveInstanceOfURLsOptimizer';
import ResolveOnlyRuleURLsOptimizer from './ResolveOnlyRuleURLsOptimizer';
import ResolveParentURLsOptimizer from './ResolveParentURLsOptimizer';
Expand Down Expand Up @@ -45,6 +46,7 @@ export {
RemoveImpliedZeroZeroCardRulesOptimize,
RemovePublisherDerivedDateRulesOptimizer,
ResolveBindingRuleURLsOptimizer,
ResolveContextURLsOptimizer,
ResolveInstanceOfURLsOptimizer,
ResolveOnlyRuleURLsOptimizer,
ResolveParentURLsOptimizer,
Expand Down
2 changes: 1 addition & 1 deletion src/optimizer/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function optimizeURL(

/**
* Resolves a URL to a name, if possible; otherwise returns undefined. If the URL resolves to a name,
* but the name does not resolve back to the same URL, then return udnefined since the name clashes with
* but the name does not resolve back to the same URL, then return undefined since the name clashes with
jafeltra marked this conversation as resolved.
Show resolved Hide resolved
* a more preferred name. This can happen if a project defines something with the same name as a FHIR
* definition.
* @param url - the url to resolve
Expand Down
48 changes: 47 additions & 1 deletion src/processor/StructureDefinitionProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import {
MappingExtractor
} from '../extractor';
import { ProcessableElementDefinition, switchQuantityRules, makeNameSushiSafe } from '.';
import { getAncestorSliceDefinition, logger } from '../utils';
import { getAncestorSliceDefinition, getPath, logger } from '../utils';
import { fshifyString } from '../exportable/common';
import { isUri } from 'valid-url';

export class StructureDefinitionProcessor {
static process(
Expand Down Expand Up @@ -98,6 +100,49 @@ export class StructureDefinitionProcessor {
if (input.baseDefinition) {
target.parent = input.baseDefinition;
}
if (target instanceof ExportableExtension && input.context) {
target.contexts = input.context.map(ctx => {
if (ctx.type === 'fhirpath') {
return {
isQuoted: true,
value: fshifyString(ctx.expression)
};
}
// element and extension contexts are a little trickier, since they may involve paths.
// we'll make a little ElementDefinition to help us out.
// if there's a #, or the whole value is a valid URL, wait until later to try to resolve the URL, since it may refer to
// another resource being processed.
// but either way, we can handle the fhirPath now.
if (ctx.expression.indexOf('#') > -1) {
const [url, fhirPath] = ctx.expression.split('#');
const fakeElement = new fhirtypes.ElementDefinition(fhirPath);
const fshPath = getPath(fakeElement);
// the fshPath from getPath removes the resource name, which is convenient here
return {
isQuoted: false,
value: `${url}#${fshPath}`
};
} else if (isUri(ctx.expression)) {
return {
isQuoted: false,
value: ctx.expression
};
} else {
const fakeElement = new fhirtypes.ElementDefinition(ctx.expression);
const fshPath = getPath(fakeElement);
// the fshPath from getPath removes the resource name, so add the resource name back to the start
// it will turn a resource name by itself into the path ".", which we don't need
let contextValue = ctx.expression.split('.')[0];
if (fshPath !== '.') {
contextValue += `.${fshPath}`;
}
return {
isQuoted: false,
value: contextValue
};
}
});
}
}

static extractRules(
Expand Down Expand Up @@ -230,6 +275,7 @@ export interface ProcessableStructureDefinition {
kind?: string;
derivation?: string;
mapping?: fhirtypes.StructureDefinitionMapping[];
context?: fhirtypes.StructureDefinitionContext[];
differential?: {
element: any[];
};
Expand Down
15 changes: 15 additions & 0 deletions test/extractor/CaretValueRuleExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('CaretValueRuleExtractor', () => {
let looseCS: any;
let looseBSSD: any;
let looseTPESD: any;
let looseExtSD: any;
let config: fshtypes.Configuration;
let defs: FHIRDefinitions;

Expand Down Expand Up @@ -48,6 +49,11 @@ describe('CaretValueRuleExtractor', () => {
)
.trim()
);
looseExtSD = JSON.parse(
fs
.readFileSync(path.join(__dirname, 'fixtures', 'extension-with-context.json'), 'utf-8')
.trim()
);
});

beforeEach(() => {
Expand All @@ -60,6 +66,15 @@ describe('CaretValueRuleExtractor', () => {
expect(caretRules).toEqual<ExportableCaretValueRule[]>([]);
});

it('should not extract any SD caret rules for context on an Extension', () => {
const caretRules = CaretValueRuleExtractor.processStructureDefinition(
looseExtSD,
defs,
config
);
expect(caretRules).toEqual<ExportableCaretValueRule[]>([]);
});

it('should extract a url-setting caret rules when a non-standard url is included on a StructureDefinition', () => {
const urlSD = cloneDeep(looseSD);
urlSD.url = 'http://diferenturl.com';
Expand Down
34 changes: 34 additions & 0 deletions test/extractor/fixtures/extension-with-context.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"resourceType": "StructureDefinition",
"id": "ExtensionWithContext",
"url": "http://hl7.org/fhir/sushi-test/StructureDefinition/ExtensionWithContext",
"name": "ExtensionWithContext",
"fhirVersion": "4.0.1",
"mapping": [
{
"identity": "rim",
"uri": "http://hl7.org/v3",
"name": "RIM Mapping"
}
],
"kind": "complex-type",
"abstract": false,
"context": [
{
"expression": "some.fhirpath",
"type": "fhirpath"
}
],
"type": "Extension",
"baseDefinition": "http://hl7.org/fhir/StructureDefinition/Extension",
"derivation": "constraint",
"differential": {
"element": [
{
"id": "Extension.url",
"path": "Extension.url",
"fixedUri": "http://hl7.org/fhir/sushi-test/StructureDefinition/ExtensionWithContext"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import '../../helpers/loggerSpy'; // side-effect: suppresses logs
import { Package } from '../../../src/processor/Package';
import {
ExportableCaretValueRule,
ExportableExtension,
ExportableProfile
} from '../../../src/exportable';
import { ExportableExtension } from '../../../src/exportable';
import optimizer from '../../../src/optimizer/plugins/RemoveDefaultExtensionContextRulesOptimizer';
import { fshtypes } from 'fsh-sushi';
const { FshCode } = fshtypes;

describe('optimizer', () => {
describe('#remove_default_extension_context_rules', () => {
Expand All @@ -20,84 +14,32 @@ describe('optimizer', () => {

it('should remove default context from extensions', () => {
const extension = new ExportableExtension('ExtraExtension');
const typeRule = new ExportableCaretValueRule('');
typeRule.caretPath = 'context[0].type';
typeRule.value = new FshCode('element');
const expressionRule = new ExportableCaretValueRule('');
expressionRule.caretPath = 'context[0].expression';
expressionRule.value = 'Element';
extension.rules = [typeRule, expressionRule];
extension.contexts = [{ value: 'Element', isQuoted: false }];
const myPackage = new Package();
myPackage.add(extension);
optimizer.optimize(myPackage);
expect(extension.rules).toHaveLength(0);
expect(extension.contexts).toHaveLength(0);
});

it('should not remove non-default context from extensions (different type)', () => {
it('should not remove non-default context from extensions', () => {
const extension = new ExportableExtension('ExtraExtension');
const typeRule = new ExportableCaretValueRule('');
typeRule.caretPath = 'context[0].type';
typeRule.value = new FshCode('fhirpath');
const expressionRule = new ExportableCaretValueRule('');
expressionRule.caretPath = 'context[0].expression';
expressionRule.value = 'Element';
extension.rules = [typeRule, expressionRule];
extension.contexts = [{ value: 'Observation', isQuoted: false }];
const myPackage = new Package();
myPackage.add(extension);
optimizer.optimize(myPackage);
expect(extension.rules).toHaveLength(2);
});

it('should not remove non-default context from extensions (different expression)', () => {
const extension = new ExportableExtension('ExtraExtension');
const typeRule = new ExportableCaretValueRule('');
typeRule.caretPath = 'context[0].type';
typeRule.value = new FshCode('element');
const expressionRule = new ExportableCaretValueRule('');
expressionRule.caretPath = 'context[0].expression';
expressionRule.value = 'BackboneElement';
extension.rules = [typeRule, expressionRule];
const myPackage = new Package();
myPackage.add(extension);
optimizer.optimize(myPackage);
expect(extension.rules).toHaveLength(2);
expect(extension.contexts).toHaveLength(1);
});

it('should not remove default context from extensions when there is more than one context', () => {
const extension = new ExportableExtension('ExtraExtension');
const typeRule = new ExportableCaretValueRule('');
typeRule.caretPath = 'context[0].type';
typeRule.value = new FshCode('element');
const expressionRule = new ExportableCaretValueRule('');
expressionRule.caretPath = 'context[0].expression';
expressionRule.value = 'Element';
const typeRule2 = new ExportableCaretValueRule('');
typeRule2.caretPath = 'context[1].type';
typeRule2.value = new FshCode('element');
const expressionRule2 = new ExportableCaretValueRule('');
expressionRule2.caretPath = 'context[1].expression';
expressionRule2.value = 'CodeSystem';
extension.rules = [typeRule, expressionRule, typeRule2, expressionRule2];
extension.contexts = [
{ value: 'Element', isQuoted: false },
{ value: 'Observation', isQuoted: false }
];
const myPackage = new Package();
myPackage.add(extension);
optimizer.optimize(myPackage);
expect(extension.rules).toHaveLength(4);
});

it('should not remove default context from profiles', () => {
// Technically, I don't think having context on a profile is allowed, but check just in case
const profile = new ExportableProfile('ExtraProfile');
const typeRule = new ExportableCaretValueRule('');
typeRule.caretPath = 'context[0].type';
typeRule.value = new FshCode('element');
const expressionRule = new ExportableCaretValueRule('');
expressionRule.caretPath = 'context[0].expression';
expressionRule.value = 'Element';
profile.rules = [typeRule, expressionRule];
const myPackage = new Package();
myPackage.add(profile);
optimizer.optimize(myPackage);
expect(profile.rules).toHaveLength(2);
expect(extension.contexts).toHaveLength(2);
});
});
});
Loading
Loading