Skip to content

Commit 7201f55

Browse files
committed
Add support for user defined functions #49
1 parent 93a3501 commit 7201f55

File tree

7 files changed

+118
-14
lines changed

7 files changed

+118
-14
lines changed

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
## latest
1+
## 2.0.0
22

33
- Raise an error in case of accessing undefined env variable #50 @ruscoder
4+
- Add support for user defined functions #49 @ruscoder
45

56
## 1.2.1
67

README.md

+29
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ context (dict): a hash of variable name/value pairs.
6868

6969
model (dict): The "model" data object specific to a domain, e.g. R4.
7070

71+
options (dict) - Custom options (see the documentation below)
72+
73+
options.userInvocationTable - a user invocation table used to replace any existing functions or define new ones (see User-defined functions documentation below)
74+
7175
## compile
7276
Returns a function that takes a resource and an optional context hash (see "evaluate"), and returns the result of evaluating the given FHIRPath expression on that resource. The advantage of this function over "evaluate" is that if you have multiple resources, the given FHIRPath expression will only be parsed once
7377

@@ -76,3 +80,28 @@ Returns a function that takes a resource and an optional context hash (see "eval
7680
path (string) - the FHIRPath expression to be parsed.
7781

7882
model (dict) - The "model" data object specific to a domain, e.g. R4.
83+
84+
options (dict) - Custom options
85+
86+
options.userInvocationTable - a user invocation table used to replace any existing functions or define new ones (see User-defined functions documentation below)
87+
88+
## User-defined functions
89+
90+
```python
91+
user_invocation_table = {
92+
"pow": {
93+
"fn": lambda inputs, exp=2: [i**exp for i in inputs],
94+
"arity": {0: [], 1: ["Integer"]},
95+
}
96+
}
97+
98+
result = evaluate(
99+
{"a": [5, 6, 7]},
100+
"a.pow()",
101+
options={"userInvocationTable": user_invocation_table},
102+
)
103+
104+
# result: [25, 36, 49]
105+
```
106+
107+
It works similarly to [fhirpath.js](https://github.com/HL7/fhirpath.js/tree/master?tab=readme-ov-file#user-defined-functions)

fhirpathpy/__init__.py

+18-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from fhirpathpy.engine.invocations.constants import constants
22
from fhirpathpy.parser import parse
33
from fhirpathpy.engine import do_eval
4-
from fhirpathpy.engine.util import arraify, get_data, set_paths
4+
from fhirpathpy.engine.util import arraify, get_data, set_paths, process_user_invocation_table
55
from fhirpathpy.engine.nodes import FP_Type, ResourceNode
66

77
__title__ = "fhirpathpy"
8-
__version__ = "1.2.1"
8+
__version__ = "2.0.0"
99
__author__ = "beda.software"
1010
__license__ = "MIT"
1111
__copyright__ = "Copyright 2025 beda.software"
@@ -14,7 +14,7 @@
1414
VERSION = __version__
1515

1616

17-
def apply_parsed_path(resource, parsedPath, context=None, model=None):
17+
def apply_parsed_path(resource, parsedPath, context=None, model=None, options=None):
1818
constants.reset()
1919
dataRoot = arraify(resource)
2020

@@ -27,7 +27,14 @@ def apply_parsed_path(resource, parsedPath, context=None, model=None):
2727
vars = {"context": resource, "ucum": "http://unitsofmeasure.org"}
2828
vars.update(context or {})
2929

30-
ctx = {"dataRoot": dataRoot, "vars": vars, "model": model}
30+
ctx = {
31+
"dataRoot": dataRoot,
32+
"vars": vars,
33+
"model": model,
34+
"userInvocationTable": process_user_invocation_table(
35+
(options or {}).get("userInvocationTable", {})
36+
),
37+
}
3138
node = do_eval(ctx, dataRoot, parsedPath["children"][0])
3239

3340
# Resolve any internal "ResourceNode" instances. Continue to let FP_Type
@@ -57,7 +64,7 @@ def visit(node):
5764
return visit(node)
5865

5966

60-
def evaluate(resource, path, context=None, model=None):
67+
def evaluate(resource, path, context=None, model=None, options=None):
6168
"""
6269
Evaluates the "path" FHIRPath expression on the given resource, using data
6370
from "context" for variables mentioned in the "path" expression.
@@ -75,14 +82,14 @@ def evaluate(resource, path, context=None, model=None):
7582
if isinstance(path, dict):
7683
node = parse(path["expression"])
7784
if "base" in path:
78-
resource = ResourceNode.create_node(resource, path['base'])
85+
resource = ResourceNode.create_node(resource, path["base"])
7986
else:
8087
node = parse(path)
8188

82-
return apply_parsed_path(resource, node, context or {}, model)
89+
return apply_parsed_path(resource, node, context or {}, model, options)
8390

8491

85-
def compile(path, model=None):
92+
def compile(path, model=None, options=None):
8693
"""
8794
Returns a function that takes a resource and an optional context hash (see
8895
"evaluate"), and returns the result of evaluating the given FHIRPath
@@ -96,4 +103,6 @@ def compile(path, model=None):
96103
97104
For example, you could pass in the result of require("fhirpath/fhir-context/r4")
98105
"""
99-
return set_paths(apply_parsed_path, parsedPath=parse(path), model=model)
106+
return set_paths(
107+
apply_parsed_path, parsedPath=parse(path), model=model, options=options
108+
)

fhirpathpy/engine/__init__.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import fhirpathpy.engine.util as util
44
from fhirpathpy.engine.nodes import TypeInfo
55
from fhirpathpy.engine.evaluators import evaluators
6-
from fhirpathpy.engine.invocations import invocation_registry
6+
from fhirpathpy.engine.invocations import (
7+
invocation_registry as base_invocation_registry,
8+
)
79

810

911
def check_integer_param(val):
@@ -45,6 +47,11 @@ def do_eval(ctx, parentData, node):
4547

4648

4749
def doInvoke(ctx, fn_name, data, raw_params):
50+
invocation_registry = {
51+
**base_invocation_registry,
52+
**(ctx["userInvocationTable"] or {}),
53+
}
54+
4855
if isinstance(fn_name, list) and len(fn_name) == 1:
4956
fn_name = fn_name[0]
5057

@@ -170,7 +177,11 @@ def func(data):
170177

171178

172179
def infix_invoke(ctx, fn_name, data, raw_params):
173-
if not fn_name in invocation_registry or not "fn" in invocation_registry[fn_name]:
180+
invocation_registry = {
181+
**base_invocation_registry,
182+
**(ctx["userInvocationTable"] or {}),
183+
}
184+
if fn_name not in invocation_registry or "fn" not in invocation_registry[fn_name]:
174185
raise Exception("Not implemented " + fn_name)
175186

176187
invocation = invocation_registry[fn_name]

fhirpathpy/engine/util.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77

88
class set_paths:
9-
def __init__(self, func, parsedPath, model=None):
9+
def __init__(self, func, parsedPath, model=None, options=None):
1010
self.func = func
1111
self.parsedPath = parsedPath
1212
self.model = model
13+
self.options = options
1314

1415
def __call__(self, resource, context=None):
15-
return self.func(resource, self.parsedPath, context or {}, self.model)
16+
return self.func(
17+
resource, self.parsedPath, context or {}, self.model, self.options
18+
)
1619

1720

1821
def get_data(value):
@@ -92,8 +95,21 @@ def uniq(arr):
9295
ordered_dict[key] = x
9396
return list(ordered_dict.values())
9497

98+
9599
def val_data_converted(val):
96100
if isinstance(val, ResourceNode):
97101
val = val.convert_data()
98102

99103
return val
104+
105+
106+
def process_user_invocation_table(table):
107+
return {
108+
name: {
109+
**entity,
110+
"fn": lambda ctx, inputs, *args: entity["fn"](
111+
[get_data(i) for i in inputs], *args
112+
),
113+
}
114+
for name, entity in table.items()
115+
}

tests/test_evaluators.py

+23
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,26 @@ def external_constant_test():
401401
def external_constant_fails_on_undefined_var_test():
402402
with pytest.raises(ValueError):
403403
evaluate({}, "%var")
404+
405+
406+
def user_invocation_table_test():
407+
user_invocation_table = {
408+
"pow": {
409+
"fn": lambda inputs, exp=2: [i ** exp for i in inputs],
410+
"arity": {0: [], 1: ["Integer"]},
411+
}
412+
}
413+
414+
result = evaluate(
415+
{"a": [5, 6, 7]},
416+
"a.pow()",
417+
options={"userInvocationTable": user_invocation_table},
418+
)
419+
assert result == [5 * 5, 6 * 6, 7 * 7]
420+
421+
result = evaluate(
422+
{"a": [5, 6, 7]},
423+
"a.pow(3)",
424+
options={"userInvocationTable": user_invocation_table},
425+
)
426+
assert result == [5 * 5 * 5, 6 * 6 * 6, 7 * 7 * 7]

tests/test_real.py

+15
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,18 @@ def reference_filter_test():
174174
"Encounter.participant.individual.reference.where($this.matches('Practitioner/'))",
175175
)
176176
assert result == ["Practitioner/dr-johns"]
177+
178+
179+
def compile_with_user_defined_table_test():
180+
user_invocation_table = {
181+
"pow": {
182+
"fn": lambda inputs, exp=2: [i ** exp for i in inputs],
183+
"arity": {0: [], 1: ["Integer"]},
184+
}
185+
}
186+
187+
expr = compile(
188+
"a.pow()",
189+
options={"userInvocationTable": user_invocation_table},
190+
)
191+
assert expr({"a": [5, 6, 7]}) == [5 * 5, 6 * 6, 7 * 7]

0 commit comments

Comments
 (0)