Skip to content

Commit d71d633

Browse files
authored
Reuse validated schema definitions (#3467)
1 parent 2a4a94e commit d71d633

5 files changed

Lines changed: 140 additions & 2 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2467,7 +2467,7 @@ def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
24672467
return cached
24682468

24692469
file_part, fragment = ([*resolved_ref.split("#", 1), ""])[:2]
2470-
raw_doc = self._get_ref_body(file_part) if file_part else self.raw_obj
2470+
raw_doc = self._get_ref_raw_doc(file_part)
24712471

24722472
target_schema: dict[str, YamlValue] | YamlValue = raw_doc
24732473
if fragment:
@@ -2478,6 +2478,15 @@ def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
24782478
self._ref_schema_object_cache[resolved_ref] = ref_schema
24792479
return ref_schema
24802480

2481+
def _get_ref_raw_doc(self, file_part: str) -> dict[str, YamlValue]:
2482+
"""Return the raw schema document for a resolved reference file part."""
2483+
match file_part:
2484+
case "":
2485+
return self.raw_obj
2486+
case current_root_ref if current_root_ref == "/".join(self.model_resolver.current_root):
2487+
return self.raw_obj
2488+
return self._get_ref_body(file_part)
2489+
24812490
def _anchor_ref_path(self, root_key: tuple[str, ...], path: list[str]) -> str: # noqa: PLR6301
24822491
"""Return the local ref path for an anchor under the current root."""
24832492
root_len = len(root_key)
@@ -5987,6 +5996,7 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
59875996
seen_definition_metadata_paths.add(definition_path_key)
59885997
obj = self._validate_schema_object(model, definition_path)
59895998
validated_definition_objects[definition_path_key] = obj
5999+
self._ref_schema_object_cache.setdefault(self.model_resolver.resolve_ref(definition_path), obj)
59906000
self.parse_id(obj, definition_path)
59916001
if obj.recursiveAnchor:
59926002
ref_path = self._anchor_ref_path(root_key, definition_path)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# generated by datamodel-codegen:
2+
# filename: same_file_ref_fast_path.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel
8+
9+
10+
class User(BaseModel):
11+
name: str
12+
13+
14+
class SameFileRefFastPath(BaseModel):
15+
user: User
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"title": "SameFileRefFastPath",
3+
"type": "object",
4+
"properties": {
5+
"user": {
6+
"$ref": "#/definitions/User"
7+
}
8+
},
9+
"required": [
10+
"user"
11+
],
12+
"definitions": {
13+
"User": {
14+
"type": "object",
15+
"properties": {
16+
"name": {
17+
"type": "string"
18+
}
19+
},
20+
"required": [
21+
"name"
22+
]
23+
}
24+
}
25+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2246,6 +2246,16 @@ def test_main_serialize_as_any_import_fast_path(output_file: Path) -> None:
22462246
)
22472247

22482248

