From e7804b449c79c81265b834c1bc3d6b7c55833ffe Mon Sep 17 00:00:00 2001 From: Chris Moesel Date: Sun, 21 Jun 2026 18:05:19 -0400 Subject: [PATCH 1/2] Fix export for slices of choice types --- src/fhirtypes/StructureDefinition.ts | 7 ++- .../StructureDefinitionExporter.test.ts | 50 +++++++++++++++++++ test/import/FSHImporter.SDRules.test.ts | 19 +++++++ test/tsconfig.json | 6 ++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts index 5f99be289..d38d8f6ee 100644 --- a/src/fhirtypes/StructureDefinition.ts +++ b/src/fhirtypes/StructureDefinition.ts @@ -300,7 +300,12 @@ export class StructureDefinition { unfoldedElements = matchingElements[0].unfoldChoiceElementTypes(fisher); newMatchingElements = unfoldedElements.filter(e => e.path.startsWith(fhirPathString)); } - } else if (matchingElements[0].id.endsWith('[x]')) { + } else if ( + matchingElements[0].id.endsWith('[x]') || + (matchingElements[0].path.endsWith('[x]') && matchingElements[0].sliceName != null) + ) { + // The element is a choice element or a slice of a choice element with multiple types. + // Find the common ancestor of all types to navigate into sub-paths (e.g., .extension). unfoldedElements = matchingElements[0].unfoldChoiceElementTypes(fisher); newMatchingElements = unfoldedElements.filter(e => e.path.startsWith(fhirPathString)); } diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts index db0ace444..25d4c5a3c 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -5442,6 +5442,56 @@ describe('StructureDefinitionExporter R4', () => { expect(loggerSpy.getAllLogs()).toHaveLength(0); }); + it('should not log an error when a rule path has a subpath of a sliced choice element', () => { + loggerSpy.reset(); + const profile = new Profile('ConstrainedObservation'); + profile.parent = 'Observation'; + // * value[x] ^slicing.discriminator[0].type = #type + // * value[x] ^slicing.discriminator[0].path = "$this" + // * value[x] ^slicing.rules = #open + // * value[x] contains valueString 0..1 and valueOther 0..1 + // * value[x][valueString] only string + // * value[x][valueOther].extension 1..1 + const slicingType = new CaretValueRule('value[x]'); + slicingType.caretPath = 'slicing.discriminator[0].type'; + slicingType.value = new FshCode('type'); + const slicingPath = new CaretValueRule('value[x]'); + slicingPath.caretPath = 'slicing.discriminator[0].path'; + slicingPath.value = '$this'; + const slicingRules = new CaretValueRule('value[x]'); + slicingRules.caretPath = 'slicing.rules'; + slicingRules.value = new FshCode('open'); + const valueXSlices = new ContainsRule('value[x]'); + valueXSlices.items = [{ name: 'valueString' }, { name: 'valueOther' }]; + const stringCard = new CardRule('value[x][valueString]'); + stringCard.min = 0; + stringCard.max = '1'; + const otherCard = new CardRule('value[x][valueOther]'); + otherCard.min = 0; + otherCard.max = '1'; + const stringType = new OnlyRule('value[x][valueString]'); + stringType.types = [{ type: 'string' }]; + const otherExtensionCard = new CardRule('value[x][valueOther].extension'); + otherExtensionCard.min = 1; + otherExtensionCard.max = '1'; + + profile.rules.push( + slicingType, + slicingPath, + slicingRules, + valueXSlices, + stringCard, + otherCard, + stringType, + otherExtensionCard + ); + + exporter.exportStructDef(profile); + const sd = pkg.profiles[0]; + expect(sd).toBeTruthy(); + expect(loggerSpy.getAllLogs()).toHaveLength(0); + }); + it('should log an error when extension is constrained with a modifier extension', () => { const extension = new Extension('StrangeExtension'); const modifier = new FlagRule('.'); diff --git a/test/import/FSHImporter.SDRules.test.ts b/test/import/FSHImporter.SDRules.test.ts index 728dba128..49bde7d46 100644 --- a/test/import/FSHImporter.SDRules.test.ts +++ b/test/import/FSHImporter.SDRules.test.ts @@ -1728,6 +1728,25 @@ describe('FSHImporter', () => { undefined ); }); + + it('should parse contains rule on a choice with two items', () => { + const input = leftAlign(` + Profile: ObservationProfile + Parent: Observation + * value[x] contains valueString 0..1 and valueOther 0..1 + * value[x][valueString] only string + * value[x][valueOther].extension 1..1 + `); + + const result = importSingleText(input); + const profile = result.profiles.get('ObservationProfile'); + expect(profile.rules).toHaveLength(5); + assertContainsRule(profile.rules[0], 'value[x]', 'valueString', 'valueOther'); + assertCardRule(profile.rules[1], 'value[x][valueString]', 0, 1); + assertCardRule(profile.rules[2], 'value[x][valueOther]', 0, 1); + assertOnlyRule(profile.rules[3], 'value[x][valueString]', { type: 'string' }); + assertCardRule(profile.rules[4], 'value[x][valueOther].extension', 1, 1); + }); }); describe('#caretValueRule', () => { diff --git a/test/tsconfig.json b/test/tsconfig.json index 43e9b46be..822dd6b67 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,9 @@ { - "extends": "../tsconfig", + "extends": "../tsconfig.json", "compilerOptions": { - "strictNullChecks": false + "strictNullChecks": false, + "noEmit": true, + "types": ["jest", "node"] }, "include": [ "./**/*.ts" From c85476f598fc7b99afe64bd08e8c517d01cc2da0 Mon Sep 17 00:00:00 2001 From: Chris Moesel Date: Wed, 24 Jun 2026 08:22:40 -0400 Subject: [PATCH 2/2] Fix bug mistakenly declaring choice types in slices obsolete --- src/fhirtypes/StructureDefinition.ts | 6 +++++- test/export/StructureDefinitionExporter.test.ts | 4 ++++ test/import/FSHImporter.SDRules.test.ts | 11 +++++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts index d38d8f6ee..abb5f014c 100644 --- a/src/fhirtypes/StructureDefinition.ts +++ b/src/fhirtypes/StructureDefinition.ts @@ -936,13 +936,17 @@ export class StructureDefinition { findObsoleteChoices(baseElement: ElementDefinition, oldTypes: ElementDefinitionType[]): string[] { // first, find all the elements representing choices for the same choice element const parentSlice = baseElement.parent()?.sliceName; + const baseSliceName = baseElement.sliceName; const choiceElements = this.elements.filter(e => { const eParentSlice = e.parent()?.sliceName; return ( e.path === baseElement.path && (parentSlice == null || parentSlice === eParentSlice || - eParentSlice?.startsWith(`${parentSlice}/`)) + eParentSlice?.startsWith(`${parentSlice}/`)) && + // When baseElement is itself a named slice, only consider its sub-slices, + // not sibling slices at the same level + (baseSliceName == null || e.sliceName?.startsWith(`${baseSliceName}/`)) ); }); const matchedThings: ElementDefinition[] = []; diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts index 25d4c5a3c..7e0613e21 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -5451,6 +5451,7 @@ describe('StructureDefinitionExporter R4', () => { // * value[x] ^slicing.rules = #open // * value[x] contains valueString 0..1 and valueOther 0..1 // * value[x][valueString] only string + // * value[x][valueOther] only Quantity or CodeableConcept // * value[x][valueOther].extension 1..1 const slicingType = new CaretValueRule('value[x]'); slicingType.caretPath = 'slicing.discriminator[0].type'; @@ -5471,6 +5472,8 @@ describe('StructureDefinitionExporter R4', () => { otherCard.max = '1'; const stringType = new OnlyRule('value[x][valueString]'); stringType.types = [{ type: 'string' }]; + const otherType = new OnlyRule('value[x][valueOther]'); + otherType.types = [{ type: 'Quantity' }, { type: 'CodeableConcept' }]; const otherExtensionCard = new CardRule('value[x][valueOther].extension'); otherExtensionCard.min = 1; otherExtensionCard.max = '1'; @@ -5483,6 +5486,7 @@ describe('StructureDefinitionExporter R4', () => { stringCard, otherCard, stringType, + otherType, otherExtensionCard ); diff --git a/test/import/FSHImporter.SDRules.test.ts b/test/import/FSHImporter.SDRules.test.ts index 49bde7d46..dcf227c57 100644 --- a/test/import/FSHImporter.SDRules.test.ts +++ b/test/import/FSHImporter.SDRules.test.ts @@ -1735,17 +1735,24 @@ describe('FSHImporter', () => { Parent: Observation * value[x] contains valueString 0..1 and valueOther 0..1 * value[x][valueString] only string + * value[x][valueOther] only Quantity or CodeableConcept * value[x][valueOther].extension 1..1 `); const result = importSingleText(input); const profile = result.profiles.get('ObservationProfile'); - expect(profile.rules).toHaveLength(5); + expect(profile.rules).toHaveLength(6); assertContainsRule(profile.rules[0], 'value[x]', 'valueString', 'valueOther'); assertCardRule(profile.rules[1], 'value[x][valueString]', 0, 1); assertCardRule(profile.rules[2], 'value[x][valueOther]', 0, 1); assertOnlyRule(profile.rules[3], 'value[x][valueString]', { type: 'string' }); - assertCardRule(profile.rules[4], 'value[x][valueOther].extension', 1, 1); + assertOnlyRule( + profile.rules[4], + 'value[x][valueOther]', + { type: 'Quantity' }, + { type: 'CodeableConcept' } + ); + assertCardRule(profile.rules[5], 'value[x][valueOther].extension', 1, 1); }); });