Skip to content
Open
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
30 changes: 26 additions & 4 deletions src/fhirtypes/ElementDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,10 @@ export class ElementDefinition {
const original = this._original ? this._original : new ElementDefinition();
return (
PROPS_AND_UNDERPROPS.some(prop => {
// Moving an extension slice into another slice is not itself a new constraint.
if (prop === 'id' && this.isCopiedExtensionSlice()) {
return false;
}
if (prop.endsWith('[x]')) {
const re = new RegExp(`^${prop.slice(0, -3)}[A-Z].*$`);
prop = Object.keys(this).find(p => re.test(p));
Expand Down Expand Up @@ -632,6 +636,14 @@ export class ElementDefinition {
// the IG publisher always requires that the type attribute is present on a slice of a choice element,
// even if this slice's type is equal to the choice element's type.
diff[prop] = cloneDeep(this[prop]);
} else if (
prop === 'type' &&
this.isCopiedExtensionSlice() &&
this.type?.some(type => type.profile?.length > 0)
) {
// A copied extension slice must identify its profile when it or its children
// are constrained. Unchanged copies are still omitted by hasDiff().
diff.type = cloneDeep(this.type);
}
}
// If the original has a sliceName, the diff needs to have a sliceName, so use the original.
Expand All @@ -641,6 +653,20 @@ export class ElementDefinition {
return diff;
}

private isCopiedExtensionSlice(): boolean {
// addSlice captures the original ID before setting sliceName, so use the
// terminal slice ID to recognize the same extension in another slice context.
const sliceIdSuffix = `.extension:${this.sliceName}`;
return (
this.sliceName != null &&
this.path.endsWith('.extension') &&
this._original?.path === this.path &&
this._original.id !== this.id &&
this.id.endsWith(sliceIdSuffix) &&
this._original.id.endsWith(sliceIdSuffix)
);
}

/**
* Apply the AddElementRule to this new element using the appropriate methods
* for specific rules for the AddElementRule's implied rules (i.e., cardinality,
Expand Down Expand Up @@ -2848,10 +2874,6 @@ export class ElementDefinition {
eClone.removeUninheritedExtensions();
if (shouldCaptureOriginal) {
eClone.captureOriginal();
} else if (eClone._original) {
// When not recapturing, we need to update the _original.id to match the new id,
// otherwise hasDiff() will return true due to the id difference alone
eClone._original.id = eClone.id;
}
return eClone;
});
Expand Down
200 changes: 200 additions & 0 deletions test/export/InheritedExtensionSlice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { StructureDefinitionExporter, Package } from '../../src/export';
import { FSHTank } from '../../src/import';
import { FHIRDefinitions } from '../../src/fhirdefs';
import {
getTestFHIRDefinitions,
importSingleText,
loggerSpy,
testDefsPath,
TestFisher
} from '../testhelpers';
import { minimalConfig } from '../utils/minimalConfig';

describe('Extension slices copied into new slices', () => {
let defs: FHIRDefinitions;

beforeAll(async () => {
defs = await getTestFHIRDefinitions(true, testDefsPath('r4-definitions'));
});

function exportText(fsh: string) {
loggerSpy.reset();
const doc = importSingleText(fsh);
const tank = new FSHTank([doc], minimalConfig);
const pkg = new Package(tank.config);
const fisher = new TestFisher(tank, defs, pkg);
const profiles = new StructureDefinitionExporter(tank, pkg, fisher).export().profiles;
expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
return profiles;
}

function exportProfiles(additionalRules: string, baseRules = '') {
return exportText(`
Extension: LinkKind
* value[x] only CodeableConcept

Profile: BasePatient
Parent: Patient
* link.extension contains LinkKind named kind 0..1
* link ^slicing.discriminator.type = #value
* link ^slicing.discriminator.path = "type"
* link ^slicing.rules = #open
* link contains replacedBy 0..*
* link[replacedBy].type = #replaced-by
${baseRules}

Profile: DerivedPatient
Parent: BasePatient
${additionalRules}
`);
}

it.each([
['new slice', 'seeAlso', '* link contains seeAlso 0..*\n* link[seeAlso].type = #seealso'],
['reslice', 'replacedBy/current', '* link[replacedBy] contains current 0..*']
])('declares the extension profile in a %s', (_label, slice, rules) => {
const profiles = exportProfiles(`${rules}
* link[${slice.replace('/', '][')}].extension[kind] 1..1
* link[${slice.replace('/', '][')}].extension[kind].valueCodeableConcept.coding.code = #current`);
const derived = profiles.find(p => p.name === 'DerivedPatient').toJSON();
const extension = derived.differential.element.find(
(e: { id: string }) => e.id === `Patient.link:${slice}.extension:kind`
);
expect(extension).toMatchObject({
min: 1,
type: [
{
code: 'Extension',
profile: ['http://hl7.org/fhir/us/minimal/StructureDefinition/LinkKind']
}
]
});
expect(
derived.differential.element.some(
(e: { id: string }) => e.id === `Patient.link:${slice}.extension:kind.value[x].coding.code`
)
).toBe(true);
});

it('declares the profile when only a descendant of the copied extension is constrained', () => {
const profiles = exportProfiles(`
* link contains seeAlso 0..*
* link[seeAlso].type = #seealso
* link[seeAlso].extension[kind].valueCodeableConcept.coding.code = #current`);
const derived = profiles.find(p => p.name === 'DerivedPatient').toJSON();
expect(
derived.differential.element.find(
(e: { id: string }) => e.id === 'Patient.link:seeAlso.extension:kind'
).type
).toEqual([
{
code: 'Extension',
profile: ['http://hl7.org/fhir/us/minimal/StructureDefinition/LinkKind']
}
]);
});

it('declares the profile when the copied extension was introduced with the slicing entry type', () => {
const profiles = exportText(`
Extension: LinkKind
* value[x] only CodeableConcept

Profile: TestPatient
Parent: Patient
* link.extension only LinkKind
* link.extension contains LinkKind named kind 0..1
* link.extension[kind] only LinkKind
* link ^slicing.discriminator.type = #value
* link ^slicing.discriminator.path = "type"
* link ^slicing.rules = #open
* link contains seeAlso 0..*
* link[seeAlso].type = #seealso
* link[seeAlso].extension[kind] 1..1
`);
const profile = profiles.find(p => p.name === 'TestPatient').toJSON();
expect(
profile.differential.element.find(
(e: { id: string }) => e.id === 'Patient.link:seeAlso.extension:kind'
)
).toEqual({
id: 'Patient.link:seeAlso.extension:kind',
path: 'Patient.link.extension',
sliceName: 'kind',
min: 1,
max: '1',
type: [
{
code: 'Extension',
profile: ['http://hl7.org/fhir/us/minimal/StructureDefinition/LinkKind']
}
]
});
});

it('omits copied extension slices when only an unrelated child is constrained', () => {
const profiles = exportProfiles(`
* link contains seeAlso 0..*
* link[seeAlso].type = #seealso`);
const derived = profiles.find(p => p.name === 'DerivedPatient').toJSON();
expect(
derived.differential.element.find(
(e: { id: string }) => e.id === 'Patient.link:seeAlso.extension:kind'
)
).toBeUndefined();
});

it('declares the profile when a connected extension is copied into an already unfolded slice', () => {
const profiles = exportProfiles(
'* link[replacedBy].extension[laterKind] 1..1',
'* link.extension contains LinkKind named laterKind 0..1'
);
const base = profiles.find(p => p.name === 'BasePatient').toJSON();
expect(
base.snapshot.element.find(
(e: { id: string }) => e.id === 'Patient.link:replacedBy.extension:laterKind'
)
).toBeUndefined();

const derived = profiles.find(p => p.name === 'DerivedPatient').toJSON();
expect(
derived.differential.element.find(
(e: { id: string }) => e.id === 'Patient.link:replacedBy.extension:laterKind'
)
).toEqual({
id: 'Patient.link:replacedBy.extension:laterKind',
path: 'Patient.link.extension',
sliceName: 'laterKind',
min: 1,
type: [
{
code: 'Extension',
profile: ['http://hl7.org/fhir/us/minimal/StructureDefinition/LinkKind']
}
]
});
});

it.each([
['slice', 'replacedBy', ''],
[
'reslice',
'replacedBy/current',
'* link[replacedBy] contains current 0..*\n* link[replacedBy][current].extension[kind] MS'
]
])('keeps changes inside an existing inherited %s differential-only', (_label, slice, rules) => {
const profiles = exportProfiles(
`* link[${slice.replace('/', '][')}].extension[kind] 1..1`,
rules
);
const derived = profiles.find(p => p.name === 'DerivedPatient').toJSON();
const extension = derived.differential.element.find(
(e: { id: string }) => e.id === `Patient.link:${slice}.extension:kind`
);
expect(extension).toEqual({
id: `Patient.link:${slice}.extension:kind`,
path: 'Patient.link.extension',
sliceName: 'kind',
min: 1
});
});
});
42 changes: 42 additions & 0 deletions test/fhirtypes/ElementDefinition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,48 @@ describe('ElementDefinition', () => {
expect(fooSliceExtensionTest.calculateDiff().type).toEqual(extensionSlice.type);
});

it('should identify a copied inherited extension only when constrained, until its baseline is recaptured', () => {
const extension = observation.findElement('Observation.component.extension');
extension.sliceIt('value', 'url');
const extensionType = new ElementDefinitionType('Extension');
extensionType.profile = ['http://example.org/fhir/StructureDefinition/ExampleExtension'];
extension.addSlice('Test', extensionType);
const component = observation.findElement('Observation.component');
component.sliceIt('value', 'code');
observation.captureOriginalElements();

const componentSlice = component.addSlice('FooSlice');
const copiedExtension = componentSlice
.unfold(fisher)
.find(e => e.id === 'Observation.component:FooSlice.extension:Test');
expect(copiedExtension.hasDiff()).toBeFalsy();
expect(
observation
.toJSON()
.differential.element.some((e: { id: string }) => e.id === copiedExtension.id)
).toBe(false);

copiedExtension.constrainCardinality(1, '*');
expect(copiedExtension.hasDiff()).toBeTruthy();
expect(copiedExtension.calculateDiff().toJSON()).toEqual({
id: 'Observation.component:FooSlice.extension:Test',
path: 'Observation.component.extension',
sliceName: 'Test',
min: 1,
type: [extensionType.toJSON()]
});

copiedExtension.captureOriginal();
expect(copiedExtension.hasDiff()).toBeFalsy();
copiedExtension.constrainCardinality(2, '*');
expect(copiedExtension.calculateDiff().toJSON()).toEqual({
id: 'Observation.component:FooSlice.extension:Test',
path: 'Observation.component.extension',
sliceName: 'Test',
min: 2
});
});

it('should unfold an element with a profile that includes a version', () => {
const referenceRange = observation.elements.find(
e => e.id === 'Observation.referenceRange.low'
Expand Down
Loading