Skip to content

Commit cd0c2d2

Browse files
Fix multiple types in array not generating Union when object has properties (#2590)
* Add support for multiple types in array with object properties * Enhance support for multiple types in schemas with object properties * Refactor parsing of schemas to support multiple types in arrays with object properties * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6ac97d8 commit cd0c2d2

4 files changed

Lines changed: 156 additions & 1 deletion

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,14 @@ def type_has_null(self) -> bool:
391391
"""Check if the type list contains null."""
392392
return isinstance(self.type, list) and "null" in self.type
393393

394+
@cached_property
395+
def has_multiple_types(self) -> bool:
396+
"""Check if the type is a list with multiple non-null types."""
397+
if not isinstance(self.type, list):
398+
return False
399+
non_null_types = [t for t in self.type if t != "null"]
400+
return len(non_null_types) > 1
401+
394402

395403
@lru_cache
396404
def get_ref_type(ref: str) -> JSONReference:
@@ -1301,6 +1309,17 @@ def parse_item( # noqa: PLR0911, PLR0912
13011309
if item.is_object or item.patternProperties:
13021310
object_path = get_special_path("object", path)
13031311
if item.properties:
1312+
if item.has_multiple_types and isinstance(item.type, list):
1313+
data_types: list[DataType] = []
1314+
data_types.append(self.parse_object(name, item, object_path, singular_name=singular_name))
1315+
data_types.extend(
1316+
self.data_type_manager.get_data_type(
1317+
self._get_type_with_mappings(t, item.format or "default"),
1318+
)
1319+
for t in item.type
1320+
if t not in {"object", "null"}
1321+
)
1322+
return self.data_type(data_types=data_types)
13041323
return self.parse_object(name, item, object_path, singular_name=singular_name)
13051324
if item.patternProperties:
13061325
# support only single key dict.
@@ -1535,6 +1554,63 @@ def parse_root_type( # noqa: PLR0912
15351554
self.results.append(data_model_root_type)
15361555
return self.data_type(reference=reference)
15371556

1557+
def _parse_multiple_types_with_properties(
1558+
self,
1559+
name: str,
1560+
obj: JsonSchemaObject,
1561+
type_list: list[str],
1562+
path: list[str],
1563+
) -> None:
1564+
"""Parse a schema with multiple types including object with properties."""
1565+
data_types: list[DataType] = []
1566+
1567+
object_path = get_special_path("object", path)
1568+
object_data_type = self.parse_object(name, obj, object_path)
1569+
data_types.append(object_data_type)
1570+
1571+
data_types.extend(
1572+
self.data_type_manager.get_data_type(
1573+
self._get_type_with_mappings(t, obj.format or "default"),
1574+
)
1575+
for t in type_list
1576+
if t not in {"object", "null"}
1577+
)
1578+
1579+
is_nullable = obj.nullable or obj.type_has_null
1580+
required = not is_nullable and not (obj.has_default and self.apply_default_values_for_required_fields)
1581+
1582+
reference = self.model_resolver.add(path, name, loaded=True, class_name=True)
1583+
self.set_title(reference.path, obj)
1584+
self.set_additional_properties(reference.path, obj)
1585+
1586+
data_model_root_type = self.data_model_root_type(
1587+
reference=reference,
1588+
fields=[
1589+
self.data_model_field_type(
1590+
data_type=self.data_type(data_types=data_types),
1591+
default=obj.default,
1592+
required=required,
1593+
constraints=obj.dict() if self.field_constraints else {},
1594+
nullable=obj.type_has_null if self.strict_nullable else None,
1595+
strip_default_none=self.strip_default_none,
1596+
extras=self.get_field_extras(obj),
1597+
use_annotated=self.use_annotated,
1598+
use_field_description=self.use_field_description,
1599+
use_inline_field_description=self.use_inline_field_description,
1600+
original_name=None,
1601+
has_default=obj.has_default,
1602+
)
1603+
],
1604+
custom_base_class=obj.custom_base_path or self.base_class,
1605+
custom_template_dir=self.custom_template_dir,
1606+
extra_template_data=self.extra_template_data,
1607+
path=self.current_source_path,
1608+
nullable=obj.type_has_null,
1609+
treat_dot_as_module=self.treat_dot_as_module,
1610+
default=obj.default if obj.has_default else UNDEFINED,
1611+
)
1612+
self.results.append(data_model_root_type)
1613+
15381614
def parse_enum_as_literal(self, obj: JsonSchemaObject) -> DataType:
15391615
"""Parse enum values as a Literal type."""
15401616
return self.data_type(literals=[i for i in obj.enum if i is not None])
@@ -1843,7 +1919,10 @@ def parse_obj(
18431919
if isinstance(data_type, EmptyDataType) and obj.properties:
18441920
self.parse_object(name, obj, path) # pragma: no cover
18451921
elif obj.properties:
1846-
self.parse_object(name, obj, path)
1922+
if obj.has_multiple_types and isinstance(obj.type, list):
1923+
self._parse_multiple_types_with_properties(name, obj, obj.type, path)
1924+
else:
1925+
self.parse_object(name, obj, path)
18471926
elif obj.patternProperties:
18481927
self.parse_root_type(name, obj, path)
18491928
elif obj.type == "object":
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# generated by datamodel-codegen:
2+
# filename: multiple_types_with_object.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional, Union
8+
9+
from pydantic import BaseModel
10+
11+
12+
class External(BaseModel):
13+
name: Optional[str] = None
14+
15+
16+
class Config(BaseModel):
17+
value: Optional[int] = None
18+
19+
20+
class TopLevelMultiType1(BaseModel):
21+
enabled: Optional[bool] = None
22+
23+
24+
class TopLevelMultiType(BaseModel):
25+
__root__: Union[TopLevelMultiType1, bool]
26+
27+
28+
class Model(BaseModel):
29+
external: Optional[Union[External, bool]] = None
30+
config: Optional[Union[Optional[Config], str]] = None
31+
top_level_ref: Optional[TopLevelMultiType] = None
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"definitions": {
4+
"TopLevelMultiType": {
5+
"type": ["boolean", "object"],
6+
"properties": {
7+
"enabled": {
8+
"type": "boolean"
9+
}
10+
}
11+
}
12+
},
13+
"type": "object",
14+
"properties": {
15+
"external": {
16+
"type": ["boolean", "object"],
17+
"properties": {
18+
"name": {
19+
"type": "string"
20+
}
21+
}
22+
},
23+
"config": {
24+
"type": ["null", "string", "object"],
25+
"properties": {
26+
"value": {
27+
"type": "integer"
28+
}
29+
}
30+
},
31+
"top_level_ref": {
32+
"$ref": "#/definitions/TopLevelMultiType"
33+
}
34+
}
35+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3000,6 +3000,16 @@ def test_main_jsonschema_empty_items_array(output_file: Path) -> None:
30003000
)
30013001

30023002

3003+
def test_main_jsonschema_multiple_types_with_object(output_file: Path) -> None:
3004+
"""Test multiple types in array including object with properties generates Union type."""
3005+
run_main_and_assert(
3006+
input_path=JSON_SCHEMA_DATA_PATH / "multiple_types_with_object.json",
3007+
output_path=output_file,
3008+
input_file_type="jsonschema",
3009+
assert_func=assert_file_content,
3010+
)
3011+
3012+
30033013
@MSGSPEC_LEGACY_BLACK_SKIP
30043014
def test_main_jsonschema_type_alias_with_circular_ref_to_class_msgspec(min_version: str, output_file: Path) -> None:
30053015
"""Test TypeAlias with circular reference to class generates quoted forward refs."""

0 commit comments

Comments
 (0)