diff --git a/docs/cli-reference/graphql-only-options.md b/docs/cli-reference/graphql-only-options.md new file mode 100644 index 000000000..e96e0509a --- /dev/null +++ b/docs/cli-reference/graphql-only-options.md @@ -0,0 +1,92 @@ +# 📋 GraphQL-only Options + +## 📋 Options + +| Option | Description | +|--------|-------------| +| [`--graphql-no-typename`](#graphql-no-typename) | Exclude __typename field from generated GraphQL models. | + +--- + +## `--graphql-no-typename` {#graphql-no-typename} + +Exclude __typename field from generated GraphQL models. + +The `--graphql-no-typename` flag prevents the generator from adding the +`typename__` field (aliased to `__typename`) to generated models. This is +useful when using generated models for GraphQL mutations, as servers typically +don't expect this field in input data. + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --graphql-no-typename # (1)! + ``` + + 1. :material-arrow-left: `--graphql-no-typename` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```graphql + type Book { + id: ID! + title: String + } + + interface Node { + id: ID! + } + + input BookInput { + title: String! + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: no-typename.graphql + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from typing import TypeAlias + + from pydantic import BaseModel + + 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 Node(BaseModel): + id: ID + + + class Book(BaseModel): + id: ID + title: String | None = None + + + class BookInput(BaseModel): + title: String + ``` + +--- + diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 66b121494..5a1254285 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -14,6 +14,7 @@ This documentation is auto-generated from test cases. | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | | 🎨 [Template Customization](template-customization.md) | 18 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | +| 📋 [GraphQL-only Options](graphql-only-options.md) | 1 | | | ⚙️ [General Options](general-options.md) | 15 | Utilities and meta options | | 📝 [Utility Options](utility-options.md) | 6 | Help, version, debug options | @@ -94,6 +95,7 @@ This documentation is auto-generated from test cases. - [`--generate-cli-command`](general-options.md#generate-cli-command) - [`--generate-prompt`](utility-options.md#generate-prompt) - [`--generate-pyproject-config`](general-options.md#generate-pyproject-config) +- [`--graphql-no-typename`](graphql-only-options.md#graphql-no-typename) ### H {#h} diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index 1cf654c8c..03e4e869d 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -162,6 +162,12 @@ datamodel-codegen [OPTIONS] | [`--use-status-code-in-response-name`](openapi-only-options.md#use-status-code-in-response-name) | Include HTTP status code in response model names. | | [`--validation`](openapi-only-options.md#validation) | Enable validation constraints (deprecated, use --field-constraints). | +### 📋 GraphQL-only Options + +| Option | Description | +|--------|-------------| +| [`--graphql-no-typename`](graphql-only-options.md#graphql-no-typename) | Exclude __typename field from generated GraphQL models. | + ### ⚙️ General Options | Option | Description | @@ -251,6 +257,7 @@ All options sorted alphabetically: - [`--generate-cli-command`](general-options.md#generate-cli-command) - Generate CLI command from pyproject.toml configuration. - [`--generate-prompt`](utility-options.md#generate-prompt) - Generate a prompt for consulting LLMs about CLI options - [`--generate-pyproject-config`](general-options.md#generate-pyproject-config) - Generate pyproject.toml configuration from CLI arguments. +- [`--graphql-no-typename`](graphql-only-options.md#graphql-no-typename) - Exclude __typename field from generated GraphQL models. - [`--help`](utility-options.md#help) - Show help message and exit - [`--http-headers`](general-options.md#http-headers) - Fetch schema from URL with custom HTTP headers. - [`--http-ignore-tls`](general-options.md#http-ignore-tls) - Disable TLS certificate verification for HTTPS requests. diff --git a/docs/graphql.md b/docs/graphql.md index 8f6a460e2..2e85e9002 100644 --- a/docs/graphql.md +++ b/docs/graphql.md @@ -207,6 +207,39 @@ class A(BaseModel): --- +## 🚫 Excluding __typename Field + +When using generated models for GraphQL mutations, the `__typename` field may cause issues +as GraphQL servers typically don't expect this field in input data. + +Use the `--graphql-no-typename` option to exclude this field: + +```bash +datamodel-codegen --input schema.graphql --input-file-type graphql --output model.py --graphql-no-typename +``` + +**Before (default):** +```python +class Book(BaseModel): + id: ID + title: String | None = None + typename__: Literal['Book'] | None = Field('Book', alias='__typename') +``` + +**After (with --graphql-no-typename):** +```python +class Book(BaseModel): + id: ID + title: String | None = None +``` + +!!! warning "Union Type Discrimination" + If your schema uses GraphQL union types and you rely on `__typename` for type + discrimination during deserialization, excluding this field may break that functionality. + Consider using this option only for input types or schemas without unions. + +--- + ## 📖 See Also - 🖥️ [CLI Reference](cli-reference/index.md) - Complete CLI options reference diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 8689f6a9c..260320fd8 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -551,6 +551,7 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) -> openapi_scopes: Optional[list[OpenAPIScope]] = [OpenAPIScope.Schemas] # noqa: UP045 include_path_parameters: bool = False openapi_include_paths: Optional[list[str]] = None # noqa: UP045 + graphql_no_typename: bool = False wrap_string_literal: Optional[bool] = None # noqa: UP045 use_title_as_name: bool = False use_operation_id_as_name: bool = False @@ -929,6 +930,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 openapi_scopes=config.openapi_scopes, include_path_parameters=config.include_path_parameters, openapi_include_paths=config.openapi_include_paths, + graphql_no_typename=config.graphql_no_typename, wrap_string_literal=config.wrap_string_literal, use_title_as_name=config.use_title_as_name, use_operation_id_as_name=config.use_operation_id_as_name, @@ -1000,6 +1002,9 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, PLR0914, PLR0915 """Execute datamodel code generation from command-line arguments.""" + vars(namespace).clear() + namespace.no_color = False + if "_ARGCOMPLETE" in os.environ: # pragma: no cover import argcomplete # noqa: PLC0415 diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index b8504e654..9cc6e20af 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -104,6 +104,7 @@ class GenerateConfigDict(TypedDict): include_path_parameters: NotRequired[bool] openapi_include_paths: NotRequired[list[str] | None] graphql_scopes: NotRequired[list[GraphQLScope] | None] + graphql_no_typename: NotRequired[bool] wrap_string_literal: NotRequired[bool | None] use_title_as_name: NotRequired[bool] use_operation_id_as_name: NotRequired[bool] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 4db2ace02..062b0cb83 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -152,6 +152,7 @@ class ParserConfigDict(TypedDict): class GraphQLParserConfigDict(ParserConfigDict): data_model_scalar_type: NotRequired[type[DataModel]] data_model_union_type: NotRequired[type[DataModel]] + graphql_no_typename: NotRequired[bool] class JSONSchemaParserConfigDict(ParserConfigDict): diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index ee52e152a..f598b8d32 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -103,6 +103,7 @@ def start_section(self, heading: str | None) -> None: extra_fields_model_options = model_options.add_mutually_exclusive_group() template_options = arg_parser.add_argument_group("Template customization") openapi_options = arg_parser.add_argument_group("OpenAPI-only options") +graphql_options = arg_parser.add_argument_group("GraphQL-only options") general_options = arg_parser.add_argument_group("General options") # ====================================================================================== @@ -958,6 +959,17 @@ def start_section(self, heading: str | None) -> None: default=None, ) +# ====================================================================================== +# Options specific to GraphQL input schemas +# ====================================================================================== +graphql_options.add_argument( + "--graphql-no-typename", + help="Exclude __typename field from generated GraphQL models. " + "Useful when using generated models for GraphQL mutations.", + action="store_true", + default=None, +) + # ====================================================================================== # General options # ====================================================================================== diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index 611bb7672..06b7712a9 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -24,6 +24,7 @@ class OptionCategory(str, Enum): MODEL = "Model Customization" TEMPLATE = "Template Customization" OPENAPI = "OpenAPI-only Options" + GRAPHQL = "GraphQL-only Options" GENERAL = "General Options" @@ -244,6 +245,10 @@ class CLIOptionMeta: deprecated_message="Use --field-constraints instead", ), # ========================================================================== + # GraphQL-only Options + # ========================================================================== + "--graphql-no-typename": CLIOptionMeta(name="--graphql-no-typename", category=OptionCategory.GRAPHQL), + # ========================================================================== # General Options # ========================================================================== "--check": CLIOptionMeta(name="--check", category=OptionCategory.GENERAL), diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 4536e1e0f..c0199f674 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -141,6 +141,7 @@ class Config: include_path_parameters: bool = False openapi_include_paths: list[str] | None = None graphql_scopes: list[GraphQLScope] | None = None + graphql_no_typename: bool = False wrap_string_literal: bool | None = None use_title_as_name: bool = False use_operation_id_as_name: bool = False @@ -336,6 +337,7 @@ class GraphQLParserConfig(ParserConfig): data_model_scalar_type: type[DataModel] = DataTypeScalar data_model_union_type: type[DataModel] = DataTypeUnion + graphql_no_typename: bool = False class JSONSchemaParserConfig(ParserConfig): diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index 06d974246..eaae0185c 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -425,7 +425,8 @@ def parse_object_like( data_model_field_type = self.parse_field(field_name_, alias, field) fields.append(data_model_field_type) - fields.append(self._typename_field(obj.name)) + if not self.config.graphql_no_typename: + fields.append(self._typename_field(obj.name)) base_classes = [] if hasattr(obj, "interfaces"): # pragma: no cover diff --git a/src/datamodel_code_generator/prompt_data.py b/src/datamodel_code_generator/prompt_data.py index 0a6224c51..61b67be36 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -57,6 +57,7 @@ "--frozen-dataclasses": "Generate frozen dataclasses with optional keyword-only fields.", "--generate-cli-command": "Generate CLI command from pyproject.toml configuration.", "--generate-pyproject-config": "Generate pyproject.toml configuration from CLI arguments.", + "--graphql-no-typename": "Exclude __typename field from generated GraphQL models.", "--http-headers": "Fetch schema from URL with custom HTTP headers.", "--http-ignore-tls": "Disable TLS certificate verification for HTTPS requests.", "--http-query-parameters": "Add query parameters to HTTP requests for remote schemas.", diff --git a/tests/data/expected/main/graphql/no_typename.py b/tests/data/expected/main/graphql/no_typename.py new file mode 100644 index 000000000..f35efb8a9 --- /dev/null +++ b/tests/data/expected/main/graphql/no_typename.py @@ -0,0 +1,39 @@ +# generated by datamodel-codegen: +# filename: no-typename.graphql +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import TypeAlias + +from pydantic import BaseModel + +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 Node(BaseModel): + id: ID + + +class Book(BaseModel): + id: ID + title: String | None = None + + +class BookInput(BaseModel): + title: String diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 0d394d2f1..5e288ef79 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -177,6 +177,7 @@ class GenerateConfig(TypedDict): include_path_parameters: NotRequired[bool] openapi_include_paths: NotRequired[list[str] | None] graphql_scopes: NotRequired[list[GraphQLScope] | None] + graphql_no_typename: NotRequired[bool] wrap_string_literal: NotRequired[bool | None] use_title_as_name: NotRequired[bool] use_operation_id_as_name: NotRequired[bool] diff --git a/tests/data/expected/parser/graphql/no_typename.py b/tests/data/expected/parser/graphql/no_typename.py new file mode 100644 index 000000000..f35efb8a9 --- /dev/null +++ b/tests/data/expected/parser/graphql/no_typename.py @@ -0,0 +1,39 @@ +# generated by datamodel-codegen: +# filename: no-typename.graphql +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import TypeAlias + +from pydantic import BaseModel + +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 Node(BaseModel): + id: ID + + +class Book(BaseModel): + id: ID + title: String | None = None + + +class BookInput(BaseModel): + title: String diff --git a/tests/data/graphql/no-typename.graphql b/tests/data/graphql/no-typename.graphql new file mode 100644 index 000000000..913113e48 --- /dev/null +++ b/tests/data/graphql/no-typename.graphql @@ -0,0 +1,12 @@ +type Book { + id: ID! + title: String +} + +interface Node { + id: ID! +} + +input BookInput { + title: String! +} diff --git a/tests/main/graphql/test_main_graphql.py b/tests/main/graphql/test_main_graphql.py index 253f4ceb7..47a02ce87 100644 --- a/tests/main/graphql/test_main_graphql.py +++ b/tests/main/graphql/test_main_graphql.py @@ -783,3 +783,27 @@ def test_main_graphql_split_graphql_schemas(output_file: Path) -> None: assert_func=assert_file_content, expected_file="split_graphql_schemas.py", ) + + +@pytest.mark.cli_doc( + options=["--graphql-no-typename"], + option_description="""Exclude __typename field from generated GraphQL models. + +The `--graphql-no-typename` flag prevents the generator from adding the +`typename__` field (aliased to `__typename`) to generated models. This is +useful when using generated models for GraphQL mutations, as servers typically +don't expect this field in input data.""", + input_schema="graphql/no-typename.graphql", + cli_args=["--graphql-no-typename"], + golden_output="graphql/no_typename.py", +) +def test_main_graphql_no_typename(output_file: Path) -> None: + """Test that --graphql-no-typename excludes typename__ field from all types.""" + run_main_and_assert( + input_path=GRAPHQL_DATA_PATH / "no-typename.graphql", + output_path=output_file, + input_file_type="graphql", + assert_func=assert_file_content, + expected_file="no_typename.py", + extra_args=["--graphql-no-typename"], + ) diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index a93257d4c..b6060c54f 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -123,6 +123,7 @@ def _baseline_generate( include_path_parameters: bool = False, openapi_include_paths: list[str] | None = None, graphql_scopes: list[GraphQLScope] | None = None, + graphql_no_typename: bool = False, wrap_string_literal: bool | None = None, use_title_as_name: bool = False, use_operation_id_as_name: bool = False, diff --git a/tests/parser/test_graphql.py b/tests/parser/test_graphql.py index 5101d88e3..5c236bd45 100644 --- a/tests/parser/test_graphql.py +++ b/tests/parser/test_graphql.py @@ -104,3 +104,32 @@ def test_create_data_model_class_decorators() -> None: result = parser._create_data_model(reference=reference, fields=[]) assert isinstance(result, DataClass) assert result.decorators == ["@dataclass_json"] + + +def test_graphql_no_typename(output_file: Path) -> None: + """Test that --graphql-no-typename excludes typename__ field from all types.""" + run_main_and_assert( + input_path=GRAPHQL_DATA_PATH / "no-typename.graphql", + output_path=output_file, + input_file_type="graphql", + assert_func=assert_file_content, + expected_file="no_typename.py", + extra_args=["--graphql-no-typename"], + ) + + +def test_graphql_typename_included_by_default(output_file: Path) -> None: + """Regression test: typename__ field is included by default.""" + + def assert_typename_present(output_path: Path, _: str | None, **_kwargs: object) -> None: + content = output_path.read_text(encoding="utf-8") + assert "typename__" in content, "typename__ field should be present by default" + assert "__typename" in content, "__typename alias should be present by default" + + run_main_and_assert( + input_path=GRAPHQL_DATA_PATH / "no-typename.graphql", + output_path=output_file, + input_file_type="graphql", + assert_func=assert_typename_present, + expected_file=None, + )