Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.3.0

- Add configurable LRU cache of compiled FHIRPath expressions instead of parsing them on every evaluation #37 (@ruscoder)
- Add proper compilation for `answers()` in TS server #37 (@ruscoder)

## 0.2.0

- Clear empty array and objects #17 (@dmitryashutov)
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,12 @@ POST /r4/parse-template
}
```

#### Cache

Expressions are compiled on every evaluation unless the `FPML_CACHE_SIZE` environment variable is set to the number of compiled expressions to keep, which speeds up repeated templates several times.

A compiled expression retains its parsed AST, so the memory cost grows with the expression length: `1024` of them take about 25mb for short expressions and up to 250mb for 1kb ones. The limit applies per cache, and there's one cache per FHIR version.

#### Strict mode

There's a flag, called `strict` that is set to `false` by default. If it set to `true`, all accesses to the variables without the percent sign will be rejected and exception will be thrown.
Expand Down Expand Up @@ -679,6 +685,23 @@ result = resolve_template(



#### Cache

There's no cache by default, expressions are compiled on every evaluation. Pass an `ExpressionCache` through `fp_options` to reuse compiled expressions, see [details](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python/README.md#caching-compiled-expressions).

Example:

```python
from fpml import ExpressionCache, resolve_template

result = resolve_template(
resource,
template,
context,
fp_options={'cache': ExpressionCache(max_size=1024)}
)
```

#### User-defined functions

There's an ability to pass user-defined functions through fp_options
Expand Down
25 changes: 25 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,31 @@ Output:
{'resourceType': 'Patient', 'name': [{'text': 'Name'}]}
```

### Caching compiled expressions

Parsing FHIRPath expressions is expensive, so expressions can be compiled once and reused via
`ExpressionCache` passed through `fp_options`. The cache size is the number of compiled expressions
kept in memory, zero disables caching.

Entries are keyed by the expression only, while compilation binds the model and the user-defined
functions, so keep one long-living cache per `fp_options`.

```python
from fhirpathpy.models import models

from fpml import ExpressionCache, resolve_template


# 1024 long expressions take up to 100mb
fp_options = {
"model": models["r4"],
"cache": ExpressionCache(max_size=1024),
}

for resource in resources:
resolve_template(resource, template, context, fp_options)
```

### Handling validation errors

