Skip to content

Commit 4ddf239

Browse files
authored
Cache JSON Schema ref facts (#3472)
* Cache JSON Schema ref facts * Harden ref path cache test
1 parent 988da55 commit 4ddf239

2 files changed

Lines changed: 79 additions & 4 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,7 @@ def __init__(
903903
self._dynamic_anchor_index: dict[tuple[str, ...], dict[str, str]] = {}
904904
self._recursive_anchor_index: dict[tuple[str, ...], list[str]] = {}
905905
self._ref_data_type_facts: dict[str, tuple[Any, bool]] = {}
906+
self._local_ref_path_cache: dict[Path, Path] = {}
906907
self._force_base_model_refs: set[str] = set()
907908
self._force_base_model_generation = False
908909
self.field_keys: set[str] = {
@@ -1994,6 +1995,12 @@ def _get_x_python_import_path(self, x_python_import: dict[str, Any]) -> str | No
19941995
raise Error(msg)
19951996
return _validate_schema_python_import_path(f"{module}.{type_name}", "x-python-import")
19961997

1998+
def _cache_ref_data_type_facts(self, resolved_ref: str, obj: JsonSchemaObject) -> None:
1999+
self._ref_data_type_facts[resolved_ref] = (
2000+
obj.extras.get("x-python-import"),
2001+
obj.type == "null" or (self.strict_nullable and obj.nullable is True),
2002+
)
2003+
19972004
def get_ref_data_type(self, ref: str) -> DataType:
19982005
"""Get a data type from a reference string.
19992006
@@ -2461,18 +2468,20 @@ def _deep_merge(self, dict1: dict[Any, Any], dict2: dict[Any, Any]) -> dict[Any,
24612468
result[key] = value
24622469
return result
24632470

2464-
def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
2465-
"""Load a JsonSchemaObject from a $ref using standard resolve/load pipeline."""
2466-
resolved_ref = self.model_resolver.resolve_ref(ref)
2471+
def _get_ref_raw_schema(self, resolved_ref: str) -> dict[str, YamlValue] | YamlValue:
24672472
file_part, fragment = ([*resolved_ref.split("#", 1), ""])[:2]
24682473
raw_doc = self._get_ref_body(file_part) if file_part else self.raw_obj
24692474

24702475
target_schema: dict[str, YamlValue] | YamlValue = raw_doc
24712476
if fragment:
24722477
pointer = split_json_pointer(raw_doc, fragment)
24732478
target_schema = get_model_by_path(raw_doc, pointer)
2479+
return target_schema
24742480

2475-
return self._validate_schema_object(target_schema, [resolved_ref])
2481+
def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
2482+
"""Load a JsonSchemaObject from a $ref using standard resolve/load pipeline."""
2483+
resolved_ref = self.model_resolver.resolve_ref(ref)
2484+
return self._validate_schema_object(self._get_ref_raw_schema(resolved_ref), [resolved_ref])
24762485

24772486
def _anchor_ref_path(self, root_key: tuple[str, ...], path: list[str]) -> str: # noqa: PLR6301
24782487
"""Return the local ref path for an anchor under the current root."""
@@ -5181,9 +5190,13 @@ def _get_ref_body(self, resolved_ref: str) -> dict[str, YamlValue]:
51815190
return self._get_ref_body_from_remote(resolved_ref)
51825191

51835192
def _resolve_local_ref_path(self, path: Path, ref: str) -> Path:
5193+
if cached_path := self._local_ref_path_cache.get(path):
5194+
return cached_path
5195+
51845196
base_path = self.base_path.resolve()
51855197
resolved_path = path.resolve()
51865198
if resolved_path.is_relative_to(base_path) or self.allow_remote_refs is True:
5199+
self._local_ref_path_cache[path] = resolved_path
51875200
return resolved_path
51885201

51895202
details = (
@@ -5506,6 +5519,7 @@ def _parse_raw_or_validated_obj(
55065519
self._check_version_specific_features(raw, path)
55075520

55085521
obj = validated_obj if validated_obj is not None else self._validate_schema_object(raw, path)
5522+
self._cache_ref_data_type_facts(self.model_resolver.join_path(tuple(path)), obj)
55095523
# Build $recursiveAnchor / $dynamicAnchor indexes for this schema
55105524
self._build_anchor_indexes(obj, path)
55115525
self.parse_obj(name, obj, path)
@@ -5953,6 +5967,7 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
59535967
with self.root_id_context(raw):
59545968
# parse $id before parsing $ref
59555969
root_obj = self._validate_schema_object(raw, path_parts or ["#"])
5970+
self._cache_ref_data_type_facts(self.model_resolver.join_path(tuple(path_parts or ["#"])), root_obj)
59565971
self.parse_id(root_obj, [*path_parts, "#"] if path_parts else ["#"])
59575972
root_key = tuple(path_parts)
59585973
if root_obj.recursiveAnchor:
@@ -5983,6 +5998,7 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
59835998
seen_definition_metadata_paths.add(definition_path_key)
59845999
obj = self._validate_schema_object(model, definition_path)
59856000
validated_definition_objects[definition_path_key] = obj
6001+
self._cache_ref_data_type_facts(self.model_resolver.join_path(tuple(definition_path)), obj)
59866002
self.parse_id(obj, definition_path)
59876003
if obj.recursiveAnchor:
59886004
ref_path = self._anchor_ref_path(root_key, definition_path)

tests/parser/test_jsonschema.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,65 @@ def test_get_x_python_import_path_handles_empty_and_incomplete_metadata() -> Non
129129
assert parser._get_x_python_import_path({"module": "os", "name": "PathLike"}) == "os.PathLike"
130130

131131

132+
def test_get_ref_data_type_uses_cached_validated_definition_facts(mocker: MockerFixture) -> None:
133+
"""Use facts from the already validated definition instead of validating the ref target again."""
134+
parser = JsonSchemaParser(
135+
json.dumps({
136+
"type": "object",
137+
"properties": {"user": {"$ref": "#/$defs/User"}},
138+
"required": ["user"],
139+
"$defs": {
140+
"User": {
141+
"type": "object",
142+
"nullable": True,
143+
"properties": {"name": {"type": "string"}},
144+
"required": ["name"],
145+
},
146+
},
147+
}),
148+
strict_nullable=True,
149+
)
150+
load_ref_schema_object = mocker.spy(parser, "_load_ref_schema_object")
151+
152+
parser.parse(format_=False)
153+
154+
load_ref_schema_object.assert_not_called()
155+
assert parser._ref_data_type_facts["#/$defs/User"] == (None, True)
156+
assert "user: Optional[User]" in dump_templates(list(parser.results))
157+
158+
159+
def test_get_ref_data_type_falls_back_when_facts_are_not_cached(mocker: MockerFixture) -> None:
160+
"""Keep the validation fallback for refs that were not parsed and cached first."""
161+
parser = JsonSchemaParser("")
162+
parser.raw_obj = {"$defs": {"User": {"type": "object"}}}
163+
load_ref_schema_object = mocker.spy(parser, "_load_ref_schema_object")
164+
165+
parser.get_ref_data_type("#/$defs/User")
166+
167+
load_ref_schema_object.assert_called_once_with("#/$defs/User")
168+
169+
170+
def test_resolve_local_ref_path_caches_safe_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
171+
"""Avoid resolving the same local ref path repeatedly after it has passed safety checks."""
172+
parser = JsonSchemaParser(tmp_path / "schema.json")
173+
target = tmp_path / "schema.json"
174+
original_resolve = Path.resolve
175+
calls: list[Path] = []
176+
177+
def resolve(path: Path, *args: Any, **kwargs: Any) -> Path:
178+
calls.append(path)
179+
return original_resolve(path, *args, **kwargs)
180+
181+
monkeypatch.setattr(Path, "resolve", resolve)
182+
183+
parser._resolve_local_ref_path(target, "schema.json")
184+
first_call_count = len(calls)
185+
assert first_call_count > 0
186+
parser._resolve_local_ref_path(target, "schema.json")
187+
188+
assert len(calls) == first_call_count
189+
190+
132191
def test_json_schema_directory_input_reads_each_source_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
133192
"""Test directory input source text is materialized once per parse."""
134193
user_path, pet_path = _write_simple_json_schemas(tmp_path)

0 commit comments

Comments
 (0)