diff --git a/redisvl/schema/validation.py b/redisvl/schema/validation.py index 3fadeab86..fece418bd 100644 --- a/redisvl/schema/validation.py +++ b/redisvl/schema/validation.py @@ -6,6 +6,7 @@ """ import json +from functools import lru_cache from typing import Any from jsonpath_ng import parse as jsonpath_parse @@ -210,6 +211,28 @@ def _validate_vector(cls, value): return type(model_name, (BaseModel,), class_dict) +@lru_cache(maxsize=512) +def _compile_json_path(path: str) -> Any: + """ + Parse a JSONPath expression, reusing the result for repeated paths. + + Schema field paths are fixed, and parsing costs ~1ms against microseconds to + evaluate, so validating per field re-did the expensive half every time. + Sharing one expression is safe: it holds no per-evaluation state. + + Args: + path: JSONPath expression, with or without the leading ``$`` + + Returns: + The parsed jsonpath-ng expression + """ + # If path doesn't start with $, add it as per JSONPath spec + if not path.startswith("$"): + path = f"$.{path}" + + return jsonpath_parse(path) + + def extract_from_json_path(obj: dict[str, Any], path: str) -> Any: """ Extract a value from a nested JSON object using a JSONPath expression. @@ -226,13 +249,7 @@ def extract_from_json_path(obj: dict[str, Any], path: str) -> Any: and supports the full JSONPath specification including filters, wildcards, and array indexing. """ - # If path doesn't start with $, add it as per JSONPath spec - if not path.startswith("$"): - path = f"$.{path}" - - # Parse and find the JSONPath expression - jsonpath_expr = jsonpath_parse(path) - matches = jsonpath_expr.find(obj) + matches = _compile_json_path(path).find(obj) # Return the first match value, or None if no matches if matches: diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 552e7b0d9..f582b999e 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -14,11 +14,13 @@ import pytest from redisvl.schema import IndexSchema +from redisvl.schema import validation as validation_module from redisvl.schema.fields import FieldTypes, VectorDataType from redisvl.schema.schema import StorageType from redisvl.schema.type_utils import TypeInferrer from redisvl.schema.validation import ( SchemaModelGenerator, + _compile_json_path, extract_from_json_path, validate_object, ) @@ -301,6 +303,51 @@ def test_extract_from_json_path(self, valid_json_data, path, expected_value): """Test extracting values using JSON paths.""" assert extract_from_json_path(valid_json_data, path) == expected_value + @pytest.fixture + def parse_calls(self, monkeypatch): + """Record every path handed to jsonpath-ng's parser.""" + calls: list[str] = [] + real_parse = validation_module.jsonpath_parse + + def counting_parse(path): + calls.append(path) + return real_parse(path) + + _compile_json_path.cache_clear() + monkeypatch.setattr(validation_module, "jsonpath_parse", counting_parse) + return calls + + def test_repeated_path_compiles_once(self, valid_json_data, parse_calls): + """A repeated path compiles once, not once per call.""" + for _ in range(25): + assert ( + extract_from_json_path(valid_json_data, "$.metadata.user") == "user123" + ) + + assert parse_calls == ["$.metadata.user"] + + def test_distinct_paths_each_compile(self, valid_json_data, parse_calls): + """Caching must not collapse distinct paths onto one expression.""" + assert extract_from_json_path(valid_json_data, "$.metadata.user") == "user123" + assert extract_from_json_path(valid_json_data, "$.metadata.rating") == 4.5 + assert len(parse_calls) == 2 + + def test_shared_expression_holds_no_state(self): + """Reusing one expression must not leak values between evaluations.""" + _compile_json_path.cache_clear() + + for expected in ("alice", "bob", "carol"): + obj = {"metadata": {"user": expected}} + assert extract_from_json_path(obj, "$.metadata.user") == expected + + assert extract_from_json_path({"metadata": {}}, "$.metadata.user") is None + + def test_leading_dollar_is_optional(self, valid_json_data): + """Both spellings of a path resolve to the same value.""" + assert extract_from_json_path( + valid_json_data, "metadata.user" + ) == extract_from_json_path(valid_json_data, "$.metadata.user") + # # -------------------- CATEGORY 2: PARAMETRIZED VALIDATOR TESTS --------------------