Skip to content

Commit 5306aef

Browse files
authored
Fix duplicate model generation for $ref with nullable (#2890)
1 parent 1d221da commit 5306aef

11 files changed

Lines changed: 262 additions & 3 deletions

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,27 @@ def has_ref_with_schema_keywords(self) -> bool:
477477
schema_affecting_fields |= {"extras"}
478478
return bool(schema_affecting_fields)
479479

480+
@cached_property
481+
def is_ref_with_nullable_only(self) -> bool:
482+
"""Check if schema has $ref with only nullable: true (no other schema-affecting keywords).
483+
484+
This is used to avoid creating duplicate models when a $ref is combined
485+
with nullable: true. In such cases, the reference should be used directly
486+
with Optional type annotation instead of merging schemas.
487+
"""
488+
if not self.ref or self.nullable is not True:
489+
return False
490+
other_fields = get_fields_set(self) - {"ref", "nullable"} - self.__metadata_only_fields__ - {"extras"}
491+
if other_fields:
492+
return False
493+
if self.extras:
494+
schema_affecting_extras = {
495+
k for k in self.extras if k not in self.__metadata_only_fields__ and not k.startswith("x-")
496+
}
497+
if schema_affecting_extras:
498+
return False
499+
return True
500+
480501

481502
@lru_cache
482503
def get_ref_type(ref: str) -> JSONReference:
@@ -1805,7 +1826,7 @@ def _handle_allof_root_model_with_constraints( # noqa: PLR0911, PLR0912
18051826
if ref_value is None:
18061827
return None # pragma: no cover
18071828

1808-
if ref_item.has_ref_with_schema_keywords:
1829+
if ref_item.has_ref_with_schema_keywords and not ref_item.is_ref_with_nullable_only:
18091830
ref_schema = self._merge_ref_with_schema(ref_item)
18101831
else:
18111832
ref_schema = self._load_ref_schema_object(ref_value)
@@ -1896,7 +1917,7 @@ def parse_combined_schema(
18961917
refs = []
18971918
for index, target_attribute in enumerate(getattr(obj, target_attribute_name, [])):
18981919
if target_attribute.ref:
1899-
if target_attribute.has_ref_with_schema_keywords:
1920+
if target_attribute.has_ref_with_schema_keywords and not target_attribute.is_ref_with_nullable_only:
19001921
merged_attr = self._merge_ref_with_schema(target_attribute)
19011922
combined_schemas.append(
19021923
model_validate(
@@ -2700,6 +2721,11 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914
27002721
item,
27012722
root_type_path,
27022723
)
2724+
if item.is_ref_with_nullable_only and item.ref:
2725+
ref_data_type = self.get_ref_data_type(item.ref)
2726+
if self.strict_nullable:
2727+
return self.data_type(data_types=[ref_data_type], is_optional=True)
2728+
return ref_data_type
27032729
if item.has_ref_with_schema_keywords:
27042730
item = self._merge_ref_with_schema(item)
27052731
if item.ref:
@@ -3523,7 +3549,7 @@ def parse_obj( # noqa: PLR0912
35233549
path: list[str],
35243550
) -> None:
35253551
"""Parse a JsonSchemaObject by dispatching to appropriate parse methods."""
3526-
if obj.has_ref_with_schema_keywords:
3552+
if obj.has_ref_with_schema_keywords and not obj.is_ref_with_nullable_only:
35273553
obj = self._merge_ref_with_schema(obj)
35283554

35293555
if obj.is_array:
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_only.yaml
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 User(BaseModel):
11+
name: str
12+
13+
14+
class Model(BaseModel):
15+
user_a: User | None = None
16+
user_b: User | None = None
17+
user_c: User | None = None
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_only.yaml
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 User(BaseModel):
11+
name: str
12+
13+
14+
class Model(BaseModel):
15+
user_a: User | None = None
16+
user_b: User | None = None
17+
user_c: User | None = None
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# generated by datamodel-codegen:
2+
# filename: ref_nullable_with_constraint.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel, RootModel, constr
8+
9+
10+
class Model(BaseModel):
11+
constrained_string: constr(min_length=5) | None = None
12+
13+
14+
class StringType(RootModel[str]):
15+
root: str
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: ref_nullable_with_extra.yaml
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 UserWithExtra(BaseModel):
11+
name: str | None = None
12+
13+
14+
class Model(BaseModel):
15+
user_with_extra: UserWithExtra | None = None
16+
17+
18+
class User(BaseModel):
19+
name: str | None = None
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: ref_nullable_with_metadata.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel, Field
8+
9+
10+
class User(BaseModel):
11+
name: str
12+
13+
14+
class Model(BaseModel):
15+
user_with_title: User | None = Field(
16+
None,
17+
description='A user reference with additional metadata',
18+
title='User with Title',
19+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
$schema: "http://json-schema.org/draft-07/schema#"
2+
definitions:
3+
User:
4+
type: object
5+
properties:
6+
name:
7+
type: string
8+
required:
9+
- name
10+
type: object
11+
properties:
12+
user_a:
13+
$ref: "#/definitions/User"
14+
nullable: true
15+
user_b:
16+
$ref: "#/definitions/User"
17+
nullable: true
18+
user_c:
19+
$ref: "#/definitions/User"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
$schema: "http://json-schema.org/draft-07/schema#"
2+
definitions:
3+
StringType:
4+
type: string
5+
type: object
6+
properties:
7+
constrained_string:
8+
$ref: "#/definitions/StringType"
9+
nullable: true
10+
minLength: 5
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
$schema: "http://json-schema.org/draft-07/schema#"
2+
definitions:
3+
User:
4+
type: object
5+
properties:
6+
name:
7+
type: string
8+
type: object
9+
properties:
10+
user_with_extra:
11+
$ref: "#/definitions/User"
12+
nullable: true
13+
if:
14+
properties:
15+
name:
16+
const: admin
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
$schema: "http://json-schema.org/draft-07/schema#"
2+
definitions:
3+
User:
4+
type: object
5+
properties:
6+
name:
7+
type: string
8+
required:
9+
- name
10+
type: object
11+
properties:
12+
user_with_title:
13+
$ref: "#/definitions/User"
14+
nullable: true
15+
title: User with Title
16+
description: A user reference with additional metadata

0 commit comments

Comments
 (0)