diff --git a/CHANGELOG.md b/CHANGELOG.md index c6912ee..4e13d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.0.0 + +- Accept an `evaluate` function in `resolve_template` instead of `fp_options`, so that the caller owns + compilation and its caching #37 (@ruscoder) +- Move the cache of compiled expressions out of the library into the caller, `ExpressionCache` is no + longer exported #37 (@ruscoder) +- Make `strict` and `evaluate` keyword-only #37 (@ruscoder) + ## 0.3.1 - Make `ExpressionCache` thread-safe, it raised a spurious `FPMLValidationError` when an entry was evicted mid-lookup diff --git a/README.md b/README.md index 6381fc8..fdbdf56 100644 --- a/README.md +++ b/README.md @@ -685,30 +685,46 @@ result = resolve_template( -#### Cache +#### Custom evaluator + +Expressions are compiled on every evaluation by default. Pass an `evaluate` function to reuse compiled expressions, cached the way your application needs, see [details](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python/README.md#using-a-custom-evaluator). -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). +An evaluator takes a resource, an expression and a context, and returns the list of results. It is +responsible for the model and the user-defined functions itself. Example: ```python -from fpml import ExpressionCache, resolve_template +from functools import lru_cache -result = resolve_template( - resource, - template, - context, - fp_options={'cache': ExpressionCache(max_size=1024)} -) +from fhirpathpy import compile +from fhirpathpy.models import models + +from fpml import resolve_template + + +@lru_cache(maxsize=2**14) +def cached_compile(expression, model_name): + return compile(expression, models.get(model_name)) + + +def evaluate(resource, expression, context): + return cached_compile(expression, "r4")(resource, context) + + +result = resolve_template(resource, template, context, evaluate=evaluate) ``` #### User-defined functions -There's an ability to pass user-defined functions through fp_options +There's an ability to pass user-defined functions to the evaluator. A custom evaluator applies them +itself, see [custom evaluator](#custom-evaluator). Example: ```python +from fpml import make_evaluator, resolve_template + user_invocation_table = { "pow": { "fn": lambda inputs, exp=2: [i**exp for i in inputs], @@ -720,7 +736,7 @@ result = resolve_template( resource, template, context, - fp_options={'userInvocationTable': user_invocation_table} + evaluate=make_evaluator(options={"userInvocationTable": user_invocation_table}) ) ``` diff --git a/python/README.md b/python/README.md index 0836201..882b376 100644 --- a/python/README.md +++ b/python/README.md @@ -25,8 +25,8 @@ result = resolve_template( resource, template, context=None, - fp_options=None, - strict=False + strict=False, + evaluate=None ) ``` @@ -35,8 +35,8 @@ result = resolve_template( - resource (Resource): The input FHIR resource to process. - template (Any): The template describing the transformation. - 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 [strict mode](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main?tab=readme-ov-file#strict-mode). +- evaluate (Optional[Evaluate], optional): Evaluates one FHIRPath expression against a resource and a context. Defaults to `make_evaluator()`, which compiles every expression on every evaluation. ### Returns: @@ -97,9 +97,10 @@ Output: ### Using FHIR data-model ```python -from fpml import resolve_template from fhirpathpy.models import models +from fpml import make_evaluator, resolve_template + template = { "resourceType": "Patient", @@ -112,11 +113,9 @@ template = { context = {} -fp_options = { - "model": models["r4"] -} +evaluate = make_evaluator(models["r4"]) -result = resolve_template(resource, template, context, fp_options) +result = resolve_template(resource, template, context, evaluate=evaluate) print(result) ``` @@ -128,7 +127,7 @@ Output: ### Using user-defined functions ```python -from fpml import resolve_template +from fpml import make_evaluator, resolve_template template = { @@ -149,11 +148,9 @@ user_invocation_table = { } } -fp_options = { - "userInvocationTable": user_invocation_table -} +evaluate = make_evaluator(options={"userInvocationTable": user_invocation_table}) -result = resolve_template(resource, template, context, fp_options) +result = resolve_template(resource, template, context, evaluate=evaluate) print(result) ``` @@ -162,31 +159,45 @@ 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. +### Using a custom evaluator -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`. A cache is safe to share between threads. +By default every expression is compiled on every evaluation, which is expensive. Pass an `evaluate` +function to reuse compiled expressions, cached the way your application needs. It takes a resource, +an expression and a context, and returns the list of results. ```python +from functools import lru_cache + +from fhirpathpy import compile from fhirpathpy.models import models -from fpml import ExpressionCache, resolve_template +from fpml import resolve_template -# 1024 long expressions take up to 100mb -fp_options = { - "model": models["r4"], - "cache": ExpressionCache(max_size=1024), -} +@lru_cache(maxsize=1024) +def cached_compile(expression, model_name): + return compile(expression, models.get(model_name)) + + +def evaluate(resource, expression, context): + return cached_compile(expression, "r4")(resource, context) + for resource in resources: - resolve_template(resource, template, context, fp_options) + resolve_template(resource, template, context, evaluate=evaluate) ``` +Compilation binds the model and the user-defined functions, so cache entries are only reusable for +the same pair. Keep them in the key, like `model_name` above, when the application evaluates against +more than one model, otherwise expressions compiled for one silently resolve against the other. + +A compiled expression retains its parsed AST, so the memory cost grows with the expression length: +`1024` of them take about 11mb for short expressions and up to 290mb for 1kb ones. Size the cache for +the number of distinct expressions your templates and questionnaires actually contain. + +`make_evaluator` builds the default evaluator from a model and a user-defined function table, as the +examples above do. A custom evaluator applies both itself. + ### Handling validation errors ```python diff --git a/python/fpml/__init__.py b/python/fpml/__init__.py index 7d93f1b..140b12b 100644 --- a/python/fpml/__init__.py +++ b/python/fpml/__init__.py @@ -1,7 +1,8 @@ import importlib.metadata -from .core.cache import ExpressionCache from .core.core_exceptions import FPMLValidationError +from .core.core_types import Evaluate +from .core.evaluator import FPOptions, make_evaluator from .core.extract import resolve_template __title__ = "fpml" @@ -11,7 +12,9 @@ __copyright__ = "Copyright 2025 beda.software" __all__ = [ - "ExpressionCache", + "Evaluate", "FPMLValidationError", + "FPOptions", + "make_evaluator", "resolve_template", ] diff --git a/python/fpml/core/cache.py b/python/fpml/core/cache.py deleted file mode 100644 index 4c9bf35..0000000 --- a/python/fpml/core/cache.py +++ /dev/null @@ -1,58 +0,0 @@ -import threading -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. Instances are safe to share between threads. - """ - - def __init__(self, max_size: int) -> None: - self.max_size = max_size - self._compiled: OrderedDict[str, CompiledExpression] = OrderedDict() - self._lock = threading.Lock() - - def compile(self, expression: str, fp_options: Optional[FPOptions]) -> CompiledExpression: - with self._lock: - cached = self._compiled.get(expression) - if cached is not None: - self._compiled.move_to_end(expression) - - return cached - - # Compiling outside the lock, so that it does not hold up the other threads - compiled = compile_expression(expression, fp_options) - - if self.max_size > 0: - with self._lock: - self._compiled[expression] = compiled - if len(self._compiled) > self.max_size: - self._compiled.popitem(last=False) - - return compiled - - def clear(self) -> None: - with self._lock: - 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 1f908a5..f634cd5 100644 --- a/python/fpml/core/core_types.py +++ b/python/fpml/core/core_types.py @@ -1,10 +1,7 @@ -from typing import TYPE_CHECKING, Any, Callable, Optional, TypedDict, Union +from typing import 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] @@ -30,30 +27,7 @@ class UserFnDefinition(TypedDict): UserInvocationTable = dict[str, UserFnDefinition] -class FPOptions(TypedDict): - """ - Optional parameters for controlling FHIRPath evaluation. - - Attributes: - model (Optional[Model]): - An optional "model" data object specific to a domain, e.g. R4. - See https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#using-data-models - userInvocationTable (Optional[UserInvocationTable]): - 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: - https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#fhirpathpy - """ - - model: NotRequired[Model] - userInvocationTable: NotRequired[UserInvocationTable] - cache: NotRequired["ExpressionCache"] +Evaluate = Callable[[Resource, str, Context], list[Any]] class MatcherResult(TypedDict): @@ -66,7 +40,7 @@ class MatcherResult(TypedDict): Resource, DictNode, Context, - Optional[FPOptions], + Evaluate, ], Optional[MatcherResult], ] diff --git a/python/fpml/core/evaluator.py b/python/fpml/core/evaluator.py new file mode 100644 index 0000000..ab1e183 --- /dev/null +++ b/python/fpml/core/evaluator.py @@ -0,0 +1,23 @@ +from typing import Any, Optional, TypedDict + +from fhirpathpy import evaluate as fhirpath_evaluate # type: ignore +from typing_extensions import NotRequired + +from .core_types import Context, Evaluate, Model, Resource, UserInvocationTable + + +class FPOptions(TypedDict): + """Options passed to fhirpathpy, see + https://github.com/beda-software/fhirpath-py?tab=readme-ov-file#user-defined-functions + """ + + userInvocationTable: NotRequired[UserInvocationTable] + + +def make_evaluator(model: Optional[Model] = None, options: Optional[FPOptions] = None) -> Evaluate: + """Builds the default evaluator, which compiles every expression on every evaluation.""" + + def evaluate(resource: Resource, expression: str, context: Context) -> list[Any]: + return fhirpath_evaluate(resource, expression, context, model, options) + + return evaluate diff --git a/python/fpml/core/extract.py b/python/fpml/core/extract.py index 0de4777..55564e7 100644 --- a/python/fpml/core/extract.py +++ b/python/fpml/core/extract.py @@ -3,13 +3,12 @@ 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 ( Context, DictNode, - FPOptions, + Evaluate, Matcher, MatcherResult, Node, @@ -18,6 +17,7 @@ StrNode, Transformer, ) +from .evaluator import make_evaluator from .utils import flatten, omit_key @@ -25,8 +25,9 @@ def resolve_template( resource: Resource, template: Any, context: Optional[Context] = None, - fp_options: Optional[FPOptions] = None, + *, strict: bool = False, + evaluate: Optional[Evaluate] = None, ) -> Any: """ Processes a given template with the specified resource and optional context. @@ -39,10 +40,12 @@ def resolve_template( resource (Resource): The input FHIR resource to process. template (Any): The template describing the transformation. 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 [strict mode](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main?tab=readme-ov-file#strict-mode). + evaluate (Optional[Evaluate], optional): Evaluates one FHIRPath expression against a + resource and a context. Defaults to make_evaluator(), which compiles every + expression on every evaluation. Returns: Any: The processed output based on the template. @@ -53,14 +56,14 @@ def resolve_template( See Also: FHIRPathMappingLanguage Specification: https://github.com/beda-software/FHIRPathMappingLanguage/tree/main?tab=readme-ov-file#specification - """ # noqa: E501 + """ result = resolve_template_recur( [], guarded_resource if strict else resource, template, # Pass resource as context because original is overriden by strict mode {"context": resource, **(context or {})}, - fp_options=fp_options, + evaluate or make_evaluator(), ) return None if result == undefined else result @@ -71,13 +74,13 @@ def resolve_template_recur( resource: Resource, template: Any, context: Context, - fp_options: Optional[FPOptions] = None, + evaluate: Evaluate, ) -> Any: result = iterate_node( start_path, {root_node_key: template}, context or {}, - lambda path, node, context: process_node(path, resource, node, context, fp_options), + lambda path, node, context: process_node(path, resource, node, context, evaluate), ) if isinstance(result, dict): return result.get(root_node_key, undefined) @@ -90,10 +93,10 @@ def process_node( resource: Resource, node: Node, context: Context, - fp_options: Optional[FPOptions], + evaluate: Evaluate, ) -> tuple[Node, Context]: if isinstance(node, dict): - new_node, new_context = process_assign_block(path, resource, node, context, fp_options) + new_node, new_context = process_assign_block(path, resource, node, context, evaluate) matchers: list[Matcher] = [ process_context_block, @@ -103,14 +106,14 @@ def process_node( ] for matcher in matchers: - result = matcher(path, resource, new_node, new_context, fp_options) + result = matcher(path, resource, new_node, new_context, evaluate) if result: return result["node"], new_context return new_node, new_context if isinstance(node, str): - return process_template_string(path, resource, node, context, fp_options), context + return process_template_string(path, resource, node, context, evaluate), context return node, context @@ -159,14 +162,14 @@ def process_template_string( resource: Resource, node: StrNode, context: Context, - fp_options: Optional[FPOptions], + evaluate: Evaluate, ) -> Any: array_template_regexp = re.compile(r"{\[\s*([\s\S]+?)\s*\]}") match = array_template_regexp.match(node) if match: expr = match.group(1) - return evaluate_expression(path, resource, expr, context, fp_options) + return evaluate_expression(path, resource, expr, context, evaluate) single_template_regexp = re.compile(r"{{\+?\s*([\s\S]+?)\s*\+?}}") result = node @@ -174,7 +177,7 @@ def process_template_string( for match in single_template_regexp.finditer(node): expr = match.group(1) try: - replacement = evaluate_expression(path, resource, expr, context, fp_options)[0] + replacement = evaluate_expression(path, resource, expr, context, evaluate)[0] except IndexError: return None if match.group(0).startswith("{{+") else undefined if match.group(0) == node: @@ -189,7 +192,7 @@ def process_context_block( resource: Resource, node: DictNode, context: Context, - fp_options: Optional[FPOptions], + evaluate: Evaluate, ) -> Optional[MatcherResult]: keys = list(node.keys()) context_regexp = re.compile(r"{{\s*(.+?)\s*}}") @@ -202,10 +205,10 @@ def process_context_block( if len(keys) > 1: raise FPMLValidationError("Context block must be presented as single key", path) - answers = evaluate_expression(path, resource, expr, context, fp_options) + answers = evaluate_expression(path, resource, expr, context, evaluate) return { "node": [ - resolve_template_recur(path, answer, node[context_key], context, fp_options) + resolve_template_recur(path, answer, node[context_key], context, evaluate) for answer in answers ] } @@ -218,7 +221,7 @@ def process_for_block( resource: Resource, node: DictNode, context: Context, - fp_options: Optional[FPOptions], + evaluate: Evaluate, ) -> Optional[MatcherResult]: keys = list(node.keys()) @@ -238,7 +241,7 @@ def process_for_block( if len(keys) > 1: raise FPMLValidationError("For block must be presented as single key", path) - answers = evaluate_expression(path, resource, expr, context, fp_options) + answers = evaluate_expression(path, resource, expr, context, evaluate) return { "node": [ @@ -251,7 +254,7 @@ def process_for_block( item_key: answer, **({index_key: index} if index_key else {}), }, - fp_options, + evaluate, ) for index, answer in enumerate(answers) ] @@ -265,7 +268,7 @@ def process_if_block( resource: Resource, node: dict[str, Any], context: Context, - fp_options: Optional[FPOptions], + evaluate: Evaluate, ) -> Optional[MatcherResult]: keys = list(node.keys()) @@ -293,15 +296,13 @@ def process_if_block( matches = if_regexp.match(if_key) expr = matches.group(1) if matches else "" - answer = evaluate_expression(path, resource, f"iif({expr}, true, false)", context, fp_options)[ - 0 - ] + answer = evaluate_expression(path, resource, f"iif({expr}, true, false)", context, evaluate)[0] new_node = ( - resolve_template_recur(path, resource, node[if_key], context, fp_options) + resolve_template_recur(path, resource, node[if_key], context, evaluate) if answer else ( - resolve_template_recur(path, resource, node[else_key], context, fp_options) + resolve_template_recur(path, resource, node[else_key], context, evaluate) if else_key else undefined ) @@ -330,14 +331,14 @@ def process_merge_block( resource: Resource, node: DictNode, context: Context, - fp_options: Optional[FPOptions], + evaluate: Evaluate, ) -> Optional[MatcherResult]: merge_key = next((k for k in node if re.match(r"{%\s*merge\s*%}", k)), None) if merge_key: merged_node = omit_key(node, merge_key) values = node[merge_key] if isinstance(node[merge_key], list) else [node[merge_key]] for value in values: - result = resolve_template_recur(path, resource, value, context, fp_options) + result = resolve_template_recur(path, resource, value, context, evaluate) if not isinstance(result, dict) and result is not None and result is not undefined: raise FPMLValidationError("Merge block must contain object", path) if result is not undefined and result is not None: @@ -351,7 +352,7 @@ def process_assign_block( resource: Resource, node: DictNode, context: Context, - fp_options: Optional[FPOptions], + evaluate: Evaluate, ) -> tuple[DictNode, Context]: extended_context = context.copy() assign_key = next((k for k in node if re.match(r"{%\s*assign\s*%}", k)), None) @@ -365,7 +366,7 @@ def process_assign_block( ) result = { key: resolve_template_recur( - [*path, key], resource, obj_value, extended_context, fp_options + [*path, key], resource, obj_value, extended_context, evaluate ) for key, obj_value in obj.items() } @@ -375,7 +376,7 @@ def process_assign_block( obj = node[assign_key] result = { key: resolve_template_recur( - [*path, key], resource, obj_value, extended_context, fp_options + [*path, key], resource, obj_value, extended_context, evaluate ) for key, obj_value in obj.items() } @@ -392,17 +393,10 @@ def evaluate_expression( resource: Resource, expression: str, context: Context, - fp_options: Optional[FPOptions] = None, + evaluate: Evaluate, ) -> list[Any]: - cache = (fp_options or {}).get("cache") - try: - compiled = ( - cache.compile(expression, fp_options) - if cache - else compile_expression(expression, fp_options) - ) - - return compiled(resource, context) + # Copy, so that an evaluator cannot leak into the sibling expressions + return evaluate(resource, expression, {**context}) except Exception as exc: raise FPMLValidationError(f"Cannot evaluate '{expression}': {exc}", path) from exc diff --git a/python/pyproject.toml b/python/pyproject.toml index 84b47a2..9a19e15 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fpml" -version = "0.3.1" +version = "1.0.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/python/tests/core/test_cache.py b/python/tests/core/test_cache.py deleted file mode 100644 index 1662e16..0000000 --- a/python/tests/core/test_cache.py +++ /dev/null @@ -1,104 +0,0 @@ -import sys -from collections.abc import Iterator -from itertools import repeat -from threading import Thread -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_stays_consistent_when_shared_between_threads(monkeypatch: pytest.MonkeyPatch) -> None: - # Stub the compilation, so that the cache bookkeeping is what threads contend over - monkeypatch.setattr(cache_module, "compile_expression", lambda expression, _: expression) - max_size = 2 - cache = ExpressionCache(max_size=max_size) - errors: list[Exception] = [] - - def hammer(expressions: Iterator[str]) -> None: - for expression in expressions: - try: - cache.compile(expression, None) - except Exception as exc: - errors.append(exc) - return - - threads = [ - # Readers keep hitting one entry while churners evict it from under them - *[Thread(target=hammer, args=(repeat("hot", 20000),)) for _ in range(4)], - *[ - Thread(target=hammer, args=((f"churn{index}-{n}" for n in range(20000)),)) - for index in range(4) - ], - ] - switch_interval = sys.getswitchinterval() - # Preempt threads aggressively to widen the window between a lookup and its eviction - sys.setswitchinterval(1e-6) - try: - for thread in threads: - thread.start() - for thread in threads: - thread.join() - finally: - sys.setswitchinterval(switch_interval) - - assert errors == [] - assert cache.size == max_size - - -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/python/tests/core/test_complex_example_fhir.py b/python/tests/core/test_complex_example_fhir.py index 7c546d9..c9294fb 100644 --- a/python/tests/core/test_complex_example_fhir.py +++ b/python/tests/core/test_complex_example_fhir.py @@ -1,5 +1,6 @@ from fhirpathpy.models import models # type: ignore +from fpml.core.evaluator import make_evaluator from fpml.core.extract import resolve_template @@ -9,7 +10,10 @@ def test_complex_example_fhir(load_yaml_fixture): expected_result = load_yaml_fixture("complex-example.fhir.result.yaml") actual_result = resolve_template( - context["QuestionnaireResponse"], template, context, fp_options={"model": models["r4"]} + context["QuestionnaireResponse"], + template, + context, + evaluate=make_evaluator(models["r4"]), ) assert actual_result == expected_result diff --git a/python/tests/core/test_evaluator.py b/python/tests/core/test_evaluator.py new file mode 100644 index 0000000..4dd429c --- /dev/null +++ b/python/tests/core/test_evaluator.py @@ -0,0 +1,46 @@ +from functools import lru_cache +from typing import Any + +from fhirpathpy import compile as fhirpath_compile # type: ignore +from fhirpathpy.models import models # type: ignore + +from fpml.core.core_types import Context, Resource +from fpml.core.extract import resolve_template + + +def test_uses_the_provided_evaluate() -> None: + def evaluate(resource: Resource, expression: str, context: Context) -> list[Any]: + return [f"{expression} of {resource['id']}"] + + result = resolve_template({"id": "foo"}, {"value": "{{ name }}"}, evaluate=evaluate) + assert result == {"value": "name of foo"} + + +def test_passes_the_resource_expression_and_context_to_evaluate() -> None: + calls = [] + + def evaluate(resource: Resource, expression: str, context: Context) -> list[Any]: + calls.append((resource, expression, sorted(context))) + return [1] + + resolve_template({"id": "foo"}, {"value": "{{ name }}"}, {"extra": "bar"}, evaluate=evaluate) + assert calls == [({"id": "foo"}, "name", ["context", "extra"])] + + +def test_evaluate_lets_the_caller_bring_its_own_cache() -> None: + compiled_expressions = [] + + @lru_cache(maxsize=64) + def cached_compile(expression: str, model_name: str) -> Any: + compiled_expressions.append(expression) + return fhirpath_compile(expression, models.get(model_name)) + + def evaluate(resource: Resource, expression: str, context: Context) -> list[Any]: + return cached_compile(expression, "r4")(resource, context) + + resource: Resource = {"resourceType": "Patient", "id": "foo"} + template = {"first": "{{ id }}", "second": "{{ id }}"} + result = resolve_template(resource, template, evaluate=evaluate) + assert result == {"first": "foo", "second": "foo"} + + assert compiled_expressions == ["id"] diff --git a/python/tests/core/test_extract.py b/python/tests/core/test_extract.py index b137dd5..a8eccd0 100644 --- a/python/tests/core/test_extract.py +++ b/python/tests/core/test_extract.py @@ -2,10 +2,11 @@ from fpml.core.constants import undefined from fpml.core.core_types import Resource, UserInvocationTable +from fpml.core.evaluator import make_evaluator from fpml.core.extract import FPMLValidationError, resolve_template -def test_transformation_with_fp_options() -> None: +def test_transformation_with_user_invocation_table() -> None: resource: Resource = {"list": [{"key": 5}, {"key": 6}, {"key": 7}]} user_invocation_table: UserInvocationTable = { "pow": { @@ -16,7 +17,7 @@ def test_transformation_with_fp_options() -> None: result = resolve_template( resource, {"resourceType": "Resource", "result": "{{ list.key.pow(2) }}"}, - fp_options={"userInvocationTable": user_invocation_table}, + evaluate=make_evaluator(options={"userInvocationTable": user_invocation_table}), ) assert result == {"resourceType": "Resource", "result": 25} diff --git a/ts/server/package.json b/ts/server/package.json index b2d4a29..7838e69 100644 --- a/ts/server/package.json +++ b/ts/server/package.json @@ -1,6 +1,6 @@ { "name": "fpml-server", - "version": "0.3.1", + "version": "1.0.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, diff --git a/ts/server/src/app.service.ts b/ts/server/src/app.service.ts index 06f4a66..60f2f2b 100644 --- a/ts/server/src/app.service.ts +++ b/ts/server/src/app.service.ts @@ -1,16 +1,18 @@ import { Injectable } from '@nestjs/common'; -import { FPOptions, resolveTemplate } from './core/extract'; -import { compileExpression, ExpressionCache } from './core/cache'; +import * as fhirpath from 'fhirpath'; +import { Evaluate, FPOptions } from './core/evaluator'; +import { resolveTemplate } from './core/extract'; +import { ExpressionCache } from './expression-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); +const toStringExpression = fhirpath.compile('x.toString()'); -// Options and their cache are bound to a model and must outlive requests to be reused -const optionsByModel = new Map(); +// A cache is bound to its model and must outlive requests to be reused +const evaluatorByModel = new Map(); @Injectable() export class AppService { @@ -25,34 +27,28 @@ export class AppService { resource, template, { root: resource, ...context }, - model, - getOptions(model), strict, + getEvaluator(model), ); } } -function getOptions(model?: Model): FPOptions { +function getEvaluator(model?: Model): Evaluate { const key = model ?? null; - const options = optionsByModel.get(key) ?? buildOptions(model); - optionsByModel.set(key, options); + const evaluate = evaluatorByModel.get(key) ?? buildEvaluator(model); + evaluatorByModel.set(key, evaluate); - return options; + return evaluate; } -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( +function buildEvaluator(model?: Model): Evaluate { + const answersExpression = fhirpath.compile( model ? 'repeat(item).where(linkId=%FPMLLinkId).answer.value' : 'repeat(item).where(linkId=%FPMLLinkId).answer.value.children()', model, - null, ); - - return { - cache: new ExpressionCache(cacheSize), + const options: FPOptions = { userInvocationTable: { answers: { fn: (inputs, linkId: string) => answersExpression(inputs, { FPMLLinkId: linkId }), @@ -65,6 +61,8 @@ function buildOptions(model?: Model): FPOptions { }, }, }; + + return new ExpressionCache(cacheSize, model, options).makeEvaluator(); } function readCacheSize(): number { diff --git a/ts/server/src/core/cache.spec.ts b/ts/server/src/core/cache.spec.ts deleted file mode 100644 index 15639a7..0000000 --- a/ts/server/src/core/cache.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -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/complex-example.aidbox.spec.ts b/ts/server/src/core/complex-example.aidbox.spec.ts index 199e32e..5305bfd 100644 --- a/ts/server/src/core/complex-example.aidbox.spec.ts +++ b/ts/server/src/core/complex-example.aidbox.spec.ts @@ -20,7 +20,6 @@ test('Test real example (aidbox)', () => { (context as any).QuestionnaireResponse, template, context, - null, ))), ).toStrictEqual(result); }); diff --git a/ts/server/src/core/complex-example.fhir.spec.ts b/ts/server/src/core/complex-example.fhir.spec.ts index b135cce..86b7e63 100644 --- a/ts/server/src/core/complex-example.fhir.spec.ts +++ b/ts/server/src/core/complex-example.fhir.spec.ts @@ -1,3 +1,4 @@ +import { makeEvaluator } from './evaluator'; import { resolveTemplate } from './extract'; import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; import * as yaml from 'js-yaml'; @@ -21,7 +22,8 @@ test('Test real example (fhir)', () => { (context as any).QuestionnaireResponse, template, context, - fhirpath_r4_model, + false, + makeEvaluator(fhirpath_r4_model), ))), ).toStrictEqual(result); }); diff --git a/ts/server/src/core/evaluator.ts b/ts/server/src/core/evaluator.ts new file mode 100644 index 0000000..33ae5f0 --- /dev/null +++ b/ts/server/src/core/evaluator.ts @@ -0,0 +1,13 @@ +import * as fhirpath from 'fhirpath'; + +export interface FPOptions { + userInvocationTable?: UserInvocationTable; +} + +export type Evaluate = (resource: any, expression: string, context: Context) => any[]; + +/** Builds the default evaluator, which compiles every expression on every evaluation. */ +export function makeEvaluator(model?: Model, options?: FPOptions): Evaluate { + return (resource, expression, context) => + fhirpath.evaluate(resource, expression, context, model, options); +} diff --git a/ts/server/src/core/example1.fhir.spec.ts b/ts/server/src/core/example1.fhir.spec.ts index 1eb48f9..493978a 100644 --- a/ts/server/src/core/example1.fhir.spec.ts +++ b/ts/server/src/core/example1.fhir.spec.ts @@ -1,5 +1,6 @@ import { QuestionnaireResponse } from 'fhir/r4b'; import { resolveTemplate } from './extract'; +import { makeEvaluator } from './evaluator'; import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; const qr: QuestionnaireResponse = { @@ -149,9 +150,13 @@ const result = { }; test('Simple transformation', () => { - expect(resolveTemplate(qr, template1, {}, fhirpath_r4_model)).toStrictEqual(result); + expect( + resolveTemplate(qr, template1, {}, false, makeEvaluator(fhirpath_r4_model)), + ).toStrictEqual(result); }); test('List transformation', () => { - expect(resolveTemplate(qr, template2, {}, fhirpath_r4_model)).toStrictEqual(result); + expect( + resolveTemplate(qr, template2, {}, false, makeEvaluator(fhirpath_r4_model)), + ).toStrictEqual(result); }); diff --git a/ts/server/src/core/extract.spec.ts b/ts/server/src/core/extract.spec.ts index 803af8d..0e67998 100644 --- a/ts/server/src/core/extract.spec.ts +++ b/ts/server/src/core/extract.spec.ts @@ -9,8 +9,6 @@ describe('Transformation', () => { resource, { resourceType: 'Resource', result: '{{ list.key }}' }, {}, - null, - null, true, ), ).toThrow(FPMLValidationError); @@ -22,8 +20,6 @@ describe('Transformation', () => { { resourceType: 'Resource', key: [1, 2, 3] }, { resourceType: 'Resource', result: '{{ Resource.key }}' }, {}, - null, - null, true, ), ).toThrow(FPMLValidationError); @@ -35,8 +31,6 @@ describe('Transformation', () => { { resourceType: 'Resource', key: [1, 2, 3] }, { resourceType: 'Resource', result: '{{ UndefinedResource.key }}' }, {}, - null, - null, true, ), ).toThrow(FPMLValidationError); @@ -48,8 +42,6 @@ describe('Transformation', () => { resource, { resourceType: 'Resource', result: '{{ %Resource.list.key }}' }, { Resource: resource }, - null, - null, true, ), ).toStrictEqual({ resourceType: 'Resource', result: 1 }); @@ -61,8 +53,6 @@ describe('Transformation', () => { resource, { resourceType: 'Resource', result: '{{ %context.list.key }}' }, { Resource: resource }, - null, - null, true, ), ).toStrictEqual({ resourceType: 'Resource', result: 1 }); @@ -74,8 +64,6 @@ describe('Transformation', () => { resource, { resourceType: 'Resource', list: [undefined, { nested: [undefined] }, undefined] }, {}, - null, - null, true, ), ).toStrictEqual({ resourceType: 'Resource' }); @@ -315,8 +303,6 @@ describe('Assign block', () => { valueA: '{{ %varB }}', }, null, - null, - null, true, ), ).toStrictEqual({ resourceType: 'Resource' }); diff --git a/ts/server/src/core/extract.ts b/ts/server/src/core/extract.ts index cb1c159..b69bba0 100644 --- a/ts/server/src/core/extract.ts +++ b/ts/server/src/core/extract.ts @@ -1,4 +1,4 @@ -import { compileExpression, ExpressionCache } from './cache'; +import { Evaluate, makeEvaluator } from './evaluator'; type Resource = Record; type Path = Array; @@ -7,11 +7,6 @@ type Path = Array; // TODO: I believe it might be re-written without using it const rootNodeKey = '__rootNode__'; -export interface FPOptions { - userInvocationTable?: UserInvocationTable; - cache?: ExpressionCache; -} - export class FPMLValidationError extends Error { errorPath: string; errorMessage: string; @@ -47,17 +42,15 @@ export function resolveTemplate( resource: Resource, template: any, context?: Context, - model?: Model, - fpOptions?: FPOptions, strict?: boolean, + evaluate?: Evaluate, ): any { const result = resolveTemplateRecur( [], strict ? guardedResourceFactory(resource) : resource, template, { context: resource, ...(context ?? {}) }, - model, - fpOptions, + evaluate ?? makeEvaluator(), ); // NOTE: for synchronization with Python implementation @@ -69,8 +62,7 @@ function resolveTemplateRecur( resource: Resource, template: any, initialContext: Context, - model?: Model, - fpOptions?: FPOptions, + evaluate: Evaluate, ): any { return iterateObject( startPath, @@ -83,8 +75,7 @@ function resolveTemplateRecur( resource, node, context, - model, - fpOptions, + evaluate, ); const matchers = [ processContextBlock, @@ -93,7 +84,7 @@ function resolveTemplateRecur( processIfBlock, ]; for (const matcher of matchers) { - const result = matcher(path, resource, newNode, newContext, model, fpOptions); + const result = matcher(path, resource, newNode, newContext, evaluate); if (result) { return { node: result.node, context: newContext }; @@ -103,7 +94,7 @@ function resolveTemplateRecur( return { node: newNode, context: newContext }; } else if (typeof node === 'string') { return { - node: processTemplateString(path, resource, node, context, model, fpOptions), + node: processTemplateString(path, resource, node, context, evaluate), context, }; } @@ -118,8 +109,7 @@ function processTemplateString( resource: Resource, node: string, context: Context, - model: Model, - fpOptions: FPOptions, + evaluate: Evaluate, ) { let match: | RegExpExecArray @@ -130,7 +120,7 @@ function processTemplateString( if (match) { const expr = match[1]; - return evaluateExpression(path, resource, expr, context, model, fpOptions); + return evaluateExpression(path, resource, expr, context, evaluate); } const singleTemplateRegExp = /{{\+?\s*([\s\S]+?)\s*\+?}}/g; @@ -138,7 +128,7 @@ function processTemplateString( while ((match = singleTemplateRegExp.exec(node)) !== null) { const expr = match[1]; - const replacement = evaluateExpression(path, resource, expr, context, model, fpOptions)[0]; + const replacement = evaluateExpression(path, resource, expr, context, evaluate)[0]; if (replacement === undefined) { if (match[0].startsWith('{{+')) { @@ -163,8 +153,7 @@ function processAssignBlock( resource: Resource, node: any, context: Context, - model: Model, - fpOptions: FPOptions, + evaluate: Evaluate, ): { node: any; context: Context } { const extendedContext = { ...context }; const keys = Object.keys(node); @@ -187,8 +176,7 @@ function processAssignBlock( resource, objValue, extendedContext, - model, - fpOptions, + evaluate, ), ), ).forEach(([key, value]) => { @@ -209,8 +197,7 @@ function processAssignBlock( resource, objValue, extendedContext, - model, - fpOptions, + evaluate, ), ), ).forEach(([key, value]) => { @@ -231,8 +218,7 @@ function processMergeBlock( resource: Resource, node: any, context: Context, - model: Model, - fpOptions: FPOptions, + evaluate: Evaluate, ): { node: any } | undefined { const keys = Object.keys(node); @@ -248,8 +234,7 @@ function processMergeBlock( resource, nodeValue, context, - model, - fpOptions, + evaluate, ); if (!isPlainObject(result) && result !== null && result !== undefined) { throw new FPMLValidationError('Merge block must contain object', path); @@ -268,8 +253,7 @@ function processForBlock( resource: Resource, node: any, context: Context, - model: Model, - fpOptions: FPOptions, + evaluate: Evaluate, ): { node: any } | undefined { const keys = Object.keys(node); @@ -287,7 +271,7 @@ function processForBlock( throw new FPMLValidationError(`For block must be presented as single key`, path); } - const answers = evaluateExpression(path, resource, expr, context, model, fpOptions); + const answers = evaluateExpression(path, resource, expr, context, evaluate); return { node: answers.map((answer, index) => resolveTemplateRecur( @@ -299,8 +283,7 @@ function processForBlock( [itemKey]: answer, ...(hasIndexKey ? { [indexKey]: index } : {}), }, - model, - fpOptions, + evaluate, ), ), }; @@ -312,8 +295,7 @@ function processContextBlock( resource: Resource, node: any, context: Context, - model: Model, - fpOptions: FPOptions, + evaluate: Evaluate, ): { node: any } | undefined { const keys = Object.keys(node); @@ -327,9 +309,9 @@ function processContextBlock( throw new FPMLValidationError('Context block must be presented as single key', path); } - const answers = evaluateExpression(path, resource, expr, context, model, fpOptions); + const answers = evaluateExpression(path, resource, expr, context, evaluate); const result: any[] = answers.map((answer) => - resolveTemplateRecur(path, answer, node[contextKey], context, model, fpOptions), + resolveTemplateRecur(path, answer, node[contextKey], context, evaluate), ); return { node: result }; @@ -341,8 +323,7 @@ function processIfBlock( resource: Resource, node: any, context: Context, - model: Model, - fpOptions: FPOptions, + evaluate: Evaluate, ): { node: any } | undefined { const keys = Object.keys(node); @@ -377,14 +358,13 @@ function processIfBlock( resource, `iif(${expr}, true, false)`, context, - model, - fpOptions, + evaluate, )[0]; const newNode = answer - ? resolveTemplateRecur(path, resource, node[ifKey], context, model, fpOptions) + ? resolveTemplateRecur(path, resource, node[ifKey], context, evaluate) : elseKey - ? resolveTemplateRecur(path, resource, node[elseKey], context, model, fpOptions) + ? resolveTemplateRecur(path, resource, node[elseKey], context, evaluate) : undefined; const isMergeBehavior = keys.length !== (elseKey ? 2 : 1); @@ -461,16 +441,11 @@ export function evaluateExpression( resource: any, expression: string, context: Context, - model: Model, - options: FPOptions, + evaluate: Evaluate, ) { try { - 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 }); + return evaluate(resource, expression, { ...context }); } catch (exc) { throw new FPMLValidationError(`Can not evaluate '${expression}': ${exc}`, path); } diff --git a/ts/server/src/expression-cache.spec.ts b/ts/server/src/expression-cache.spec.ts new file mode 100644 index 0000000..c182a1c --- /dev/null +++ b/ts/server/src/expression-cache.spec.ts @@ -0,0 +1,52 @@ +import * as fhirpath from 'fhirpath'; +import { ExpressionCache } from './expression-cache'; +import { resolveTemplate } from './core/extract'; + +describe('ExpressionCache', () => { + test('reuses compiled expression', () => { + const cache = new ExpressionCache(16); + const compiled = cache.compile('list.key'); + + expect(cache.compile('list.key')).toBe(compiled); + }); + + test('evicts least recently used expression', () => { + const cache = new ExpressionCache(2); + const first = cache.compile('first'); + const middle = cache.compile('middle'); + cache.compile('first'); + cache.compile('last'); + + expect(cache.size).toBe(2); + expect(cache.compile('first')).toBe(first); + expect(cache.compile('middle')).not.toBe(middle); + }); + + test('does not cache with zero max size', () => { + const cache = new ExpressionCache(0); + const compiled = cache.compile('list.key'); + + expect(cache.compile('list.key')).not.toBe(compiled); + }); + + test('clear drops compiled expressions', () => { + const cache = new ExpressionCache(16); + const compiled = cache.compile('list.key'); + cache.clear(); + + expect(cache.compile('list.key')).not.toBe(compiled); + }); + + test('its evaluator compiles a 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 evaluate = new ExpressionCache(16).makeEvaluator(); + const result = resolveTemplate(resource, template, {}, false, evaluate); + + 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/expression-cache.ts similarity index 53% rename from ts/server/src/core/cache.ts rename to ts/server/src/expression-cache.ts index 7c4f534..7215408 100644 --- a/ts/server/src/core/cache.ts +++ b/ts/server/src/expression-cache.ts @@ -1,32 +1,28 @@ import * as fhirpath from 'fhirpath'; -import type { FPOptions } from './extract'; +import type { Evaluate, FPOptions } from './core/evaluator'; 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. + * Entries are keyed by the expression only, so the model and the options are bound + * to the cache itself. Zero max size disables caching. */ export class ExpressionCache { private readonly compiled = new Map(); - constructor(private readonly maxSize: number) {} + constructor( + private readonly maxSize: number, + private readonly model?: Model, + private readonly options?: FPOptions, + ) {} + + makeEvaluator(): Evaluate { + return (resource, expression, context) => this.compile(expression)(resource, context); + } - compile(expression: string, model: Model, options: FPOptions): CompiledExpression { + compile(expression: string): CompiledExpression { const cached = this.compiled.get(expression); if (cached) { this.compiled.delete(expression); @@ -35,7 +31,7 @@ export class ExpressionCache { return cached; } - const compiled = compileExpression(expression, model, options); + const compiled = fhirpath.compile(expression, this.model, this.options); if (this.maxSize > 0) { this.compiled.set(expression, compiled); if (this.compiled.size > this.maxSize) {