Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions src/datamodel_code_generator/parser/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,27 @@ def has_ref_with_schema_keywords(self) -> bool:
schema_affecting_fields |= {"extras"}
return bool(schema_affecting_fields)

@cached_property
def is_ref_with_nullable_only(self) -> bool:
"""Check if schema has $ref with only nullable: true (no other schema-affecting keywords).

This is used to avoid creating duplicate models when a $ref is combined
with nullable: true. In such cases, the reference should be used directly
with Optional type annotation instead of merging schemas.
"""
if not self.ref or self.nullable is not True:
return False
other_fields = get_fields_set(self) - {"ref", "nullable"} - self.__metadata_only_fields__ - {"extras"}
if other_fields:
return False
if self.extras:
schema_affecting_extras = {
k for k in self.extras if k not in self.__metadata_only_fields__ and not k.startswith("x-")
}
if schema_affecting_extras:
return False
return True


@lru_cache
def get_ref_type(ref: str) -> JSONReference:
Expand Down Expand Up @@ -1805,7 +1826,7 @@ def _handle_allof_root_model_with_constraints( # noqa: PLR0911, PLR0912
if ref_value is None:
return None # pragma: no cover

if ref_item.has_ref_with_schema_keywords:
if ref_item.has_ref_with_schema_keywords and not ref_item.is_ref_with_nullable_only:
ref_schema = self._merge_ref_with_schema(ref_item)
else:
ref_schema = self._load_ref_schema_object(ref_value)
Expand Down Expand Up @@ -1896,7 +1917,7 @@ def parse_combined_schema(
refs = []
for index, target_attribute in enumerate(getattr(obj, target_attribute_name, [])):
if target_attribute.ref:
if target_attribute.has_ref_with_schema_keywords:
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(
Expand Down Expand Up @@ -2700,6 +2721,11 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914
item,
root_type_path,
)
if item.is_ref_with_nullable_only and item.ref:
ref_data_type = self.get_ref_data_type(item.ref)
if self.strict_nullable:
return self.data_type(data_types=[ref_data_type], is_optional=True)
return ref_data_type
if item.has_ref_with_schema_keywords:
item = self._merge_ref_with_schema(item)
if item.ref:
Expand Down Expand Up @@ -3523,7 +3549,7 @@ def parse_obj( # noqa: PLR0912
path: list[str],
) -> None:
"""Parse a JsonSchemaObject by dispatching to appropriate parse methods."""
if obj.has_ref_with_schema_keywords:
if obj.has_ref_with_schema_keywords and not obj.is_ref_with_nullable_only:
obj = self._merge_ref_with_schema(obj)

if obj.is_array:
Expand Down
17 changes: 17 additions & 0 deletions tests/data/expected/main/jsonschema/ref_nullable_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# generated by datamodel-codegen:
# filename: ref_nullable_only.yaml
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class User(BaseModel):
name: str


class Model(BaseModel):
user_a: User | None = None
user_b: User | None = None
user_c: User | None = None
17 changes: 17 additions & 0 deletions tests/data/expected/main/jsonschema/ref_nullable_only_strict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# generated by datamodel-codegen:
# filename: ref_nullable_only.yaml
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class User(BaseModel):
name: str


class Model(BaseModel):
user_a: User | None = None
user_b: User | None = None
user_c: User | None = None
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# generated by datamodel-codegen:
# filename: ref_nullable_with_constraint.yaml
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel, RootModel, constr


class Model(BaseModel):
constrained_string: constr(min_length=5) | None = None


class StringType(RootModel[str]):
root: str
Comment thread
koxudaxi marked this conversation as resolved.
19 changes: 19 additions & 0 deletions tests/data/expected/main/jsonschema/ref_nullable_with_extra.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# generated by datamodel-codegen:
# filename: ref_nullable_with_extra.yaml
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class UserWithExtra(BaseModel):
name: str | None = None


class Model(BaseModel):
user_with_extra: UserWithExtra | None = None


class User(BaseModel):
name: str | None = None
19 changes: 19 additions & 0 deletions tests/data/expected/main/jsonschema/ref_nullable_with_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# generated by datamodel-codegen:
# filename: ref_nullable_with_metadata.yaml
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel, Field


class User(BaseModel):
name: str


class Model(BaseModel):
user_with_title: User | None = Field(
None,
description='A user reference with additional metadata',
title='User with Title',
)
19 changes: 19 additions & 0 deletions tests/data/jsonschema/ref_nullable_only.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
$schema: "http://json-schema.org/draft-07/schema#"
definitions:
User:
type: object
properties:
name:
type: string
required:
- name
type: object
properties:
user_a:
$ref: "#/definitions/User"
nullable: true
user_b:
$ref: "#/definitions/User"
nullable: true
user_c:
$ref: "#/definitions/User"
10 changes: 10 additions & 0 deletions tests/data/jsonschema/ref_nullable_with_constraint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
$schema: "http://json-schema.org/draft-07/schema#"
definitions:
StringType:
type: string
type: object
properties:
constrained_string:
$ref: "#/definitions/StringType"
nullable: true
minLength: 5
16 changes: 16 additions & 0 deletions tests/data/jsonschema/ref_nullable_with_extra.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
$schema: "http://json-schema.org/draft-07/schema#"
definitions:
User:
type: object
properties:
name:
type: string
type: object
properties:
user_with_extra:
$ref: "#/definitions/User"
nullable: true
if:
properties:
name:
const: admin
16 changes: 16 additions & 0 deletions tests/data/jsonschema/ref_nullable_with_metadata.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
$schema: "http://json-schema.org/draft-07/schema#"
definitions:
User:
type: object
properties:
name:
type: string
required:
- name
type: object
properties:
user_with_title:
$ref: "#/definitions/User"
nullable: true
title: User with Title
description: A user reference with additional metadata
85 changes: 85 additions & 0 deletions tests/main/jsonschema/test_main_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7405,3 +7405,88 @@ def test_x_python_type_union_anyof(output_file: Path) -> None:
input_file_type=None,
assert_func=assert_file_content,
)


def test_ref_nullable_only_no_duplicate_model(output_file: Path) -> None:
"""Test that $ref + nullable: true does not create duplicate models.

When a property has $ref with nullable: true, it should use the referenced
type directly (e.g., User) with Optional type annotation, not create a new
model with a derived name (e.g., UserA, UserB).

See: https://github.com/koxudaxi/datamodel-code-generator/discussions/1792
"""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_only.yaml",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="ref_nullable_only.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel"],
)


