From 939718332de81b5f69177d692bfe5d14cd2bce62 Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Mon, 16 Sep 2024 09:13:21 -0400 Subject: [PATCH] Assign date or dateTime that contains only year A FHIR date or dateTime may be 4-digit year. The FSH parser will assign this value a NUMBER token type. When assigning to a date or dateTime element, if a type mismatch occurs, and the original value is a number, try to assign the raw value. If it is a valid 4-digit year, it will be assigned successfully. Otherwise, the original error will be thrown and logged. --- src/export/CodeSystemExporter.ts | 39 +++++- src/export/InstanceExporter.ts | 27 +++- src/export/StructureDefinitionExporter.ts | 60 +++++++- src/export/ValueSetExporter.ts | 35 ++++- test/export/CodeSystemExporter.test.ts | 86 ++++++++++++ test/export/InstanceExporter.test.ts | 31 +++++ .../StructureDefinitionExporter.test.ts | 129 ++++++++++++++++++ test/export/ValueSetExporter.test.ts | 43 ++++++ 8 files changed, 444 insertions(+), 6 deletions(-) diff --git a/src/export/CodeSystemExporter.ts b/src/export/CodeSystemExporter.ts index 36ef05091..e88f9522d 100644 --- a/src/export/CodeSystemExporter.ts +++ b/src/export/CodeSystemExporter.ts @@ -16,7 +16,7 @@ import { logger } from '../utils/FSHLogger'; import { MasterFisher, assembleFSHPath, resolveSoftIndexing } from '../utils'; import { InstanceExporter, Package } from '.'; import { CannotResolvePathError, MismatchedTypeError } from '../errors'; -import { isEqual } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; export class CodeSystemExporter { constructor( @@ -157,11 +157,46 @@ export class CodeSystemExporter { } return replacedRule; } catch (originalErr) { + // if the value is a number, it may have been a four-digit number + // that we tried to assign to a date or dateTime. + // a four-digit number could be a valid year, so see if it can be assigned. + if ( + originalErr instanceof MismatchedTypeError && + ['date', 'dateTime'].includes(originalErr.elementType) && + ['number', 'bigint'].includes(typeof rule.value) + ) { + try { + const retryRule = cloneDeep(rule); + const { pathParts } = codeSystemSD.validateValueAtPath( + path, + retryRule.rawValue, + this.fisher + ); + if (pathParts.some(part => isExtension(part.base))) { + ruleMap.set(assembleFSHPath(pathParts).replace(/\[0+\]/g, ''), { pathParts }); + } + retryRule.value = retryRule.rawValue; + return retryRule; + } catch (retryErr) { + if (retryErr instanceof MismatchedTypeError) { + logger.error(originalErr.message, rule.sourceInfo); + if (originalErr.stack) { + logger.debug(originalErr.stack); + } + } else { + logger.error(retryErr.message, rule.sourceInfo); + if (retryErr.stack) { + logger.debug(retryErr.stack); + } + } + return null; + } + } // if an Instance has an id that looks like a number, bigint, or boolean, // we may have tried to assign that value instead of an Instance. // try to fish up an Instance with the rule's raw value. // if we find one, try assigning that instead. - if ( + else if ( originalErr instanceof MismatchedTypeError && ['number', 'bigint', 'boolean'].includes(typeof rule.value) ) { diff --git a/src/export/InstanceExporter.ts b/src/export/InstanceExporter.ts index f2a9bb6f6..c2a8c74cd 100644 --- a/src/export/InstanceExporter.ts +++ b/src/export/InstanceExporter.ts @@ -237,11 +237,36 @@ export class InstanceExporter implements Fishable { } doRuleValidation(rule instanceof AssignmentRule ? rule.value : null); } catch (originalErr) { + // if the value is a number, it may have been a four-digit number + // that we tried to assign to a date or dateTime. + // a four-digit number could be a valid year, so see if it can be assigned. + if ( + rule instanceof AssignmentRule && + originalErr instanceof MismatchedTypeError && + ['date', 'dateTime'].includes(originalErr.elementType) && + ['number', 'bigint'].includes(typeof rule.value) + ) { + try { + doRuleValidation(rule.rawValue); + } catch (retryErr) { + if (retryErr instanceof MismatchedTypeError) { + logger.error(originalErr.message, rule.sourceInfo); + if (originalErr.stack) { + logger.debug(originalErr.stack); + } + } else { + logger.error(retryErr.message, rule.sourceInfo); + if (retryErr.stack) { + logger.debug(retryErr.stack); + } + } + } + } // if an Instance has an id that looks like a number, bigint, or boolean, // we may have tried to validate with that value instead of an Instance. // try to fish up an Instance with the rule's raw value. // if we find one, try validating with that instead. - if ( + else if ( rule instanceof AssignmentRule && originalErr instanceof MismatchedTypeError && ['number', 'bigint', 'boolean'].includes(typeof rule.value) diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts index 09ff205f9..48205b165 100644 --- a/src/export/StructureDefinitionExporter.ts +++ b/src/export/StructureDefinitionExporter.ts @@ -801,7 +801,15 @@ export class StructureDefinitionExporter implements Fishable { } const replacedRule = replaceReferences(rule, this.tank, this); try { - element.assignValue(replacedRule.value, replacedRule.exactly, this); + // since we have the element already, we can check for the "date parsed as number" special case now instead of after an exception + if ( + ['date', 'dateTime'].includes(element.type[0].code) && + ['number', 'bigint'].includes(typeof replacedRule.value) + ) { + element.assignValue(replacedRule.rawValue, replacedRule.exactly, this); + } else { + element.assignValue(replacedRule.value, replacedRule.exactly, this); + } } catch (originalErr) { // if an Instance has an id that looks like a number, bigint, or boolean, // we may have tried to assign that value instead of an Instance. @@ -911,7 +919,34 @@ export class StructureDefinitionExporter implements Fishable { } else if (rule instanceof CaretValueRule) { const replacedRule = replaceReferences(rule, this.tank, this); if (replacedRule.path !== '') { - element.setInstancePropertyByPath(replacedRule.caretPath, replacedRule.value, this); + try { + element.setInstancePropertyByPath(replacedRule.caretPath, replacedRule.value, this); + } catch (originalErr) { + // if the value is a number, it may have been a four-digit number + // that we tried to assign to a date or dateTime. + // a four-digit number could be a valid year, so see if it can be assigned. + if ( + originalErr instanceof MismatchedTypeError && + ['date', 'dateTime'].includes(originalErr.elementType) && + ['number', 'bigint'].includes(typeof replacedRule.value) + ) { + try { + element.setInstancePropertyByPath( + replacedRule.caretPath, + replacedRule.rawValue, + this + ); + } catch (retryErr) { + if (retryErr instanceof MismatchedTypeError) { + throw originalErr; + } else { + throw retryErr; + } + } + } else { + throw originalErr; + } + } } else { if (replacedRule.isInstance) { if (this.deferredCaretRules.has(structDef)) { @@ -927,7 +962,28 @@ export class StructureDefinitionExporter implements Fishable { this ); } catch (originalErr) { + // if the value is a number, it may have been a four-digit number + // that we tried to assign to a date or dateTime. + // a four-digit number could be a valid year, so see if it can be assigned. if ( + originalErr instanceof MismatchedTypeError && + ['date', 'dateTime'].includes(originalErr.elementType) && + ['number', 'bigint'].includes(typeof replacedRule.value) + ) { + try { + structDef.setInstancePropertyByPath( + replacedRule.caretPath, + replacedRule.rawValue, + this + ); + } catch (retryErr) { + if (retryErr instanceof MismatchedTypeError) { + throw originalErr; + } else { + throw retryErr; + } + } + } else if ( originalErr instanceof MismatchedTypeError && ['number', 'bigint', 'boolean'].includes(typeof rule.value) ) { diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts index e27ca9ed7..89a2391d1 100644 --- a/src/export/ValueSetExporter.ts +++ b/src/export/ValueSetExporter.ts @@ -26,7 +26,7 @@ import { setImpliedPropertiesOnInstance } from '../fhirtypes/common'; import { isUri } from 'valid-url'; -import { flatMap, partition, xor } from 'lodash'; +import { cloneDeep, flatMap, partition, xor } from 'lodash'; export class ValueSetExporter { constructor( @@ -240,6 +240,39 @@ export class ValueSetExporter { ruleMap.set(assembleFSHPath(pathParts).replace(/\[0+\]/g, ''), { pathParts }); return rule; } catch (originalErr) { + // if the value is a number, it may have been a four-digit number + // that we tried to assign to a date or dateTime. + // a four-digit number could be a valid year, so see if it can be assigned. + if ( + originalErr instanceof MismatchedTypeError && + ['date', 'dateTime'].includes(originalErr.elementType) && + ['number', 'bigint'].includes(typeof rule.value) + ) { + try { + const retryRule = cloneDeep(rule); + const { pathParts } = valueSetSD.validateValueAtPath( + rule.caretPath, + retryRule.rawValue, + this.fisher + ); + ruleMap.set(assembleFSHPath(pathParts).replace(/\[0+\]/g, ''), { pathParts }); + retryRule.value = retryRule.rawValue; + return retryRule; + } catch (retryErr) { + if (retryErr instanceof MismatchedTypeError) { + logger.error(originalErr.message, rule.sourceInfo); + if (originalErr.stack) { + logger.debug(originalErr.stack); + } + } else { + logger.error(retryErr.message, rule.sourceInfo); + if (retryErr.stack) { + logger.debug(retryErr.stack); + } + } + return null; + } + } // if an Instance has an id that looks like a number, bigint, or boolean, // we may have tried to assign that value instead of an Instance. // try to fish up an Instance with the rule's raw value. diff --git a/test/export/CodeSystemExporter.test.ts b/test/export/CodeSystemExporter.test.ts index d1e5b9c24..501b5a3ee 100644 --- a/test/export/CodeSystemExporter.test.ts +++ b/test/export/CodeSystemExporter.test.ts @@ -1231,6 +1231,92 @@ describe('CodeSystemExporter', () => { ); }); + it('should assign a date that was parsed as a number', () => { + // CodeSystem: CaretCodeSystem + // * #someCode "Some Code" + // * ^extension[0].url = "http://example.org/SomeExt" + // * ^extension[0].valueDate = 2023 + const codeSystem = new FshCodeSystem('CaretCodeSystem'); + const someCode = new ConceptRule('someCode', 'Some Code'); + const extensionUrl = new CaretValueRule(''); + extensionUrl.caretPath = 'extension[0].url'; + extensionUrl.value = 'http://example.org/SomeExt'; + const extensionValue = new CaretValueRule(''); + extensionValue.caretPath = 'extension[0].valueDate'; + extensionValue.value = BigInt(2023); + extensionValue.rawValue = '2023'; + codeSystem.rules.push(someCode, extensionUrl, extensionValue); + doc.codeSystems.set(codeSystem.name, codeSystem); + + const exported = exporter.export().codeSystems; + expect(exported.length).toBe(1); + expect(exported[0]).toEqual({ + resourceType: 'CodeSystem', + id: 'CaretCodeSystem', + name: 'CaretCodeSystem', + content: 'complete', + url: 'http://hl7.org/fhir/us/minimal/CodeSystem/CaretCodeSystem', + count: 1, + status: 'draft', + extension: [ + { + url: 'http://example.org/SomeExt', + valueDate: '2023' + } + ], + concept: [ + { + code: 'someCode', + display: 'Some Code' + } + ] + }); + }); + + it('should assign a dateTime that was parsed as a number', () => { + // CodeSystem: CaretCodeSystem + // * #someCode "Some Code" + // * #someCode ^property[0].code = #standard + // * #someCode ^property[0].valueDateTime = 0081 + const codeSystem = new FshCodeSystem('CaretCodeSystem'); + const someCode = new ConceptRule('someCode', 'Some Code'); + const propertyCode = new CaretValueRule(''); + propertyCode.pathArray = ['#someCode']; + propertyCode.caretPath = 'property[0].code'; + propertyCode.value = new FshCode('standard'); + const propertyValue = new CaretValueRule(''); + propertyValue.pathArray = ['#someCode']; + propertyValue.caretPath = 'property[0].valueDateTime'; + propertyValue.value = BigInt(81); + propertyValue.rawValue = '0081'; + codeSystem.rules.push(someCode, propertyCode, propertyValue); + doc.codeSystems.set(codeSystem.name, codeSystem); + + const exported = exporter.export().codeSystems; + expect(exported.length).toBe(1); + expect(exported[0]).toEqual({ + resourceType: 'CodeSystem', + id: 'CaretCodeSystem', + name: 'CaretCodeSystem', + content: 'complete', + url: 'http://hl7.org/fhir/us/minimal/CodeSystem/CaretCodeSystem', + count: 1, + status: 'draft', + concept: [ + { + code: 'someCode', + display: 'Some Code', + property: [ + { + code: 'standard', + valueDateTime: '0081' + } + ] + } + ] + }); + }); + describe('#insertRules', () => { let cs: FshCodeSystem; let ruleSet: RuleSet; diff --git a/test/export/InstanceExporter.test.ts b/test/export/InstanceExporter.test.ts index 2ff78d280..818085577 100644 --- a/test/export/InstanceExporter.test.ts +++ b/test/export/InstanceExporter.test.ts @@ -10117,6 +10117,37 @@ describe('InstanceExporter', () => { }); }); + it('should assign a date that was parsed as a number', () => { + // Instance: ExampleObs + // InstanceOf: Observation + // * extension[0].url = "http://example.org/SomeExt" + // * extension[0].valueDate = 2055 + const observation = new Instance('ExampleObs'); + observation.instanceOf = 'Observation'; + const extensionUrl = new AssignmentRule('extension[0].url'); + extensionUrl.value = 'http://example.org/SomeExt'; + const extensionValue = new AssignmentRule('extension[0].valueDate'); + extensionValue.value = BigInt(2055); + extensionValue.rawValue = '2055'; + observation.rules.push(extensionUrl, extensionValue); + const exportedInstance = exportInstance(observation); + expect(exportedInstance.extension[0].valueDate).toEqual('2055'); + }); + + it('should assign a dateTime that was parsed as a number', () => { + // Instance: ExampleObs + // InstanceOf: Observation + // * valueDateTime = 0895 + const observation = new Instance('ExampleObs'); + observation.instanceOf = 'Observation'; + const assignmentRule = new AssignmentRule('valueDateTime'); + assignmentRule.value = BigInt(895); + assignmentRule.rawValue = '0895'; + observation.rules.push(assignmentRule); + const exportedInstance = exportInstance(observation); + expect(exportedInstance.valueDateTime).toEqual('0895'); + }); + describe('#TimeTravelingResources', () => { it('should export a R5 ActorDefinition in a R4 IG', () => { // Instance: AD1 diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts index e66042e87..c5bfa9b3b 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -6392,6 +6392,40 @@ describe('StructureDefinitionExporter R4', () => { expect(assignedId.patternString).toBe('my-special-id'); }); + it('should apply an AssignmentRule to a date when the date was parsed as a number', () => { + // Profile: MyPatient + // Parent: Patient + // * birthDate = 1998 + const profile = new Profile('MyPatient'); + profile.parent = 'Patient'; + const birthDate = new AssignmentRule('birthDate'); + birthDate.value = BigInt(1998); + birthDate.rawValue = '1998'; + profile.rules.push(birthDate); + + const exported = exporter.exportStructDef(profile); + const assignedDate = exported.findElement('Patient.birthDate'); + expect(assignedDate.patternDate).toBe('1998'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should apply an AssignmentRule to a dateTime when the dateTime was parsed as a number', () => { + // Profile: MyObs + // Parent: Observation + // * effectiveDateTime = 2023 + const profile = new Profile('MyObs'); + profile.parent = 'Observation'; + const effectiveDateTime = new AssignmentRule('effectiveDateTime'); + effectiveDateTime.value = BigInt(2023); + effectiveDateTime.rawValue = '2023'; + profile.rules.push(effectiveDateTime); + + const exported = exporter.exportStructDef(profile); + const assignedDateTime = exported.findElement('Observation.effective[x]:effectiveDateTime'); + expect(assignedDateTime.patternDateTime).toBe('2023'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + it('should not apply an incorrect AssignmentRule', () => { const profile = new Profile('Foo'); profile.parent = 'Observation'; @@ -8534,6 +8568,101 @@ describe('StructureDefinitionExporter R4', () => { null ]); }); + + it('should apply a CaretValueRule with no path to a date when the date was parsed as a number', () => { + // Profile: MyObs + // Parent: Observation + // * ^extension[0].url = "http://example.org/SomeExt" + // * ^extension[0].valueDate = 2023 + const profile = new Profile('MyObs'); + profile.parent = 'Observation'; + const extensionUrl = new CaretValueRule(''); + extensionUrl.caretPath = 'extension[0].url'; + extensionUrl.value = 'http://example.org/SomeExt'; + const extensionValue = new CaretValueRule(''); + extensionValue.caretPath = 'extension[0].valueDate'; + extensionValue.value = BigInt(2023); + extensionValue.rawValue = '2023'; + profile.rules.push(extensionUrl, extensionValue); + + const exported = exporter.exportStructDef(profile); + expect(exported.extension[0]).toEqual({ + url: 'http://example.org/SomeExt', + valueDate: '2023' + }); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should apply a CaretValueRule with no path to a dateTime when the dateTime was parsed as a number', () => { + // Profile: MyObs + // Parent: Observation + // * ^date = 2024 + const profile = new Profile('MyObs'); + profile.parent = 'Observation'; + const dateRule = new CaretValueRule(''); + dateRule.caretPath = 'date'; + dateRule.value = BigInt(2024); + dateRule.rawValue = '2024'; + profile.rules.push(dateRule); + + const exported = exporter.exportStructDef(profile); + expect(exported.date).toBe('2024'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should apply a CaretValueRule with a path to a date when the date was parsed as a number', () => { + // Profile: MyObs + // Parent: Observation + // * code ^extension[0].url = "http://example.org/SomeDateExt" + // * code ^extension[0].valueDate = 0500 + const profile = new Profile('MyObs'); + profile.parent = 'Observation'; + const extensionUrl = new CaretValueRule('code'); + extensionUrl.caretPath = 'extension[0].url'; + extensionUrl.value = 'http://example.org/SomeDateExt'; + const extensionValue = new CaretValueRule('code'); + extensionValue.caretPath = 'extension[0].valueDate'; + extensionValue.value = BigInt(500); + extensionValue.rawValue = '0500'; + profile.rules.push(extensionUrl, extensionValue); + + const exported = exporter.exportStructDef(profile); + const codeElement = exported.findElement('Observation.code'); + expect(codeElement.extension).toEqual([ + { + url: 'http://example.org/SomeDateExt', + valueDate: '0500' + } + ]); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should apply a CaretValueRule with a path to a dateTime when the dateTime was parsed as a number', () => { + // Profile: MyObs + // Parent: Observation + // * code ^extension[0].url = "http://example.org/SomeDateTimeExt" + // * code ^extension[0].valueDateTime = 0500 + const profile = new Profile('MyObs'); + profile.parent = 'Observation'; + const extensionUrl = new CaretValueRule('code'); + extensionUrl.caretPath = 'extension[0].url'; + extensionUrl.value = 'http://example.org/SomeDateTimeExt'; + const extensionValue = new CaretValueRule('code'); + extensionValue.caretPath = 'extension[0].valueDateTime'; + extensionValue.value = BigInt(500); + extensionValue.rawValue = '0500'; + profile.rules.push(extensionUrl, extensionValue); + + const exported = exporter.exportStructDef(profile); + const codeElement = exported.findElement('Observation.code'); + expect(codeElement.extension).toEqual([ + { + url: 'http://example.org/SomeDateTimeExt', + valueDateTime: '0500' + } + ]); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); }); describe('#ObeysRule', () => { diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts index b6f9a35a4..95142eafa 100644 --- a/test/export/ValueSetExporter.test.ts +++ b/test/export/ValueSetExporter.test.ts @@ -1852,6 +1852,49 @@ describe('ValueSetExporter', () => { ); }); + it('should assign a date that was parsed as a number', () => { + // ValueSet: BreakfastVS + // Title: "Breakfast Values" + // * ^extension[0].url = "http://example.org/SomeExt" + // * ^extension[0].valueDate = 2023 + const valueSet = new FshValueSet('BreakfastVS'); + valueSet.title = 'Breakfast Values'; + const extensionUrl = new CaretValueRule(''); + extensionUrl.caretPath = 'extension[0].url'; + extensionUrl.value = 'http://example.org/SomeExt'; + const extensionValue = new CaretValueRule(''); + extensionValue.caretPath = 'extension[0].valueDate'; + extensionValue.value = BigInt(2023); + extensionValue.rawValue = '2023'; + valueSet.rules.push(extensionUrl, extensionValue); + doc.valueSets.set(valueSet.name, valueSet); + + const exported = exporter.export().valueSets; + expect(exported.length).toBe(1); + expect(exported[0].extension[0]).toEqual({ + url: 'http://example.org/SomeExt', + valueDate: '2023' + }); + }); + + it('should assign a dateTime that was parsed as a number', () => { + // ValueSet: BreakfastVS + // Title: "Breakfast Values" + // * ^date = 2024 + const valueSet = new FshValueSet('BreakfastVS'); + valueSet.title = 'Breakfast Values'; + const dateRule = new CaretValueRule(''); + dateRule.caretPath = 'date'; + dateRule.value = BigInt(2024); + dateRule.rawValue = '2024'; + valueSet.rules.push(dateRule); + doc.valueSets.set(valueSet.name, valueSet); + + const exported = exporter.export().valueSets; + expect(exported.length).toBe(1); + expect(exported[0].date).toEqual('2024'); + }); + it('should export a value set with an extension', () => { const valueSet = new FshValueSet('BreakfastVS'); valueSet.title = 'Breakfast Values';