Skip to content

Commit debf5c8

Browse files
authored
Create type aliases for nested elements with titles when use-title-as-name is enabled (#2891)
* Create type aliases for nested elements with titles when use-title-as-name is enabled * Add patternProperties and propertyNames support for use-title-as-name
1 parent 5306aef commit debf5c8

5 files changed

Lines changed: 312 additions & 7 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,7 +1912,7 @@ def parse_combined_schema(
19121912
target_attribute_name: str,
19131913
) -> list[DataType]:
19141914
"""Parse combined schema (anyOf, oneOf, allOf) into a list of data types."""
1915-
base_object = model_dump(obj, exclude={target_attribute_name}, exclude_unset=True, by_alias=True)
1915+
base_object = model_dump(obj, exclude={target_attribute_name, "title"}, exclude_unset=True, by_alias=True)
19161916
combined_schemas: list[JsonSchemaObject] = []
19171917
refs = []
19181918
for index, target_attribute in enumerate(getattr(obj, target_attribute_name, [])):
@@ -2657,12 +2657,14 @@ def parse_property_names( # noqa: PLR0912
26572657
dict_key=key_type,
26582658
)
26592659

2660-
def _should_create_type_alias_for_title(self, item: JsonSchemaObject, name: str) -> bool:
2660+
def _should_create_type_alias_for_title( # noqa: PLR0911
2661+
self, item: JsonSchemaObject, name: str
2662+
) -> bool:
26612663
"""Check if a type alias should be created for an inline type with title.
26622664
26632665
When use_title_as_name is enabled and the item has a title, certain inline types
2664-
(array, dict, oneOf/anyOf unions, enum as literal) should create a type alias
2665-
instead of being inlined.
2666+
(array, dict, oneOf/anyOf unions, enum as literal, primitive types) should create
2667+
a type alias instead of being inlined.
26662668
"""
26672669
if not (self.use_title_as_name and item.title):
26682670
return False
@@ -2686,11 +2688,27 @@ def _should_create_type_alias_for_title(self, item: JsonSchemaObject, name: str)
26862688
and isinstance(item.additionalProperties, JsonSchemaObject)
26872689
):
26882690
return True
2689-
return bool(
2691+
if item.patternProperties:
2692+
return True
2693+
if item.propertyNames:
2694+
return True
2695+
if (
26902696
item.enum
26912697
and not self.ignore_enum_constraints
26922698
and self.should_parse_enum_as_literal(item, property_name=name)
2699+
):
2700+
return True
2701+
is_primitive = (
2702+
item.type
2703+
and not item.is_array
2704+
and not item.is_object
2705+
and not item.anyOf
2706+
and not item.oneOf
2707+
and not item.allOf
2708+
and not item.ref
2709+
and not (item.enum and not self.ignore_enum_constraints)
26932710
)
2711+
return bool(is_primitive)
26942712

26952713
def parse_item( # noqa: PLR0911, PLR0912, PLR0914
26962714
self,
@@ -2789,7 +2807,11 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914
27892807
python_type_flags = self._get_python_type_flags(item)
27902808
dict_flags = python_type_flags or {"is_dict": True}
27912809
return self.data_type(
2792-
data_types=[self.parse_item(name, item.additionalProperties, object_path)],
2810+
data_types=[
2811+
self.parse_item(
2812+
name, item.additionalProperties, get_special_path("additionalProperties", object_path)
2813+
)
2814+
],
27932815
**dict_flags,
27942816
)
27952817
return self.data_type_manager.get_data_type(
@@ -3007,7 +3029,9 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915
30073029
python_type_flags = self._get_python_type_flags(obj)
30083030
dict_flags = python_type_flags or {"is_dict": True}
30093031
data_type = self.data_type(
3010-
data_types=[self.parse_item(name, obj.additionalProperties, path)],
3032+
data_types=[
3033+
self.parse_item(name, obj.additionalProperties, get_special_path("additionalProperties", path))
3034+
],
30113035
**dict_flags,
30123036
)
30133037
elif obj.enum and not self.ignore_enum_constraints:
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# generated by datamodel-codegen:
2+
# filename: use_title_as_name_nested_titles.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import NotRequired, TypedDict
8+
9+
type MyArrayItem = str
10+
11+
12+
type MyArray = list[MyArrayItem]
13+
14+
15+
type MyObjectProp = str
16+
17+
18+
type MyObject = dict[str, MyObjectProp]
19+
20+
21+
type MyOneOfBranch = str
22+
23+
24+
type MyOneOf = MyOneOfBranch | float
25+
26+
27+
type MyAnyOfBranch = bool
28+
29+
30+
type MyAnyOf = MyAnyOfBranch | int
31+
32+
33+
type MyDeepItem = int
34+
35+
36+
type MyNestedArrayItem = list[MyDeepItem]
37+
38+
39+
type MyNestedArray = list[MyNestedArrayItem]
40+
41+
42+
type MyPatternValue = str
43+
44+
45+
type MyPatternObj = dict[str, MyPatternValue]
46+
47+
48+
type MyPropValue = int
49+
50+
51+
type MyPropNamesObj = dict[str, MyPropValue]
52+
53+
54+
class Foo(TypedDict):
55+
array: NotRequired[MyArray]
56+
object: NotRequired[MyObject]
57+
oneOf: NotRequired[MyOneOf]
58+
anyOf: NotRequired[MyAnyOf]
59+
nestedArray: NotRequired[MyNestedArray]
60+
patternObj: NotRequired[MyPatternObj]
61+
propNamesObj: NotRequired[MyPropNamesObj]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# generated by datamodel-codegen:
2+
# filename: use_title_as_name_nested_titles.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, Field, RootModel, constr
10+
11+
12+
class Model(RootModel[Any]):
13+
root: Any
14+
15+
16+
class MyArrayItem(RootModel[str]):
17+
root: str = Field(..., title='MyArrayItem')
18+
19+
20+
class MyArray(RootModel[list[MyArrayItem]]):
21+
root: list[MyArrayItem] = Field(..., title='MyArray')
22+
23+
24+
class MyObjectProp(RootModel[str]):
25+
root: str = Field(..., title='MyObjectProp')
26+
27+
28+
class MyObject(RootModel[dict[str, MyObjectProp]]):
29+
root: dict[str, MyObjectProp] = Field(..., title='MyObject')
30+
31+
32+
class MyOneOfBranch(RootModel[str]):
33+
root: str = Field(..., title='MyOneOfBranch')
34+
35+
36+
class MyOneOf(RootModel[MyOneOfBranch | float]):
37+
root: MyOneOfBranch | float = Field(..., title='MyOneOf')
38+
39+
40+
class MyAnyOfBranch(RootModel[bool]):
41+
root: bool = Field(..., title='MyAnyOfBranch')
42+
43+
44+
class MyAnyOf(RootModel[MyAnyOfBranch | int]):
45+
root: MyAnyOfBranch | int = Field(..., title='MyAnyOf')
46+
47+
48+
class MyDeepItem(RootModel[int]):
49+
root: int = Field(..., title='MyDeepItem')
50+
51+
52+
class MyNestedArrayItem(RootModel[list[MyDeepItem]]):
53+
root: list[MyDeepItem] = Field(..., title='MyNestedArrayItem')
54+
55+
56+
class MyNestedArray(RootModel[list[MyNestedArrayItem]]):
57+
root: list[MyNestedArrayItem] = Field(..., title='MyNestedArray')
58+
59+
60+
class MyPatternValue(RootModel[str]):
61+
root: str = Field(..., title='MyPatternValue')
62+
63+
64+
class MyPatternObj(RootModel[dict[constr(pattern=r'^S_'), MyPatternValue]]):
65+
root: dict[constr(pattern=r'^S_'), MyPatternValue] = Field(
66+
..., title='MyPatternObj'
67+
)
68+
69+
70+
class MyPropValue(RootModel[int]):
71+
root: int = Field(..., title='MyPropValue')
72+
73+
74+
class MyPropNamesObj(RootModel[dict[constr(pattern=r'^[a-z]+$'), MyPropValue]]):
75+
root: dict[constr(pattern=r'^[a-z]+$'), MyPropValue] = Field(
76+
..., title='MyPropNamesObj'
77+
)
78+
79+
80+
class Foo(BaseModel):
81+
array: MyArray | None = Field(None, title='MyArray')
82+
object: MyObject | None = Field(None, title='MyObject')
83+
oneOf: MyOneOf | None = Field(None, title='MyOneOf')
84+
anyOf: MyAnyOf | None = Field(None, title='MyAnyOf')
85+
nestedArray: MyNestedArray | None = Field(None, title='MyNestedArray')
86+
patternObj: MyPatternObj | None = Field(None, title='MyPatternObj')
87+
propNamesObj: MyPropNamesObj | None = Field(None, title='MyPropNamesObj')
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$defs": {
4+
"Foo": {
5+
"type": "object",
6+
"properties": {
7+
"array": {
8+
"title": "MyArray",
9+
"type": "array",
10+
"items": {
11+
"title": "MyArrayItem",
12+
"type": "string"
13+
}
14+
},
15+
"object": {
16+
"title": "MyObject",
17+
"type": "object",
18+
"additionalProperties": {
19+
"title": "MyObjectProp",
20+
"type": "string"
21+
}
22+
},
23+
"oneOf": {
24+
"title": "MyOneOf",
25+
"oneOf": [
26+
{
27+
"title": "MyOneOfBranch",
28+
"type": "string"
29+
},
30+
{
31+
"type": "number"
32+
}
33+
]
34+
},
35+
"anyOf": {
36+
"title": "MyAnyOf",
37+
"anyOf": [
38+
{
39+
"title": "MyAnyOfBranch",
40+
"type": "boolean"
41+
},
42+
{
43+
"type": "integer"
44+
}
45+
]
46+
},
47+
"nestedArray": {
48+
"title": "MyNestedArray",
49+
"type": "array",
50+
"items": {
51+
"title": "MyNestedArrayItem",
52+
"type": "array",
53+
"items": {
54+
"title": "MyDeepItem",
55+
"type": "integer"
56+
}
57+
}
58+
},
59+
"patternObj": {
60+
"title": "MyPatternObj",
61+
"type": "object",
62+
"patternProperties": {
63+
"^S_": {
64+
"title": "MyPatternValue",
65+
"type": "string"
66+
}
67+
}
68+
},
69+
"propNamesObj": {
70+
"title": "MyPropNamesObj",
71+
"type": "object",
72+
"propertyNames": {
73+
"pattern": "^[a-z]+$"
74+
},
75+
"additionalProperties": {
76+
"title": "MyPropValue",
77+
"type": "integer"
78+
}
79+
}
80+
}
81+
}
82+
}
83+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3104,6 +3104,56 @@ def test_jsonschema_use_title_as_name_inline_types_pydantic(output_file: Path) -
31043104
)
31053105

31063106

3107+
@BLACK_PY313_SKIP
3108+
def test_jsonschema_use_title_as_name_nested_titles(output_file: Path) -> None:
3109+
"""Test use-title-as-name creates type aliases for nested elements with titles.
3110+
3111+
When use_title_as_name is enabled, nested elements like array items,
3112+
additionalProperties values, and oneOf/anyOf branches that have their own
3113+
titles should also create type aliases.
3114+
3115+
Fixes: https://github.com/koxudaxi/datamodel-code-generator/issues/2887
3116+
"""
3117+
run_main_and_assert(
3118+
input_path=JSON_SCHEMA_DATA_PATH / "use_title_as_name_nested_titles.json",
3119+
output_path=output_file,
3120+
input_file_type="jsonschema",
3121+
assert_func=assert_file_content,
3122+
expected_file="use_title_as_name_nested_titles.py",
3123+
extra_args=[
3124+
"--use-title-as-name",
3125+
"--output-model-type",
3126+
"typing.TypedDict",
3127+
"--target-python-version",
3128+
"3.13",
3129+
"--use-union-operator",
3130+
"--use-standard-collections",
3131+
"--skip-root-model",
3132+
],
3133+
)
3134+
3135+
3136+
@BLACK_PY313_SKIP
3137+
def test_jsonschema_use_title_as_name_nested_titles_pydantic(output_file: Path) -> None:
3138+
"""Test use-title-as-name with Pydantic v2 creates named types for nested elements."""
3139+
run_main_and_assert(
3140+
input_path=JSON_SCHEMA_DATA_PATH / "use_title_as_name_nested_titles.json",
3141+
output_path=output_file,
3142+
input_file_type="jsonschema",
3143+
assert_func=assert_file_content,
3144+
expected_file="use_title_as_name_nested_titles_pydantic.py",
3145+
extra_args=[
3146+
"--use-title-as-name",
3147+
"--output-model-type",
3148+
"pydantic_v2.BaseModel",
3149+
"--target-python-version",
3150+
"3.13",
3151+
"--use-union-operator",
3152+
"--use-standard-collections",
3153+
],
3154+
)
3155+
3156+
31073157
def test_main_jsonschema_has_default_value(output_file: Path) -> None:
31083158
"""Test default value handling."""
31093159
run_main_and_assert(

0 commit comments

Comments
 (0)