From 8ad51948a8befc97e8fa2b0784b69783df2e9c66 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 16:13:56 +0700 Subject: [PATCH 01/11] Clean empty arrays and objects --- ts/server/src/core/extract.spec.ts | 75 +++++++++++++++++++++++------- ts/server/src/core/extract.ts | 55 ++++++++++++++++------ 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/ts/server/src/core/extract.spec.ts b/ts/server/src/core/extract.spec.ts index 6c51d8d..07a48d0 100644 --- a/ts/server/src/core/extract.spec.ts +++ b/ts/server/src/core/extract.spec.ts @@ -3,6 +3,38 @@ import { FPMLValidationError, resolveTemplate } from './extract'; describe('Transformation', () => { const resource = { list: [{ key: 1 }, { key: 2 }, { key: 3 }] } as any; + test.skip('null values are not removed', () => { + const resourceWithEmptyArrayNullable = { + root: { resourceType: 'Example', list: [null, { nested: null }, null] }, + } as any; + expect( + resolveTemplate( + resourceWithEmptyArrayNullable, + { root: '{{ %Resource.root }}' }, + { Resource: resourceWithEmptyArrayNullable }, + null, + null, + true, + ), + ).toStrictEqual({ root: { resourceType: 'Example', list: [null, { nested: null }] } }); + }); + + test('undefined value are removed', () => { + const resourceWithEmptyArray = { + root: { resourceType: 'Example', list: [undefined, { nested: undefined }] }, + } as any; + expect( + resolveTemplate( + resourceWithEmptyArray, + { root: '{{ %Resource.root }}' }, + { Resource: resourceWithEmptyArray }, + null, + null, + true, + ), + ).toStrictEqual({ root: { resourceType: 'Example' } }); + }); + test('fails on accessing props of resource in strict mode', () => { expect(() => resolveTemplate(resource, { key: '{{ list.key }}' }, {}, null, null, true), @@ -61,12 +93,12 @@ describe('Transformation', () => { ).toStrictEqual({ key: 1 }); }); - test('for empty object return empty object', () => { - expect(resolveTemplate(resource, {})).toStrictEqual({}); + test('for empty object return undefined', () => { + expect(resolveTemplate(resource, {})).toBeUndefined(); }); - test('for empty array return empty array', () => { - expect(resolveTemplate(resource, [])).toStrictEqual([]); + test('for empty array return undefined', () => { + expect(resolveTemplate(resource, [])).toBeUndefined(); }); test('for array of arrays returns flattened array', () => { @@ -92,8 +124,12 @@ describe('Transformation', () => { expect(resolveTemplate(resource, { key: null })).toStrictEqual({ key: null }); }); - test('for object with undefined keys returns undefined keys', () => { - expect(resolveTemplate(resource, { key: undefined })).toStrictEqual({ key: undefined }); + test('for object with undefined keys returns undefined', () => { + expect(resolveTemplate(resource, { key: undefined })).toBeUndefined(); + }); + + test('for object with undefined keys returns only defined keys', () => { + expect(resolveTemplate(resource, { key: undefined, key2: 1 })).toStrictEqual({ key2: 1 }); }); test('for object with non-null keys returns non-null keys', () => { @@ -230,13 +266,18 @@ describe('Assign block', () => { test('works with undefined intermediate values', () => { expect( - resolveTemplate(resource, { - '{% assign %}': [{ varA: '{{ {} }}' }, { varB: '{{ %varA }}' }], - valueA: '{{ %varB }}', - }), - ).toStrictEqual({ - valueA: undefined, - }); + resolveTemplate( + resource, + { + '{% assign %}': [{ varA: '{{ {} }}' }, { varB: '{{ %varA }}' }], + valueA: '{{ %varB }}', + }, + null, + null, + null, + true, + ), + ).toBeUndefined(); }); test('works with multiple vars as array of objects', () => { @@ -478,9 +519,7 @@ describe('If block', () => { "{% if key != 'value' %}": { nested: "{{ 'true' + key }}" }, }, }), - ).toStrictEqual({ - result: undefined, - }); + ).toBeUndefined(); }); test('returns null for falsy condition with nullable else branch', () => { @@ -600,7 +639,7 @@ describe('If block', () => { resolveTemplate(resource, { result: { myKey: 1, - "{% if key = 'value' %}": [], + "{% if key = 'value' %}": [{ key1: true }], }, }), ).toThrow(FPMLValidationError); @@ -612,7 +651,7 @@ describe('If block', () => { result: { myKey: 1, "{% if key != 'value' %}": {}, - '{% else %}': [], + '{% else %}': [{ key1: true }], }, }), ).toThrow(FPMLValidationError); diff --git a/ts/server/src/core/extract.ts b/ts/server/src/core/extract.ts index a1633ea..f92fc77 100644 --- a/ts/server/src/core/extract.ts +++ b/ts/server/src/core/extract.ts @@ -106,7 +106,7 @@ function resolveTemplateRecur( return { node, context }; }, - )[rootNodeKey]; + )?.[rootNodeKey]; } function processTemplateString( @@ -164,7 +164,6 @@ function processAssignBlock( ): { node: any; context: Context } { const extendedContext = { ...context }; const keys = Object.keys(node); - const assignRegExp = /{%\s*assign\s*%}/; const assignKey = keys.find((k) => k.match(assignRegExp)); if (assignKey) { @@ -177,8 +176,17 @@ function processAssignBlock( ); } - Object.entries( - resolveTemplateRecur(path, resource, obj, extendedContext, model, fpOptions), + return Object.entries( + mapValues(obj, (objValue) => + resolveTemplateRecur( + path, + resource, + objValue, + extendedContext, + model, + fpOptions, + ), + ), ).forEach(([key, value]) => { extendedContext[key] = value; }); @@ -191,13 +199,15 @@ function processAssignBlock( ); } Object.entries( - resolveTemplateRecur( - path, - resource, - node[assignKey], - extendedContext, - model, - fpOptions, + mapValues(node[assignKey], (objValue) => + resolveTemplateRecur( + path, + resource, + objValue, + extendedContext, + model, + fpOptions, + ), ), ).forEach(([key, value]) => { extendedContext[key] = value; @@ -398,19 +408,36 @@ type Transformer = (path: Path, node: any, context: Context) => { node: any; con function iterateObject(startPath: Path, obj: any, context: Context, transform: Transformer): any { if (Array.isArray(obj)) { // Arrays are flattened and null/undefined values are removed here - return obj + const cleanedArray = obj .flatMap((value, index) => { const result = transform([...startPath, index], value, context); - return iterateObject([...startPath, index], result.node, result.context, transform); + const testResult = iterateObject( + [...startPath, index], + result.node, + result.context, + transform, + ); + console.log(testResult); + return testResult; }) .filter((x) => x !== null && x !== undefined); + return cleanedArray.length ? cleanedArray : undefined; } else if (isPlainObject(obj)) { - return mapValues(obj, (value, key) => { + const objResult = mapValues(obj, (value, key) => { const result = transform([...startPath, key], value, context); return iterateObject([...startPath, key], result.node, result.context, transform); }); + + if (isPlainObject(objResult)) { + const cleanedObject = Object.entries(objResult).filter(([, value]) => value !== undefined); + if (!cleanedObject.length) { + return undefined; + } + return Object.fromEntries(cleanedObject); + } + return objResult; } return transform(startPath, obj, context).node; From 2c250b76b385619f6fd1ce9fa55ea7989c1f4bc7 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 17:44:19 +0700 Subject: [PATCH 02/11] Python vestion: remove undefined and empty array values --- python/fpml/core/extract.py | 39 ++++++++++++++++++++----------- python/tests/core/test_extract.py | 33 ++++++++++++++------------ 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/python/fpml/core/extract.py b/python/fpml/core/extract.py index 1ed170a..d47eab4 100644 --- a/python/fpml/core/extract.py +++ b/python/fpml/core/extract.py @@ -42,7 +42,7 @@ def resolve_template( context (Optional[Context], optional): Additional context data. Defaults to None. fp_options (Optional[FPOptions], optional): Options for controlling FHIRPath evaluation. Defaults to None. strict (bool, optional): Whether to enforce strict mode. Defaults to False. - See more details on + See more details on [strict mode](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main?tab=readme-ov-file#strict-mode). Returns: @@ -72,12 +72,16 @@ def resolve_template_recur( context: Context, fp_options: Optional[FPOptions] = None, ) -> Any: - return iterate_node( + result = iterate_node( start_path, {root_node_key: template}, context or {}, lambda path, node, context: process_node(path, resource, node, context, fp_options), - ).get(root_node_key, None) + ) + if isinstance(result, dict): + return result.get(root_node_key, None) + + return None def process_node( @@ -113,7 +117,7 @@ def process_node( def iterate_node(start_path: Path, node: Node, context: Context, transform: Transformer) -> Node: if isinstance(node, list): # Arrays are flattened and null/undefined values are removed here - return flatten( + cleaned_array = flatten( [ value for value in [ @@ -127,9 +131,11 @@ def iterate_node(start_path: Path, node: Node, context: Context, transform: Tran if value is not None and value is not undefined ] ) + + return cleaned_array or undefined if isinstance(node, dict): # undefined values are removed from dicts, but nulls are preserved - return { + cleaned_object = { key: value for key, value in { key: iterate_node( @@ -142,6 +148,11 @@ def iterate_node(start_path: Path, node: Node, context: Context, transform: Tran if value is not undefined } + if len(cleaned_object) == 0: + return undefined + + return cleaned_object + return transform(start_path, node, context)[0] @@ -354,18 +365,20 @@ def process_assign_block( raise FPMLValidationError( "Assign block must accept only one key per object", path ) - result = resolve_template_recur(path, resource, obj, extended_context, fp_options) + result = { + key: resolve_template_recur( + path, resource, obj_value, extended_context, fp_options + ) + for key, obj_value in obj.items() + } key = next(iter(obj.keys())) extended_context.update({key: result.get(key, None)}) elif isinstance(node[assign_key], dict) and len(node[assign_key]) == 1: obj = node[assign_key] - result = resolve_template_recur( - path, - resource, - obj, - extended_context, - fp_options, - ) + result = { + key: resolve_template_recur(path, resource, obj_value, extended_context, fp_options) + for key, obj_value in obj.items() + } key = next(iter(obj.keys())) extended_context.update({key: result.get(key, None)}) else: diff --git a/python/tests/core/test_extract.py b/python/tests/core/test_extract.py index b5b6796..00d331a 100644 --- a/python/tests/core/test_extract.py +++ b/python/tests/core/test_extract.py @@ -58,11 +58,11 @@ def test_transformation_works_on_accessing_props_of_implicit_context_in_strict_m def test_transformation_for_empty_object_return_empty_object() -> None: - assert resolve_template({}, {}) == {} + assert resolve_template({}, {}) is None def test_transformation_for_empty_array_return_empty_array() -> None: - assert resolve_template({}, []) == [] + assert resolve_template({}, []) is None def test_transformation_for_array_of_arrays_returns_flattened_array() -> None: @@ -82,7 +82,7 @@ def test_transformation_for_object_with_null_keys_returns_null_keys() -> None: def test_transformation_for_object_with_undefined_keys_clears_undefined_keys() -> None: - assert resolve_template({}, {"key": undefined}) == {} + assert resolve_template({}, {"key": undefined}) is None def test_transformation_for_object_with_non_null_keys_returns_non_null_keys() -> None: @@ -123,7 +123,7 @@ def test_transformation_for_non_empty_array_expression_return_first_element() -> def test_transformation_for_empty_array_expression_clears_undefined_keys() -> None: resource: Resource = {"list": []} - assert resolve_template(resource, {"result": "{{ list.where($this = 0) }}"}) == {} + assert resolve_template(resource, {"result": "{{ list.where($this = 0) }}"})is None def test_transformation_for_empty_array_nullable_expression_returns_null() -> None: @@ -141,23 +141,26 @@ def test_transformation_for_template_expression_returns_resolved_template() -> N ) -def test_transformation_for_empty_array_template_expression_clears_undefined_keys() -> None: +def test_transformation_for_empty_array_template_expression_returns_undefined() -> None: resource: Resource = {"list": [{"key": 1}, {"key": 2}, {"key": 3}]} assert ( resolve_template( resource, - {"result": "/Patient/{{ list.where($this = 0) }}/_history/{{ list.last() }}"}, + "/Patient/{{ list.where($this = 0) }}/_history/{{ list.last() }}", ) - == {} + is None ) def test_transformation_for_empty_array_nullable_template_expression_returns_null() -> None: resource: Resource = {"list": [{"key": 1}, {"key": 2}, {"key": 3}]} - assert resolve_template( - resource, - {"result": "/Patient/{{+ list.where($this = 0) +}}/_history/{{ list.last() }}"}, - ) == {"result": None} + assert ( + resolve_template( + resource, + "/Patient/{{+ list.where($this = 0) +}}/_history/{{ list.last() }}", + ) + is None + ) def test_transformation_for_multiline_template_works_properly() -> None: @@ -234,7 +237,7 @@ def test_assign_block_with_undefined_intermediate_values() -> None: "valueA": "{{ %varB }}", }, ) - == {} + is None ) @@ -508,7 +511,7 @@ def test_if_block_clears_undefined_keys_for_falsy_condition_without_else_branch( }, }, ) - assert result == {} + assert result is None def test_if_block_returns_null_for_falsy_condition_with_nullable_else_branch() -> None: @@ -622,7 +625,7 @@ def test_if_block_fails_on_implicit_merge_with_non_object_returned_from_if_branc { "result": { "myKey": 1, - "{% if key = 'value' %}": [], + "{% if key = 'value' %}": [{"key1": True}], }, }, ) @@ -639,7 +642,7 @@ def test_if_block_fails_on_implicit_merge_with_non_object_returned_from_else_bra "result": { "myKey": 1, "{% if key != 'value' %}": {}, - "{% else %}": [], + "{% else %}": [{"key1": True}], }, }, ) From bcf40c029e562b3668ea5b910fcfdd0cce73957f Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 17:46:27 +0700 Subject: [PATCH 03/11] Fix linter errors --- python/fpml/core/extract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fpml/core/extract.py b/python/fpml/core/extract.py index d47eab4..9f38f36 100644 --- a/python/fpml/core/extract.py +++ b/python/fpml/core/extract.py @@ -372,7 +372,7 @@ def process_assign_block( for key, obj_value in obj.items() } key = next(iter(obj.keys())) - extended_context.update({key: result.get(key, None)}) + extended_context.update({key: result.get(key)}) elif isinstance(node[assign_key], dict) and len(node[assign_key]) == 1: obj = node[assign_key] result = { @@ -380,7 +380,7 @@ def process_assign_block( for key, obj_value in obj.items() } key = next(iter(obj.keys())) - extended_context.update({key: result.get(key, None)}) + extended_context.update({key: result.get(key)}) else: raise FPMLValidationError("Assign block must accept array or object", path) return omit_key(node, assign_key), extended_context From dabeee2b2ea79ea85411ece849384da6ae7ddd03 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 19:43:52 +0700 Subject: [PATCH 04/11] Fix review notes in python code --- python/fpml/core/extract.py | 19 +++++++++---------- python/tests/core/test_extract.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/python/fpml/core/extract.py b/python/fpml/core/extract.py index 9f38f36..57ac249 100644 --- a/python/fpml/core/extract.py +++ b/python/fpml/core/extract.py @@ -79,9 +79,9 @@ def resolve_template_recur( lambda path, node, context: process_node(path, resource, node, context, fp_options), ) if isinstance(result, dict): - return result.get(root_node_key, None) + return result.get(root_node_key, undefined) - return None + return undefined def process_node( @@ -148,10 +148,7 @@ def iterate_node(start_path: Path, node: Node, context: Context, transform: Tran if value is not undefined } - if len(cleaned_object) == 0: - return undefined - - return cleaned_object + return cleaned_object or undefined return transform(start_path, node, context)[0] @@ -367,20 +364,22 @@ def process_assign_block( ) result = { key: resolve_template_recur( - path, resource, obj_value, extended_context, fp_options + [*path, key], resource, obj_value, extended_context, fp_options ) for key, obj_value in obj.items() } key = next(iter(obj.keys())) - extended_context.update({key: result.get(key)}) + extended_context.update({key: result[key] if result[key] != undefined else None}) elif isinstance(node[assign_key], dict) and len(node[assign_key]) == 1: obj = node[assign_key] result = { - key: resolve_template_recur(path, resource, obj_value, extended_context, fp_options) + key: resolve_template_recur( + [*path, key], resource, obj_value, extended_context, fp_options + ) for key, obj_value in obj.items() } key = next(iter(obj.keys())) - extended_context.update({key: result.get(key)}) + extended_context.update({key: result[key] if result[key] != undefined else None}) else: raise FPMLValidationError("Assign block must accept array or object", path) return omit_key(node, assign_key), extended_context diff --git a/python/tests/core/test_extract.py b/python/tests/core/test_extract.py index 00d331a..17840dc 100644 --- a/python/tests/core/test_extract.py +++ b/python/tests/core/test_extract.py @@ -58,11 +58,11 @@ def test_transformation_works_on_accessing_props_of_implicit_context_in_strict_m def test_transformation_for_empty_object_return_empty_object() -> None: - assert resolve_template({}, {}) is None + assert resolve_template({}, {}) == undefined def test_transformation_for_empty_array_return_empty_array() -> None: - assert resolve_template({}, []) is None + assert resolve_template({}, []) == undefined def test_transformation_for_array_of_arrays_returns_flattened_array() -> None: @@ -82,7 +82,7 @@ def test_transformation_for_object_with_null_keys_returns_null_keys() -> None: def test_transformation_for_object_with_undefined_keys_clears_undefined_keys() -> None: - assert resolve_template({}, {"key": undefined}) is None + assert resolve_template({}, {"key": undefined}) == undefined def test_transformation_for_object_with_non_null_keys_returns_non_null_keys() -> None: @@ -97,7 +97,7 @@ def test_transformation_for_array_of_objects_returns_original_array() -> None: def test_transformation_for_null_returns_null() -> None: - assert resolve_template({}, None) is None + assert resolve_template({}, None) == undefined def test_transformation_for_constant_string_returns_constant_string() -> None: @@ -123,7 +123,7 @@ def test_transformation_for_non_empty_array_expression_return_first_element() -> def test_transformation_for_empty_array_expression_clears_undefined_keys() -> None: resource: Resource = {"list": []} - assert resolve_template(resource, {"result": "{{ list.where($this = 0) }}"})is None + assert resolve_template(resource, {"result": "{{ list.where($this = 0) }}"}) == undefined def test_transformation_for_empty_array_nullable_expression_returns_null() -> None: @@ -148,7 +148,7 @@ def test_transformation_for_empty_array_template_expression_returns_undefined() resource, "/Patient/{{ list.where($this = 0) }}/_history/{{ list.last() }}", ) - is None + == undefined ) @@ -159,7 +159,7 @@ def test_transformation_for_empty_array_nullable_template_expression_returns_nul resource, "/Patient/{{+ list.where($this = 0) +}}/_history/{{ list.last() }}", ) - is None + == undefined ) @@ -237,7 +237,7 @@ def test_assign_block_with_undefined_intermediate_values() -> None: "valueA": "{{ %varB }}", }, ) - is None + == undefined ) @@ -511,7 +511,7 @@ def test_if_block_clears_undefined_keys_for_falsy_condition_without_else_branch( }, }, ) - assert result is None + assert result == undefined def test_if_block_returns_null_for_falsy_condition_with_nullable_else_branch() -> None: From e3b1bf7f761fb34cefdb945c09393b8c0bd7bd79 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 19:44:28 +0700 Subject: [PATCH 05/11] Fix review notes in JS code --- ts/server/src/core/extract.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/ts/server/src/core/extract.ts b/ts/server/src/core/extract.ts index f92fc77..1dffea8 100644 --- a/ts/server/src/core/extract.ts +++ b/ts/server/src/core/extract.ts @@ -177,9 +177,9 @@ function processAssignBlock( } return Object.entries( - mapValues(obj, (objValue) => + mapValues(obj, (objValue, objKey) => resolveTemplateRecur( - path, + [...path, objKey], resource, objValue, extendedContext, @@ -199,9 +199,9 @@ function processAssignBlock( ); } Object.entries( - mapValues(node[assignKey], (objValue) => + mapValues(node[assignKey], (objValue, objKey) => resolveTemplateRecur( - path, + [...path, objKey], resource, objValue, extendedContext, @@ -412,14 +412,12 @@ function iterateObject(startPath: Path, obj: any, context: Context, transform: T .flatMap((value, index) => { const result = transform([...startPath, index], value, context); - const testResult = iterateObject( + return iterateObject( [...startPath, index], result.node, result.context, transform, ); - console.log(testResult); - return testResult; }) .filter((x) => x !== null && x !== undefined); return cleanedArray.length ? cleanedArray : undefined; @@ -430,14 +428,9 @@ function iterateObject(startPath: Path, obj: any, context: Context, transform: T return iterateObject([...startPath, key], result.node, result.context, transform); }); - if (isPlainObject(objResult)) { - const cleanedObject = Object.entries(objResult).filter(([, value]) => value !== undefined); - if (!cleanedObject.length) { - return undefined; - } - return Object.fromEntries(cleanedObject); - } - return objResult; + const cleanedObject = filterValues(objResult, (_key, value) => value !== undefined); + + return Object.entries(cleanedObject).length ? cleanedObject : undefined; } return transform(startPath, obj, context).node; @@ -455,6 +448,14 @@ function mapValues(obj: object, fn: (value: any, key: string) => any) { ); } +function filterValues(obj: object, fn: (value: any, key: string) => any) { + return Object.fromEntries( + Object.entries(obj).filter(([key, value]) => { + return [key, fn(value, key)]; + }), + ); +} + function omitKey(obj: any, key: string) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [key]: _, ...rest } = obj; From 19c0cd72be7d1132414f40c72372b5f11497ccf9 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 19:46:22 +0700 Subject: [PATCH 06/11] Fix wrong refactored tests --- python/tests/core/test_extract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/tests/core/test_extract.py b/python/tests/core/test_extract.py index 17840dc..f2f29c1 100644 --- a/python/tests/core/test_extract.py +++ b/python/tests/core/test_extract.py @@ -97,7 +97,7 @@ def test_transformation_for_array_of_objects_returns_original_array() -> None: def test_transformation_for_null_returns_null() -> None: - assert resolve_template({}, None) == undefined + assert resolve_template({}, None) is None def test_transformation_for_constant_string_returns_constant_string() -> None: @@ -159,7 +159,7 @@ def test_transformation_for_empty_array_nullable_template_expression_returns_nul resource, "/Patient/{{+ list.where($this = 0) +}}/_history/{{ list.last() }}", ) - == undefined + is None ) From 63be0493bf96178c4b9570987bc6e08257abdbdf Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 19:55:37 +0700 Subject: [PATCH 07/11] Fix JS tests --- ts/server/src/core/extract.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/ts/server/src/core/extract.ts b/ts/server/src/core/extract.ts index 1dffea8..5eef09a 100644 --- a/ts/server/src/core/extract.ts +++ b/ts/server/src/core/extract.ts @@ -428,9 +428,11 @@ function iterateObject(startPath: Path, obj: any, context: Context, transform: T return iterateObject([...startPath, key], result.node, result.context, transform); }); - const cleanedObject = filterValues(objResult, (_key, value) => value !== undefined); - - return Object.entries(cleanedObject).length ? cleanedObject : undefined; + const cleanedObject = Object.entries(objResult).filter(([, value]) => value !== undefined); + if (!cleanedObject.length) { + return undefined; + } + return Object.fromEntries(cleanedObject); } return transform(startPath, obj, context).node; @@ -448,14 +450,6 @@ function mapValues(obj: object, fn: (value: any, key: string) => any) { ); } -function filterValues(obj: object, fn: (value: any, key: string) => any) { - return Object.fromEntries( - Object.entries(obj).filter(([key, value]) => { - return [key, fn(value, key)]; - }), - ); -} - function omitKey(obj: any, key: string) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [key]: _, ...rest } = obj; From 0eddd47457247b7505aaf12791be849700aa761f Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 20:04:55 +0700 Subject: [PATCH 08/11] [Python] Return None instead of undefined from resolve_template because undefined is for internal puprposes only --- python/fpml/core/extract.py | 4 +++- python/tests/core/test_extract.py | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/python/fpml/core/extract.py b/python/fpml/core/extract.py index 57ac249..38241d0 100644 --- a/python/fpml/core/extract.py +++ b/python/fpml/core/extract.py @@ -55,7 +55,7 @@ def resolve_template( FHIRPathMappingLanguage Specification: https://github.com/beda-software/FHIRPathMappingLanguage/tree/main?tab=readme-ov-file#specification """ # noqa: E501 - return resolve_template_recur( + result = resolve_template_recur( [], guarded_resource if strict else resource, template, @@ -64,6 +64,8 @@ def resolve_template( fp_options=fp_options, ) + return None if result == undefined else result + def resolve_template_recur( start_path: Path, diff --git a/python/tests/core/test_extract.py b/python/tests/core/test_extract.py index f2f29c1..13b0d1e 100644 --- a/python/tests/core/test_extract.py +++ b/python/tests/core/test_extract.py @@ -58,11 +58,11 @@ def test_transformation_works_on_accessing_props_of_implicit_context_in_strict_m def test_transformation_for_empty_object_return_empty_object() -> None: - assert resolve_template({}, {}) == undefined + assert resolve_template({}, {}) is None def test_transformation_for_empty_array_return_empty_array() -> None: - assert resolve_template({}, []) == undefined + assert resolve_template({}, []) is None def test_transformation_for_array_of_arrays_returns_flattened_array() -> None: @@ -82,7 +82,7 @@ def test_transformation_for_object_with_null_keys_returns_null_keys() -> None: def test_transformation_for_object_with_undefined_keys_clears_undefined_keys() -> None: - assert resolve_template({}, {"key": undefined}) == undefined + assert resolve_template({}, {"key": undefined}) is None def test_transformation_for_object_with_non_null_keys_returns_non_null_keys() -> None: @@ -123,7 +123,7 @@ def test_transformation_for_non_empty_array_expression_return_first_element() -> def test_transformation_for_empty_array_expression_clears_undefined_keys() -> None: resource: Resource = {"list": []} - assert resolve_template(resource, {"result": "{{ list.where($this = 0) }}"}) == undefined + assert resolve_template(resource, {"result": "{{ list.where($this = 0) }}"}) is None def test_transformation_for_empty_array_nullable_expression_returns_null() -> None: @@ -148,7 +148,7 @@ def test_transformation_for_empty_array_template_expression_returns_undefined() resource, "/Patient/{{ list.where($this = 0) }}/_history/{{ list.last() }}", ) - == undefined + is None ) @@ -237,7 +237,7 @@ def test_assign_block_with_undefined_intermediate_values() -> None: "valueA": "{{ %varB }}", }, ) - == undefined + is None ) @@ -511,7 +511,7 @@ def test_if_block_clears_undefined_keys_for_falsy_condition_without_else_branch( }, }, ) - assert result == undefined + assert result is None def test_if_block_returns_null_for_falsy_condition_with_nullable_else_branch() -> None: From 5d24c3f0e5e53a71ff82942523d3d65c0a9c1503 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 20:15:35 +0700 Subject: [PATCH 09/11] Fix formatter issue --- python/tests/core/test_extract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tests/core/test_extract.py b/python/tests/core/test_extract.py index 13b0d1e..fa14cc4 100644 --- a/python/tests/core/test_extract.py +++ b/python/tests/core/test_extract.py @@ -62,7 +62,7 @@ def test_transformation_for_empty_object_return_empty_object() -> None: def test_transformation_for_empty_array_return_empty_array() -> None: - assert resolve_template({}, []) is None + assert resolve_template({}, []) is None def test_transformation_for_array_of_arrays_returns_flattened_array() -> None: From 39c1a85951354b6e2ac764061a7653a75103e95b Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 20:16:03 +0700 Subject: [PATCH 10/11] Add python skipped test for preserve nulls in arrays --- python/tests/core/test_extract.py | 14 ++++++++++++++ ts/server/src/core/extract.spec.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/python/tests/core/test_extract.py b/python/tests/core/test_extract.py index fa14cc4..c095807 100644 --- a/python/tests/core/test_extract.py +++ b/python/tests/core/test_extract.py @@ -763,3 +763,17 @@ def test_merge_block_fails_on_merge_with_non_object() -> None: "{% merge %}": [1, 2], }, ) + + +@pytest.mark.skip(reason="https://github.com/beda-software/FHIRPathMappingLanguage/issues/30") +def test_null_values_are_not_removed_from_array() -> None: + resource_with_empty_array_nullable = { + "root": {"resourceType": "Example", "list": [None, {"nested": None}, None]}, + } + + result = resolve_template( + resource_with_empty_array_nullable, + {"root": "{{ %Resource.root }}"}, + {"Resource": resource_with_empty_array_nullable}, + ) + assert result == {"root": {"resourceType": "Example", "list": [None, {"nested": None}]}} diff --git a/ts/server/src/core/extract.spec.ts b/ts/server/src/core/extract.spec.ts index 07a48d0..bde149d 100644 --- a/ts/server/src/core/extract.spec.ts +++ b/ts/server/src/core/extract.spec.ts @@ -3,6 +3,7 @@ import { FPMLValidationError, resolveTemplate } from './extract'; describe('Transformation', () => { const resource = { list: [{ key: 1 }, { key: 2 }, { key: 3 }] } as any; + // https://github.com/beda-software/FHIRPathMappingLanguage/issues/30 test.skip('null values are not removed', () => { const resourceWithEmptyArrayNullable = { root: { resourceType: 'Example', list: [null, { nested: null }, null] }, From 699da1c1bfd74cf76014db3a10291853eb3e8643 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 30 Jun 2025 20:25:30 +0700 Subject: [PATCH 11/11] [JS] resolveTemplate erturn null instead of undefined same as python implementation. For better maintenance --- ts/server/src/core/extract.spec.ts | 14 +++++++------- ts/server/src/core/extract.ts | 12 +++++------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/ts/server/src/core/extract.spec.ts b/ts/server/src/core/extract.spec.ts index bde149d..a42ef4b 100644 --- a/ts/server/src/core/extract.spec.ts +++ b/ts/server/src/core/extract.spec.ts @@ -95,11 +95,11 @@ describe('Transformation', () => { }); test('for empty object return undefined', () => { - expect(resolveTemplate(resource, {})).toBeUndefined(); + expect(resolveTemplate(resource, {})).toBeNull(); }); test('for empty array return undefined', () => { - expect(resolveTemplate(resource, [])).toBeUndefined(); + expect(resolveTemplate(resource, [])).toBeNull(); }); test('for array of arrays returns flattened array', () => { @@ -126,7 +126,7 @@ describe('Transformation', () => { }); test('for object with undefined keys returns undefined', () => { - expect(resolveTemplate(resource, { key: undefined })).toBeUndefined(); + expect(resolveTemplate(resource, { key: undefined })).toBeNull(); }); test('for object with undefined keys returns only defined keys', () => { @@ -168,7 +168,7 @@ describe('Transformation', () => { }); test('for empty array expression returns undefined', () => { - expect(resolveTemplate(resource, '{{ list.where($this = 0) }}')).toStrictEqual(undefined); + expect(resolveTemplate(resource, '{{ list.where($this = 0) }}')).toBeNull(); }); test('for empty array nullable expression returns null', () => { @@ -187,7 +187,7 @@ describe('Transformation', () => { resource, '/Patient/{{ list.where($this = 0) }}/_history/{{ list.last() }}', ), - ).toStrictEqual(undefined); + ).toBeNull(); }); test('for empty array nullable template expression returns null', () => { @@ -278,7 +278,7 @@ describe('Assign block', () => { null, true, ), - ).toBeUndefined(); + ).toBeNull(); }); test('works with multiple vars as array of objects', () => { @@ -520,7 +520,7 @@ describe('If block', () => { "{% if key != 'value' %}": { nested: "{{ 'true' + key }}" }, }, }), - ).toBeUndefined(); + ).toBeNull(); }); test('returns null for falsy condition with nullable else branch', () => { diff --git a/ts/server/src/core/extract.ts b/ts/server/src/core/extract.ts index 5eef09a..94e88bd 100644 --- a/ts/server/src/core/extract.ts +++ b/ts/server/src/core/extract.ts @@ -50,7 +50,7 @@ export function resolveTemplate( fpOptions?: FPOptions, strict?: boolean, ): any { - return resolveTemplateRecur( + const result = resolveTemplateRecur( [], strict ? guardedResourceFactory(resource) : resource, template, @@ -58,6 +58,9 @@ export function resolveTemplate( model, fpOptions, ); + + // NOTE: for synchronization with Python implementation + return result === undefined ? null : result; } function resolveTemplateRecur( @@ -412,12 +415,7 @@ function iterateObject(startPath: Path, obj: any, context: Context, transform: T .flatMap((value, index) => { const result = transform([...startPath, index], value, context); - return iterateObject( - [...startPath, index], - result.node, - result.context, - transform, - ); + return iterateObject([...startPath, index], result.node, result.context, transform); }) .filter((x) => x !== null && x !== undefined); return cleanedArray.length ? cleanedArray : undefined;