From dea6bed3cda841cbf788149c8590ddd3d5bb5d1d Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Fri, 1 Mar 2024 10:57:55 -0500 Subject: [PATCH 1/5] Replace type.aggregation on ElementDefinition when modified If a rule modifies an element or child element of the type.aggregation array, any values on type.aggregation inherited from the parent are completely replaced. Properties of ElementDefinition with this behavior are defined in REPLACEMENT_PROPS. --- src/fhirtypes/ElementDefinition.ts | 63 +++++++- src/fhirtypes/common.ts | 3 + .../StructureDefinitionExporter.test.ts | 136 ++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts index 28a0330a7..fffffa3b0 100644 --- a/src/fhirtypes/ElementDefinition.ts +++ b/src/fhirtypes/ElementDefinition.ts @@ -11,7 +11,7 @@ import { } from 'lodash'; import { minify } from 'html-minifier-terser'; import { isUri } from 'valid-url'; -import { StructureDefinition } from './StructureDefinition'; +import { PathPart, StructureDefinition } from './StructureDefinition'; import { CodeableConcept, CodeableReference, @@ -66,7 +66,8 @@ import { setPropertyOnDefinitionInstance, splitOnPathPeriods, isReferenceType, - isModifierExtension + isModifierExtension, + getArrayIndex } from './common'; import { Fishable, @@ -472,6 +473,56 @@ export class ElementDefinition { this._original = undefined; } + clearOriginalProperty(pathParts: PathPart[]): void { + let currentOriginalElement: any = this._original; + // eslint-disable-next-line + let currentActualElement: any = this; + const clearPath = this.calculateClearPath(pathParts); + for (const [i, pathPart] of clearPath.entries()) { + if (i < clearPath.length - 1) { + const key = pathPart.primitive ? `_${pathPart.base}` : pathPart.base; + currentOriginalElement = currentOriginalElement?.[key]; + currentActualElement = currentActualElement?.[key]; + const currentIndex = getArrayIndex(pathPart); + if (currentIndex != null) { + if (Array.isArray(currentOriginalElement)) { + currentOriginalElement = currentOriginalElement[currentIndex]; + } + if (Array.isArray(currentActualElement)) { + currentActualElement = currentActualElement[currentIndex]; + } + } + } else { + if ( + currentOriginalElement?.[pathPart.base] != null && + currentActualElement?.[pathPart.base] != null + ) { + currentActualElement[pathPart.base] = undefined; + // there may be a corresponding underscore property + if (pathPart.primitive && currentActualElement?.[`_${pathPart.base}`] != null) { + currentActualElement[`_${pathPart.base}`] = undefined; + } + } + currentOriginalElement[pathPart.base] = undefined; + if (pathPart.primitive) { + currentOriginalElement[`_${pathPart.base}`] = undefined; + } + } + } + } + + private calculateClearPath(pathParts: PathPart[]): PathPart[] { + for (const replacementPath of REPLACEMENT_PROPS) { + if ( + replacementPath.length <= pathParts.length && + replacementPath.every((rp, index) => rp === pathParts[index].base) + ) { + return pathParts.slice(0, replacementPath.length); + } + } + return []; + } + /** * Determines if the state of the current element differs from the stored "original". * @returns {boolean} true if the state of the current element differs from the stored "original", false otherwise @@ -3130,3 +3181,11 @@ const PROPS_AND_UNDERPROPS: string[] = PROPS.reduce((collect: string[], prop) => * See http://hl7.org/fhir/elementdefinition.html#interpretation. */ const ADDITIVE_PROPS = ['mapping', 'constraint']; + +/** + * These list properties are replaced in child profiles. If they are modified + * in a profile, the snapshot should contain only entries in that profile, + * and not in the parent profile. Each property is given as a list of + * path parts. + */ +const REPLACEMENT_PROPS = [['type', 'aggregation']]; diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts index e1a9fc0ad..ab898fb33 100644 --- a/src/fhirtypes/common.ts +++ b/src/fhirtypes/common.ts @@ -91,6 +91,9 @@ export function setPropertyOnDefinitionInstance( ): void { const instanceSD = instance.getOwnStructureDefinition(fisher); const { assignedValue, pathParts } = instanceSD.validateValueAtPath(path, value, fisher); + if (instance instanceof ElementDefinition) { + instance.clearOriginalProperty(pathParts); + } if (!(instance instanceof CodeSystem || instance instanceof ValueSet)) { const knownSlices = determineKnownSlices(instanceSD, new Map([[path, { pathParts }]]), fisher); setImpliedPropertiesOnInstance(instance, instanceSD, [path], [], fisher, knownSlices); diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts index 78edecc27..3dc767c71 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -8189,6 +8189,142 @@ describe('StructureDefinitionExporter R4', () => { expect(ed.type[0]).toEqual(expectedType); expect(loggerSpy.getAllMessages()).toHaveLength(0); }); + + it('should apply CaretValueRules on the aggregation of a type and replace the parent values', () => { + // Profile: ParentObservation + // Parent: Observation + // * component ^type.aggregation[0] = #contained + // * component ^type.aggregation[1] = #referenced + const parentObservation = new Profile('ParentObservation'); + parentObservation.parent = 'Observation'; + const parentAggregationContained = new CaretValueRule('component'); + parentAggregationContained.caretPath = 'type.aggregation[0]'; + parentAggregationContained.value = new FshCode('contained'); + const parentAggregationReferenced = new CaretValueRule('component'); + parentAggregationReferenced.caretPath = 'type.aggregation[1]'; + parentAggregationReferenced.value = new FshCode('referenced'); + parentObservation.rules.push(parentAggregationContained, parentAggregationReferenced); + doc.profiles.set(parentObservation.name, parentObservation); + // Profile: ChildObservation + // Parent: ParentObservation + // * component ^type.aggregation[0] = #contained + const childObservation = new Profile('ChildObservation'); + childObservation.parent = 'ParentObservation'; + const childAggregationContained = new CaretValueRule('component'); + childAggregationContained.caretPath = 'type.aggregation[0]'; + childAggregationContained.value = new FshCode('contained'); + childObservation.rules.push(childAggregationContained); + doc.profiles.set(childObservation.name, childObservation); + + exporter.export(); + const parentSd = pkg.profiles[0]; + const parentComponent = parentSd.elements.find(el => el.id === 'Observation.component'); + expect(parentComponent.type[0].aggregation).toEqual(['contained', 'referenced']); + const childSd = pkg.profiles[1]; + const childComponent = childSd.elements.find(el => el.id === 'Observation.component'); + expect(childComponent.type[0].aggregation).toEqual(['contained']); + expect(childComponent.calculateDiff().type[0].aggregation).toEqual(['contained']); + }); + + it('should apply CaretValueRules on elements within the aggregation of a type and replace the parent values', () => { + // Profile: ParentObservation + // Parent: Observation + // * component ^type.aggregation[0] = #contained + // * component ^type.aggregation[1] = #referenced + // * component ^type.aggregation[1].extension[0].url = "http://example.org" + // * component ^type.aggregation[1].extension[0].valueString = "parent value" + const parentObservation = new Profile('ParentObservation'); + parentObservation.parent = 'Observation'; + const parentAggregationContained = new CaretValueRule('component'); + parentAggregationContained.caretPath = 'type.aggregation[0]'; + parentAggregationContained.value = new FshCode('contained'); + const parentAggregationReferenced = new CaretValueRule('component'); + parentAggregationReferenced.caretPath = 'type.aggregation[1]'; + parentAggregationReferenced.value = new FshCode('referenced'); + const parentExtensionUrl = new CaretValueRule('component'); + parentExtensionUrl.caretPath = 'type.aggregation[1].extension[0].url'; + parentExtensionUrl.value = 'http://example.org'; + const parentExtensionValue = new CaretValueRule('component'); + parentExtensionValue.caretPath = 'type.aggregation[1].extension[0].valueString'; + parentExtensionValue.value = 'parent value'; + parentObservation.rules.push( + parentAggregationContained, + parentAggregationReferenced, + parentExtensionUrl, + parentExtensionValue + ); + doc.profiles.set(parentObservation.name, parentObservation); + // Profile: ChildObservation + // Parent: ParentObservation + // * component ^type.aggregation[0].extension[0].url = "http://example.org" + // * component ^type.aggregation[0].extension[0].valueString = "child value" + // * component ^type.aggregation[0] = #contained + // * component ^type.aggregation[1] = #bundled + const childObservation = new Profile('ChildObservation'); + childObservation.parent = 'ParentObservation'; + const childExtensionUrl = new CaretValueRule('component'); + childExtensionUrl.caretPath = 'type.aggregation[0].extension[0].url'; + childExtensionUrl.value = 'http://example.org'; + const childExtensionValue = new CaretValueRule('component'); + childExtensionValue.caretPath = 'type.aggregation[0].extension[0].valueString'; + childExtensionValue.value = 'child value'; + const childAggregationContained = new CaretValueRule('component'); + childAggregationContained.caretPath = 'type.aggregation[0]'; + childAggregationContained.value = new FshCode('contained'); + const childAggregationBundled = new CaretValueRule('component'); + childAggregationBundled.caretPath = 'type.aggregation[1]'; + childAggregationBundled.value = new FshCode('bundled'); + childObservation.rules.push( + childExtensionUrl, + childExtensionValue, + childAggregationContained, + childAggregationBundled + ); + doc.profiles.set(childObservation.name, childObservation); + + exporter.export(); + const parentSd = pkg.profiles[0]; + const parentComponent = parentSd.elements.find(el => el.id === 'Observation.component'); + expect(parentComponent.type[0].aggregation).toEqual(['contained', 'referenced']); + expect(parentComponent.type[0]._aggregation).toEqual([ + null, + { + extension: [ + { + url: 'http://example.org', + valueString: 'parent value' + } + ] + } + ]); + const childSd = pkg.profiles[1]; + const childComponent = childSd.elements.find(el => el.id === 'Observation.component'); + expect(childComponent.type[0].aggregation).toEqual(['contained', 'bundled']); + expect(childComponent.type[0]._aggregation).toEqual([ + { + extension: [ + { + url: 'http://example.org', + valueString: 'child value' + } + ] + }, + null + ]); + const childComponentDiff = childComponent.calculateDiff(); + expect(childComponentDiff.type[0].aggregation).toEqual(['contained', 'bundled']); + expect(childComponentDiff.type[0]._aggregation).toEqual([ + { + extension: [ + { + url: 'http://example.org', + valueString: 'child value' + } + ] + }, + null + ]); + }); }); describe('#ObeysRule', () => { From 20cb86d2902e3e4fedae5c61bc4c408a71ab8b96 Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Thu, 14 Mar 2024 11:14:25 -0400 Subject: [PATCH 2/5] Handle parent having only underscore property --- src/fhirtypes/ElementDefinition.ts | 18 ++-- .../StructureDefinitionExporter.test.ts | 87 +++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts index fffffa3b0..e0c85d8d2 100644 --- a/src/fhirtypes/ElementDefinition.ts +++ b/src/fhirtypes/ElementDefinition.ts @@ -493,19 +493,17 @@ export class ElementDefinition { } } } else { - if ( - currentOriginalElement?.[pathPart.base] != null && - currentActualElement?.[pathPart.base] != null - ) { - currentActualElement[pathPart.base] = undefined; - // there may be a corresponding underscore property - if (pathPart.primitive && currentActualElement?.[`_${pathPart.base}`] != null) { - currentActualElement[`_${pathPart.base}`] = undefined; + if (currentOriginalElement?.[pathPart.base] != null) { + currentOriginalElement[pathPart.base] = undefined; + if (currentActualElement?.[pathPart.base] != null) { + currentActualElement[pathPart.base] = undefined; } } - currentOriginalElement[pathPart.base] = undefined; - if (pathPart.primitive) { + if (pathPart.primitive && currentOriginalElement?.[`_${pathPart.base}`] != null) { currentOriginalElement[`_${pathPart.base}`] = undefined; + if (currentActualElement?.[`_${pathPart.base}`] != null) { + currentActualElement[`_${pathPart.base}`] = undefined; + } } } } diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts index 3dc767c71..a511b7127 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -8325,6 +8325,93 @@ describe('StructureDefinitionExporter R4', () => { null ]); }); + + it('should apply CaretValueRules on elements within the aggregation of a type and replace the children of parent values when there is no parent value', () => { + // Profile: ParentObservation + // Parent: Observation + // * component ^type.aggregation[1].extension[0].url = "http://example.org" + // * component ^type.aggregation[1].extension[0].valueString = "parent value" + const parentObservation = new Profile('ParentObservation'); + parentObservation.parent = 'Observation'; + const parentExtensionUrl = new CaretValueRule('component'); + parentExtensionUrl.caretPath = 'type.aggregation[1].extension[0].url'; + parentExtensionUrl.value = 'http://example.org'; + const parentExtensionValue = new CaretValueRule('component'); + parentExtensionValue.caretPath = 'type.aggregation[1].extension[0].valueString'; + parentExtensionValue.value = 'parent value'; + parentObservation.rules.push(parentExtensionUrl, parentExtensionValue); + doc.profiles.set(parentObservation.name, parentObservation); + // Profile: ChildObservation + // Parent: ParentObservation + // * component ^type.aggregation[0].extension[0].url = "http://example.org" + // * component ^type.aggregation[0].extension[0].valueString = "child value" + // * component ^type.aggregation[0] = #contained + // * component ^type.aggregation[1] = #bundled + const childObservation = new Profile('ChildObservation'); + childObservation.parent = 'ParentObservation'; + const childExtensionUrl = new CaretValueRule('component'); + childExtensionUrl.caretPath = 'type.aggregation[0].extension[0].url'; + childExtensionUrl.value = 'http://example.org'; + const childExtensionValue = new CaretValueRule('component'); + childExtensionValue.caretPath = 'type.aggregation[0].extension[0].valueString'; + childExtensionValue.value = 'child value'; + const childAggregationContained = new CaretValueRule('component'); + childAggregationContained.caretPath = 'type.aggregation[0]'; + childAggregationContained.value = new FshCode('contained'); + const childAggregationBundled = new CaretValueRule('component'); + childAggregationBundled.caretPath = 'type.aggregation[1]'; + childAggregationBundled.value = new FshCode('bundled'); + childObservation.rules.push( + childExtensionUrl, + childExtensionValue, + childAggregationContained, + childAggregationBundled + ); + doc.profiles.set(childObservation.name, childObservation); + + exporter.export(); + const parentSd = pkg.profiles[0]; + const parentComponent = parentSd.elements.find(el => el.id === 'Observation.component'); + expect(parentComponent.type[0].aggregation).toBeUndefined(); + expect(parentComponent.type[0]._aggregation).toEqual([ + null, + { + extension: [ + { + url: 'http://example.org', + valueString: 'parent value' + } + ] + } + ]); + const childSd = pkg.profiles[1]; + const childComponent = childSd.elements.find(el => el.id === 'Observation.component'); + expect(childComponent.type[0].aggregation).toEqual(['contained', 'bundled']); + expect(childComponent.type[0]._aggregation).toEqual([ + { + extension: [ + { + url: 'http://example.org', + valueString: 'child value' + } + ] + }, + null + ]); + const childComponentDiff = childComponent.calculateDiff(); + expect(childComponentDiff.type[0].aggregation).toEqual(['contained', 'bundled']); + expect(childComponentDiff.type[0]._aggregation).toEqual([ + { + extension: [ + { + url: 'http://example.org', + valueString: 'child value' + } + ] + }, + null + ]); + }); }); describe('#ObeysRule', () => { From 78c00022461ce7ac4645088d8b7d70fa3ba742cc Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Thu, 21 Mar 2024 13:44:25 -0400 Subject: [PATCH 3/5] Track remaining replacement properties on each instance of ElementDefinition --- src/fhirtypes/ElementDefinition.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts index dec7fd63c..c1f6d3528 100644 --- a/src/fhirtypes/ElementDefinition.ts +++ b/src/fhirtypes/ElementDefinition.ts @@ -285,6 +285,7 @@ export class ElementDefinition { structDef: StructureDefinition; private _original: ElementDefinition; private _edStructureDefinition: StructureDefinition; + private _replacementProps: string[][]; /** * Constructs a new ElementDefinition with the given ID. @@ -292,6 +293,7 @@ export class ElementDefinition { */ constructor(id = '') { this.id = id; + this._replacementProps = cloneDeep(REPLACEMENT_PROPS); } get id(): string { @@ -510,13 +512,15 @@ export class ElementDefinition { } private calculateClearPath(pathParts: PathPart[]): PathPart[] { - for (const replacementPath of REPLACEMENT_PROPS) { - if ( + const replacementIndex = this._replacementProps.findIndex( + replacementPath => replacementPath.length <= pathParts.length && replacementPath.every((rp, index) => rp === pathParts[index].base) - ) { - return pathParts.slice(0, replacementPath.length); - } + ); + if (replacementIndex >= 0) { + const clearPath = pathParts.slice(0, this._replacementProps[replacementIndex].length); + this._replacementProps.splice(replacementIndex, 1); + return clearPath; } return []; } From 75956105d8d9719575942f39677b9880ba28def0 Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Fri, 22 Mar 2024 14:51:12 -0400 Subject: [PATCH 4/5] simply conditionals when clearing original properties --- src/fhirtypes/ElementDefinition.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts index c1f6d3528..a7b48bfa7 100644 --- a/src/fhirtypes/ElementDefinition.ts +++ b/src/fhirtypes/ElementDefinition.ts @@ -497,15 +497,11 @@ export class ElementDefinition { } else { if (currentOriginalElement?.[pathPart.base] != null) { currentOriginalElement[pathPart.base] = undefined; - if (currentActualElement?.[pathPart.base] != null) { - currentActualElement[pathPart.base] = undefined; - } + currentActualElement[pathPart.base] = undefined; } if (pathPart.primitive && currentOriginalElement?.[`_${pathPart.base}`] != null) { currentOriginalElement[`_${pathPart.base}`] = undefined; - if (currentActualElement?.[`_${pathPart.base}`] != null) { - currentActualElement[`_${pathPart.base}`] = undefined; - } + currentActualElement[`_${pathPart.base}`] = undefined; } } } From 8b3bd9f318e36420464f2566ae6c7560c9e5ddf6 Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Wed, 27 Mar 2024 14:40:52 -0400 Subject: [PATCH 5/5] Add clarifying comments regarding replacement properties --- src/fhirtypes/ElementDefinition.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts index a7b48bfa7..c585fdc35 100644 --- a/src/fhirtypes/ElementDefinition.ts +++ b/src/fhirtypes/ElementDefinition.ts @@ -514,7 +514,9 @@ export class ElementDefinition { replacementPath.every((rp, index) => rp === pathParts[index].base) ); if (replacementIndex >= 0) { + // Build the clearPath from the actual pathParts, since we want to know about primitive types. const clearPath = pathParts.slice(0, this._replacementProps[replacementIndex].length); + // each replacement property only has to get cleared once, so remove it from the list. this._replacementProps.splice(replacementIndex, 1); return clearPath; } @@ -3191,5 +3193,7 @@ const ADDITIVE_PROPS = ['mapping', 'constraint']; * in a profile, the snapshot should contain only entries in that profile, * and not in the parent profile. Each property is given as a list of * path parts. + * For more context and a specific example, see this Zulip thread: + * https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/restricting.20aggregation.20type/near/413120070 */ const REPLACEMENT_PROPS = [['type', 'aggregation']];