Skip to content
Draft
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
24 changes: 24 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Release type: patch

This release fixes an issue where `schema-codegen` generated nullable input
fields without default values, causing `TypeError` when instantiating inputs
with empty `{}` or with only required fields.

Nullable input fields are now generated using `strawberry.Maybe[T | None]`,
which allows them to be omitted when constructing the input type.

Before:

```python
@strawberry.input
class HealthResultInput:
some_number: int | None # Required - causes TypeError with {}
```

After:

```python
@strawberry.input
class HealthResultInput:
some_number: strawberry.Maybe[int | None] # Optional - works with {}
```
49 changes: 44 additions & 5 deletions strawberry/schema_codegen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,23 @@ def _is_federation_link_directive(directive: ConstDirectiveNode) -> bool:
).startswith("https://specs.apollo.dev/federation")


def _is_nullable(field_type: TypeNode) -> bool:
"""Check if a field type is nullable (not wrapped in NonNullTypeNode)."""
return not isinstance(field_type, NonNullTypeNode)


def _get_field_type(
field_type: TypeNode, was_non_nullable: bool = False
field_type: TypeNode,
was_non_nullable: bool = False,
*,
wrap_in_maybe: bool = False,
) -> cst.BaseExpression:
expr: cst.BaseExpression | None

if isinstance(field_type, NonNullTypeNode):
return _get_field_type(field_type.type, was_non_nullable=True)
return _get_field_type(
field_type.type, was_non_nullable=True, wrap_in_maybe=wrap_in_maybe
)
if isinstance(field_type, ListTypeNode):
expr = cst.Subscript(
value=cst.Name("list"),
Expand All @@ -138,12 +148,29 @@ def _get_field_type(
if was_non_nullable:
return expr

return cst.BinaryOperation(
# For nullable types, add | None
nullable_expr = cst.BinaryOperation(
left=expr,
operator=cst.BitOr(),
right=cst.Name("None"),
)

# For input fields, wrap nullable types in strawberry.Maybe[...]
if wrap_in_maybe:
return cst.Subscript(
value=cst.Attribute(
value=cst.Name("strawberry"),
attr=cst.Name("Maybe"),
),
slice=[
cst.SubscriptElement(
cst.Index(value=nullable_expr),
)
],
)

return nullable_expr


def _sanitize_argument(value: ArgumentValue) -> cst.SimpleString | cst.Name | cst.List:
if isinstance(value, bool):
Expand Down Expand Up @@ -230,6 +257,8 @@ def _get_field(
field: FieldDefinitionNode | InputValueDefinitionNode,
is_apollo_federation: bool,
imports: set[Import],
*,
is_input_field: bool = False,
) -> cst.SimpleStatementLine:
name = to_snake_case(field.name.value)
alias: str | None = None
Expand All @@ -238,12 +267,15 @@ def _get_field(
name = f"{name}_"
alias = field.name.value

# For input types, wrap nullable fields in strawberry.Maybe[...]
wrap_in_maybe = is_input_field and _is_nullable(field.type)

return cst.SimpleStatementLine(
body=[
cst.AnnAssign(
target=cst.Name(name),
annotation=cst.Annotation(
_get_field_type(field.type),
_get_field_type(field.type, wrap_in_maybe=wrap_in_maybe),
),
value=_get_field_value(
field,
Expand Down Expand Up @@ -440,11 +472,18 @@ def _get_class_definition(
else []
)

is_input_type = isinstance(definition, InputObjectTypeDefinitionNode)

class_definition = cst.ClassDef(
name=cst.Name(definition.name.value),
body=cst.IndentedBlock(
body=[
_get_field(field, is_apollo_federation, imports)
_get_field(
field,
is_apollo_federation,
imports,
is_input_field=is_input_type,
)
for field in definition.fields
]
),
Expand Down
125 changes: 120 additions & 5 deletions tests/schema_codegen/test_input_types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import textwrap

import strawberry
from strawberry.schema_codegen import codegen


Expand Down Expand Up @@ -40,12 +41,126 @@ class Example:
h: list[bool]
i: list[str]
j: list[strawberry.ID]
k: list[int | None] | None
l: list[float | None] | None
m: list[bool | None] | None
n: list[str | None] | None
o: list[strawberry.ID | None] | None
k: strawberry.Maybe[list[int | None] | None]
l: strawberry.Maybe[list[float | None] | None]
m: strawberry.Maybe[list[bool | None] | None]
n: strawberry.Maybe[list[str | None] | None]
o: strawberry.Maybe[list[strawberry.ID | None] | None]
"""
).strip()

assert codegen(schema).strip() == expected


def test_nullable_input_fields_use_maybe():
"""Nullable input fields should use strawberry.Maybe so they can be omitted."""
schema = """
input HealthResultInput {
someNumber: Int
}
"""

generated_code = codegen(schema)

expected = textwrap.dedent(
"""
import strawberry

@strawberry.input
class HealthResultInput:
some_number: strawberry.Maybe[int | None]
"""
).strip()

assert generated_code.strip() == expected


def test_nullable_input_fields_can_be_omitted():
"""Generated input types with nullable fields can be instantiated without them."""
schema = """
input HealthResultInput {
someNumber: Int
}
"""

generated_code = codegen(schema)

namespace = {}
exec(generated_code, namespace) # noqa: S102

HealthResultInput = namespace["HealthResultInput"]

# Nullable fields should be optional - can be omitted
instance = HealthResultInput()
assert instance.some_number is None


def test_mixed_required_and_nullable_input_fields():
"""Input types with both required and nullable fields work correctly."""
schema = """
input MultiFieldInput {
requiredField: String!
optionalInt: Int
optionalString: String
}
"""

generated_code = codegen(schema)

namespace = {}
exec(generated_code, namespace) # noqa: S102

MultiFieldInput = namespace["MultiFieldInput"]

# Should be able to provide only the required field
instance = MultiFieldInput(required_field="test")

assert instance.required_field == "test"
assert instance.optional_int is None
assert instance.optional_string is None


def test_maybe_fields_support_some_values():
"""Maybe fields work correctly when a value is provided via Some."""
schema = """
input HealthResultInput {
someNumber: Int
}
"""

generated_code = codegen(schema)

namespace = {}
exec(generated_code, namespace) # noqa: S102

HealthResultInput = namespace["HealthResultInput"]

# Provide a value - it should be wrapped in Some
instance = HealthResultInput(some_number=strawberry.Some(42))

assert instance.some_number is not None
assert instance.some_number.value == 42


def test_maybe_fields_distinguish_absent_from_null():
"""Maybe fields can distinguish between absent and explicit null."""
schema = """
input HealthResultInput {
someNumber: Int
}
"""

generated_code = codegen(schema)

namespace = {}
exec(generated_code, namespace) # noqa: S102

HealthResultInput = namespace["HealthResultInput"]

absent_instance = HealthResultInput()
assert absent_instance.some_number is None

# Explicit null is represented as Some(None)
null_instance = HealthResultInput(some_number=strawberry.Some(None))
assert null_instance.some_number is not None
assert null_instance.some_number.value is None
Loading