From af1b64c70dc5eaa2432d91f74104764a3ded34f0 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Fri, 4 Sep 2026 17:05:57 +0200 Subject: [PATCH 1/2] Add opt-in cache of compiled FHIRPath expressions Closes #37 --- CHANGELOG.md | 5 ++ README.md | 23 +++++++++ python/README.md | 25 ++++++++++ python/fpml/__init__.py | 7 ++- python/fpml/core/cache.py | 51 +++++++++++++++++++ python/fpml/core/core_types.py | 9 +++- python/fpml/core/extract.py | 14 ++++-- python/tests/core/test_cache.py | 62 +++++++++++++++++++++++ ts/server/src/app.service.ts | 86 ++++++++++++++++++++++---------- ts/server/src/core/cache.spec.ts | 53 ++++++++++++++++++++ ts/server/src/core/cache.ts | 56 +++++++++++++++++++++ ts/server/src/core/extract.ts | 17 +++---- 12 files changed, 366 insertions(+), 42 deletions(-) create mode 100644 python/fpml/core/cache.py create mode 100644 python/tests/core/test_cache.py create mode 100644 ts/server/src/core/cache.spec.ts create mode 100644 ts/server/src/core/cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c14d5..086b22d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index cba0303..6381fc8 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/python/README.md b/python/README.md index dfda426..c091f78 100644 --- a/python/README.md +++ b/python/README.md @@ -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 diff --git a/python/fpml/__init__.py b/python/fpml/__init__.py index 6dd4ca6..7d93f1b 100644 --- a/python/fpml/__init__.py +++ b/python/fpml/__init__.py @@ -1,5 +1,6 @@ import importlib.metadata +from .core.cache import ExpressionCache from .core.core_exceptions import FPMLValidationError from .core.extract import resolve_template @@ -9,4 +10,8 @@ __license__ = "MIT" __copyright__ = "Copyright 2025 beda.software" -__all__ = ["FPMLValidationError", "resolve_template"] +__all__ = [ + "ExpressionCache", + "FPMLValidationError", + "resolve_template", +] diff --git a/python/fpml/core/cache.py b/python/fpml/core/cache.py new file mode 100644 index 0000000..3ad11fb --- /dev/null +++ b/python/fpml/core/cache.py @@ -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) diff --git a/python/fpml/core/core_types.py b/python/fpml/core/core_types.py index ae7b9dd..1f908a5 100644 --- a/python/fpml/core/core_types.py +++ b/python/fpml/core/core_types.py @@ -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] @@ -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: @@ -47,6 +53,7 @@ class FPOptions(TypedDict): model: NotRequired[Model] userInvocationTable: NotRequired[UserInvocationTable] + cache: NotRequired["ExpressionCache"] class MatcherResult(TypedDict): diff --git a/python/fpml/core/extract.py b/python/fpml/core/extract.py index fa3fdc6..0de4777 100644 --- a/python/fpml/core/extract.py +++ b/python/fpml/core/extract.py @@ -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 ( @@ -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 diff --git a/python/tests/core/test_cache.py b/python/tests/core/test_cache.py new file mode 100644 index 0000000..38a8cc7 --- /dev/null +++ b/python/tests/core/test_cache.py @@ -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"] diff --git a/ts/server/src/app.service.ts b/ts/server/src/app.service.ts index 7d908a0..06f4a66 100644 --- a/ts/server/src/app.service.ts +++ b/ts/server/src/app.service.ts @@ -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(); @Injectable() export class AppService { @@ -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); +} diff --git a/ts/server/src/core/cache.spec.ts b/ts/server/src/core/cache.spec.ts new file mode 100644 index 0000000..15639a7 --- /dev/null +++ b/ts/server/src/core/cache.spec.ts @@ -0,0 +1,53 @@ +import * as fhirpath from 'fhirpath'; +import { ExpressionCache } from './cache'; +import { resolveTemplate } from './extract'; + +describe('ExpressionCache', () => { + test('reuses compiled expression', () => { + const cache = new ExpressionCache(16); + const compiled = cache.compile('list.key', null, null); + + expect(cache.compile('list.key', null, null)).toBe(compiled); + }); + + test('evicts least recently used expression', () => { + const cache = new ExpressionCache(2); + const first = cache.compile('first', null, null); + const middle = cache.compile('middle', null, null); + cache.compile('first', null, null); + cache.compile('last', null, null); + + expect(cache.size).toBe(2); + expect(cache.compile('first', null, null)).toBe(first); + expect(cache.compile('middle', null, null)).not.toBe(middle); + }); + + test('does not cache with zero max size', () => { + const cache = new ExpressionCache(0); + const compiled = cache.compile('list.key', null, null); + + expect(cache.compile('list.key', null, null)).not.toBe(compiled); + }); + + test('clear drops compiled expressions', () => { + const cache = new ExpressionCache(16); + const compiled = cache.compile('list.key', null, null); + cache.clear(); + + expect(cache.compile('list.key', null, null)).not.toBe(compiled); + }); + + test('resolveTemplate compiles repeated expression once', () => { + const compile = jest.spyOn(fhirpath, 'compile'); + const resource = { list: [{ key: 1 }, { key: 2 }] } as any; + const template = { first: '{{ list.key }}', second: '{{ list.key }}' }; + const result = resolveTemplate(resource, template, {}, null, { + cache: new ExpressionCache(16), + }); + + expect(result).toStrictEqual({ first: 1, second: 1 }); + expect(compile.mock.calls.map(([expression]) => expression)).toStrictEqual(['list.key']); + + compile.mockRestore(); + }); +}); diff --git a/ts/server/src/core/cache.ts b/ts/server/src/core/cache.ts new file mode 100644 index 0000000..7c4f534 --- /dev/null +++ b/ts/server/src/core/cache.ts @@ -0,0 +1,56 @@ +import * as fhirpath from 'fhirpath'; +import type { FPOptions } from './extract'; + +export type CompiledExpression = (resource: any, context?: Context) => any[]; + +export function compileExpression( + expression: string, + model: Model, + options: FPOptions, +): CompiledExpression { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { cache, ...fhirpathOptions } = options ?? {}; + + return fhirpath.compile(expression, model, fhirpathOptions); +} + +/** + * 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 options. + * Zero max size disables caching. + */ +export class ExpressionCache { + private readonly compiled = new Map(); + + constructor(private readonly maxSize: number) {} + + compile(expression: string, model: Model, options: FPOptions): CompiledExpression { + const cached = this.compiled.get(expression); + if (cached) { + this.compiled.delete(expression); + this.compiled.set(expression, cached); + + return cached; + } + + const compiled = compileExpression(expression, model, options); + if (this.maxSize > 0) { + this.compiled.set(expression, compiled); + if (this.compiled.size > this.maxSize) { + this.compiled.delete(this.compiled.keys().next().value); + } + } + + return compiled; + } + + clear() { + this.compiled.clear(); + } + + get size() { + return this.compiled.size; + } +} diff --git a/ts/server/src/core/extract.ts b/ts/server/src/core/extract.ts index c92376f..cb1c159 100644 --- a/ts/server/src/core/extract.ts +++ b/ts/server/src/core/extract.ts @@ -1,4 +1,4 @@ -import * as fhirpath from 'fhirpath'; +import { compileExpression, ExpressionCache } from './cache'; type Resource = Record; type Path = Array; @@ -9,6 +9,7 @@ const rootNodeKey = '__rootNode__'; export interface FPOptions { userInvocationTable?: UserInvocationTable; + cache?: ExpressionCache; } export class FPMLValidationError extends Error { @@ -464,14 +465,12 @@ export function evaluateExpression( options: FPOptions, ) { try { - return fhirpath.evaluate( - resource, - expression, - // fhirpath mutates context https://github.com/HL7/fhirpath.js/issues/155 - { ...context }, - model, - options, - ); + const compiled = options?.cache + ? options.cache.compile(expression, model, options) + : compileExpression(expression, model, options); + + // fhirpath mutates context https://github.com/HL7/fhirpath.js/issues/155 + return compiled(resource, { ...context }); } catch (exc) { throw new FPMLValidationError(`Can not evaluate '${expression}': ${exc}`, path); } From ae9dc02639363fe33fd75c53502de01f084038fe Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Fri, 4 Sep 2026 17:06:03 +0200 Subject: [PATCH 2/2] Bump to 0.3.0 --- python/pyproject.toml | 2 +- ts/server/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index a9febdc..e1fe368 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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 = [ diff --git a/ts/server/package.json b/ts/server/package.json index 4c3d0b8..c67a1fe 100644 --- a/ts/server/package.json +++ b/ts/server/package.json @@ -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,