diff --git a/ts/server/.eslintrc.js b/ts/server/.eslintrc.js index 184098b..22b0f52 100644 --- a/ts/server/.eslintrc.js +++ b/ts/server/.eslintrc.js @@ -14,6 +14,7 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js'], rules: { + 'prettier/prettier': 0, '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', diff --git a/ts/server/src/utils/extract.spec.ts b/ts/server/src/utils/extract.spec.ts index c085a0d..434fbc3 100644 --- a/ts/server/src/utils/extract.spec.ts +++ b/ts/server/src/utils/extract.spec.ts @@ -1,5 +1,6 @@ import { QuestionnaireResponse } from 'fhir/r4b'; -import { embededFHIRPath, resolveTemplate } from './extract'; +import { resolveTemplate } from './extract'; +import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; const qr: QuestionnaireResponse = { resourceType: 'QuestionnaireResponse', @@ -149,11 +150,13 @@ const result = { describe('Extraction', () => { test('Simple transformation', () => { - expect(resolveTemplate(qr, template1)).toStrictEqual(result); + expect(resolveTemplate(qr, template1, {}, fhirpath_r4_model)).toStrictEqual(result); }); + test('List transformation', () => { - expect(resolveTemplate(qr, template2)).toStrictEqual(result); + expect(resolveTemplate(qr, template2, {}, fhirpath_r4_model)).toStrictEqual(result); }); + test('Partial strings', () => { expect( resolveTemplate( @@ -167,18 +170,168 @@ describe('Extraction', () => { }); }); -describe('Partial expression', () => { - test('Search partial expression', () => { - const { before, after, expression } = embededFHIRPath('Patient/{{Patient.id}}'); +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' }, + ], + }); + }); +}); + +describe('Transformation', () => { + const resource = { list: [1, 2, 3] } as any; - expect(before).toBe('Patient/'); - expect(expression).toBe('Patient.id'); - expect(after).toBe(''); + test('for empty object return empty object', () => { + expect(resolveTemplate(resource, {})).toStrictEqual({}); + }); + + test('for empty array return empty array', () => { + expect(resolveTemplate(resource, [])).toStrictEqual([]); + }); + + test('for array of arrays returns flattened array', () => { + expect( + resolveTemplate(resource, [ + [1, 2, 3], + [4, 5, 6], + ]), + ).toStrictEqual([1, 2, 3, 4, 5, 6]); + }); + + test('for array with nulls returns compacted array', () => { + expect(resolveTemplate(resource, [[1, null, 2, null, 3]])).toStrictEqual([1, 2, 3]); + }); + + test('for object with null keys returns null keys', () => { + expect(resolveTemplate(resource, { key: null })).toStrictEqual({ key: null }); + }); + + test('for object with non-null keys returns non-null keys', () => { + expect(resolveTemplate(resource, { key: 1 })).toStrictEqual({ key: 1 }); + }); + + test('for array of objects returns original array', () => { + expect(resolveTemplate(resource, [{ list: [1, 2, 3] }, { list: [4, 5, 6] }])).toStrictEqual( + [{ list: [1, 2, 3] }, { list: [4, 5, 6] }], + ); + }); + + test('for null returns null', () => { + expect(resolveTemplate(resource, null)).toStrictEqual(null); + }); + + test('for constant string returns constant string', () => { + expect(resolveTemplate(resource, 'string')).toStrictEqual('string'); + }); + + test('for constant number returns constant number', () => { + expect(resolveTemplate(resource, 1)).toStrictEqual(1); + }); + + test('for false returns false', () => { + expect(resolveTemplate(resource, false)).toStrictEqual(false); + }); + + test('for true returns true', () => { + expect(resolveTemplate(resource, true)).toStrictEqual(true); + }); + + test('for non-empty array expression return first element', () => { + expect(resolveTemplate(resource, '{{ list }}')).toStrictEqual(1); + }); + + test('for empty array expression returns null', () => { + expect(resolveTemplate(resource, '{{ list.where($this = 0) }}')).toStrictEqual(null); + }); + + test.skip('for template expression returns resolved template', () => { + expect( + resolveTemplate(resource, '/{{ list[1] }}/{{ list[2] }}/{{ list[3] }}'), + ).toStrictEqual('/1/2/3'); + }); + + test('for empty array template expression returns resolved template', () => { + expect( + resolveTemplate( + resource, + '/Patient/{{ list.where($this = 0) }}/_history/{{ list.last() }}', + ), + ).toStrictEqual('/Patient/null/_history/3'); }); }); -describe('Context usage', () => { - test('use context', () => { +describe('Assign usage', () => { + test('use assign', () => { + expect( + resolveTemplate( + { + resourceType: 'Resource', + sourceValue: 100, + } as any, + { + '{% 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, + valueC: 0, + + nested: { + valueA: { x: 100 }, + valueB: 101, + valueC: 201, + }, + }); + }); +}); + +describe('For block', () => { + test('works properly in full example', () => { expect( resolveTemplate( { @@ -186,20 +339,208 @@ describe('Context usage', () => { list: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], } as any, { - list: { - '{{ list }}': { - key: '{{ key }}', - foo: '{{ %root.foo }}', + listArr: [ + { + '{% for index, item in list %}': { + key: '{{ %item.key }}', + foo: '{{ foo }}', + index: '{{ %index }}', + }, + }, + { + '{% for item in list %}': { + key: '{{ %item.key }}', + foo: '{{ foo }}', + }, + }, + ], + listObj: { + '{% for item in list %}': { + key: '{{ %item.key }}', + foo: '{{ foo }}', }, }, }, ), ).toStrictEqual({ - list: [ + listArr: [ + { key: 'a', foo: 'bar', index: 0 }, + { key: 'b', foo: 'bar', index: 1 }, + { key: 'c', foo: 'bar', index: 2 }, { key: 'a', foo: 'bar' }, { key: 'b', foo: 'bar' }, { key: 'c', foo: 'bar' }, ], + listObj: [ + { key: 'a', foo: 'bar' }, + { key: 'b', foo: 'bar' }, + { key: 'c', foo: 'bar' }, + ], + }); + }); + + test('has context from local assign block', () => { + expect( + resolveTemplate({} as any, { + '{% assign %}': { + localList: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], + }, + listArr: [ + { + '{% for item in %localList %}': { + key: '{{ %item.key }}', + }, + }, + ], + }), + ).toStrictEqual({ + listArr: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], + }); + }); +}); + +describe('If block', () => { + const resource: any = { + key: 'value', + }; + + test('works properly for truthy if branch at root level', () => { + expect( + resolveTemplate(resource, { + "{% if key = 'value' %}": { nested: "{{ 'true' + key }}" }, + '{% else %}': { nested: "{{ 'false' + key }}" }, + }), + ).toStrictEqual({ + nested: 'truevalue', + }); + }); + + test('works properly for truthy if branch', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key = 'value' %}": { nested: "{{ 'true' + key }}" }, + '{% else %}': { nested: "{{ 'false' + key }}" }, + }, + }), + ).toStrictEqual({ + result: { nested: 'truevalue' }, + }); + }); + + test('works properly for truthy if branch without else', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key = 'value' %}": { nested: "{{ 'true' + key }}" }, + }, + }), + ).toStrictEqual({ + result: { nested: 'truevalue' }, + }); + }); + + test('works properly for falsy if branch', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key != 'value' %}": { nested: "{{ 'true' + key }}" }, + '{% else %}': { nested: "{{ 'false' + key }}" }, + }, + }), + ).toStrictEqual({ + result: { nested: 'falsevalue' }, + }); + }); + + test('works properly for falsy if branch without else', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key != 'value' %}": { nested: "{{ 'true' + key }}" }, + }, + }), + ).toStrictEqual({ + result: null, + }); + }); + + test('works properly for nested if', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key = 'value' %}": { + "{% if key = 'value' %}": 'value', + }, + }, + }), + ).toStrictEqual({ + result: 'value', + }); + }); + + test('works properly for nested else', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key != 'value' %}": null, + '{% else %}': { + "{% if key != 'value' %}": null, + '{% else %}': 'value', + }, + }, + }), + ).toStrictEqual({ + result: 'value', + }); + }); +}); + +describe('Merge block', () => { + const resource: any = { + key: 'value', + }; + + test('works properly with single merge block', () => { + expect( + resolveTemplate(resource, { + '{% merge %}': { a: 1 }, + }), + ).toStrictEqual({ + a: 1, + }); + }); + + test('works properly with multiple merge blocks', () => { + expect( + resolveTemplate(resource, { + '{% merge %}': [{ a: 1 }, { b: 2 }], + }), + ).toStrictEqual({ + a: 1, + b: 2, + }); + }); + + test('works properly with multiple merge blocks containing nulls', () => { + expect( + resolveTemplate(resource, { + '{% merge %}': [{ a: 1 }, null, { b: 2 }], + }), + ).toStrictEqual({ + a: 1, + b: 2, + }); + }); + + test('works properly with multiple merge blocks overriding', () => { + expect( + resolveTemplate(resource, { + '{% merge %}': [{ x: 1, y: 2 }, { y: 3 }], + }), + ).toStrictEqual({ + x: 1, + y: 3, }); }); }); diff --git a/ts/server/src/utils/extract.ts b/ts/server/src/utils/extract.ts index 1215f3e..2340b56 100644 --- a/ts/server/src/utils/extract.ts +++ b/ts/server/src/utils/extract.ts @@ -1,14 +1,14 @@ import { Resource } from 'fhir/r4b'; import * as fhirpath from 'fhirpath'; -import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; -interface Embeded { +interface Embedded { before: string; after: string; expression: string; } -export function embededFHIRPath(a: string): Embeded | undefined { +// TODO rewrite using regex and multiple embedding +export function embeddedFHIRPath(a: string): Embedded | undefined { const start = a.search('{{'); const stop = a.search('}}'); if (start === -1 || stop === -1) { @@ -25,77 +25,239 @@ export function embededFHIRPath(a: string): Embeded | undefined { }; } -export function resolveTemplate(qr: Resource, template: object): object { - return resolveTemplateRecur(qr, { root: qr }, template); +export function resolveTemplate(qr: Resource, template: any, context?: any, model?: any): any { + return resolveTemplateRecur(qr, { rootNode: template }, context, model)['rootNode']; } -function resolveTemplateRecur(qr: Resource, context: object, template: object): object { - return iterateObject(template, (a) => { - if (typeof a === 'object' && Object.keys(a).length == 1) { - const key = Object.keys(a)[0]!; - const embeded = embededFHIRPath(key); - if (embeded) { - const { expression: keyExpr } = embeded; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: any[] = []; - const answers = fhirpath.evaluate(qr, keyExpr, context, fhirpath_r4_model); - for (const c of answers) { - result.push(resolveTemplateRecur(c, context, a[key])); + +function resolveTemplateRecur( + resource: Resource, + template: any, + initialContext: object, + model: any, +): any { + return iterateObject(template, initialContext, (node, context) => { + if (isPlainObject(node)) { + const { node: newNode, context: newContext } = processAssignBlock( + resource, + node, + context, + model, + ); + const matchers = [matchForBlock, matchContextBlock, matchIfBlock, matchMergeBlock]; + for (const matcher of matchers) { + const result = matcher(resource, newNode, newContext, model); + + if (result) { + return { node: result.node, context: newContext }; } - return result; - } else { - return a; } - } else if (typeof a === 'string') { - const embeded = embededFHIRPath(a); - if (embeded) { - const result = fhirpath.evaluate( - qr, - embeded.expression, - context, - fhirpath_r4_model, - )[0]; - if (embeded.before || embeded.after) { - return `${embeded.before}${result}${embeded.after}`; - } else { - return result; + + return { node: newNode, context: newContext }; + } else if (typeof node === 'string') { + const embedded = embeddedFHIRPath(node); + + if (embedded) { + const result = + fhirpath.evaluate(resource, embedded.expression, context, model)[0] ?? null; + if (embedded.before || embedded.after) { + return { + node: `${embedded.before}${result}${embedded.after}`, + context, + }; } - } else { - return a; + + return { + node: result, + context, + }; } } - return a; + + return { node, context }; }); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Transform = (a: any) => any; +function processAssignBlock(resource: Resource, node: any, context: any, model: any) { + const extendedContext = { ...context }; + const keys = Object.keys(node); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function iterateObject(obj: object, transform: Transform): any { - if (Array.isArray(obj)) { - const transformedArray = []; - for (let i = 0; i < obj.length; i++) { - const value = obj[i]; - if (typeof value === 'object') { - transformedArray.push(iterateObject(transform(value), transform)); - } else { - transformedArray.push(transform(value)); - } + const assignRegExp = /{%\s*assign\s*%}/; + const assignKey = keys.find((k) => k.match(assignRegExp)); + if (assignKey) { + if (Array.isArray(node[assignKey])) { + node[assignKey].forEach((obj) => { + Object.entries(resolveTemplate(resource, obj, extendedContext, model)).forEach( + ([key, value]) => { + extendedContext[key] = value; + }, + ); + }); + } else if (isPlainObject(node[assignKey])) { + Object.entries( + resolveTemplate(resource, node[assignKey], extendedContext, model), + ).forEach(([key, value]) => { + extendedContext[key] = value; + }); + } else { + throw new Error('Assign block must accept array or object'); } - return transformedArray; - } else if (typeof obj === 'object') { - const transformedObject = {}; - for (const key in obj) { - // eslint-disable-next-line no-prototype-builtins - if (obj.hasOwnProperty(key)) { - const value = obj[key]; - if (typeof value === 'object') { - transformedObject[key] = iterateObject(transform(value), transform); - } else { - transformedObject[key] = transform(value); - } - } + + return { node: omitKey(node, assignKey), context: extendedContext }; + } + + return { node, context }; +} + +function matchForBlock(resource: Resource, node: any, context: any, model: any) { + const keys = Object.keys(node); + + const forRegExp = /{%\s*for\s+(?:(\w+?)\s*,\s*)?(\w+?)\s+in\s+(.+?)\s*%}/; + 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'); + } + + const matches = forKey.match(forRegExp); + const hasIndexKey = matches.length === 4; + const indexKey = hasIndexKey ? matches[1] : null; + const itemKey = hasIndexKey ? matches[2] : matches[1]; + const expr = hasIndexKey ? matches[3] : matches[2]; + + const answers = fhirpath.evaluate(resource, expr, context, model); + return { + node: answers.map((answer, index) => + resolveTemplate( + resource, + node[forKey], + { + ...context, + [itemKey]: answer, + ...(hasIndexKey ? { [indexKey]: index } : {}), + }, + model, + ), + ), + }; + } +} + +function matchContextBlock(resource: Resource, node: any, context: any, model: any) { + const keys = Object.keys(node); + + const contextRegExp = /{{\s*(.+?)\s*}}/; + 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'); + } + const matches = contextKey.match(contextRegExp); + + const expr = matches[1]; + const answers = fhirpath.evaluate(resource, expr, context, model); + const result: any[] = answers.map((answer) => + resolveTemplate(answer, node[contextKey], context, model), + ); + + return { node: result }; + } +} + +function matchMergeBlock(resource: Resource, node: any, context: any, model: any) { + const keys = Object.keys(node); + + const mergeRegExp = /{%\s*merge\s*%}/; + const mergehKey = keys.find((k) => k.match(mergeRegExp)); + if (mergehKey) { + if (keys.length > 1) { + throw new Error('Merge block must be presented as single key'); + } + + return { + node: (Array.isArray(node[mergehKey]) ? node[mergehKey] : [node[mergehKey]]).reduce( + (mergeAcc, nodeValue) => { + const result = resolveTemplate(resource, nodeValue, context, model); + if (!isPlainObject(result) && result !== null) { + throw new Error('Merge block must contain object'); + } + + return { ...mergeAcc, ...(result || {}) }; + }, + omitKey(node, mergehKey), + ), + }; + } +} + +function matchIfBlock(resource: Resource, node: any, context: any, model: any) { + const keys = Object.keys(node); + + const ifRegExp = /{%\s*if\s+(.+?)\s*%}/; + const elseRegExp = /{%\s*else\s*%}/; + const ifKey = keys.find((k) => k.match(ifRegExp)); + + 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]; + + const answers = fhirpath.evaluate(resource, expr, context, model); + if (answers.length !== 1) { + throw new Error('If block must accept expression that returns single boolean value'); + } + + const answer = answers[0]; + if (typeof answer !== 'boolean') { + throw new Error('If block must accept expression that returns single boolean value'); } - return transformedObject; + + return { + node: answer + ? resolveTemplate(resource, node[ifKey], context, model) + : elseKey + ? resolveTemplate(resource, node[elseKey], context, model) + : null, + }; } } + +type Transformer = (node: any, context: any) => { node: any; context: any }; + +function iterateObject(obj: any, context: any, transform: Transformer): any { + if (Array.isArray(obj)) { + return obj + .flatMap((value) => { + const { node: newNode, context: newContext } = transform(value, context); + + return iterateObject(newNode, newContext, transform); + }) + .filter((x) => x !== null); + } else if (isPlainObject(obj)) { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + const { node: newNode, context: newContext } = transform(value, context); + + return [key, iterateObject(newNode, newContext, transform)]; + }), + ); + } + + return transform(obj, context).node; +} + +function isPlainObject(obj: any) { + return Object.prototype.toString.call(obj) === '[object Object]'; +} + +function omitKey(obj: any, key: string) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [key]: _, ...rest } = obj; + + return rest; +}