diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts index 6b8b2b8fd..c585fdc35 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, @@ -284,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. @@ -291,6 +293,7 @@ export class ElementDefinition { */ constructor(id = '') { this.id = id; + this._replacementProps = cloneDeep(REPLACEMENT_PROPS); } get id(): string { @@ -472,6 +475,54 @@ 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) { + currentOriginalElement[pathPart.base] = undefined; + currentActualElement[pathPart.base] = undefined; + } + if (pathPart.primitive && currentOriginalElement?.[`_${pathPart.base}`] != null) { + currentOriginalElement[`_${pathPart.base}`] = undefined; + currentActualElement[`_${pathPart.base}`] = undefined; + } + } + } + } + + private calculateClearPath(pathParts: PathPart[]): PathPart[] { + const replacementIndex = this._replacementProps.findIndex( + replacementPath => + replacementPath.length <= pathParts.length && + 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; + } + 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 @@ -3136,3 +3187,13 @@ 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. + * 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']]; diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts index f2638f995..c7b73fcc6 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..a511b7127 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -8189,6 +8189,229 @@ 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 + ]); + }); + + 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', () => {