From b9b50f71ff38a4908d3ab12cdbbe74ea272a1e07 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Mon, 27 May 2024 13:21:09 +0200 Subject: [PATCH] feat: add implicit merge for if block instead of merge Closes #7 --- ts/server/src/utils/example1.fhir.spec.ts | 161 ++++++++ ts/server/src/utils/extract.spec.ts | 456 ++++++++++------------ ts/server/src/utils/extract.ts | 96 ++--- 3 files changed, 401 insertions(+), 312 deletions(-) create mode 100644 ts/server/src/utils/example1.fhir.spec.ts diff --git a/ts/server/src/utils/example1.fhir.spec.ts b/ts/server/src/utils/example1.fhir.spec.ts new file mode 100644 index 0000000..42a8455 --- /dev/null +++ b/ts/server/src/utils/example1.fhir.spec.ts @@ -0,0 +1,161 @@ +import { QuestionnaireResponse } from 'fhir/r4b'; +import { resolveTemplate } from './extract'; +import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; + +const qr: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'completed', + item: [ + { + linkId: 'name', + item: [ + { + linkId: 'last-name', + answer: [ + { + valueString: 'Beda', + }, + ], + }, + { + linkId: 'first-name', + answer: [ + { + valueString: 'Ilya', + }, + ], + }, + { + linkId: 'middle-name', + answer: [ + { + valueString: 'Alekseevich', + }, + ], + }, + ], + }, + { + linkId: 'birth-date', + answer: [ + { + valueDate: '2023-05-01', + }, + ], + }, + { + linkId: 'gender', + answer: [ + { + valueCoding: { code: 'male' }, + }, + ], + }, + { + linkId: 'ssn', + answer: [ + { + valueString: '123', + }, + ], + }, + { + linkId: 'mobile', + answer: [ + { + valueString: '11231231231', + }, + ], + }, + ], +}; + +const template1 = { + resourceType: 'Patient', + name: [ + { + family: "{{ QuestionnaireResponse.repeat(item).where(linkId='last-name').answer.value }}", + given: [ + "{{ QuestionnaireResponse.repeat(item).where(linkId='first-name').answer.value }}", + "{{ QuestionnaireResponse.repeat(item).where(linkId='middle-name').answer.value }}", + ], + }, + ], + birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", + gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", + telecom: [ + { + system: 'phone', + value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", + }, + ], + identifier: [ + { + system: 'http://hl7.org/fhir/sid/us-ssn', + value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", + }, + ], +}; + + +const template2 = { + resourceType: 'Patient', + name: { + "{{ QuestionnaireResponse.item.where(linkId='name') }}": { + family: "{{ item.where(linkId='last-name').answer.valueString }}", + given: [ + "{{ item.where(linkId='first-name').answer.valueString }}", + "{{ item.where(linkId='middle-name').answer.valueString }}", + ], + }, + }, + birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", + gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", + telecom: [ + { + system: 'phone', + value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", + }, + ], + identifier: [ + { + system: 'http://hl7.org/fhir/sid/us-ssn', + value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", + }, + ], +}; + +const result = { + birthDate: '2023-05-01', + gender: 'male', + identifier: [ + { + system: 'http://hl7.org/fhir/sid/us-ssn', + value: '123', + }, + ], + name: [ + { + family: 'Beda', + given: ['Ilya', 'Alekseevich'], + }, + ], + resourceType: 'Patient', + telecom: [ + { + system: 'phone', + value: '11231231231', + }, + ], +}; + +describe('Extraction', () => { + test('Simple transformation', () => { + expect(resolveTemplate(qr, template1, {}, fhirpath_r4_model)).toStrictEqual(result); + }); + + test('List transformation', () => { + expect(resolveTemplate(qr, template2, {}, fhirpath_r4_model)).toStrictEqual(result); + }); +}); + diff --git a/ts/server/src/utils/extract.spec.ts b/ts/server/src/utils/extract.spec.ts index 15737f5..7cdad82 100644 --- a/ts/server/src/utils/extract.spec.ts +++ b/ts/server/src/utils/extract.spec.ts @@ -1,203 +1,4 @@ -import { QuestionnaireResponse } from 'fhir/r4b'; -import { resolveTemplate } from './extract'; -import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; - -const qr: QuestionnaireResponse = { - resourceType: 'QuestionnaireResponse', - status: 'completed', - item: [ - { - linkId: 'name', - item: [ - { - linkId: 'last-name', - answer: [ - { - valueString: 'Beda', - }, - ], - }, - { - linkId: 'first-name', - answer: [ - { - valueString: 'Ilya', - }, - ], - }, - { - linkId: 'middle-name', - answer: [ - { - valueString: 'Alekseevich', - }, - ], - }, - ], - }, - { - linkId: 'birth-date', - answer: [ - { - valueDate: '2023-05-01', - }, - ], - }, - { - linkId: 'gender', - answer: [ - { - valueCoding: { code: 'male' }, - }, - ], - }, - { - linkId: 'ssn', - answer: [ - { - valueString: '123', - }, - ], - }, - { - linkId: 'mobile', - answer: [ - { - valueString: '11231231231', - }, - ], - }, - ], -}; - -const template1 = { - resourceType: 'Patient', - name: [ - { - family: "{{ QuestionnaireResponse.repeat(item).where(linkId='last-name').answer.value }}", - given: [ - "{{ QuestionnaireResponse.repeat(item).where(linkId='first-name').answer.value }}", - "{{ QuestionnaireResponse.repeat(item).where(linkId='middle-name').answer.value }}", - ], - }, - ], - birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", - gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", - telecom: [ - { - system: 'phone', - value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", - }, - ], - identifier: [ - { - system: 'http://hl7.org/fhir/sid/us-ssn', - value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", - }, - ], -}; - -const template2 = { - resourceType: 'Patient', - name: { - "{{ QuestionnaireResponse.item.where(linkId='name') }}": { - family: "{{ item.where(linkId='last-name').answer.valueString }}", - given: [ - "{{ item.where(linkId='first-name').answer.valueString }}", - "{{ item.where(linkId='middle-name').answer.valueString }}", - ], - }, - }, - birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", - gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", - telecom: [ - { - system: 'phone', - value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", - }, - ], - identifier: [ - { - system: 'http://hl7.org/fhir/sid/us-ssn', - value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", - }, - ], -}; - -const result = { - birthDate: '2023-05-01', - gender: 'male', - identifier: [ - { - system: 'http://hl7.org/fhir/sid/us-ssn', - value: '123', - }, - ], - name: [ - { - family: 'Beda', - given: ['Ilya', 'Alekseevich'], - }, - ], - resourceType: 'Patient', - telecom: [ - { - system: 'phone', - value: '11231231231', - }, - ], -}; - -describe('Extraction', () => { - test('Simple transformation', () => { - expect(resolveTemplate(qr, template1, {}, fhirpath_r4_model)).toStrictEqual(result); - }); - - test('List transformation', () => { - expect(resolveTemplate(qr, template2, {}, fhirpath_r4_model)).toStrictEqual(result); - }); - - test('Partial strings', () => { - expect( - resolveTemplate( - { - resourceType: 'Patient', - id: 'foo', - }, - { reference: 'Patient/{{Patient.id}}' }, - ), - ).toStrictEqual({ reference: 'Patient/foo' }); - }); -}); - -describe('Context usage', () => { - const resource: any = { - foo: 'bar', - list: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], - }; - test('use context', () => { - expect( - resolveTemplate( - resource, - { - list: { - '{{ list }}': { - key: '{{ key }}', - foo: '{{ %root.foo }}', - }, - }, - }, - { root: resource }, - ), - ).toStrictEqual({ - list: [ - { key: 'a', foo: 'bar' }, - { key: 'b', foo: 'bar' }, - { key: 'c', foo: 'bar' }, - ], - }); - }); -}); +import { FPMLValidationError, resolveTemplate } from './extract'; describe('Transformation', () => { const resource = { list: [{ key: 1 }, { key: 2 }, { key: 3 }] } as any; @@ -292,43 +93,120 @@ describe('Transformation', () => { ), ).toStrictEqual(undefined); }); + + test('fails with incorrect fhirpath expression', () => { + expect(() => resolveTemplate({} as any, "{{ item.where(linkId='a) }}")).toThrowError( + FPMLValidationError, + ); + }); }); -describe('Assign usage', () => { - test('use assign', () => { +describe('Context block', () => { + const resource: any = { + foo: 'bar', + list: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], + }; + + test('passes result as resource', () => { expect( resolveTemplate( + resource, { - resourceType: 'Resource', - sourceValue: 100, - } as any, - { - '{% assign %}': [ - { - varA: { - '{% assign %}': [ - { - varX: '{{ Resource.sourceValue.first() }}', - }, - ], - - x: '{{ %varX }}', - }, + list: { + '{{ list }}': { + key: '{{ key }}', + foo: '{{ %root.foo }}', }, - { varB: '{{ %varA.x + 1 }}' }, - { varC: 0 }, - ], - nested: { - '{% assign %}': { varC: '{{ %varA.x + %varB }}' }, - valueA: '{{ %varA }}', - valueB: '{{ %varB }}', - valueC: '{{ %varC }}', }, + }, + { root: resource }, + ), + ).toStrictEqual({ + list: [ + { key: 'a', foo: 'bar' }, + { key: 'b', foo: 'bar' }, + { key: 'c', foo: 'bar' }, + ], + }); + }); +}); + +describe('Assign block', () => { + const resource = { + resourceType: 'Resource', + sourceValue: 100, + } as any; + + test('works with single var as object', () => { + expect( + resolveTemplate(resource, { + '{% assign %}': { var: 100 }, + value: '{{ %var }}', + }), + ).toStrictEqual({ + value: 100, + }); + }); + + test('works with multiple vars as array of objects', () => { + expect( + resolveTemplate(resource, { + '{% assign %}': [{ varA: 100 }, { varB: '{{ %varA + 100}}' }], + valueA: '{{ %varA }}', + valueB: '{{ %varB }}', + }), + ).toStrictEqual({ + valueA: 100, + valueB: 200, + }); + }); + + test('has isolated nested context', () => { + expect( + resolveTemplate(resource, { + '{% assign %}': { varC: 100 }, + nested: { + '{% assign %}': { varC: 200 }, + valueC: '{{ %varC }}', + }, + valueC: '{{ %varC }}', + }), + ).toStrictEqual({ + valueC: 100, + nested: { + valueC: 200, + }, + }); + }); + + test('works properly in full example', () => { + expect( + resolveTemplate(resource, { + '{% assign %}': [ + { + varA: { + '{% assign %}': [ + { + varX: '{{ Resource.sourceValue.first() }}', + }, + ], + + x: '{{ %varX }}', + }, + }, + { varB: '{{ %varA.x + 1 }}' }, + { varC: 0 }, + ], + nested: { + '{% assign %}': { varC: '{{ %varA.x + %varB }}' }, valueA: '{{ %varA }}', valueB: '{{ %varB }}', valueC: '{{ %varC }}', }, - ), + valueA: '{{ %varA }}', + valueB: '{{ %varB }}', + valueC: '{{ %varC }}', + }), ).toStrictEqual({ valueA: { x: 100 }, valueB: 101, @@ -341,6 +219,33 @@ describe('Assign usage', () => { }, }); }); + + test('fails with multiple keys in object', () => { + expect(() => + resolveTemplate(resource, { + '{% assign %}': { varA: 100, varB: 200 }, + value: '{{ %var }}', + }), + ).toThrowError(FPMLValidationError); + }); + + test('fails with multiple keys in array of objects', () => { + expect(() => + resolveTemplate(resource, { + '{% assign %}': [{ varA: 100, varB: 200 }], + value: '{{ %var }}', + }), + ).toThrowError(FPMLValidationError); + }); + + test('fails with non-array and non-object as value', () => { + expect(() => + resolveTemplate(resource, { + '{% assign %}': 1, + value: '{{ %var }}', + }), + ).toThrowError(FPMLValidationError); + }); }); describe('For block', () => { @@ -410,6 +315,15 @@ describe('For block', () => { listArr: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], }); }); + + test('fails with other keys passed', () => { + expect(() => + resolveTemplate({ list: [1, 2, 3] } as any, { + userKey: 1, + '{% for key in %list %}': '{{ %key }}', + }), + ).toThrowError(FPMLValidationError); + }); }); describe('If block', () => { @@ -417,7 +331,7 @@ describe('If block', () => { key: 'value', }; - test('works properly for truthy if branch at root level', () => { + test('returns if branch for truthy condition at root level', () => { expect( resolveTemplate(resource, { "{% if key = 'value' %}": { nested: "{{ 'true' + key }}" }, @@ -428,7 +342,7 @@ describe('If block', () => { }); }); - test('works properly for truthy if branch', () => { + test('returns if branch for truthy condition', () => { expect( resolveTemplate(resource, { result: { @@ -441,7 +355,7 @@ describe('If block', () => { }); }); - test('works properly for truthy if branch without else', () => { + test('returns if branch for truthy condition without else branch', () => { expect( resolveTemplate(resource, { result: { @@ -453,7 +367,7 @@ describe('If block', () => { }); }); - test('works properly for falsy if branch', () => { + test('returns else branch for falsy condition', () => { expect( resolveTemplate(resource, { result: { @@ -466,7 +380,7 @@ describe('If block', () => { }); }); - test('works properly for falsy if branch without else', () => { + test('returns null for falsy condition without else branch', () => { expect( resolveTemplate(resource, { result: { @@ -478,7 +392,7 @@ describe('If block', () => { }); }); - test('works properly for nested if', () => { + test('returns if branch for nested if', () => { expect( resolveTemplate(resource, { result: { @@ -492,7 +406,7 @@ describe('If block', () => { }); }); - test('works properly for nested else', () => { + test('returns else branch for nested else', () => { expect( resolveTemplate(resource, { result: { @@ -507,53 +421,81 @@ describe('If block', () => { result: 'value', }); }); -}); -describe('Merge block', () => { - const resource: any = { - key: 'value', - }; - - test('works properly with single merge block', () => { + test('implicitly merges with null returned', () => { expect( resolveTemplate(resource, { - '{% merge %}': { a: 1 }, + result: { + myKey: 1, + "{% if key = 'value' %}": null, + }, }), ).toStrictEqual({ - a: 1, + result: { + myKey: 1, + }, }); }); - test('works properly with multiple merge blocks', () => { + test('implicitly merges with object returned for truthy condition', () => { expect( resolveTemplate(resource, { - '{% merge %}': [{ a: 1 }, { b: 2 }], + result: { + myKey: 1, + "{% if key = 'value' %}": { + anotherKey: 2, + }, + }, }), ).toStrictEqual({ - a: 1, - b: 2, + result: { + myKey: 1, + anotherKey: 2, + }, }); }); - test('works properly with multiple merge blocks containing nulls', () => { + test('implicitly merges with object returned for falsy condition', () => { expect( resolveTemplate(resource, { - '{% merge %}': [{ a: 1 }, null, { b: 2 }], + result: { + myKey: 1, + "{% if key != 'value' %}": { + anotherKey: 2, + }, + '{% else %}': { + anotherKey: 3, + }, + }, }), ).toStrictEqual({ - a: 1, - b: 2, + result: { + myKey: 1, + anotherKey: 3, + }, }); }); - test('works properly with multiple merge blocks overriding', () => { - expect( + test('fails on implicitly merge with non-object returned from if branch', () => { + expect(() => resolveTemplate(resource, { - '{% merge %}': [{ x: 1, y: 2 }, { y: 3 }], + result: { + myKey: 1, + "{% if key = 'value' %}": [], + }, }), - ).toStrictEqual({ - x: 1, - y: 3, - }); + ).toThrow(FPMLValidationError); + }); + + test('fails on implicitly merge with non-object returned from else branch', () => { + expect(() => + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key != 'value' %}": {}, + '{% else %}': [], + }, + }), + ).toThrow(FPMLValidationError); }); }); diff --git a/ts/server/src/utils/extract.ts b/ts/server/src/utils/extract.ts index d01b276..7608eb1 100644 --- a/ts/server/src/utils/extract.ts +++ b/ts/server/src/utils/extract.ts @@ -6,6 +6,8 @@ interface FPOptions { userInvocationTable?: UserInvocationTable; } +export class FPMLValidationError extends Error {} + export function resolveTemplate( resource: Resource, template: any, @@ -35,12 +37,7 @@ function resolveTemplateRecur( model, fpOptions, ); - const matchers = [ - processForBlock, - processContextBlock, - processIfBlock, - processMergeBlock, - ]; + const matchers = [processContextBlock, processForBlock, processIfBlock]; for (const matcher of matchers) { const result = matcher(resource, newNode, newContext, model, fpOptions); @@ -69,7 +66,9 @@ function processTemplateString( fpOptions: FPOptions, ) { const templateRegExp = /{{-?\s*(.+?)\s*-?}}/g; - let match: RegExpExecArray | { [Symbol.replace](string: string, replaceValue: string): string; }[]; + let match: + | RegExpExecArray + | { [Symbol.replace](string: string, replaceValue: string): string }[]; let result: any = node; while ((match = templateRegExp.exec(node)) !== null) { @@ -110,6 +109,12 @@ function processAssignBlock( if (assignKey) { if (Array.isArray(node[assignKey])) { node[assignKey].forEach((obj) => { + if (Object.keys(obj).length !== 1) { + throw new FPMLValidationError( + 'Assign block must accept only one key per object', + ); + } + Object.entries( resolveTemplate(resource, obj, extendedContext, model, fpOptions), ).forEach(([key, value]) => { @@ -117,13 +122,16 @@ function processAssignBlock( }); }); } else if (isPlainObject(node[assignKey])) { + if (Object.keys(node[assignKey]).length !== 1) { + throw new FPMLValidationError('Assign block must accept only one key per object'); + } Object.entries( resolveTemplate(resource, node[assignKey], extendedContext, model, fpOptions), ).forEach(([key, value]) => { extendedContext[key] = value; }); } else { - throw new Error('Assign block must accept array or object'); + throw new FPMLValidationError('Assign block must accept array or object'); } return { node: omitKey(node, assignKey), context: extendedContext }; @@ -145,7 +153,7 @@ function processForBlock( const forKey = keys.find((k) => k.match(forRegExp)); if (forKey) { if (keys.length > 1) { - throw new Error('For block must be presented as single key'); + throw new FPMLValidationError('For block must be presented as single key'); } const matches = forKey.match(forRegExp); @@ -186,7 +194,7 @@ function processContextBlock( const contextKey = keys.find((k) => k.match(contextRegExp)); if (contextKey) { if (keys.length > 1) { - throw new Error('Context block must be presented as single key'); + throw new FPMLValidationError('Context block must be presented as single key'); } const matches = contextKey.match(contextRegExp); @@ -200,38 +208,6 @@ function processContextBlock( } } -function processMergeBlock( - resource: Resource, - node: any, - context: Context, - model: Model, - fpOptions: FPOptions, -): { node: any } | undefined { - const keys = Object.keys(node); - - const mergeRegExp = /{%\s*merge\s*%}/; - const mergeKey = keys.find((k) => k.match(mergeRegExp)); - if (mergeKey) { - if (keys.length > 1) { - throw new Error('Merge block must be presented as single key'); - } - - return { - node: (Array.isArray(node[mergeKey]) ? node[mergeKey] : [node[mergeKey]]).reduce( - (mergeAcc, nodeValue) => { - const result = resolveTemplate(resource, nodeValue, context, model, fpOptions); - if (!isPlainObject(result) && result !== null) { - throw new Error('Merge block must contain object'); - } - - return { ...mergeAcc, ...(result || {}) }; - }, - {}, - ), - }; - } -} - function processIfBlock( resource: Resource, node: any, @@ -248,11 +224,6 @@ function processIfBlock( if (ifKey) { const elseKey = keys.find((k) => k.match(elseRegExp)); - const maxKeysCount = elseKey ? 2 : 1; - if (keys.length > maxKeysCount) { - throw new Error('If block must contain only if and optional else keys'); - } - const matches = ifKey.match(ifRegExp); const expr = matches[1]; @@ -264,13 +235,28 @@ function processIfBlock( fpOptions, )[0]; - return { - node: answer - ? resolveTemplate(resource, node[ifKey], context, model, fpOptions) - : elseKey - ? resolveTemplate(resource, node[elseKey], context, model, fpOptions) - : null, - }; + const newNode = answer + ? resolveTemplate(resource, node[ifKey], context, model, fpOptions) + : elseKey + ? resolveTemplate(resource, node[elseKey], context, model, fpOptions) + : null; + + const isMergeBehavior = keys.length !== (elseKey ? 2 : 1); + if (isMergeBehavior) { + if (!isPlainObject(newNode) && newNode !== null) { + throw new FPMLValidationError( + 'If/else block must return object for implicit merge into existing node', + ); + } + + return { + node: { + ...omitKey(omitKey(node, ifKey), elseKey), + ...(newNode !== null ? newNode : {}), + }, + }; + } + return { node: newNode }; } } @@ -333,7 +319,7 @@ export function evaluateExpression( options, ); } catch (exc) { - throw new Error( + throw new FPMLValidationError( `Can not evaluate "${expression}": ${exc}\nContext:\n${JSON.stringify( context, null,