diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 00ab39444..eb7df3d5b 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -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: @@ -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) @@ -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( @@ -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: @@ -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: diff --git a/tests/data/expected/main/jsonschema/ref_nullable_only.py b/tests/data/expected/main/jsonschema/ref_nullable_only.py new file mode 100644 index 000000000..77fd48805 --- /dev/null +++ b/tests/data/expected/main/jsonschema/ref_nullable_only.py @@ -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 diff --git a/tests/data/expected/main/jsonschema/ref_nullable_only_strict.py b/tests/data/expected/main/jsonschema/ref_nullable_only_strict.py new file mode 100644 index 000000000..77fd48805 --- /dev/null +++ b/tests/data/expected/main/jsonschema/ref_nullable_only_strict.py @@ -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 diff --git a/tests/data/expected/main/jsonschema/ref_nullable_with_constraint.py b/tests/data/expected/main/jsonschema/ref_nullable_with_constraint.py new file mode 100644 index 000000000..114dd6489 --- /dev/null +++ b/tests/data/expected/main/jsonschema/ref_nullable_with_constraint.py @@ -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 diff --git a/tests/data/expected/main/jsonschema/ref_nullable_with_extra.py b/tests/data/expected/main/jsonschema/ref_nullable_with_extra.py new file mode 100644 index 000000000..dd829a90c --- /dev/null +++ b/tests/data/expected/main/jsonschema/ref_nullable_with_extra.py @@ -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 diff --git a/tests/data/expected/main/jsonschema/ref_nullable_with_metadata.py b/tests/data/expected/main/jsonschema/ref_nullable_with_metadata.py new file mode 100644 index 000000000..7b0fdfc74 --- /dev/null +++ b/tests/data/expected/main/jsonschema/ref_nullable_with_metadata.py @@ -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', + ) diff --git a/tests/data/jsonschema/ref_nullable_only.yaml b/tests/data/jsonschema/ref_nullable_only.yaml new file mode 100644 index 000000000..b7de26b66 --- /dev/null +++ b/tests/data/jsonschema/ref_nullable_only.yaml @@ -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" diff --git a/tests/data/jsonschema/ref_nullable_with_constraint.yaml b/tests/data/jsonschema/ref_nullable_with_constraint.yaml new file mode 100644 index 000000000..78f68828b --- /dev/null +++ b/tests/data/jsonschema/ref_nullable_with_constraint.yaml @@ -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 diff --git a/tests/data/jsonschema/ref_nullable_with_extra.yaml b/tests/data/jsonschema/ref_nullable_with_extra.yaml new file mode 100644 index 000000000..4ef7e5790 --- /dev/null +++ b/tests/data/jsonschema/ref_nullable_with_extra.yaml @@ -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 diff --git a/tests/data/jsonschema/ref_nullable_with_metadata.yaml b/tests/data/jsonschema/ref_nullable_with_metadata.yaml new file mode 100644 index 000000000..6daa96bfc --- /dev/null +++ b/tests/data/jsonschema/ref_nullable_with_metadata.yaml @@ -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 diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 925f1cc80..78f3dfdff 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -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"], + )