def test_ref_nullable_only_strict_nullable(output_file: Path) -> None:
"""Test $ref + nullable: true with --strict-nullable flag.

The output should be the same as without strict-nullable for this case,
using the referenced type directly with Optional annotation.
"""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_only.yaml",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="ref_nullable_only_strict.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--strict-nullable"],
)


def test_ref_nullable_with_metadata_no_duplicate_model(output_file: Path) -> None:
"""Test $ref + nullable: true + metadata (title/description) does not merge.

When a property has $ref with nullable: true and metadata-only fields like
title or description, it should not create a new model. The metadata should
be applied to the field, and the reference should be used directly.
"""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_with_metadata.yaml",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="ref_nullable_with_metadata.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--strict-nullable"],
)


def test_ref_nullable_with_constraint_creates_model(output_file: Path) -> None:
"""Test $ref + nullable: true + constraint DOES create a merged model.

When a property has $ref with nullable: true AND a schema constraint like
minLength, it should merge the schemas and create a new model because the
constraint affects the schema structure.
"""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_with_constraint.yaml",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="ref_nullable_with_constraint.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--strict-nullable"],
)


def test_ref_nullable_with_extra_creates_model(output_file: Path) -> None:
"""Test $ref + nullable: true + schema-affecting extras DOES create a merged model.

When a property has $ref with nullable: true AND schema-affecting extras like
'if', 'then', 'else', it should merge the schemas and create a new model.
"""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_with_extra.yaml",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="ref_nullable_with_extra.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--strict-nullable"],
)
Loading