```python
Expand Down
7 changes: 6 additions & 1 deletion python/fpml/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import importlib.metadata

from .core.cache import ExpressionCache
from .core.core_exceptions import FPMLValidationError
from .core.extract import resolve_template

Expand All @@ -9,4 +10,8 @@
__license__ = "MIT"
__copyright__ = "Copyright 2025 beda.software"

__all__ = ["FPMLValidationError", "resolve_template"]
__all__ = [
"ExpressionCache",
"FPMLValidationError",
"resolve_template",
]
51 changes: 51 additions & 0 deletions python/fpml/core/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from collections import OrderedDict
from typing import Any, Callable, Optional, cast

from fhirpathpy import compile as fhirpath_compile # type: ignore

from .core_types import FPOptions

CompiledExpression = Callable[..., list[Any]]


def compile_expression(expression: str, fp_options: Optional[FPOptions]) -> CompiledExpression:
options = cast(dict, fp_options or {}).copy()
model = options.pop("model", None)
options.pop("cache", None)

return fhirpath_compile(expression, model, options)


class ExpressionCache:
"""LRU cache of compiled FHIRPath expressions.

Entries are keyed by the expression only, while compilation binds the model
and the user-defined functions, so use a separate cache per fp_options.
Zero max size disables caching.
"""

def __init__(self, max_size: int) -> None:
self.max_size = max_size
self._compiled: OrderedDict[str, CompiledExpression] = OrderedDict()

def compile(self, expression: str, fp_options: Optional[FPOptions]) -> CompiledExpression:
cached = self._compiled.get(expression)
if cached is not None:
self._compiled.move_to_end(expression)

return cached

compiled = compile_expression(expression, fp_options)
if self.max_size > 0:
self._compiled[expression] = compiled
if len(self._compiled) > self.max_size:
self._compiled.popitem(last=False)

return compiled

def clear(self) -> None:
self._compiled.clear()

@property
def size(self) -> int:
return len(self._compiled)
9 changes: 8 additions & 1 deletion python/fpml/core/core_types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from typing import Any, Callable, Optional, TypedDict, Union
from typing import TYPE_CHECKING, Any, Callable, Optional, TypedDict, Union

from typing_extensions import NotRequired

if TYPE_CHECKING:
from .cache import ExpressionCache

Resource = dict[str, Any]
Node = Any
DictNode = dict[str, Any]
Expand Down Expand Up @@ -39,6 +42,9 @@ class FPOptions(TypedDict):
A table of user-defined functions that
can be used in FHIRPath expressions during template processing.
See https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#user-defined-functions
cache (Optional[ExpressionCache]):
A cache of compiled expressions, e.g. ExpressionCache(max_size=1024).
Expressions are compiled on every evaluation when it's not passed.

See Also:
FHIRPath py Documentation:
Expand All @@ -47,6 +53,7 @@ class FPOptions(TypedDict):

model: NotRequired[Model]
userInvocationTable: NotRequired[UserInvocationTable]
cache: NotRequired["ExpressionCache"]


class MatcherResult(TypedDict):
Expand Down
14 changes: 9 additions & 5 deletions python/fpml/core/extract.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import re
from typing import Any, Optional, cast

from fhirpathpy import evaluate # type: ignore

from fpml.core.guarded_resource import guarded_resource

from .cache import compile_expression
from .constants import root_node_key, undefined
from .core_exceptions import FPMLValidationError
from .core_types import (
Expand Down Expand Up @@ -395,10 +394,15 @@ def evaluate_expression(
context: Context,
fp_options: Optional[FPOptions] = None,
) -> list[Any]:
fp_options_copy = cast(dict, fp_options or {}).copy()
model = fp_options_copy.pop("model", None)
cache = (fp_options or {}).get("cache")

try:
return evaluate(resource, expression, context, model, options=fp_options_copy)
compiled = (
cache.compile(expression, fp_options)
if cache
else compile_expression(expression, fp_options)
)

return compiled(resource, context)
except Exception as exc:
raise FPMLValidationError(f"Cannot evaluate '{expression}': {exc}", path) from exc
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "fpml"
version = "0.2.0"
version = "0.3.0"
description = "The FHIRPath mapping language is a data DSL designed to convert data from QuestionnaireResponse (and not only) to any FHIR Resource."
authors = [{ name = "Beda Software", email = "ilya@beda.software" }]
maintainers = [
Expand Down
62 changes: 62 additions & 0 deletions python/tests/core/test_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from typing import Any

import pytest

from fpml.core import cache as cache_module
from fpml.core.cache import ExpressionCache
from fpml.core.core_types import Resource
from fpml.core.extract import resolve_template


def test_reuses_compiled_expression() -> None:
cache = ExpressionCache(max_size=16)
compiled = cache.compile("list.key", None)
assert cache.compile("list.key", None) is compiled


def test_evicts_least_recently_used_expression() -> None:
max_size = 2
cache = ExpressionCache(max_size=max_size)
first = cache.compile("first", None)
middle = cache.compile("middle", None)
cache.compile("first", None)
cache.compile("last", None)
assert cache.size == max_size

assert cache.compile("first", None) is first
assert cache.compile("middle", None) is not middle


def test_does_not_cache_with_zero_max_size() -> None:
cache = ExpressionCache(max_size=0)
compiled = cache.compile("list.key", None)
assert cache.compile("list.key", None) is not compiled


def test_clear_drops_compiled_expressions() -> None:
cache = ExpressionCache(max_size=16)
compiled = cache.compile("list.key", None)

cache.clear()
assert cache.compile("list.key", None) is not compiled


def test_resolve_template_compiles_repeated_expression_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
compiled_expressions = []
original_compile = cache_module.fhirpath_compile

def counting_compile(expression: str, model: Any = None, options: Any = None) -> Any:
compiled_expressions.append(expression)
return original_compile(expression, model, options)

monkeypatch.setattr(cache_module, "fhirpath_compile", counting_compile)
resource: Resource = {"list": [{"key": 1}, {"key": 2}]}
template = {"first": "{{ list.key }}", "second": "{{ list.key }}"}
result = resolve_template(
resource, template, fp_options={"cache": ExpressionCache(max_size=16)}
)
assert result == {"first": 1, "second": 1}

assert compiled_expressions == ["list.key"]
2 changes: 1 addition & 1 deletion ts/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fpml-server",
"version": "0.2.0",
"version": "0.3.0",
"description": "The FHIRPath mapping language is a data DSL designed to convert data from QuestionnaireResponse (and not only) to any FHIR Resource.",
"author": "beda.software",
"private": true,
Expand Down
86 changes: 60 additions & 26 deletions ts/server/src/app.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { Injectable } from '@nestjs/common';
import { FPOptions, resolveTemplate } from './core/extract';
import * as fhirpath from 'fhirpath';
import { compileExpression, ExpressionCache } from './core/cache';

const cacheSizeEnvVar = 'FPML_CACHE_SIZE';

// Opt-in: a compiled expression retains its parsed AST, see README for the memory cost
const cacheSize = readCacheSize();

const toStringExpression = compileExpression('x.toString()', null, null);

// Options and their cache are bound to a model and must outlive requests to be reused
const optionsByModel = new Map<Model | null, FPOptions>();

@Injectable()
export class AppService {
Expand All @@ -11,37 +21,61 @@ export class AppService {
model?: Model,
strict?: boolean,
): object {
const options: FPOptions = {
userInvocationTable: {
answers: {
fn: (inputs, linkId: string) => {
return fhirpath.evaluate(
inputs,
model
? `repeat(item).where(linkId='${linkId}').answer.value`
: `repeat(item).where(linkId='${linkId}').answer.value.children()`,
null,
model,
null,
);
},
arity: { 0: [], 1: ['String'] },
},
// Get rid of toString once it's fixed https://github.com/HL7/fhirpath.js/issues/156
toString: {
fn: (inputs) => fhirpath.evaluate({ x: inputs }, 'x.toString()'),
arity: { 0: [] },
},
},
};

return resolveTemplate(
resource,
template,
{ root: resource, ...context },
model,
options,
getOptions(model),
strict,
);
}
}

function getOptions(model?: Model): FPOptions {
const key = model ?? null;
const options = optionsByModel.get(key) ?? buildOptions(model);
optionsByModel.set(key, options);

return options;
}

function buildOptions(model?: Model): FPOptions {
// The linkId travels as a variable to keep the expression constant, so it is
// compiled once per model and cannot break the expression when it holds a quote
const answersExpression = compileExpression(
model
? 'repeat(item).where(linkId=%FPMLLinkId).answer.value'
: 'repeat(item).where(linkId=%FPMLLinkId).answer.value.children()',
model,
null,
);

return {
cache: new ExpressionCache(cacheSize),
userInvocationTable: {
answers: {
fn: (inputs, linkId: string) => answersExpression(inputs, { FPMLLinkId: linkId }),
arity: { 0: [], 1: ['String'] },
},
// Get rid of toString once it's fixed https://github.com/HL7/fhirpath.js/issues/156
toString: {
fn: (inputs) => toStringExpression({ x: inputs }),
arity: { 0: [] },
},
},
};
}

function readCacheSize(): number {
const rawSize = process.env[cacheSizeEnvVar];
if (!rawSize) {
return 0;
}

if (!/^\d+$/.test(rawSize)) {
throw new Error(`${cacheSizeEnvVar} must be a non-negative integer, got '${rawSize}'`);
}

return Number.parseInt(rawSize, 10);
}
Loading
Loading