diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..7f0df21a1b --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,35 @@ +--- +release type: patch +social_messages: + x: >- + Strawberry {version} is out! This release fixes schema codegen silently + renaming fields whose GraphQL names don't survive camel-casing. 🍓 + https://strawberry.rocks/release/{version} + linkedin: >- + Strawberry {version} is out. This release fixes schema codegen so that + GraphQL field names such as `some_field` or `allowCustomExportURL` keep + their original names in the generated schema instead of being silently + renamed. +--- + +This release fixes schema codegen silently renaming fields whose GraphQL names +are not reproduced by camel-casing. + +Strawberry derives the GraphQL name of a field by camel-casing its Python name, +so generating `some_field` from a GraphQL field named `some_field` produced a +schema exposing `someField` instead. The same happened to names containing +acronyms, such as `allowCustomExportURL`, which came back as +`allowCustomExportUrl`. + +Codegen now adds an explicit alias whenever camel-casing would not give the +original name back: + +```python +@strawberry.type +class Example: + some_field: int | None = strawberry.field(name="some_field") + allow_custom_export_url: bool = strawberry.field(name="allowCustomExportURL") +``` + +Fields whose GraphQL names convert to the same Python name (for example +`someField` and `some_field`) are also no longer silently dropped. diff --git a/strawberry/schema_codegen/__init__.py b/strawberry/schema_codegen/__init__.py index 1921671b05..7dc26f5e3c 100644 --- a/strawberry/schema_codegen/__init__.py +++ b/strawberry/schema_codegen/__init__.py @@ -39,7 +39,7 @@ NullValueNode, ) -from strawberry.utils.str_converters import to_snake_case +from strawberry.utils.str_converters import to_camel_case, to_snake_case if TYPE_CHECKING: from graphql.language.ast import ConstDirectiveNode @@ -332,19 +332,41 @@ def _get_field_value( return None +def _make_unique_name(name: str, used_names: set[str]) -> str: + """Return a variant of *name* not in *used_names*, and reserve it. + + Two GraphQL names can convert to the same Python name (e.g. `someField` and + `some_field`); appending underscores stops the later one from silently + overwriting the earlier one. + """ + while name in used_names: + name += "_" + + used_names.add(name) + + return name + + def _get_field( field: FieldDefinitionNode | InputValueDefinitionNode, is_apollo_federation: bool, imports: set[Import], *, is_input_field: bool = False, + used_names: set[str], ) -> cst.SimpleStatementLine: - name = to_snake_case(field.name.value) - alias: str | None = None + graphql_name = field.name.value + name = to_snake_case(graphql_name) if keyword.iskeyword(name): name = f"{name}_" - alias = field.name.value + + name = _make_unique_name(name, used_names) + + # Strawberry derives the GraphQL name by camel-casing the Python name, so an + # explicit alias is needed whenever that would not give the original name + # back, e.g. for `some_field`, `URL` or names aliased above. + alias = graphql_name if to_camel_case(name) != graphql_name else None # For input types, wrap nullable fields in strawberry.Maybe[...] wrap_in_maybe = is_input_field and _is_nullable(field.type) @@ -571,6 +593,7 @@ def _get_class_definition( is_input_type = isinstance(definition, InputObjectTypeDefinitionNode) + used_names: set[str] = set() class_definition = cst.ClassDef( name=cst.Name(definition.name.value), body=cst.IndentedBlock( @@ -580,6 +603,7 @@ def _get_class_definition( is_apollo_federation, imports, is_input_field=is_input_type, + used_names=used_names, ) for field in definition.fields ] diff --git a/tests/schema_codegen/test_names.py b/tests/schema_codegen/test_names.py index fded87d1f7..e0716668e8 100644 --- a/tests/schema_codegen/test_names.py +++ b/tests/schema_codegen/test_names.py @@ -48,8 +48,58 @@ def test_converts_names_to_snake_case(): @strawberry.type class Example: some_field: str - allow_custom_export_url: bool - allow_insecure_tls: bool + allow_custom_export_url: bool = strawberry.field(name="allowCustomExportURL") + allow_insecure_tls: bool = strawberry.field(name="allowInsecureTLS") + """ + ).strip() + + assert codegen(schema).strip() == expected + + +def test_keeps_graphql_names_that_do_not_survive_camel_casing(): + # Strawberry camel-cases Python names to get the GraphQL name, so names that + # are not reproduced by that conversion need an explicit alias. + schema = """ + type Example { + some_field: Int + URL: String + } + """ + + expected = textwrap.dedent( + """ + from __future__ import annotations + import strawberry + + @strawberry.type + class Example: + some_field: int | None = strawberry.field(name="some_field") + url: str | None = strawberry.field(name="URL") + """ + ).strip() + + assert codegen(schema).strip() == expected + + +def test_handles_names_converting_to_the_same_python_name(): + # `someField` and `some_field` both convert to `some_field`; the second one + # must not overwrite the first. + schema = """ + type Example { + someField: String + some_field: Int + } + """ + + expected = textwrap.dedent( + """ + from __future__ import annotations + import strawberry + + @strawberry.type + class Example: + some_field: str | None + some_field_: int | None = strawberry.field(name="some_field") """ ).strip()