From 8ef06be589f612398437acdc8f4fa57fc0002a3b Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Fri, 21 Jul 2023 08:15:10 -0400 Subject: [PATCH] Fix problems when setting values on child elements in primitive arrays (#1302) * Don't create extra elements on primitive arrays When checking the existing slices on a primitive array, the slice names are on the objects within the underscore-prefixed array. * Maintain parallel arrays for primitives Primitive arrays are represented with two arrays: one that stores the primitive values, and one that stores child elements. When creating an element at a given index in one array, also create an element at the same index in the other. Slice names at a given index should be equal. When resoving soft indices under manual slice ordering, treat "no slice name" as its own group of slices. This helps guarantee correct array indices when assigning child properties of a primitive array. * Code cleanup and additional tests In places where code coverage reported uncovered code, add tests where possible. Some uncovered code represented logically impossible scenarios, so that code is removed. * Fill up new array when creating other half of primitive array A primitive array may start out containing some elements, such as when working with ElementDefinition.type.profile. Fill up the array so that each half is the same size. * Refactor to eliminate "underscored" helper variable * Check for empty error log at end of slice tests * Revert changes to strict soft index resolution Guarantees about not accessing named slices without the slice name only apply when soft indexing is used. The resulting numeric indices, therefore, may be spaced out in order to account for the presence of named slices. --- src/export/InstanceExporter.ts | 11 +- src/fhirtypes/common.ts | 139 ++++-- src/utils/Processing.ts | 9 +- test/export/InstanceExporter.test.ts | 450 ++++++++++++++++-- .../StructureDefinitionExporter.test.ts | 70 +++ ...finition.setInstancePropertyByPath.test.ts | 5 +- ...n-elementdefinition-type-must-support.json | 251 ++++++++++ 7 files changed, 843 insertions(+), 92 deletions(-) create mode 100644 test/testhelpers/testdefs/StructureDefinition-elementdefinition-type-must-support.json diff --git a/src/export/InstanceExporter.ts b/src/export/InstanceExporter.ts index 3e00e1b2d..781c0ba22 100644 --- a/src/export/InstanceExporter.ts +++ b/src/export/InstanceExporter.ts @@ -480,10 +480,17 @@ export class InstanceExporter implements Fishable { // 2 - The child element is n..m, but it has k < n elements // there's a special exception for the "value" child of a primitive, // since the actual value may be present on the parent primitive element. + // if the parent primitive is an object, the value will be in the "assignedValue" attribute. + // otherwise, the value is the parent primitive itself. if ( (child.min > 0 && instanceChild == null && - !(child.id.endsWith('.value') && parentPrimitive != null)) || + !( + child.id.endsWith('.value') && + (typeof parentPrimitive === 'object' + ? parentPrimitive?.assignedValue + : parentPrimitive) != null + )) || (Array.isArray(instanceChild) && instanceChild.length < child.min) ) { // Can't point to any specific rule, so give sourceInfo of entire instance @@ -608,7 +615,7 @@ export class InstanceExporter implements Fishable { ' See: https://hl7.org/fhir/uv/shorthand/reference.html#sliced-array-paths\n' + ` Path: ${slicingEl.path}\n` + ` Slice: ${matchedNames.join(' or ')}\n` + - ` Value: ${valueString}}`, + ` Value: ${valueString}`, fshDef.sourceInfo ); } else { diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts index b691405e6..03097562a 100644 --- a/src/fhirtypes/common.ts +++ b/src/fhirtypes/common.ts @@ -101,16 +101,22 @@ export function createUsefulSlices( // If this is a primitive and the path continues to a nested element of the primitive, // then we need to look at the special property that starts with _ instead. - const key = - pathPart.primitive && i < pathParts.length - 1 ? `_${pathPart.base}` : pathPart.base; - + let key: string; + if (pathPart.primitive && i < pathParts.length - 1) { + key = `_${pathPart.base}`; + } else { + key = pathPart.base; + } const ruleIndex = getArrayIndex(pathPart); let effectiveIndex = ruleIndex; let sliceName: string; if (ruleIndex != null) { // If the array doesn't exist, create it - if (current[key] == null) { - current[key] = []; + if (pathPart.primitive) { + current[pathPart.base] ??= []; + current[`_${pathPart.base}`] ??= []; + } else { + current[key] ??= []; } sliceName = pathPart.brackets ? getSliceName(pathPart) : null; if (sliceName) { @@ -145,7 +151,7 @@ export function createUsefulSlices( * So we should put the rule at the end of the component, which is effectiveIndex = 3 */ if (ruleIndex >= sliceIndices.length) { - effectiveIndex = ruleIndex - sliceIndices.length + current[key].length; + effectiveIndex = ruleIndex - sliceIndices.length + current[pathPart.base].length; } else { effectiveIndex = sliceIndices[ruleIndex]; } @@ -153,7 +159,7 @@ export function createUsefulSlices( // This is an array entry that does not have a named slice (so a typical numeric index) knownSlices.set( currentPath, - Math.max(ruleIndex + 1, knownSlices.get(currentPath) ?? 0) + Math.max(effectiveIndex + 1, knownSlices.get(currentPath) ?? 0) ); } if (pathPart.brackets != null) { @@ -170,15 +176,35 @@ export function createUsefulSlices( j === effectiveIndex && current[key][effectiveIndex] == null ) { - current[key][effectiveIndex] = {}; + if (pathPart.primitive) { + current[pathPart.base][effectiveIndex] = {}; + current[`_${pathPart.base}`][effectiveIndex] = {}; + } else { + current[key][effectiveIndex] = {}; + } } else if (j >= current[key].length) { if (sliceName) { // _sliceName is used to later differentiate which slice an element represents - current[key].push({ _sliceName: sliceName }); + if (pathPart.primitive) { + current[pathPart.base].push({ _sliceName: sliceName }); + current[`_${pathPart.base}`].push({ _sliceName: sliceName }); + } else { + current[key].push({ _sliceName: sliceName }); + } } else if (j === effectiveIndex) { - current[key].push({}); + if (pathPart.primitive) { + current[pathPart.base].push({}); + current[`_${pathPart.base}`].push({}); + } else { + current[key].push({}); + } } else { - current[key].push(null); + if (pathPart.primitive) { + current[pathPart.base].push(null); + current[`_${pathPart.base}`].push(null); + } else { + current[key].push(null); + } } } } @@ -402,8 +428,23 @@ export function setImpliedPropertiesOnInstance( } // check the children for instance of this element const children = currentElement.def.children(true); + // special handling: if our current element has no slice name, we need guarantee the defined minimum + // this is the only place where we do this, in order to accomodate cases where some named slices already exist + let existingSliceCount = 0; + if (finalMin < currentElement.def.min && currentElement.def.sliceName == null) { + // our final min was lowered by slices, so add that to our indices. + const slicePaths = currentElement.def + .getSlices() + .map(el => `${tracePath}[${el.sliceName}]`); + slicePaths.forEach(slicePath => { + if (knownSlices.has(slicePath)) { + existingSliceCount += knownSlices.get(slicePath); + } + }); + } for (let idx = 0; idx < finalMin; idx++) { - const newHistory = traceParts.slice(-1)[0] + (idx > 0 ? `[${idx}]` : ''); + const effectiveIdx = idx + existingSliceCount; + const newHistory = traceParts.slice(-1)[0] + (effectiveIdx > 0 ? `[${effectiveIdx}]` : ''); elementsToCheck.push( ...children.map( child => @@ -502,7 +543,7 @@ export function setImpliedPropertiesOnInstance( } sortedRulePaths.forEach(path => { const { pathParts } = instanceOfStructureDefinition.validateValueAtPath(path, null, fisher); - setPropertyOnInstance(instanceDef, pathParts, sdRuleMap.get(path), fisher, manualSliceOrdering); + setPropertyOnInstance(instanceDef, pathParts, sdRuleMap.get(path), fisher); }); } @@ -544,8 +585,7 @@ export function setPropertyOnInstance( instance: StructureDefinition | ElementDefinition | InstanceDefinition | ValueSet | CodeSystem, pathParts: PathPart[], assignedValue: any, - fisher: Fishable, - manualSliceOrdering = false + fisher: Fishable ): void { if (assignedValue != null) { // If we can assign the value on the StructureDefinition StructureDefinition, then we can set the @@ -554,14 +594,33 @@ export function setPropertyOnInstance( for (const [i, pathPart] of pathParts.entries()) { // When a primitive has child elements, a _ is appended to the name of the primitive // According to https://www.hl7.org/fhir/json.html#primitive - const key = - pathPart.primitive && i < pathParts.length - 1 ? `_${pathPart.base}` : pathPart.base; + let key: string; + if (pathPart.primitive && i < pathParts.length - 1) { + key = `_${pathPart.base}`; + } else { + key = pathPart.base; + } // If this part of the path indexes into an array, the index will be the last bracket let index = getArrayIndex(pathPart); let sliceName: string; if (index != null) { - // If the array doesn't exist, create it - if (current[key] == null) current[key] = []; + if (pathPart.primitive) { + // we may need to create or update one or both arrays + if (current[pathPart.base] == null) { + current[pathPart.base] = + current[`_${pathPart.base}`]?.map((x: any) => + x?._sliceName != null ? { _sliceName: x._sliceName } : null + ) ?? []; + } + if (current[`_${pathPart.base}`] == null) { + current[`_${pathPart.base}`] = current[pathPart.base].map((x: any) => + x?._sliceName != null ? { _sliceName: x._sliceName } : null + ); + } + } else if (current[key] == null) { + // if the array doesn't exist, create it + current[key] = []; + } sliceName = getSliceName(pathPart); if (sliceName) { const sliceIndices: number[] = []; @@ -581,33 +640,41 @@ export function setPropertyOnInstance( } else { index = sliceIndices[index]; } - } else if (manualSliceOrdering) { - const sliceIndices: number[] = []; - current[pathPart.base]?.forEach((el: any, i: number) => { - if (el?._sliceName == null) { - sliceIndices.push(i); - } - }); - // Convert the index in terms of the slice to the corresponding index in the overall array - if (index >= sliceIndices.length) { - index = index - sliceIndices.length + current[key].length; - } else { - index = sliceIndices[index]; - } } // If the index doesn't exist in the array, add it and lesser indices // Empty elements should be null, not undefined, according to https://www.hl7.org/fhir/json.html#primitive for (let j = 0; j <= index; j++) { if (j < current[key].length && j === index && current[key][index] == null) { - current[key][index] = {}; + if (pathPart.primitive) { + // a value may already exist on one of the arrays, so only assign an empty object if it is nullish + current[pathPart.base][index] ??= {}; + current[`_${pathPart.base}`][index] ??= {}; + } else { + current[key][index] = {}; + } } else if (j >= current[key].length) { if (sliceName) { // _sliceName is used to later differentiate which slice an element represents - current[key].push({ _sliceName: sliceName }); + if (pathPart.primitive) { + current[pathPart.base].push({ _sliceName: sliceName }); + current[`_${pathPart.base}`].push({ _sliceName: sliceName }); + } else { + current[key].push({ _sliceName: sliceName }); + } } else if (j === index) { - current[key].push({}); + if (pathPart.primitive) { + current[pathPart.base].push({}); + current[`_${pathPart.base}`].push({}); + } else { + current[key].push({}); + } } else { - current[key].push(null); + if (pathPart.primitive) { + current[pathPart.base].push(null); + current[`_${pathPart.base}`].push(null); + } else { + current[key].push(null); + } } } } diff --git a/src/utils/Processing.ts b/src/utils/Processing.ts index 5a7b633fc..eb708e698 100644 --- a/src/utils/Processing.ts +++ b/src/utils/Processing.ts @@ -474,8 +474,15 @@ export function checkNullValuesOnArray(resource: any, parentName = '', priorPath } if (Array.isArray(property)) { const nullIndexes: number[] = []; + const hasUnderscoreArray = Array.isArray(resource[`_${propertyKey}`]); property.forEach((element: any, index: number) => { - if (element === null) nullIndexes.push(index); + // if property is a primitive array, also check the corresponding index in the underscore property + if ( + element === null && + (!hasUnderscoreArray || resource[`_${propertyKey}`][index] == null) + ) { + nullIndexes.push(index); + } if (isPlainObject(element)) { // If we encounter an object property, we'll want to check its properties as well checkNullValuesOnArray(element, resourceName, `${currentPath}[${index}]`); diff --git a/test/export/InstanceExporter.test.ts b/test/export/InstanceExporter.test.ts index 9e031dcff..b5b61c423 100644 --- a/test/export/InstanceExporter.test.ts +++ b/test/export/InstanceExporter.test.ts @@ -4606,6 +4606,342 @@ describe('InstanceExporter', () => { expect(exportedInstance.rest[0].resource[0]).toBeNull(); expect(exportedInstance.rest[0].resource[1]).toEqual({ type: 'type' }); }); + + it('should assign extensions on elements of a primitive array', () => { + // Instance: MyAllergies + // InstanceOf: AllergyIntolerance + // * patient = Reference(SomePatient) + // * category[0] = #environment + // * category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + // * category[2] = #food + const instance = new Instance('MyAllergies'); + instance.instanceOf = 'AllergyIntolerance'; + const patientReference = new AssignmentRule('patient'); + patientReference.value = new FshReference('SomePatient'); + const categoryEnvironment = new AssignmentRule('category[0]'); + categoryEnvironment.value = new FshCode('environment'); + const categoryExtension = new AssignmentRule( + 'category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + categoryExtension.value = new FshCode('unknown'); + const categoryFood = new AssignmentRule('category[2]'); + categoryFood.value = new FshCode('food'); + instance.rules.push(patientReference, categoryEnvironment, categoryExtension, categoryFood); + const exportedInstance = exportInstance(instance); + expect(exportedInstance.category).toHaveLength(3); + expect(exportedInstance.category[0]).toBe('environment'); + expect(exportedInstance.category[1]).toBeNull(); + expect(exportedInstance.category[2]).toBe('food'); + expect(exportedInstance._category).toHaveLength(3); + expect(exportedInstance._category[0]).toBeNull(); + expect(exportedInstance._category[1]).toEqual({ + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }); + expect(exportedInstance._category[2]).toBeNull(); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + + it('should assign extensions on elements of a primitive array when extensions are assigned before the values', () => { + // Instance: MyAllergies + // InstanceOf: AllergyIntolerance + // * patient = Reference(SomePatient) + // * category[0].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + // * category[1] = #environment + // * category[2] = #food + const instance = new Instance('MyAllergies'); + instance.instanceOf = 'AllergyIntolerance'; + const patientReference = new AssignmentRule('patient'); + patientReference.value = new FshReference('SomePatient'); + const categoryExtension = new AssignmentRule( + 'category[0].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + categoryExtension.value = new FshCode('unknown'); + const categoryEnvironment = new AssignmentRule('category[1]'); + categoryEnvironment.value = new FshCode('environment'); + const categoryFood = new AssignmentRule('category[2]'); + categoryFood.value = new FshCode('food'); + instance.rules.push(patientReference, categoryExtension, categoryEnvironment, categoryFood); + const exportedInstance = exportInstance(instance); + expect(exportedInstance.category).toHaveLength(3); + expect(exportedInstance.category[0]).toBeNull(); + expect(exportedInstance.category[1]).toBe('environment'); + expect(exportedInstance.category[2]).toBe('food'); + expect(exportedInstance._category).toHaveLength(3); + expect(exportedInstance._category[0]).toEqual({ + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }); + expect(exportedInstance._category[1]).toBeNull(); + expect(exportedInstance._category[2]).toBeNull(); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + + it('should assign extensions and values on out-of-order elements on a primitive array', () => { + // Instance: MyAllergies + // InstanceOf: AllergyIntolerance + // * patient = Reference(SomePatient) + // * category[2] = #food + // * category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + // * category[0] = #environment + const instance = new Instance('MyAllergies'); + instance.instanceOf = 'AllergyIntolerance'; + const patientReference = new AssignmentRule('patient'); + patientReference.value = new FshReference('SomePatient'); + const categoryFood = new AssignmentRule('category[2]'); + categoryFood.value = new FshCode('food'); + const categoryExtension = new AssignmentRule( + 'category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + categoryExtension.value = new FshCode('unknown'); + const categoryEnvironment = new AssignmentRule('category[0]'); + categoryEnvironment.value = new FshCode('environment'); + instance.rules.push(patientReference, categoryFood, categoryExtension, categoryEnvironment); + + const exportedInstance = exportInstance(instance); + expect(exportedInstance.category).toHaveLength(3); + expect(exportedInstance.category[0]).toBe('environment'); + expect(exportedInstance.category[1]).toBeNull(); + expect(exportedInstance.category[2]).toBe('food'); + expect(exportedInstance._category).toHaveLength(3); + expect(exportedInstance._category[0]).toBeNull(); + expect(exportedInstance._category[1]).toEqual({ + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }); + expect(exportedInstance._category[2]).toBeNull(); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + + it('should assign extensions and values on out-of-order elements on a primitive array when extensions are assigned before values', () => { + // Instance: MyAllergies + // InstanceOf: AllergyIntolerance + // * patient = Reference(SomePatient) + // * category[2].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + // * category[1] = #food + // * category[0] = #environment + const instance = new Instance('MyAllergies'); + instance.instanceOf = 'AllergyIntolerance'; + const patientReference = new AssignmentRule('patient'); + patientReference.value = new FshReference('SomePatient'); + const categoryExtension = new AssignmentRule( + 'category[2].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + categoryExtension.value = new FshCode('unknown'); + const categoryFood = new AssignmentRule('category[1]'); + categoryFood.value = new FshCode('food'); + const categoryEnvironment = new AssignmentRule('category[0]'); + categoryEnvironment.value = new FshCode('environment'); + instance.rules.push(patientReference, categoryExtension, categoryFood, categoryEnvironment); + + const exportedInstance = exportInstance(instance); + expect(exportedInstance.category).toHaveLength(3); + expect(exportedInstance.category[0]).toBe('environment'); + expect(exportedInstance.category[1]).toBe('food'); + expect(exportedInstance.category[2]).toBeNull(); + expect(exportedInstance._category).toHaveLength(3); + expect(exportedInstance._category[0]).toBeNull(); + expect(exportedInstance._category[1]).toBeNull(); + expect(exportedInstance._category[2]).toEqual({ + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + + it('should assign values and extensions on elements of a primitive array at the same index', () => { + // Instance: MyAllergies + // InstanceOf: AllergyIntolerance + // * patient = Reference(SomePatient) + // * category[0].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + // * category[0] = #environment + // * category[1] = #food + const instance = new Instance('MyAllergies'); + instance.instanceOf = 'AllergyIntolerance'; + const patientReference = new AssignmentRule('patient'); + patientReference.value = new FshReference('SomePatient'); + const categoryExtension = new AssignmentRule( + 'category[0].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + categoryExtension.value = new FshCode('unknown'); + const categoryEnvironment = new AssignmentRule('category[0]'); + categoryEnvironment.value = new FshCode('environment'); + const categoryFood = new AssignmentRule('category[1]'); + categoryFood.value = new FshCode('food'); + instance.rules.push(patientReference, categoryExtension, categoryEnvironment, categoryFood); + + const exportedInstance = exportInstance(instance); + expect(exportedInstance.category).toHaveLength(2); + expect(exportedInstance.category[0]).toBe('environment'); + expect(exportedInstance.category[1]).toBe('food'); + expect(exportedInstance._category).toHaveLength(2); + expect(exportedInstance._category[0]).toEqual({ + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }); + expect(exportedInstance._category[1]).toBeNull(); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + + it('should assign extensions on elements of a sliced primitive array', () => { + // Profile: SlicedAllergyIntolerance + // Parent: AllergyIntolerance + // * category ^slicing.discriminator.type = #value + // * category ^slicing.discriminator.path = "$this" + // * category ^slicing.rules = #open + // * category contains Primary 0..* and Secondary 0..* + const profile = new Profile('SlicedAllergyIntolerance'); + profile.parent = 'AllergyIntolerance'; + const slicingType = new CaretValueRule('category'); + slicingType.caretPath = 'slicing.discriminator.type'; + slicingType.value = new FshCode('value'); + const slicingPath = new CaretValueRule('category'); + slicingPath.caretPath = 'slicing.discriminator.path'; + slicingPath.value = '$this'; + const slicingRules = new CaretValueRule('category'); + slicingRules.caretPath = 'slicing.rules'; + slicingRules.value = new FshCode('open'); + const categoryContains = new ContainsRule('category'); + categoryContains.items = [{ name: 'Primary' }, { name: 'Secondary' }]; + const primaryCard = new CardRule('category[Primary]'); + primaryCard.min = 0; + primaryCard.max = '*'; + const secondaryCard = new CardRule('category[Secondary]'); + secondaryCard.min = 0; + secondaryCard.max = '*'; + profile.rules.push( + slicingType, + slicingPath, + slicingRules, + categoryContains, + primaryCard, + secondaryCard + ); + doc.profiles.set(profile.name, profile); + // Instance: MyAllergies + // InstanceOf: SlicedAllergyIntolerance + // * patient = Reference(SomePatient) + // * category[Secondary][+] = #environment + // * category[Primary][+] = #food + // * category[Primary][+].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + // * category[Primary][+] = #medication + // * category[+].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + const instance = new Instance('MyAllergies'); + instance.instanceOf = 'SlicedAllergyIntolerance'; + const patientReference = new AssignmentRule('patient'); + patientReference.value = new FshReference('SomePatient'); + const secondaryEnvironment = new AssignmentRule('category[Secondary][+]'); + secondaryEnvironment.value = new FshCode('environment'); + const primaryFood = new AssignmentRule('category[Primary][+]'); + primaryFood.value = new FshCode('food'); + const primaryExtension = new AssignmentRule( + 'category[Primary][+].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + primaryExtension.value = new FshCode('unknown'); + const unnamedExtension = new AssignmentRule( + 'category[+].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + const primaryMedication = new AssignmentRule('category[Primary][+]'); + primaryMedication.value = new FshCode('medication'); + unnamedExtension.value = new FshCode('unknown'); + instance.rules.push( + patientReference, + secondaryEnvironment, + primaryFood, + primaryExtension, + primaryMedication, + unnamedExtension + ); + + const exported = exportInstance(instance); + expect(exported.category).toHaveLength(5); + expect(exported.category).toEqual(['environment', 'food', null, 'medication', null]); + expect(exported._category).toHaveLength(5); + expect(exported._category).toEqual([ + null, + null, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }, + null, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + } + ]); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + + it('should log an error when a required primitive value element is missing on the second element of a parent array primitive, with strict slice ordering enabled', () => { + // Profile: TestAllergyIntolerance + // Parent: AllergyIntolerance + // * category 1..* + // * category.value 1..1 + // * patient = Reference(SomePatient) + const allergyProfile = new Profile('TestAllergyIntolerance'); + allergyProfile.parent = 'AllergyIntolerance'; + const categoryCard = new CardRule('category'); + categoryCard.min = 1; + categoryCard.max = '*'; + const valueCard = new CardRule('category.value'); + valueCard.min = 1; + valueCard.max = '1'; + const patientValue = new AssignmentRule('patient'); + patientValue.value = new FshReference('SomePatient'); + allergyProfile.rules.push(categoryCard, valueCard, patientValue); + doc.profiles.set(allergyProfile.name, allergyProfile); + // Instance: MyAllergies + // InstanceOf: TestAllergyIntolerance + // * category[0] = #environment + // * category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + const allergyInstance = new Instance('MyAllergies') + .withFile('AllergyInstance.fsh') + .withLocation([14, 3, 21, 28]); + allergyInstance.instanceOf = 'TestAllergyIntolerance'; + const environmentCategory = new AssignmentRule('category[0]'); + environmentCategory.value = new FshCode('environment'); + const unknownCategory = new AssignmentRule( + 'category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + unknownCategory.value = new FshCode('unknown'); + allergyInstance.rules.push(environmentCategory, unknownCategory); + doc.instances.set(allergyInstance.name, allergyInstance); + + exportInstance(allergyInstance); + expect(loggerSpy.getAllMessages('error')).toHaveLength(1); + expect(loggerSpy.getLastMessage('error')).toMatch( + /Element AllergyIntolerance.category.value has minimum cardinality 1.*File: AllergyInstance\.fsh.*Line: 14 - 21/s + ); + }); }); it('should only create optional slices that are defined even if sibling in array has more slices than other siblings', () => { @@ -4803,6 +5139,40 @@ describe('InstanceExporter', () => { expect(exported.address[0]._line[1].extension[0].url).toBe('foo'); }); + it('should assign extensions and values on out-of-order elements on a primitive array', () => { + // Instance: MyAllergies + // InstanceOf: AllergyIntolerance + // * patient = Reference(SomePatient) + // * category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown + // * category[0] = #environment + const instance = new Instance('MyAllergies'); + instance.instanceOf = 'AllergyIntolerance'; + const patientReference = new AssignmentRule('patient'); + patientReference.value = new FshReference('SomePatient'); + const categoryValue = new AssignmentRule('category[0]'); + categoryValue.value = new FshCode('environment'); + const categoryExtension = new AssignmentRule( + 'category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' + ); + categoryExtension.value = new FshCode('unknown'); + instance.rules.push(patientReference, categoryExtension, categoryValue); + + const exportedInstance = exportInstance(instance); + expect(exportedInstance.category).toHaveLength(2); + expect(exportedInstance.category[0]).toBe('environment'); + expect(exportedInstance._category).toHaveLength(2); + expect(exportedInstance._category[0]).toBeNull(); + expect(exportedInstance._category[1]).toEqual({ + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + }); + it('should assign children of primitive value arrays on an instance with out of order rules', () => { const assignedValRule1 = new AssignmentRule('address[0].line[1].extension[0].url'); assignedValRule1.value = 'bar'; @@ -6901,10 +7271,6 @@ describe('InstanceExporter', () => { ); }); - it.skip('should assign sliced elements on a sliced primitive', () => { - /* Need example of sliced primitive */ - }); - // Content Reference it('should assign a child of a contentReference element', () => { const barRule = new AssignmentRule('compose.exclude.version'); @@ -7348,52 +7714,7 @@ describe('InstanceExporter', () => { ); }); - it.skip('should log an error when a required primitive value element is missing on the second element of a parent array primitive, with manual slice ordering enabled', () => { - // this should work once the existing problems with extensions on array primitives are resolved - tank.config.instanceOptions = { manualSliceOrdering: true }; - // Profile: TestAllergyIntolerance - // Parent: AllergyIntolerance - // * category 1..* - // * category.value 1..1 - // * patient = Reference(SomePatient) - const allergyProfile = new Profile('TestAllergyIntolerance'); - allergyProfile.parent = 'AllergyIntolerance'; - const categoryCard = new CardRule('category'); - categoryCard.min = 1; - categoryCard.max = '*'; - const valueCard = new CardRule('category.value'); - valueCard.min = 1; - valueCard.max = '1'; - const patientValue = new AssignmentRule('patient'); - patientValue.value = new FshReference('SomePatient'); - allergyProfile.rules.push(categoryCard, valueCard, patientValue); - doc.profiles.set(allergyProfile.name, allergyProfile); - // Instance: MyAllergies - // InstanceOf: TestAllergyIntolerance - // * category[0] = #environment - // * category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode = #unknown - const allergyInstance = new Instance('MyAllergies') - .withFile('AllergyInstance.fsh') - .withLocation([14, 3, 21, 28]); - allergyInstance.instanceOf = 'TestAllergyIntolerance'; - const environmentCategory = new AssignmentRule('category[0]'); - environmentCategory.value = new FshCode('environment'); - const unknownCategory = new AssignmentRule( - 'category[1].extension[http://hl7.org/fhir/StructureDefinition/data-absent-reason].valueCode' - ); - unknownCategory.value = new FshCode('unknown'); - allergyInstance.rules.push(environmentCategory, unknownCategory); - doc.instances.set(allergyInstance.name, allergyInstance); - - exportInstance(allergyInstance); - expect(loggerSpy.getAllMessages('error')).toHaveLength(1); - expect(loggerSpy.getLastMessage('error')).toMatch( - /Element AllergyIntolerance.category.value has minimum cardinality 1.*File: AllergyInstance\.fsh.*Line: 14 - 21/s - ); - }); - - it.skip('should log an error when a required primitive value element is missing on the parent sliced array primitive', () => { - // this should work once the existing problems with extensions on array primitives are resolved + it('should log an error when a required primitive value element is missing on the parent sliced array primitive', () => { // Profile: SlicedAllergyIntolerance // Parent: AllergyIntolerance // * category 1..* @@ -7467,10 +7788,35 @@ describe('InstanceExporter', () => { allergyInstance.rules.push(flexibleZero, flexibleOne, strictZero, strictOne); doc.instances.set(allergyInstance.name, allergyInstance); - exportInstance(allergyInstance); + const exported = exportInstance(allergyInstance); + // because manual slice ordering is off, the slice order in category is: + // Flexible[0], Strict[0], Strict[1], Flexible[1] + expect(exported.category).toHaveLength(4); + expect(exported.category).toEqual([null, 'food', null, 'environment']); + expect(exported._category).toHaveLength(4); + expect(exported._category).toEqual([ + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }, + null, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/data-absent-reason', + valueCode: 'unknown' + } + ] + }, + null + ]); expect(loggerSpy.getAllMessages('error')).toHaveLength(1); expect(loggerSpy.getLastMessage('error')).toMatch( - /Element AllergyIntolerance.category\[Strict\].value has minimum cardinality 1.*File: AllergyInstance\.fsh.*Line: 15 - 22/s + /Element AllergyIntolerance.category:Strict\.value has minimum cardinality 1.*File: AllergyInstance\.fsh.*Line: 15 - 22/s ); }); diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts index dca4c42e5..8fa2cbe26 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -91,6 +91,19 @@ describe('StructureDefinitionExporter R4', () => { ).trim() ); defs.add(pastPlanet); + const typeMustSupport = JSON.parse( + readFileSync( + path.join( + __dirname, + '..', + 'testhelpers', + 'testdefs', + 'StructureDefinition-elementdefinition-type-must-support.json' + ), + 'utf-8' + ).trim() + ); + defs.add(typeMustSupport); loadFromPath(path.join(__dirname, '..', 'testhelpers', 'testdefs'), 'r4-definitions', defs); }); @@ -7595,6 +7608,63 @@ describe('StructureDefinitionExporter R4', () => { valueString: 'ChildName' }); }); + + it('should apply CaretValueRules on the targetProfile of a type', () => { + // Alias: $typeMS = http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support + doc.aliases.set( + '$typeMS', + 'http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support' + ); + // Profile: MyDiagnosticReport + // Parent: DiagnosticReport + // * result only Reference(observation-bodyheight or observation-bodyweight) + // * ^type[0].targetProfile[0].extension[$typeMS].valueBoolean = true + // * ^type[0].targetProfile[1].extension[$typeMS].valueBoolean = true + const profile = new Profile('MyDiagnosticReport'); + profile.parent = 'DiagnosticReport'; + const resultOnly = new OnlyRule('result'); + resultOnly.types = [ + { type: 'observation-bodyheight', isReference: true }, + { type: 'observation-bodyweight', isReference: true } + ]; + const firstExtension = new CaretValueRule('result'); + firstExtension.caretPath = 'type[0].targetProfile[0].extension[$typeMS].valueBoolean'; + firstExtension.value = true; + const secondExtension = new CaretValueRule('result'); + secondExtension.caretPath = 'type[0].targetProfile[1].extension[$typeMS].valueBoolean'; + secondExtension.value = true; + profile.rules.push(resultOnly, firstExtension, secondExtension); + + exporter.exportStructDef(profile); + const sd = pkg.profiles[0]; + + const ed = sd.elements.find(e => e.id === 'DiagnosticReport.result'); + expect(ed.type).toHaveLength(1); + const expectedType = new ElementDefinitionType('Reference').withTargetProfiles( + 'http://hl7.org/fhir/StructureDefinition/bodyheight', + 'http://hl7.org/fhir/StructureDefinition/bodyweight' + ); + expectedType._targetProfile = [ + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support', + valueBoolean: true + } + ] + }, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support', + valueBoolean: true + } + ] + } + ]; + expect(ed.type[0]).toEqual(expectedType); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); }); describe('#ObeysRule', () => { diff --git a/test/fhirtypes/ElementDefinition.setInstancePropertyByPath.test.ts b/test/fhirtypes/ElementDefinition.setInstancePropertyByPath.test.ts index ea2fb6c1a..2e2574335 100644 --- a/test/fhirtypes/ElementDefinition.setInstancePropertyByPath.test.ts +++ b/test/fhirtypes/ElementDefinition.setInstancePropertyByPath.test.ts @@ -100,7 +100,10 @@ describe('ElementDefinition', () => { it('should change a part of an instance property in an array', () => { status.setInstancePropertyByPath('type[0].profile[0]', 'foo', fisher); expect(status.type.length).toBe(1); - expect(status.type[0]).toEqual(new ElementDefinitionType('code').withProfiles('foo')); + // setting an element in the profile array creates an empty element at the corresponding index in the _profile array + const expectedType = new ElementDefinitionType('code').withProfiles('foo'); + expectedType._profile = [{}]; + expect(status.type[0]).toEqual(expectedType); }); // Complex values diff --git a/test/testhelpers/testdefs/StructureDefinition-elementdefinition-type-must-support.json b/test/testhelpers/testdefs/StructureDefinition-elementdefinition-type-must-support.json new file mode 100644 index 000000000..560da2f8c --- /dev/null +++ b/test/testhelpers/testdefs/StructureDefinition-elementdefinition-type-must-support.json @@ -0,0 +1,251 @@ +{ + "resourceType" : "StructureDefinition", + "id" : "elementdefinition-type-must-support", + "text" : { + "status" : "extensions", + "div" : "
\r\n\r\n\r\n\r\n
NameFlagsCard.TypeDescription & Constraints\"doco\"
\".\"\".\" Extension 0..1ExtensionIf true, the specified type/profile/target must be supported by implementations
\".\"\".\"\".\" extension 0..0
\".\"\".\"\".\" url 1..1uri"http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support"
\".\"\".\"\".\" value[x] 1..1booleanValue of extension

\"doco\" Documentation for this format
" + }, + "extension" : [{ + "url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-wg", + "valueCode" : "fhir" + }, + { + "url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-fmm", + "valueInteger" : 2 + }, + { + "url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status", + "valueCode" : "trial-use" + }], + "url" : "http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support", + "version" : "1.0.0", + "name" : "TypeMustSupport", + "title" : "Type must support", + "status" : "draft", + "experimental" : false, + "date" : "2015-02-28", + "publisher" : "HL7 International / FHIR Infrastructure", + "contact" : [{ + "telecom" : [{ + "system" : "url", + "value" : "http://hl7.org/Special/committees/fhir-i" + }] + }], + "description" : "If true indicates that the specified type, profile or targetProfile must be supported by implementations.", + "jurisdiction" : [{ + "coding" : [{ + "system" : "http://unstats.un.org/unsd/methods/m49/m49.htm", + "code" : "001" + }] + }], + "fhirVersion" : "5.0.0", + "mapping" : [{ + "identity" : "rim", + "uri" : "http://hl7.org/v3", + "name" : "RIM Mapping" + }], + "kind" : "complex-type", + "abstract" : false, + "context" : [{ + "type" : "element", + "expression" : "ElementDefinition.type" + }, + { + "type" : "element", + "expression" : "ElementDefinition.type.profile" + }, + { + "type" : "element", + "expression" : "ElementDefinition.type.targetProfile" + }], + "type" : "Extension", + "baseDefinition" : "http://hl7.org/fhir/StructureDefinition/Extension", + "derivation" : "constraint", + "snapshot" : { + "element" : [{ + "id" : "Extension", + "path" : "Extension", + "short" : "If true, the specified type/profile/target must be supported by implementations", + "definition" : "If true indicates that the specified type, profile or targetProfile must be supported by implementations.", + "comment" : "An element may be labelled as must support. This extension clarifies which types/profiles/targetProfiles are must-support. It has no meaning if the element itself is not must-support. If the element is labelled must-support, and none of the options are labelled as must support, then an application must support at least one of the possible options, but is not required to support all of them. Specific details on what it means to 'support' the specified profile will be defined either by the implementation guide, by the profile and/or in usage notes for the element holding the extension. If this extension is not declared on a mustSupport element, the presumption is that implementations need only support one of the potential types unless other documentation in the specification explicitly dictates otherwise.", + "min" : 0, + "max" : "1", + "base" : { + "path" : "Extension", + "min" : 0, + "max" : "*" + }, + "constraint" : [{ + "key" : "ele-1", + "severity" : "error", + "human" : "All FHIR elements must have a @value or children", + "expression" : "hasValue() or (children().count() > id.count())", + "source" : "http://hl7.org/fhir/StructureDefinition/Element" + }, + { + "key" : "ext-1", + "severity" : "error", + "human" : "Must have either extensions or value[x], not both", + "expression" : "extension.exists() != value.exists()", + "source" : "http://hl7.org/fhir/StructureDefinition/Extension" + }], + "isModifier" : false + }, + { + "id" : "Extension.id", + "path" : "Extension.id", + "representation" : ["xmlAttr"], + "short" : "Unique id for inter-element referencing", + "definition" : "Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", + "min" : 0, + "max" : "1", + "base" : { + "path" : "Element.id", + "min" : 0, + "max" : "1" + }, + "type" : [{ + "extension" : [{ + "url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-fhir-type", + "valueUrl" : "id" + }], + "code" : "http://hl7.org/fhirpath/System.String" + }], + "condition" : ["ele-1"], + "isModifier" : false, + "isSummary" : false, + "mapping" : [{ + "identity" : "rim", + "map" : "n/a" + }] + }, + { + "id" : "Extension.extension", + "path" : "Extension.extension", + "slicing" : { + "discriminator" : [{ + "type" : "value", + "path" : "url" + }], + "description" : "Extensions are always sliced by (at least) url", + "rules" : "open" + }, + "short" : "Extension", + "definition" : "An Extension", + "min" : 0, + "max" : "0", + "base" : { + "path" : "Element.extension", + "min" : 0, + "max" : "*" + }, + "type" : [{ + "code" : "Extension" + }], + "constraint" : [{ + "key" : "ele-1", + "severity" : "error", + "human" : "All FHIR elements must have a @value or children", + "expression" : "hasValue() or (children().count() > id.count())", + "source" : "http://hl7.org/fhir/StructureDefinition/Element" + }, + { + "key" : "ext-1", + "severity" : "error", + "human" : "Must have either extensions or value[x], not both", + "expression" : "extension.exists() != value.exists()", + "source" : "http://hl7.org/fhir/StructureDefinition/Extension" + }], + "isModifier" : false, + "isSummary" : false + }, + { + "id" : "Extension.url", + "path" : "Extension.url", + "representation" : ["xmlAttr"], + "short" : "identifies the meaning of the extension", + "definition" : "Source of the definition for the extension code - a logical name or a URL.", + "comment" : "The definition may point directly to a computable or human-readable definition of the extensibility codes, or it may be a logical URI as declared in some other specification. The definition SHALL be a URI for the Structure Definition defining the extension.", + "min" : 1, + "max" : "1", + "base" : { + "path" : "Extension.url", + "min" : 1, + "max" : "1" + }, + "type" : [{ + "extension" : [{ + "url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-fhir-type", + "valueUrl" : "uri" + }], + "code" : "http://hl7.org/fhirpath/System.String" + }], + "fixedUri" : "http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support", + "isModifier" : false, + "isSummary" : false, + "mapping" : [{ + "identity" : "rim", + "map" : "N/A" + }] + }, + { + "id" : "Extension.value[x]", + "path" : "Extension.value[x]", + "short" : "Value of extension", + "definition" : "Value of extension - must be one of a constrained set of the data types (see [Extensibility](http://hl7.org/fhir/5.0.0-snapshot3/extensibility.html) for a list).", + "min" : 1, + "max" : "1", + "base" : { + "path" : "Extension.value[x]", + "min" : 0, + "max" : "1" + }, + "type" : [{ + "code" : "boolean" + }], + "condition" : ["ext-1"], + "constraint" : [{ + "key" : "ele-1", + "severity" : "error", + "human" : "All FHIR elements must have a @value or children", + "expression" : "hasValue() or (children().count() > id.count())", + "source" : "http://hl7.org/fhir/StructureDefinition/Element" + }], + "isModifier" : false, + "isSummary" : false, + "mapping" : [{ + "identity" : "rim", + "map" : "N/A" + }] + }] + }, + "differential" : { + "element" : [{ + "id" : "Extension", + "path" : "Extension", + "short" : "If true, the specified type/profile/target must be supported by implementations", + "definition" : "If true indicates that the specified type, profile or targetProfile must be supported by implementations.", + "comment" : "An element may be labelled as must support. This extension clarifies which types/profiles/targetProfiles are must-support. It has no meaning if the element itself is not must-support. If the element is labelled must-support, and none of the options are labelled as must support, then an application must support at least one of the possible options, but is not required to support all of them. Specific details on what it means to 'support' the specified profile will be defined either by the implementation guide, by the profile and/or in usage notes for the element holding the extension. If this extension is not declared on a mustSupport element, the presumption is that implementations need only support one of the potential types unless other documentation in the specification explicitly dictates otherwise.", + "min" : 0, + "max" : "1" + }, + { + "id" : "Extension.extension", + "path" : "Extension.extension", + "max" : "0" + }, + { + "id" : "Extension.url", + "path" : "Extension.url", + "fixedUri" : "http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support" + }, + { + "id" : "Extension.value[x]", + "path" : "Extension.value[x]", + "min" : 1, + "type" : [{ + "code" : "boolean" + }] + }] + } +} \ No newline at end of file