Skip to content

Commit 19f1fb7

Browse files
authored
Fix deep hierarchy type inheritance in allOf property overrides (#2843)
1 parent 95cd6d5 commit 19f1fb7

10 files changed

Lines changed: 306 additions & 10 deletions

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,11 +1775,24 @@ def _merge_properties_with_parent_constraints(
17751775
}
17761776
return model_validate(self.SCHEMA_OBJECT_TYPE, merged_obj_dict)
17771777

1778-
def _get_inherited_field_type(self, prop_name: str, base_classes: list[Reference]) -> DataType | None:
1779-
"""Get the data type for an inherited property from parent schemas."""
1778+
def _get_inherited_field_type( # noqa: PLR0912
1779+
self, prop_name: str, base_classes: list[Reference], visited: frozenset[str] | None = None
1780+
) -> DataType | None:
1781+
"""Get the data type for an inherited property from parent schemas.
1782+
1783+
Recursively traverses the inheritance chain when a parent property
1784+
doesn't have type information but the parent itself inherits from another schema.
1785+
"""
1786+
if visited is None:
1787+
visited = frozenset()
1788+
17801789
for base in base_classes:
17811790
if not base.path: # pragma: no cover
17821791
continue
1792+
if base.path in visited: # pragma: no cover
1793+
continue
1794+
visited |= {base.path}
1795+
17831796
if "#" in base.path:
17841797
file_part, fragment = base.path.split("#", 1)
17851798
ref = f"{file_part}#{fragment}" if file_part else f"#{fragment}"
@@ -1789,14 +1802,21 @@ def _get_inherited_field_type(self, prop_name: str, base_classes: list[Reference
17891802
parent_schema = self._load_ref_schema_object(ref)
17901803
except Exception: # pragma: no cover # noqa: BLE001, S112
17911804
continue
1792-
if not parent_schema.properties: # pragma: no cover
1793-
continue
1794-
prop_schema = parent_schema.properties.get(prop_name)
1795-
if not isinstance(prop_schema, JsonSchemaObject): # pragma: no cover
1796-
continue
1797-
result = self._build_lightweight_type(prop_schema)
1798-
if result is not None:
1799-
return result
1805+
1806+
if parent_schema.properties:
1807+
prop_schema = parent_schema.properties.get(prop_name)
1808+
if isinstance(prop_schema, JsonSchemaObject):
1809+
result = self._build_lightweight_type(prop_schema)
1810+
if result is not None:
1811+
return result
1812+
1813+
if parent_schema.allOf:
1814+
grandparent_refs = [self.model_resolver.add_ref(item.ref) for item in parent_schema.allOf if item.ref]
1815+
if grandparent_refs:
1816+
result = self._get_inherited_field_type(prop_name, grandparent_refs, visited)
1817+
if result is not None:
1818+
return result
1819+
18001820
return None
18011821

18021822
def _schema_signature(self, prop_schema: JsonSchemaObject | bool) -> str | bool: # noqa: FBT001, PLR6301
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# generated by datamodel-codegen:
2+
# filename: all_of_deep_hierarchy_property_override.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel, constr
8+
9+
10+
class Entity(BaseModel):
11+
type: str
12+
13+
14+
class Thing(Entity):
15+
type: str
16+
name: constr(min_length=1)
17+
18+
19+
class Person(Thing):
20+
type: str | None = 'playground:Person'
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# generated by datamodel-codegen:
2+
# filename: all_of_hierarchy_inline_allof.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Any
8+
9+
from pydantic import BaseModel
10+
11+
12+
class Parent(BaseModel):
13+
name: str | None = None
14+
status: Any | None = 'parent-status'
15+
16+
17+
class Child(Parent):
18+
status: Any | None = 'child-status'
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# generated by datamodel-codegen:
2+
# filename: all_of_hierarchy_property_not_in_ancestor.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Any
8+
9+
from pydantic import BaseModel
10+
11+
12+
class Grandparent(BaseModel):
13+
name: str | None = None
14+
15+
16+
class Parent(Grandparent):
17+
category: Any | None = 'parent-category'
18+
19+
20+
class Child(Parent):
21+
category: Any | None = 'child-category'
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# generated by datamodel-codegen:
2+
# filename: all_of_very_deep_hierarchy_property_override.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 Base(BaseModel):
11+
id: str
12+
13+
14+
class Entity(Base):
15+
type: str
16+
17+
18+
class Thing(Entity):
19+
type: str | None = 'Thing'
20+
name: str
21+
22+
23+
class Person(Thing):
24+
type: str | None = 'Person'
25+
age: int | None = None
26+
27+
28+
class SpecificPerson(Person):
29+
type: str | None = 'SpecificPerson'
30+
id: str | None = 'specific-id'
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://example.com/person.schema.json",
4+
"title": "Person",
5+
"$defs": {
6+
"Entity": {
7+
"type": "object",
8+
"properties": {
9+
"type": { "type": "string", "default": "playground:Entity" }
10+
},
11+
"required": ["type"]
12+
},
13+
"Thing": {
14+
"allOf": [
15+
{ "$ref": "#/$defs/Entity" }
16+
],
17+
"type": "object",
18+
"properties": {
19+
"type": { "default": "playground:Thing" },
20+
"name": { "type": "string", "minLength": 1 }
21+
},
22+
"required": ["type", "name"]
23+
}
24+
},
25+
"type": "object",
26+
"allOf": [
27+
{ "$ref": "#/$defs/Thing" }
28+
],
29+
"properties": {
30+
"type": { "default": "playground:Person" }
31+
}
32+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"title": "Child",
4+
"$defs": {
5+
"Parent": {
6+
"allOf": [
7+
{
8+
"type": "object",
9+
"properties": {
10+
"name": { "type": "string" }
11+
}
12+
}
13+
],
14+
"type": "object",
15+
"properties": {
16+
"status": { "default": "parent-status" }
17+
}
18+
}
19+
},
20+
"type": "object",
21+
"allOf": [
22+
{ "$ref": "#/$defs/Parent" }
23+
],
24+
"properties": {
25+
"status": { "default": "child-status" }
26+
}
27+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"title": "Child",
4+
"$defs": {
5+
"Grandparent": {
6+
"type": "object",
7+
"properties": {
8+
"name": { "type": "string" }
9+
}
10+
},
11+
"Parent": {
12+
"allOf": [
13+
{ "$ref": "#/$defs/Grandparent" }
14+
],
15+
"type": "object",
16+
"properties": {
17+
"category": { "default": "parent-category" }
18+
}
19+
}
20+
},
21+
"type": "object",
22+
"allOf": [
23+
{ "$ref": "#/$defs/Parent" }
24+
],
25+
"properties": {
26+
"category": { "default": "child-category" }
27+
}
28+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"title": "SpecificPerson",
4+
"$defs": {
5+
"Base": {
6+
"type": "object",
7+
"properties": {
8+
"id": { "type": "string" }
9+
},
10+
"required": ["id"]
11+
},
12+
"Entity": {
13+
"allOf": [
14+
{ "$ref": "#/$defs/Base" }
15+
],
16+
"type": "object",
17+
"properties": {
18+
"type": { "type": "string", "default": "Entity" }
19+
},
20+
"required": ["type"]
21+
},
22+
"Thing": {
23+
"allOf": [
24+
{ "$ref": "#/$defs/Entity" }
25+
],
26+
"type": "object",
27+
"properties": {
28+
"type": { "default": "Thing" },
29+
"name": { "type": "string" }
30+
},
31+
"required": ["name"]
32+
},
33+
"Person": {
34+
"allOf": [
35+
{ "$ref": "#/$defs/Thing" }
36+
],
37+
"type": "object",
38+
"properties": {
39+
"type": { "default": "Person" },
40+
"age": { "type": "integer" }
41+
}
42+
}
43+
},
44+
"type": "object",
45+
"allOf": [
46+
{ "$ref": "#/$defs/Person" }
47+
],
48+
"properties": {
49+
"type": { "default": "SpecificPerson" },
50+
"id": { "default": "specific-id" }
51+
}
52+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,6 +1423,54 @@ def test_main_all_of_multi_ref_with_property_override(output_file: Path) -> None
14231423
)
14241424

14251425

1426+
def test_main_all_of_deep_hierarchy_property_override(output_file: Path) -> None:
1427+
"""Test allOf with deep hierarchy inherits types from grandparent when parent has partial override."""
1428+
with chdir(JSON_SCHEMA_DATA_PATH):
1429+
run_main_and_assert(
1430+
input_path=Path("all_of_deep_hierarchy_property_override.json"),
1431+
output_path=output_file,
1432+
input_file_type="jsonschema",
1433+
assert_func=assert_file_content,
1434+
expected_file="all_of_deep_hierarchy_property_override.py",
1435+
)
1436+
1437+
1438+
def test_main_all_of_very_deep_hierarchy_property_override(output_file: Path) -> None:
1439+
"""Test allOf with 4+ levels of hierarchy inherits types from great-grandparent."""
1440+
with chdir(JSON_SCHEMA_DATA_PATH):
1441+
run_main_and_assert(
1442+
input_path=Path("all_of_very_deep_hierarchy_property_override.json"),
1443+
output_path=output_file,
1444+
input_file_type="jsonschema",
1445+
assert_func=assert_file_content,
1446+
expected_file="all_of_very_deep_hierarchy_property_override.py",
1447+
)
1448+
1449+
1450+
def test_main_all_of_hierarchy_property_not_in_ancestor(output_file: Path) -> None:
1451+
"""Test allOf hierarchy when property override is not found in any ancestor."""
1452+
with chdir(JSON_SCHEMA_DATA_PATH):
1453+
run_main_and_assert(
1454+
input_path=Path("all_of_hierarchy_property_not_in_ancestor.json"),
1455+
output_path=output_file,
1456+
input_file_type="jsonschema",
1457+
assert_func=assert_file_content,
1458+
expected_file="all_of_hierarchy_property_not_in_ancestor.py",
1459+
)
1460+
1461+
1462+
def test_main_all_of_hierarchy_inline_allof(output_file: Path) -> None:
1463+
"""Test allOf hierarchy when parent has inline allOf without $ref."""
1464+
with chdir(JSON_SCHEMA_DATA_PATH):
1465+
run_main_and_assert(
1466+
input_path=Path("all_of_hierarchy_inline_allof.json"),
1467+
output_path=output_file,
1468+
input_file_type="jsonschema",
1469+
assert_func=assert_file_content,
1470+
expected_file="all_of_hierarchy_inline_allof.py",
1471+
)
1472+
1473+
14261474
@pytest.mark.skipif(
14271475
black.__version__.split(".")[0] >= "24",
14281476
reason="Installed black doesn't support the old style",

0 commit comments

Comments
 (0)