From e8e9d15d8ebb81489143c94a6440d2e9fedee434 Mon Sep 17 00:00:00 2001 From: Gregor Lichtner Date: Fri, 16 Oct 2026 21:08:59 +0200 Subject: [PATCH] Preserve extension profiles when cloning inherited slices --- src/fhirtypes/ElementDefinition.ts | 30 ++- test/export/InheritedExtensionSlice.test.ts | 200 ++++++++++++++++++++ test/fhirtypes/ElementDefinition.test.ts | 42 ++++ 3 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 test/export/InheritedExtensionSlice.test.ts diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts index fbf425b3b..f6df8b2b8 100644 --- a/src/fhirtypes/ElementDefinition.ts +++ b/src/fhirtypes/ElementDefinition.ts @@ -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)); @@ -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. @@ -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, @@ -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; }); diff --git a/test/export/InheritedExtensionSlice.test.ts b/test/export/InheritedExtensionSlice.test.ts new file mode 100644 index 000000000..923899f1f --- /dev/null +++ b/test/export/InheritedExtensionSlice.test.ts @@ -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 + }); + }); +}); diff --git a/test/fhirtypes/ElementDefinition.test.ts b/test/fhirtypes/ElementDefinition.test.ts index 151889ffb..816845cb1 100644 --- a/test/fhirtypes/ElementDefinition.test.ts +++ b/test/fhirtypes/ElementDefinition.test.ts @@ -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'