Skip to content
87 changes: 82 additions & 5 deletions src/datamodel_code_generator/parser/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ def __init__(
*self.field_extra_keys,
*self.field_extra_keys_without_x_prefix,
}
self._circular_ref_cache: dict[str, bool] = {}

if self.data_model_field_type.can_have_extra_keys:
self.get_field_extra_key: Callable[[str], str] = (
Expand Down Expand Up @@ -1656,6 +1657,10 @@ def _merge_ref_with_schema(self, obj: JsonSchemaObject) -> JsonSchemaObject:
if not obj.ref:
return obj

resolved_ref = self.model_resolver.resolve_ref(obj.ref)
if self._is_ref_circular(resolved_ref):
return obj

ref_schema = self._load_ref_schema_object(obj.ref)
ref_dict = model_dump(ref_schema, exclude_unset=True, by_alias=True)
current_dict = model_dump(obj, exclude={"ref"}, exclude_unset=True, by_alias=True)
Expand All @@ -1664,6 +1669,55 @@ def _merge_ref_with_schema(self, obj: JsonSchemaObject) -> JsonSchemaObject:

return model_validate(self.SCHEMA_OBJECT_TYPE, merged)

def _is_ref_circular(self, resolved_ref: str) -> bool:
"""Check if a resolved $ref target contains a circular reference (cached)."""
if resolved_ref in self._circular_ref_cache:
return self._circular_ref_cache[resolved_ref]
try:
result = self._has_ref_cycle(resolved_ref, resolved_ref, set())
except Exception: # noqa: BLE001 # pragma: no cover
result = False
self._circular_ref_cache[resolved_ref] = result
return result

def _has_ref_cycle(self, ref_to_check: str, target: str, visited: set[str]) -> bool:
"""Check if the schema at ref_to_check contains a reference back to target."""
visited.add(ref_to_check)
file_part, _, fragment = ref_to_check.partition("#")
base_path = Path(file_part).parent if file_part else self.model_resolver.current_base_path
root_path = file_part.split("/") if file_part else self.model_resolver.current_root
base_url = file_part or self.model_resolver.base_url
with (
self.model_resolver.current_base_path_context(base_path),
self.model_resolver.base_url_context(base_url),
self.model_resolver.current_root_context(root_path),
):
raw_doc = self._get_ref_body(file_part) if file_part else self.raw_obj
raw_obj: Any = raw_doc
if fragment:
pointer = [p for p in fragment.split("/") if p]
raw_obj = get_model_by_path(raw_doc, pointer)
return self._walk_for_ref(raw_obj, target, visited)

def _walk_for_ref(self, data: dict[str, Any] | list[Any], target: str, visited: set[str]) -> bool:
"""Recursively walk raw dict/list data looking for a $ref that resolves to target."""
if isinstance(data, dict):
ref_value = data.get("$ref")
if isinstance(ref_value, str):
try:
resolved = self.model_resolver.resolve_ref(ref_value)
except Exception: # noqa: BLE001
resolved = ref_value
if resolved == target:
return True
if resolved not in visited and self._has_ref_cycle(resolved, target, visited):
return True
for value in data.values():
if isinstance(value, (dict, list)) and self._walk_for_ref(value, target, visited):
return True
return False
return any(isinstance(item, (dict, list)) and self._walk_for_ref(item, target, visited) for item in data)

def _merge_primitive_schemas(self, items: list[JsonSchemaObject]) -> JsonSchemaObject:
"""Merge multiple primitive schemas by computing the intersection of their constraints."""
if len(items) == 1:
Expand Down Expand Up @@ -2189,12 +2243,18 @@ def parse_combined_schema(
if target_attribute.ref:
if target_attribute.has_ref_with_schema_keywords and not target_attribute.is_ref_with_nullable_only:
merged_attr = self._merge_ref_with_schema(target_attribute)
combined_schemas.append(
model_validate(
self.SCHEMA_OBJECT_TYPE,
self._deep_merge(base_object, model_dump(merged_attr, exclude_unset=True, by_alias=True)),
if merged_attr.ref:
combined_schemas.append(merged_attr)
refs.append(index)
else:
combined_schemas.append(
model_validate(
self.SCHEMA_OBJECT_TYPE,
self._deep_merge(
base_object, model_dump(merged_attr, exclude_unset=True, by_alias=True)
),
)
)
)
else:
combined_schemas.append(target_attribute)
refs.append(index)
Expand Down Expand Up @@ -3992,6 +4052,18 @@ def _handle_python_import(
"""Mark x-python-import reference as loaded to skip model generation."""
self.model_resolver.add(path, name, class_name=True, loaded=True)

def _is_named_schema_definition_path(self, path: list[str]) -> bool:
"""Check if path points to a named schema entry under definitions/$defs."""
current_root = list(self.model_resolver.current_root)
expected_path_length = len(current_root) + 2
if len(path) != expected_path_length:
return False

schema_container_path = path[len(current_root)]
return path[: len(current_root)] == current_root and any(
schema_container_path == schema_path for schema_path, _ in self.schema_paths
)

def parse_obj( # noqa: PLR0912
self,
name: str,
Expand All @@ -4001,6 +4073,11 @@ def parse_obj( # noqa: PLR0912
"""Parse a JsonSchemaObject by dispatching to appropriate parse methods."""
if obj.has_ref_with_schema_keywords and not obj.is_ref_with_nullable_only:
obj = self._merge_ref_with_schema(obj)
if obj.ref:
if self._is_named_schema_definition_path(path):
self.parse_root_type(name, obj, path)
self.parse_ref(obj, path)
return

if obj.is_array:
self.parse_array(name, obj, path)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# generated by datamodel-codegen:
# filename: root.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class Model(BaseModel):
root: Context | None = None


class Context(BaseModel):
child: Child | None = None


class Child(BaseModel):
parent: Context | None = None


Model.update_forward_refs()
Context.update_forward_refs()
36 changes: 36 additions & 0 deletions tests/data/expected/main/jsonschema/circular_ref_indirect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# generated by datamodel-codegen:
# filename: circular_ref_indirect.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from enum import Enum

from pydantic import BaseModel


class Kind(Enum):
x = 'x'
y = 'y'


class NodeC(BaseModel):
value: str | None = None


class Model(BaseModel):
root: NodeA | None = None


class NodeA(BaseModel):
kind: Kind | None = None
c: NodeC | None = None
b: NodeB | None = None


class NodeB(BaseModel):
a: NodeA | None = None


Model.update_forward_refs()
NodeA.update_forward_refs()
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# generated by datamodel-codegen:
# filename: circular_ref_ref_with_schema_keywords.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class Model(BaseModel):
root: Node | None = None


class Node(BaseModel):
__root__: BaseNode


class BaseNode(BaseModel):
next: Node | None = None


Model.update_forward_refs()
Node.update_forward_refs()
14 changes: 14 additions & 0 deletions tests/data/expected/main/jsonschema/circular_ref_root_with_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# generated by datamodel-codegen:
# filename: circular_ref_root_with_type.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class Node(BaseModel):
child: Node | None = None


Node.update_forward_refs()
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# generated by datamodel-codegen:
# filename: circular_ref_with_schema_keywords.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class Context(BaseModel):
name: str | None = None
children: list[Context] | None = None


class Model(BaseModel):
root: Context | None = None


Context.update_forward_refs()
11 changes: 11 additions & 0 deletions tests/data/expected/main/jsonschema/x_python_import_unused.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# generated by datamodel-codegen:
# filename: x_python_import_unused.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class Model(BaseModel):
name: str | None = None
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$defs": {
"Context": {
"type": "object",
"properties": {
"child": {
"anyOf": [
{
"type": "object",
"$ref": "nested/child.json#/$defs/Child"
}
]
}
}
}
},
"$ref": "#/$defs/Context"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$defs": {
"Child": {
"type": "object",
"x-bad-ref": {
"$ref": "#missing"
},
"properties": {
"parent": {
"anyOf": [
{
"type": "object",
"$ref": "../context.json#/$defs/Context"
}
]
}
}
}
},
"$ref": "#/$defs/Child"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"root": {
"$ref": "defs/context.json#/$defs/Context"
}
}
}
39 changes: 39 additions & 0 deletions tests/data/jsonschema/circular_ref_indirect.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"$defs": {
"NodeA": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["x", "y"]
},
"c": {
"anyOf": [{"$ref": "#/$defs/NodeC"}]
},
"b": {
"anyOf": [{"type": "object", "$ref": "#/$defs/NodeB"}]
}
}
},
"NodeB": {
"type": "object",
"properties": {
"a": {
"anyOf": [{"type": "object", "$ref": "#/$defs/NodeA"}]
}
}
},
"NodeC": {
"type": "object",
"properties": {
"value": {
"type": "string"
}
}
}
},
"type": "object",
"properties": {
"root": {"$ref": "#/$defs/NodeA"}
}
}
22 changes: 22 additions & 0 deletions tests/data/jsonschema/circular_ref_ref_with_schema_keywords.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$defs": {
"Node": {
"$ref": "#/$defs/BaseNode",
"type": "object"
},
"BaseNode": {
"type": "object",
"properties": {
"next": {
"$ref": "#/$defs/Node"
}
}
}
},
"type": "object",
"properties": {
"root": {
"$ref": "#/$defs/Node"
}
}
}
14 changes: 14 additions & 0 deletions tests/data/jsonschema/circular_ref_root_with_type.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$defs": {
"Node": {
"type": "object",
"properties": {
"child": {
"anyOf": [{"type": "object", "$ref": "#/$defs/Node"}]
}
}
}
},
"$ref": "#/$defs/Node",
"type": "object"
}
Loading
Loading