From 0476ab34398c92a57c8bd7a1b0d3d38a50733c54 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sat, 10 Jan 2026 08:20:23 +0000 Subject: [PATCH] Fix empty list default for GraphQL list fields When GraphQL input fields have empty list defaults (e.g., `requiredList: [String!]! = []`), the generated code was incorrectly producing: - `Field(...)` for required lists (ignoring the default) - `Field([])` for nullable lists (using mutable default) This fix ensures all list fields with empty defaults generate `Field(default_factory=list)`, which is the correct way to handle mutable defaults in Pydantic. Changes: - Add early check in `_get_default_as_pydantic_model()` for any list type with empty default - Update `__str__()` to not skip default handling when `required=True` but `has_default=True` - Update field argument generation to not add `...` when there's already a `default_factory` Fixes issue where GraphQL schemas with default empty arrays would generate incorrect Pydantic code. --- .../model/pydantic/base_model.py | 9 ++-- .../main/graphql/empty_list_default.py | 44 ++++++++++++++++++ .../graphql/pydantic_v2_empty_list_default.py | 45 +++++++++++++++++++ tests/data/graphql/empty_list_default.graphql | 18 ++++++++ tests/main/graphql/test_main_graphql.py | 29 ++++++++++++ 5 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 tests/data/expected/main/graphql/empty_list_default.py create mode 100644 tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py create mode 100644 tests/data/graphql/empty_list_default.graphql diff --git a/src/datamodel_code_generator/model/pydantic/base_model.py b/src/datamodel_code_generator/model/pydantic/base_model.py index 6368a9379..baf8e80dd 100644 --- a/src/datamodel_code_generator/model/pydantic/base_model.py +++ b/src/datamodel_code_generator/model/pydantic/base_model.py @@ -126,9 +126,12 @@ def _get_strict_field_constraint_value(self, constraint: str, value: Any) -> Any return value return int(value) - def _get_default_as_pydantic_model(self) -> str | None: + def _get_default_as_pydantic_model(self) -> str | None: # noqa: PLR0911, PLR0912 if isinstance(self.default, WrappedDefault): return f"lambda :{self.default!r}" + # Handle empty list defaults for any list type (including primitive types like String) + if self.data_type.is_list and isinstance(self.default, list) and not self.default: + return STANDARD_LIST for data_type in self.data_type.data_types or (self.data_type,): # TODO: Check nested data_types if data_type.is_dict: @@ -220,7 +223,7 @@ def __str__(self) -> str: # noqa: PLR0912 elif isinstance(discriminator, dict): # pragma: no cover data["discriminator"] = discriminator["propertyName"] - if self.required: + if self.required and not self.has_default: default_factory = None elif self.default is not UNDEFINED and self.default is not None and "default_factory" not in data: default_factory = self._get_default_as_pydantic_model() @@ -249,7 +252,7 @@ def __str__(self) -> str: # noqa: PLR0912 if self.use_annotated: field_arguments = self._process_annotated_field_arguments(field_arguments) - elif self.required: + elif self.required and not default_factory: field_arguments = ["...", *field_arguments] elif not default_factory: default_repr = repr_set_sorted(self.default) if isinstance(self.default, set) else repr(self.default) diff --git a/tests/data/expected/main/graphql/empty_list_default.py b/tests/data/expected/main/graphql/empty_list_default.py new file mode 100644 index 000000000..e9789027f --- /dev/null +++ b/tests/data/expected/main/graphql/empty_list_default.py @@ -0,0 +1,44 @@ +# generated by datamodel-codegen: +# filename: empty_list_default.graphql +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal, TypeAlias + +from pydantic import BaseModel, Field + +Boolean: TypeAlias = bool +""" +The `Boolean` scalar type represents `true` or `false`. +""" + + +ID: TypeAlias = str +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. +""" + + +String: TypeAlias = str +""" +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. +""" + + +class ItemInput(BaseModel): + id: ID + typename__: Literal['ItemInput'] | None = Field('ItemInput', alias='__typename') + + +class TestInput(BaseModel): + nullableList: list[String] | None = Field( + default_factory=list, description='Nullable list with empty default' + ) + requiredItems: list[ItemInput] = Field( + default_factory=list, description='Required list of items with empty default' + ) + requiredList: list[String] = Field( + default_factory=list, description='Required list with empty default' + ) + typename__: Literal['TestInput'] | None = Field('TestInput', alias='__typename') diff --git a/tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py b/tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py new file mode 100644 index 000000000..ea3232924 --- /dev/null +++ b/tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py @@ -0,0 +1,45 @@ +# generated by datamodel-codegen: +# filename: empty_list_default.graphql +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field +from typing_extensions import TypeAliasType + +Boolean = TypeAliasType("Boolean", bool) +""" +The `Boolean` scalar type represents `true` or `false`. +""" + + +ID = TypeAliasType("ID", str) +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. +""" + + +String = TypeAliasType("String", str) +""" +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. +""" + + +class ItemInput(BaseModel): + id: ID + typename__: Literal['ItemInput'] | None = Field('ItemInput', alias='__typename') + + +class TestInput(BaseModel): + nullableList: list[String] | None = Field( + default_factory=list, description='Nullable list with empty default' + ) + requiredItems: list[ItemInput] = Field( + default_factory=list, description='Required list of items with empty default' + ) + requiredList: list[String] = Field( + default_factory=list, description='Required list with empty default' + ) + typename__: Literal['TestInput'] | None = Field('TestInput', alias='__typename') diff --git a/tests/data/graphql/empty_list_default.graphql b/tests/data/graphql/empty_list_default.graphql new file mode 100644 index 000000000..5c59e6b71 --- /dev/null +++ b/tests/data/graphql/empty_list_default.graphql @@ -0,0 +1,18 @@ +type Query { + test: String +} + +input ItemInput { + id: ID! +} + +input TestInput { + "Required list with empty default" + requiredList: [String!]! = [] + + "Nullable list with empty default" + nullableList: [String!] = [] + + "Required list of items with empty default" + requiredItems: [ItemInput!]! = [] +} diff --git a/tests/main/graphql/test_main_graphql.py b/tests/main/graphql/test_main_graphql.py index 14b2ae52a..a0ff0261a 100644 --- a/tests/main/graphql/test_main_graphql.py +++ b/tests/main/graphql/test_main_graphql.py @@ -890,3 +890,32 @@ def test_main_graphql_no_typename(output_file: Path) -> None: expected_file="no_typename.py", extra_args=["--graphql-no-typename"], ) + + +@pytest.mark.parametrize( + ("output_model", "expected_output"), + [ + ( + "pydantic.BaseModel", + "empty_list_default.py", + ), + ( + "pydantic_v2.BaseModel", + "pydantic_v2_empty_list_default.py", + ), + ], +) +def test_main_graphql_empty_list_default(output_model: str, expected_output: str, output_file: Path) -> None: + """Test that empty list defaults in GraphQL input types generate default_factory=list. + + This test verifies that fields like `requiredList: [String!]! = []` correctly + generate `Field(default_factory=list)` instead of `Field(...)` or `Field([])`. + """ + run_main_and_assert( + input_path=GRAPHQL_DATA_PATH / "empty_list_default.graphql", + output_path=output_file, + input_file_type="graphql", + assert_func=assert_file_content, + expected_file=expected_output, + extra_args=["--output-model-type", output_model], + )