Skip to content

Commit 2a4a94e

Browse files
authored
Reuse validated JSON Schema objects (#3466)
* Reuse validated JSON Schema objects * Cache JSON Schema refs on first load
1 parent 307fa6a commit 2a4a94e

9 files changed

Lines changed: 263 additions & 9 deletions

File tree

src/datamodel_code_generator/model/base.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -431,13 +431,15 @@ def _can_collect_imports_without_type_hint(self, *, needs_annotated: bool) -> bo
431431
return False
432432

433433
data_type = self.data_type
434+
if data_type.use_serialize_as_any:
435+
return False
436+
434437
type_name = data_type.alias or data_type.type
435438
if type_name and (UNION_PREFIX in type_name or OPTIONAL_PREFIX in type_name):
436439
return False
437440

438441
return not (
439-
data_type.reference
440-
or data_type.data_types
442+
data_type.data_types
441443
or data_type.dict_key
442444
or data_type.literals
443445
or data_type.enum_member_literals
@@ -472,23 +474,33 @@ def _needs_optional_import_without_type_hint(self) -> bool:
472474
):
473475
return False
474476

477+
if self._reference_source_nullable_without_type_hint():
478+
return True
479+
475480
match self.nullable:
476481
case True:
477482
return True
478483
case False:
479484
return False
480-
case None if self.required:
481-
return bool(self.type_has_null)
482485
case None:
483-
return bool(self.fall_back_to_nullable)
486+
return bool(self.type_has_null) if self.required else bool(self.fall_back_to_nullable)
484487
return False # pragma: no cover
485488

489+
def _reference_source_nullable_without_type_hint(self) -> bool:
490+
"""Return whether a referenced non-alias model should make this type optional."""
491+
reference = self.data_type.reference
492+
if reference is None:
493+
return False
494+
source = reference.source
495+
return not getattr(source, "is_alias", False) and bool(getattr(source, "nullable", False))
496+
486497
def _has_renderable_data_type_without_type_hint(self) -> bool:
487498
"""Return whether this simple DataType renders to something other than None."""
488499
data_type = self.data_type
489500
return bool(
490501
data_type.alias
491502
or data_type.type
503+
or data_type.reference
492504
or data_type.is_dict
493505
or data_type.is_list
494506
or data_type.is_set

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,7 @@ def __init__(
901901
self._dynamic_anchor_index: dict[tuple[str, ...], dict[str, str]] = {}
902902
self._recursive_anchor_index: dict[tuple[str, ...], list[str]] = {}
903903
self._ref_data_type_facts: dict[str, tuple[Any, bool]] = {}
904+
self._ref_schema_object_cache: dict[str, JsonSchemaObject] = {}
904905
self._force_base_model_refs: set[str] = set()
905906
self._force_base_model_generation = False
906907
self.field_keys: set[str] = {
@@ -2462,6 +2463,9 @@ def _deep_merge(self, dict1: dict[Any, Any], dict2: dict[Any, Any]) -> dict[Any,
24622463
def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
24632464
"""Load a JsonSchemaObject from a $ref using standard resolve/load pipeline."""
24642465
resolved_ref = self.model_resolver.resolve_ref(ref)
2466+
if (cached := self._ref_schema_object_cache.get(resolved_ref)) is not None:
2467+
return cached
2468+
24652469
file_part, fragment = ([*resolved_ref.split("#", 1), ""])[:2]
24662470
raw_doc = self._get_ref_body(file_part) if file_part else self.raw_obj
24672471

@@ -2470,7 +2474,9 @@ def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
24702474
pointer = split_json_pointer(raw_doc, fragment)
24712475
target_schema = get_model_by_path(raw_doc, pointer)
24722476

2473-
return self._validate_schema_object(target_schema, [resolved_ref])
2477+
ref_schema = self._validate_schema_object(target_schema, [resolved_ref])
2478+
self._ref_schema_object_cache[resolved_ref] = ref_schema
2479+
return ref_schema
24742480

24752481
def _anchor_ref_path(self, root_key: tuple[str, ...], path: list[str]) -> str: # noqa: PLR6301
24762482
"""Return the local ref path for an anchor under the current root."""
@@ -5486,14 +5492,24 @@ def parse_raw_obj(
54865492
path: list[str],
54875493
) -> None:
54885494
"""Parse a raw dictionary into a JsonSchemaObject and process it."""
5495+
self._parse_raw_or_validated_obj(name, raw, path)
5496+
5497+
def _parse_raw_or_validated_obj(
5498+
self,
5499+
name: str,
5500+
raw: dict[str, YamlValue] | YamlValue,
5501+
path: list[str],
5502+
validated_obj: JsonSchemaObject | None = None,
5503+
) -> None:
5504+
"""Parse a raw schema, reusing a validated object when available."""
54895505
if isinstance(raw, dict) and "x-python-import" in raw:
54905506
self._handle_python_import(name, path)
54915507
return
54925508

54935509
# Strict mode: check for version-specific features before validation
54945510
self._check_version_specific_features(raw, path)
54955511

5496-
obj = self._validate_schema_object(raw, path)
5512+
obj = validated_obj if validated_obj is not None else self._validate_schema_object(raw, path)
54975513
# Build $recursiveAnchor / $dynamicAnchor indexes for this schema
54985514
self._build_anchor_indexes(obj, path)
54995515
self.parse_obj(name, obj, path)
@@ -5964,11 +5980,13 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
59645980
*definition_entries,
59655981
]
59665982
seen_definition_metadata_paths: set[tuple[str, ...]] = set()
5983+
validated_definition_objects: dict[tuple[str, ...], JsonSchemaObject] = {}
59675984
for _key, model, definition_path in definition_metadata_entries:
59685985
if (definition_path_key := tuple(definition_path)) in seen_definition_metadata_paths:
59695986
continue
59705987
seen_definition_metadata_paths.add(definition_path_key)
59715988
obj = self._validate_schema_object(model, definition_path)
5989+
validated_definition_objects[definition_path_key] = obj
59725990
self.parse_id(obj, definition_path)
59735991
if obj.recursiveAnchor:
59745992
ref_path = self._anchor_ref_path(root_key, definition_path)
@@ -5986,7 +6004,12 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
59866004
for key, model, path in definition_entries:
59876005
reference = self.model_resolver.get(path)
59886006
if not reference or not reference.loaded:
5989-
self.parse_raw_obj(key, model, path)
6007+
self._parse_raw_or_validated_obj(
6008+
key,
6009+
model,
6010+
path,
6011+
validated_definition_objects.get(tuple(path)),
6012+
)
59906013

59916014
key = tuple(path_parts)
59926015
reserved_refs = set(self.reserved_refs.get(key) or [])
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# generated by datamodel-codegen:
2+
# filename: ref_nullable_import_fast_path.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional
8+
9+
from pydantic import BaseModel
10+
11+
12+
class User(BaseModel):
13+
name: str
14+
15+
16+
class RefNullableImportFastPath(BaseModel):
17+
maybe_user: Optional[User]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# generated by datamodel-codegen:
2+
# filename: serialize_as_any_import_fast_path.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel, SerializeAsAny
8+
9+
10+
class User(BaseModel):
11+
name: str
12+
13+
14+
class Admin(User):
15+
role: str
16+
17+
18+
class SerializeAsAnyImportFastPath(BaseModel):
19+
user: SerializeAsAny[User]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"title": "RefNullableImportFastPath",
3+
"type": "object",
4+
"properties": {
5+
"maybe_user": {
6+
"$ref": "#/definitions/User"
7+
}
8+
},
9+
"required": [
10+
"maybe_user"
11+
],
12+
"definitions": {
13+
"User": {
14+
"type": [
15+
"object",
16+
"null"
17+
],
18+
"properties": {
19+
"name": {
20+
"type": "string"
21+
}
22+
},
23+
"required": [
24+
"name"
25+
]
26+
}
27+
}
28+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"title": "SerializeAsAnyImportFastPath",
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+
"Admin": {
25+
"allOf": [
26+
{
27+
"$ref": "#/definitions/User"
28+
},
29+
{
30+
"type": "object",
31+
"properties": {
32+
"role": {
33+
"type": "string"
34+
}
35+
},
36+
"required": [
37+
"role"
38+
]
39+
}
40+
]
41+
}
42+
}
43+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2219,6 +2219,33 @@ def test_main_import_fast_path_primitives(output_file: Path) -> None:
22192219
)
22202220

22212221

2222+
def test_main_ref_nullable_import_fast_path(output_file: Path) -> None:
2223+
"""Test referenced nullable fields keep Optional imports with no union operator."""
2224+
run_main_and_assert(
2225+
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_import_fast_path.json",
2226+
output_path=output_file,
2227+
input_file_type="jsonschema",
2228+
assert_func=assert_file_content,
2229+
extra_args=["--no-use-union-operator"],
2230+
)
2231+
2232+
2233+
def test_main_serialize_as_any_import_fast_path(output_file: Path) -> None:
2234+
"""Test SerializeAsAny reference fields keep generated imports."""
2235+
run_main_and_assert(
2236+
input_path=JSON_SCHEMA_DATA_PATH / "serialize_as_any_import_fast_path.json",
2237+
output_path=output_file,
2238+
input_file_type="jsonschema",
2239+
assert_func=assert_file_content,
2240+
extra_args=[
2241+
"--output-model-type",
2242+
"pydantic_v2.BaseModel",
2243+
"--use-serialize-as-any",
2244+
],
2245+
force_exec_validation=True,
2246+
)
2247+
2248+
22222249
def test_main_root_model_with_additional_properties_literal(min_version: str, output_file: Path) -> None:
22232250
"""Test root model additional properties with literal types."""
22242251
run_main_and_assert(

tests/model/test_base.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from dataclasses import dataclass
56
from pathlib import Path
67
from tempfile import NamedTemporaryFile
78
from typing import Any
@@ -59,6 +60,14 @@ class C(DataModel):
5960
"""Test helper class for DataModel testing without template path."""
6061

6162

63+
@dataclass
64+
class ReferenceSource:
65+
"""Test helper for reference source nullability."""
66+
67+
nullable: bool
68+
is_alias: bool = False
69+
70+
6271
template: str = """{%- for decorator in decorators -%}
6372
{{ decorator }}
6473
{%- endfor %}
@@ -451,7 +460,6 @@ def test_data_field() -> None:
451460
@pytest.mark.parametrize(
452461
"data_type",
453462
[
454-
DataType(reference=Reference(path="Ref", original_name="Ref", name="Ref")),
455463
DataType(data_types=[DataType(type="str")]),
456464
DataType(is_dict=True, dict_key=DataType(type="str")),
457465
DataType(literals=["value"]),
@@ -559,6 +567,24 @@ def test_field_import_fast_path_collects_simple_data_type_imports(
559567
assert field.imports == expected_imports
560568

561569

570+
def test_field_import_fast_path_collects_nullable_reference_import() -> None:
571+
"""Test reference source nullability contributes Optional without rendering type hints."""
572+
reference = Reference(path="#/definitions/User", name="User")
573+
reference.source = ReferenceSource(nullable=True)
574+
field = DataModelFieldBase(name="user", data_type=DataType(reference=reference), required=True)
575+
576+
assert field.imports == (IMPORT_OPTIONAL,)
577+
578+
579+
def test_field_import_fast_path_ignores_nullable_alias_reference() -> None:
580+
"""Test nullable aliases do not make the referencing field import Optional."""
581+
reference = Reference(path="#/definitions/UserAlias", name="UserAlias")
582+
reference.source = ReferenceSource(nullable=True, is_alias=True)
583+
field = DataModelFieldBase(name="user", data_type=DataType(reference=reference), required=True)
584+
585+
assert field.imports == ()
586+
587+
562588
def test_field_import_fallback_collects_annotated_import() -> None:
563589
"""Test the fallback path includes Annotated when requested."""
564590
field = DataModelFieldBase(name="a", data_type=DataType(type="str"), required=True)

0 commit comments

Comments
 (0)