2249+
def test_main_same_file_ref_fast_path(output_file: Path) -> None:
2250+
"""Test same-file refs keep generated output unchanged."""
2251+
run_main_and_assert(
2252+
input_path=JSON_SCHEMA_DATA_PATH / "same_file_ref_fast_path.json",
2253+
output_path=output_file,
2254+
input_file_type="jsonschema",
2255+
assert_func=assert_file_content,
2256+
)
2257+
2258+
22492259
def test_main_root_model_with_additional_properties_literal(min_version: str, output_file: Path) -> None:
22502260
"""Test root model additional properties with literal types."""
22512261
run_main_and_assert(

tests/parser/test_jsonschema.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ def _validate_schema_object(
6363
return super()._validate_schema_object(raw, path)
6464

6565

66+
class RefBodyCountingJsonSchemaParser(CountingJsonSchemaParser):
67+
"""JsonSchemaParser that records reference body loads."""
68+
69+
def __init__(self, *args: Any, **kwargs: Any) -> None:
70+
"""Initialize the parser and reference body recorder."""
71+
super().__init__(*args, **kwargs)
72+
self.ref_body_paths: list[str] = []
73+
self.ref_bodies: dict[str, dict[str, Any]] = {}
74+
75+
def _get_ref_body(self, resolved_ref: str) -> dict[str, Any]:
76+
self.ref_body_paths.append(resolved_ref)
77+
return self.ref_bodies[resolved_ref]
78+
79+
6680
@pytest.fixture(autouse=True)
6781
def block_dns_by_default(mocker: MockerFixture) -> None:
6882
"""Keep tests that mock httpx.get independent from external DNS."""
@@ -108,24 +122,88 @@ def test_load_ref_schema_object_caches_refs() -> None:
108122
]
109123

110124

125+
def test_load_ref_schema_object_reuses_current_raw_obj_for_current_root_ref() -> None:
126+
"""Test same-document refs use the current raw object instead of loading a ref body."""
127+
parser = RefBodyCountingJsonSchemaParser("")
128+
parser.raw_obj = {
129+
"definitions": {
130+
"User": {
131+
"type": "object",
132+
"properties": {"name": {"type": "string"}},
133+
},
134+
},
135+
}
136+
137+
with parser.model_resolver.current_root_context(["current.json"]):
138+
schema = parser._load_ref_schema_object("#/definitions/User")
139+
140+
assert schema.type == "object"
141+
assert parser.ref_body_paths == []
142+
assert parser.validation_paths == [
143+
("current.json#/definitions/User",),
144+
]
145+
146+
147+
def test_load_ref_schema_object_loads_non_current_root_ref_body() -> None:
148+
"""Test refs outside the current root still load their referenced body."""
149+
parser = RefBodyCountingJsonSchemaParser("")
150+
parser.raw_obj = {}
151+
parser.ref_bodies["other.json"] = {
152+
"definitions": {
153+
"User": {
154+
"type": "object",
155+
"properties": {"name": {"type": "string"}},
156+
},
157+
},
158+
}
159+
160+
with parser.model_resolver.current_root_context(["current.json"]):
161+
schema = parser._load_ref_schema_object("other.json#/definitions/User")
162+
163+
assert schema.type == "object"
164+
assert parser.ref_body_paths == ["other.json"]
165+
assert parser.validation_paths == [
166+
("other.json#/definitions/User",),
167+
]
168+
169+
111170
def test_parse_file_reuses_validated_definition_objects() -> None:
112-
"""Test definitions validated for metadata are reused when parsed."""
171+
"""Test definitions validated for metadata are reused when parsed and referenced."""
113172
parser = CountingJsonSchemaParser("")
114173
raw = {
174+
"type": "object",
175+
"properties": {
176+
"user": {"$ref": "#/definitions/User"},
177+
"member": {"$ref": "#/definitions/Namespaces/$defs/Member"},
178+
},
115179
"definitions": {
116180
"User": {
117181
"type": "object",
118182
"properties": {"name": {"type": "string"}},
119183
},
184+
"Namespaces": {
185+
"$defs": {
186+
"Member": {
187+
"type": "object",
188+
"properties": {"id": {"type": "integer"}},
189+
},
190+
},
191+
},
120192
},
121193
}
122194

123195
parser._parse_file(raw, "Root", [])
124196

125197
assert parser.validation_paths.count(("#/definitions", "User")) == 1
198+
assert parser.validation_paths.count(("#/definitions", "Namespaces", "$defs", "Member")) == 1
199+
assert ("#/definitions/User",) not in parser.validation_paths
200+
assert ("#/definitions/Namespaces/$defs/Member",) not in parser.validation_paths
126201
reference = parser.model_resolver.get(["#/definitions", "User"])
127202
assert reference is not None
128203
assert reference.loaded
204+
nested_reference = parser.model_resolver.get(["#/definitions", "Namespaces", "$defs", "Member"])
205+
assert nested_reference is not None
206+
assert nested_reference.loaded
129207

130208

131209
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)