diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..79d7caa328 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,12 @@ +Release type: minor + +This release changes the `strawberry.Maybe` type to provide a more consistent and intuitive API for handling optional fields in GraphQL inputs. + +**Breaking Change**: The `Maybe` type definition has been changed from `Union[Some[Union[T, None]], None]` to `Union[Some[T], None]`. This means: + +- `Maybe[str]` now only accepts string values or absent fields (refuses explicit null) +- `Maybe[str | None]` accepts strings, null, or absent fields (maintains previous behavior) + +This provides a cleaner API where `if field is not None` consistently means "field was provided" for all Maybe fields. A codemod is available to automatically migrate your code: `strawberry upgrade maybe-optional` + +See the [breaking changes documentation](https://strawberry.rocks/docs/breaking-changes/0.279.0) for migration details. diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 9a0b946c60..8827009f06 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -4,6 +4,7 @@ title: List of breaking changes and deprecations # List of breaking changes and deprecations +- [Version 0.279.0 - 19 August 2025](./breaking-changes/0.279.0.md) - [Version 0.268.0 - 10 May 2025](./breaking-changes/0.268.0.md) - [Version 0.249.0 - 18 November 2024](./breaking-changes/0.249.0.md) - [Version 0.243.0 - 25 September 2024](./breaking-changes/0.243.0.md) diff --git a/docs/breaking-changes/0.279.0.md b/docs/breaking-changes/0.279.0.md new file mode 100644 index 0000000000..d1a530e8ae --- /dev/null +++ b/docs/breaking-changes/0.279.0.md @@ -0,0 +1,136 @@ +--- +title: 0.279.0 Breaking Changes +slug: breaking-changes/0.279.0 +--- + +# v0.279.0 Breaking Changes + +This release changes the `strawberry.Maybe` type definition to provide a more +consistent and intuitive API for handling optional fields. + +## What Changed + +The `Maybe` type definition has been changed from: + +```python +Maybe: TypeAlias = Union[Some[Union[T, None]], None] +``` + +to: + +```python +Maybe: TypeAlias = Union[Some[T], None] +``` + +## Impact on Your Code + +### Type Annotations + +If you were using `Maybe[T]` and expecting to handle explicit `null` values, you +now need to explicitly declare this with `Maybe[T | None]`: + +```python +# Before (0.278.0 and earlier) +field: strawberry.Maybe[str] # Could handle Some(None) + +# After (0.279.0+) +field: strawberry.Maybe[str] # Only handles Some("value") or None (absent) +field: strawberry.Maybe[str | None] # Handles Some("value"), Some(None), or None +``` + +### Runtime Behavior + +The runtime behavior changes to provide more consistent field checking: + +- `Maybe[str]` now represents "field present with non-null value" or "field + absent" +- `Maybe[str | None]` represents "field present with value", "field present but + null", or "field absent" + +This means `Maybe[str]` can no longer receive explicit `null` values - they will +cause a validation error. + +### Consistent Field Checking + +This change provides a single, consistent way to check if a field was provided, +regardless of whether the field allows null values: + +```python +@strawberry.input +class UpdateUserInput: + # Can be provided with a value or not provided at all + name: strawberry.Maybe[str] + + # Can be provided with a value, provided as null, or not provided at all + phone: strawberry.Maybe[str | None] + + +@strawberry.mutation +def update_user(input: UpdateUserInput) -> User: + # Same checking pattern for both fields + if input.name is not None: # Field was provided + user.name = input.name.value # Type checker knows this is str + + if input.phone is not None: # Field was provided + user.phone = input.phone.value # Type checker knows this is str | None +``` + +The key benefit is that `if field is not None` now consistently means "field was +provided" for all Maybe fields. + +## Migration + +### Automatic Migration + +Strawberry provides a codemod to automatically update your code: + +```bash +strawberry upgrade maybe-optional +``` + +The codemod will automatically convert `Maybe[T]` to `Maybe[T | None]` to +maintain the previous behavior. + +### Manual Migration + +Review your `Maybe` usage and decide whether you need the union type: + +```python +# If you only need "present" vs "absent" (most common case) +field: strawberry.Maybe[str] + +# If you need "present with value", "present but null", and "absent" +field: strawberry.Maybe[str | None] +``` + +## Why This Change? + +After extensive discussion, we decided that changing `Maybe` to `Some[T] | None` +(instead of `Some[T | None] | None`) provides the best developer experience +because: + +1. **Consistent field checking**: You can always use `if field is not None:` to + check if a field was provided, regardless of whether the field allows null + values + +2. **Preserves existing behavior**: You can still get the previous behavior by + using `Maybe[T | None]` when you need to handle explicit nulls + +3. **Better type safety**: `Maybe[str]` now properly indicates that the field + cannot be null, while `Maybe[str | None]` explicitly allows nulls + +4. **Cleaner API**: There's now "one true way" to check if a field was provided, + making the API more intuitive + +The new approach provides both simplicity for common cases and flexibility for +complex scenarios where null handling is needed. + +## Need Help? + +- See the [Maybe documentation](../types/maybe.md) for comprehensive usage + examples +- Check the + [migration guide](../types/maybe.md#migration-from-previous-versions) for + detailed migration instructions +- If you encounter issues, please + [report them on GitHub](https://github.com/strawberry-graphql/strawberry/issues) diff --git a/docs/types/input-types.md b/docs/types/input-types.md index 6c3125ee32..2d63baf656 100644 --- a/docs/types/input-types.md +++ b/docs/types/input-types.md @@ -84,42 +84,10 @@ type Point2D { -When you need to distinguish between a field being set to `null` or its value -and the field being absent, optional arguments would not suffice. In this case -you can use `strawberry.Maybe` like so: - - - -```python -import strawberry -from typing import Optional - - -@strawberry.input -class UpdateUserInput: - name: str | None = None - phone: strawberry.Maybe[str] - - -@strawberry.type -class Mutation: - def update_user(self, user_id: strawberry.ID, input: UpdateUserInput) -> User: - if name := input.name: - ... # update name... - if input.phone: - phone = input.phone.value # can be str | None - ... # update phone... -``` - -```graphql -type UpdateUserInput { - name: String = null - phone: String - -} -``` - - +When you need to distinguish between a field being set to `null` versus being +completely absent (common in update operations), you can use `strawberry.Maybe`. +See the [Maybe documentation](./maybe.md) for comprehensive examples and usage +patterns. ## API @@ -162,6 +130,14 @@ input SearchBy @oneOf { + + +OneOf inputs use `strawberry.Maybe` to distinguish between fields that are +explicitly not provided versus those that might be set to null. See the +[Maybe documentation](./maybe.md) for more details on this usage pattern. + + + ## Deprecating fields Fields can be deprecated using the argument `deprecation_reason`. @@ -188,7 +164,7 @@ class Point2D: z: Optional[float] = strawberry.field( deprecation_reason="3D coordinates are deprecated" ) - label: strawberry.Maybe[str] + label: Optional[str] = None ``` ```graphql @@ -196,7 +172,7 @@ input Point2D { x: Float! y: Float! z: Float @deprecated(reason: "3D coordinates are deprecated") - label: String + label: String = null } ``` diff --git a/docs/types/maybe.md b/docs/types/maybe.md new file mode 100644 index 0000000000..13b8678791 --- /dev/null +++ b/docs/types/maybe.md @@ -0,0 +1,215 @@ +--- +title: Maybe +--- + +# Maybe + +In GraphQL, there's an important distinction between a field that is `null` and +a field that is completely absent from the input. Strawberry's `Maybe` type +allows you to differentiate between these states: + +For `Maybe[str]`: + +1. **Field present with a value**: `Some("hello")` +2. **Field completely absent**: `None` + +For `Maybe[str | None]` (when you need to handle explicit nulls): + +1. **Field present with a value**: `Some("hello")` +2. **Field present but explicitly null**: `Some(None)` +3. **Field completely absent**: `None` + +This is particularly useful for update operations where you need to distinguish +between "set this field to null" and "don't change this field at all". + +## What problem does `strawberry.Maybe` solve? + +Consider this common scenario: you have a user profile with an optional phone +number, and you want to provide an update mutation. With traditional nullable +types, you can't distinguish between: + +- "Set the phone number to null" (remove the phone number) +- "Don't change the phone number" (leave it as is) + +Both would be represented as `phone: null` in your GraphQL mutation. + +## Basic Usage + +Here's how to use `Maybe` in your Strawberry schema: + +```python +import strawberry + + +@strawberry.input +class UpdateUserInput: + name: str | None = None # Traditional optional field + phone: strawberry.Maybe[str | None] # Maybe field + + +@strawberry.type +class User: + name: str + phone: str | None + + +@strawberry.type +class Mutation: + @strawberry.mutation + def update_user(self, user_id: str, input: UpdateUserInput) -> User: + user = get_user(user_id) # Your user retrieval logic + + # Traditional optional field - only update if provided + if input.name is not None: + user.name = input.name + + # Maybe field - check if field was provided at all + if input.phone is not None: # Field was provided + user.phone = input.phone.value # Access the actual value + # If input.phone is None, the field wasn't provided - no change + + return user +``` + +## Understanding Some() + +When a `Maybe` field has a value (including `null`), it's wrapped in a `Some()` +container: + +```python +# Field provided with a string value +phone = strawberry.Some("555-1234") +print(phone.value) # "555-1234" + +# Field provided with null value +phone = strawberry.Some(None) +print(phone.value) # None + +# Field not provided at all +phone = None +print(phone) # None +``` + +## GraphQL Schema + +When you use `Maybe` in your schema, it appears as a nullable field in GraphQL: + +```python +@strawberry.input +class UpdateUserInput: + phone: strawberry.Maybe[str | None] +``` + +Generates this GraphQL schema: + +```graphql +input UpdateUserInput { + phone: String +} +``` + +## Common Patterns + +### Input Types for Updates + +`Maybe` is most commonly used in input types for update operations: + +```python +@strawberry.input +class UpdatePostInput: + title: strawberry.Maybe[str] # Can be set to new value or omitted + content: strawberry.Maybe[str] + published: strawberry.Maybe[bool] + tags: strawberry.Maybe[list[str] | None] # Can be set, cleared, or omitted + + +@strawberry.type +class Mutation: + @strawberry.mutation + def update_post(self, post_id: str, input: UpdatePostInput) -> Post: + post = get_post(post_id) + + # Only update fields that were explicitly provided + if input.title: + post.title = input.title.value + if input.content: + post.content = input.content.value + if input.published: + post.published = input.published.value + if input.tags: + post.tags = input.tags.value # Could be None to clear tags + + return post +``` + +## Maybe vs Optional vs Nullable + +Understanding the differences between these approaches: + +| Type | Python | GraphQL | Absent | Null | Value | +| ------------------------------- | ---------- | --------- | -------- | ------------- | -------------- | +| `str` | Required | `String!` | ❌ Error | ❌ Error | ✅ Value | +| `str \| None` | Optional | `String` | ✅ None | ✅ None | ✅ Value | +| `strawberry.Maybe[str]` | Maybe | `String` | ✅ None | ❌ Error | ✅ Some(value) | +| `strawberry.Maybe[str \| None]` | Maybe+Null | `String` | ✅ None | ✅ Some(None) | ✅ Some(value) | + +## Best Practices + +### When to Use Maybe + +Use `Maybe` when you need to distinguish between: + +- Field not provided (no change) +- Field provided with null (clear/remove) +- Field provided with value (set/update) + +Common use cases: + +- Update mutations +- Patch operations +- Optional filters that need to distinguish between "not filtering" and + "filtering by null" + +### When to Use Optional Instead + +Use regular optional types (`str | None`) when: + +- You only need two states: value or null +- The field absence and null have the same meaning +- You're defining output types (GraphQL responses) + +### Error Handling + +Always check if a `Maybe` field was provided before accessing its value: + +```python +# Good +if input.phone is not None: + user.phone = input.phone.value + +# Bad - will raise AttributeError if phone is None +user.phone = input.phone.value +``` + +### Helper Functions + +You can create helper functions to make Maybe handling cleaner: + +```python +def update_if_provided(obj, field_name: str, maybe_value): + """Update object field only if Maybe value was provided.""" + if maybe_value is not None: + setattr(obj, field_name, maybe_value.value) + + +# Usage +update_if_provided(user, "phone", input.phone) +update_if_provided(user, "email", input.email) +``` + +## Related Types + +- [Input Types](./input-types.md) - Using Maybe in input type definitions +- [Resolvers](./resolvers.md) - Using Maybe in resolver arguments +- [Union Types](./union.md) - Combining Maybe with union types +- [Scalars](./scalars.md) - Custom scalar types with Maybe diff --git a/docs/types/resolvers.md b/docs/types/resolvers.md index cd49fb5ec1..eb69e360c5 100644 --- a/docs/types/resolvers.md +++ b/docs/types/resolvers.md @@ -142,11 +142,7 @@ type Query { ### Optional arguments -Optional or nullable arguments can be expressed using `Optional`. If you need to -differentiate between `null` (maps to `None` in Python) and no arguments being -passed, you can use `strawberry.Maybe`: - - +Optional or nullable arguments can be expressed using `Optional`: ```python from typing import Optional @@ -160,50 +156,12 @@ class Query: if name is None: return "Hello world!" return f"Hello {name}!" - - @strawberry.field - def greet(self, name: strawberry.Maybe[str] = None) -> str: - if name: - if name.value: - return f"Hello {name.value}!" - else: - return "Name was null!" - else: - return "Name was not set!" -``` - -```graphql -type Query { - hello(name: String = null): String! - greet(name: String): String! -} ``` - - -Like this you will get the following responses: - - - -```graphql -{ - unset: greet - null: greet(name: null) - name: greet(name: "Dominique") -} -``` - -```json -{ - "data": { - "unset": "Name was not set!", - "null": "Name was null!", - "name": "Hello Dominique!" - } -} -``` - - +If you need to differentiate between `null` and no arguments being passed (for +example, distinguishing between "set to null" vs "don't change"), you can use +`strawberry.Maybe`. See the [Maybe documentation](./maybe.md) for comprehensive +usage examples and patterns. ### Annotated Arguments @@ -221,7 +179,7 @@ class Query: @strawberry.field def greet( self, - name: strawberry.Maybe[str] = None, + name: Optional[str] = None, is_morning: Annotated[ Optional[bool], strawberry.argument( diff --git a/strawberry/cli/commands/upgrade/__init__.py b/strawberry/cli/commands/upgrade/__init__.py index 4c18016f6a..29bffb1616 100644 --- a/strawberry/cli/commands/upgrade/__init__.py +++ b/strawberry/cli/commands/upgrade/__init__.py @@ -10,6 +10,7 @@ from strawberry.cli.app import app from strawberry.codemods.annotated_unions import ConvertUnionToAnnotatedUnion +from strawberry.codemods.maybe_optional import ConvertMaybeToOptional from strawberry.codemods.update_imports import UpdateImportsCodemod from ._run_codemod import run_codemod @@ -17,6 +18,7 @@ codemods = { "annotated-union": ConvertUnionToAnnotatedUnion, "update-imports": UpdateImportsCodemod, + "maybe-optional": ConvertMaybeToOptional, } diff --git a/strawberry/codemods/__init__.py b/strawberry/codemods/__init__.py index e69de29bb2..4e77d06ad9 100644 --- a/strawberry/codemods/__init__.py +++ b/strawberry/codemods/__init__.py @@ -0,0 +1,9 @@ +from .annotated_unions import ConvertUnionToAnnotatedUnion +from .maybe_optional import ConvertMaybeToOptional +from .update_imports import UpdateImportsCodemod + +__all__ = [ + "ConvertMaybeToOptional", + "ConvertUnionToAnnotatedUnion", + "UpdateImportsCodemod", +] diff --git a/strawberry/codemods/maybe_optional.py b/strawberry/codemods/maybe_optional.py new file mode 100644 index 0000000000..bc37a0956b --- /dev/null +++ b/strawberry/codemods/maybe_optional.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import libcst as cst +import libcst.matchers as m +from libcst._nodes.expression import BaseExpression # noqa: TC002 +from libcst.codemod import CodemodContext, VisitorBasedCodemodCommand +from libcst.codemod.visitors import AddImportsVisitor + + +class ConvertMaybeToOptional(VisitorBasedCodemodCommand): + DESCRIPTION: str = ( + "Converts strawberry.Maybe[T] to strawberry.Maybe[T | None] " + "to match the new Maybe type definition" + ) + + def __init__( + self, + context: CodemodContext, + use_pipe_syntax: bool = True, # Default to pipe syntax for modern Python + ) -> None: + self.use_pipe_syntax = use_pipe_syntax + super().__init__(context) + + @m.leave( + m.Subscript( + value=m.Attribute(value=m.Name("strawberry"), attr=m.Name("Maybe")) + | m.Name("Maybe") + ) + ) + def leave_maybe_subscript( + self, original_node: cst.Subscript, updated_node: cst.Subscript + ) -> BaseExpression: + # Skip if it's not a strawberry.Maybe or imported Maybe + if isinstance(original_node.value, cst.Name): + # Check if this is an imported Maybe from strawberry + # For now, we'll assume any standalone "Maybe" is from strawberry + # In a more robust implementation, we'd track imports + pass + + # Get the inner type + if isinstance(original_node.slice, (list, tuple)): + if len(original_node.slice) != 1: + return original_node + slice_element = original_node.slice[0] + else: + slice_element = original_node.slice + + if not isinstance(slice_element, cst.SubscriptElement): + return original_node + + if not isinstance(slice_element.slice, cst.Index): + return original_node + + inner_type = slice_element.slice.value + + # Check if the inner type already includes None + if self._already_includes_none(inner_type): + return original_node + + # Create the new union type with None + new_type: BaseExpression + if self.use_pipe_syntax: + new_type = cst.BinaryOperation( + left=inner_type, + operator=cst.BitOr( + whitespace_before=cst.SimpleWhitespace(" "), + whitespace_after=cst.SimpleWhitespace(" "), + ), + right=cst.Name("None"), + ) + else: + # Use Union[T, None] syntax + AddImportsVisitor.add_needed_import(self.context, "typing", "Union") + new_type = cst.Subscript( + value=cst.Name("Union"), + slice=[ + cst.SubscriptElement(slice=cst.Index(value=inner_type)), + cst.SubscriptElement(slice=cst.Index(value=cst.Name("None"))), + ], + ) + + # Return the updated Maybe[T | None] + return updated_node.with_changes( + slice=[cst.SubscriptElement(slice=cst.Index(value=new_type))] + ) + + def _already_includes_none(self, node: BaseExpression) -> bool: + """Check if the type already includes None (e.g., T | None or Union[T, None]).""" + # Check for T | None pattern + if isinstance(node, cst.BinaryOperation) and isinstance( + node.operator, cst.BitOr + ): + if isinstance(node.right, cst.Name) and node.right.value == "None": + return True + # Recursively check left side for chained unions + if self._already_includes_none(node.left): + return True + + # Check for Union[..., None] pattern + if ( + isinstance(node, cst.Subscript) + and isinstance(node.value, cst.Name) + and node.value.value == "Union" + ): + # Handle both list and tuple slice formats + slice_elements = ( + node.slice if isinstance(node.slice, (list, tuple)) else [node.slice] + ) + for element in slice_elements: + if ( + isinstance(element, cst.SubscriptElement) + and isinstance(element.slice, cst.Index) + and isinstance(element.slice.value, cst.Name) + and element.slice.value.value == "None" + ): + return True + + return False diff --git a/strawberry/schema/schema.py b/strawberry/schema/schema.py index 12753e1225..3a8874b4bb 100644 --- a/strawberry/schema/schema.py +++ b/strawberry/schema/schema.py @@ -50,6 +50,7 @@ from strawberry.extensions.runner import SchemaExtensionsRunner from strawberry.printer import print_schema from strawberry.schema.schema_converter import GraphQLCoreConverter +from strawberry.schema.validation_rules.maybe_null import MaybeNullValidationRule from strawberry.schema.validation_rules.one_of import OneOfInputValidationRule from strawberry.types.base import ( StrawberryObjectDefinition, @@ -124,6 +125,7 @@ def validate_document( ) -> list[GraphQLError]: validation_rules = ( *validation_rules, + MaybeNullValidationRule, OneOfInputValidationRule, ) return validate( diff --git a/strawberry/schema/schema_converter.py b/strawberry/schema/schema_converter.py index 70865604d0..68d3864a9e 100644 --- a/strawberry/schema/schema_converter.py +++ b/strawberry/schema/schema_converter.py @@ -854,6 +854,13 @@ def from_maybe_optional( NoneType = type(None) if type_ is None or type_ is NoneType: return self.from_type(type_) + if isinstance(type_, StrawberryMaybe): + # StrawberryMaybe should always generate optional types + # because Maybe[T] = Union[Some[T], None] (field can be absent) + # But we need to handle the case where of_type is itself optional + if isinstance(type_.of_type, StrawberryOptional): + return self.from_type(type_.of_type.of_type) + return self.from_type(type_.of_type) if isinstance(type_, StrawberryOptional): return self.from_type(type_.of_type) return GraphQLNonNull(self.from_type(type_)) diff --git a/strawberry/schema/validation_rules/maybe_null.py b/strawberry/schema/validation_rules/maybe_null.py new file mode 100644 index 0000000000..2c7d24fb17 --- /dev/null +++ b/strawberry/schema/validation_rules/maybe_null.py @@ -0,0 +1,136 @@ +from typing import Any + +from graphql import ( + ArgumentNode, + GraphQLError, + GraphQLNamedType, + ObjectValueNode, + ValidationContext, + ValidationRule, + get_named_type, +) + +from strawberry.types.base import StrawberryMaybe, StrawberryOptional +from strawberry.utils.str_converters import to_camel_case + + +class MaybeNullValidationRule(ValidationRule): + """Validates that Maybe[T] fields do not receive explicit null values. + + This rule ensures that: + - Maybe[T] fields can only be omitted or have non-null values + - Maybe[T | None] fields can be omitted, null, or have non-null values + + This provides clear semantics where Maybe[T] means "either present with value or absent" + and Maybe[T | None] means "present with value, present but null, or absent". + """ + + def __init__(self, validation_context: ValidationContext) -> None: + super().__init__(validation_context) + + def enter_argument(self, node: ArgumentNode, *_args: Any) -> None: + # Check if this is a null value + if node.value.kind != "null_value": + return + + # Get the argument definition from the schema + argument_def = self.context.get_argument() + if not argument_def: + return + + # Check if this argument corresponds to a Maybe[T] (not Maybe[T | None]) + # The argument type extensions should contain the Strawberry type info + strawberry_arg_info = argument_def.extensions.get("strawberry-definition") + if not strawberry_arg_info: + return + + # Get the Strawberry type from the argument info + field_type = getattr(strawberry_arg_info, "type", None) + if not field_type: + return + + if isinstance(field_type, StrawberryMaybe) and not isinstance( + field_type.of_type, StrawberryOptional + ): + # This is Maybe[T] - should not accept null values + type_name = self._get_type_name(field_type.of_type) + + self.report_error( + GraphQLError( + f"Expected value of type '{type_name}', found null. " + f"Argument '{node.name.value}' of type 'Maybe[{type_name}]' cannot be explicitly set to null. " + f"Use 'Maybe[{type_name} | None]' if you need to allow null values.", + nodes=[node], + ) + ) + + def enter_object_value(self, node: ObjectValueNode, *_args: Any) -> None: + # Get the input type for this object + input_type = get_named_type(self.context.get_input_type()) + if not input_type: + return + + # Get the Strawberry type definition from extensions + strawberry_type = input_type.extensions.get("strawberry-definition") + if not strawberry_type: + return + + # Check each field in the object for null Maybe[T] violations + self.validate_maybe_fields(node, input_type, strawberry_type) + + def validate_maybe_fields( + self, node: ObjectValueNode, input_type: GraphQLNamedType, strawberry_type: Any + ) -> None: + # Create a map of field names to field nodes for easy lookup + field_node_map = {field.name.value: field for field in node.fields} + + # Check each field in the Strawberry type definition + if not hasattr(strawberry_type, "fields"): + return + + for field_def in strawberry_type.fields: + # Resolve the actual GraphQL field name using the same logic as NameConverter + if field_def.graphql_name is not None: + field_name = field_def.graphql_name + else: + # Apply auto_camel_case conversion if enabled (default behavior) + field_name = to_camel_case(field_def.python_name) + + # Check if this field is present in the input and has a null value + if field_name in field_node_map: + field_node = field_node_map[field_name] + + # Check if this field has a null value + if field_node.value.kind == "null_value": + # Check if this is a Maybe[T] (not Maybe[T | None]) + field_type = field_def.type + if isinstance(field_type, StrawberryMaybe) and not isinstance( + field_type.of_type, StrawberryOptional + ): + # This is Maybe[T] - should not accept null values + type_name = self._get_type_name(field_type.of_type) + self.report_error( + GraphQLError( + f"Expected value of type '{type_name}', found null. " + f"Field '{field_name}' of type 'Maybe[{type_name}]' cannot be explicitly set to null. " + f"Use 'Maybe[{type_name} | None]' if you need to allow null values.", + nodes=[field_node], + ) + ) + + def _get_type_name(self, type_: Any) -> str: + """Get a readable type name for error messages.""" + if hasattr(type_, "__name__"): + return type_.__name__ + # Handle Strawberry types that don't have __name__ + if hasattr(type_, "of_type") and hasattr(type_.of_type, "__name__"): + # For StrawberryList, StrawberryOptional, etc. + return ( + f"list[{type_.of_type.__name__}]" + if "List" in str(type_.__class__) + else type_.of_type.__name__ + ) + return str(type_) + + +__all__ = ["MaybeNullValidationRule"] diff --git a/strawberry/types/arguments.py b/strawberry/types/arguments.py index 1b4ff94166..496d3ce2d5 100644 --- a/strawberry/types/arguments.py +++ b/strawberry/types/arguments.py @@ -191,10 +191,24 @@ def convert_argument( from strawberry.relay.types import GlobalID # TODO: move this somewhere else and make it first class - if isinstance(type_, StrawberryOptional): + # Handle StrawberryMaybe first, since it extends StrawberryOptional + if isinstance(type_, StrawberryMaybe): + # Check if this is Maybe[T | None] (has StrawberryOptional as of_type) + if isinstance(type_.of_type, StrawberryOptional): + # This is Maybe[T | None] - allows null values + res = convert_argument(value, type_.of_type, scalar_registry, config) + + return Some(res) + + # This is Maybe[T] - validation for null values is handled by MaybeNullValidationRule + # Convert the value and wrap in Some() res = convert_argument(value, type_.of_type, scalar_registry, config) - return Some(res) if isinstance(type_, StrawberryMaybe) else res + return Some(res) + + # Handle regular StrawberryOptional (not Maybe) + if isinstance(type_, StrawberryOptional): + return convert_argument(value, type_.of_type, scalar_registry, config) if value is None: return None diff --git a/strawberry/types/maybe.py b/strawberry/types/maybe.py index 802c10fe65..a0021218e9 100644 --- a/strawberry/types/maybe.py +++ b/strawberry/types/maybe.py @@ -31,7 +31,7 @@ def __bool__(self) -> bool: if TYPE_CHECKING: - Maybe: TypeAlias = Union[Some[Union[T, None]], None] + Maybe: TypeAlias = Union[Some[T], None] else: # we do this trick so we can inspect that at runtime class Maybe(Generic[T]): ... diff --git a/tests/codemods/test_maybe_optional.py b/tests/codemods/test_maybe_optional.py new file mode 100644 index 0000000000..95abb006fc --- /dev/null +++ b/tests/codemods/test_maybe_optional.py @@ -0,0 +1,178 @@ +from libcst.codemod import CodemodTest + +from strawberry.codemods.maybe_optional import ConvertMaybeToOptional + + +class TestConvertMaybeToOptional(CodemodTest): + TRANSFORM = ConvertMaybeToOptional + + def test_simple_maybe(self) -> None: + before = """ + from strawberry import Maybe + + field: Maybe[str] + """ + + after = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[str, None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_simple_maybe_union_syntax(self) -> None: + before = """ + from strawberry import Maybe + + field: Maybe[str] + """ + + after = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[str, None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_strawberry_maybe(self) -> None: + before = """ + import strawberry + + field: strawberry.Maybe[int] + """ + + after = """ + import strawberry + from typing import Union + + field: strawberry.Maybe[Union[int, None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_nested_type(self) -> None: + before = """ + from strawberry import Maybe + + field: Maybe[List[str]] + """ + + after = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[List[str], None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_already_has_none_pipe(self) -> None: + before = """ + from strawberry import Maybe + + field: Maybe[Union[str, None]] + """ + + after = """ + from strawberry import Maybe + + field: Maybe[Union[str, None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_already_has_none_union(self) -> None: + before = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[str, None]] + """ + + after = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[str, None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_multiple_maybe_fields(self) -> None: + before = """ + from strawberry import Maybe + + @strawberry.type + class User: + name: Maybe[str] + age: Maybe[int] + email: Maybe[str] + """ + + after = """ + from strawberry import Maybe + from typing import Union + + @strawberry.type + class User: + name: Maybe[Union[str, None]] + age: Maybe[Union[int, None]] + email: Maybe[Union[str, None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_function_annotation(self) -> None: + before = """ + from strawberry import Maybe + + def get_user() -> Maybe[User]: + return None + """ + + after = """ + from strawberry import Maybe + from typing import Union + + def get_user() -> Maybe[Union[User, None]]: + return None + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_generic_type(self) -> None: + before = """ + from strawberry import Maybe + + field: Maybe[Dict[str, Any]] + """ + + after = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[Dict[str, Any], None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) + + def test_union_type_inside_maybe(self) -> None: + before = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[str, int]] + """ + + after = """ + from strawberry import Maybe + from typing import Union + + field: Maybe[Union[Union[str, int], None]] + """ + + self.assertCodemod(before, after, use_pipe_syntax=False) diff --git a/tests/schema/test_maybe.py b/tests/schema/test_maybe.py index 9e872414aa..2a82fd7f6d 100644 --- a/tests/schema/test_maybe.py +++ b/tests/schema/test_maybe.py @@ -1,5 +1,5 @@ from textwrap import dedent -from typing import Optional +from typing import Optional, Union import pytest @@ -23,7 +23,7 @@ def user(self) -> User: @strawberry.input class UpdateUserInput: - phone: strawberry.Maybe[str] + phone: strawberry.Maybe[Union[str, None]] @strawberry.type class Mutation: @@ -107,7 +107,7 @@ def test_optional_argument_maybe() -> None: @strawberry.type class Query: @strawberry.field - def hello(self, name: strawberry.Maybe[str] = None) -> str: + def hello(self, name: strawberry.Maybe[Union[str, None]] = None) -> str: if name: return "None" if name.value is None else name.value @@ -154,7 +154,7 @@ def hello(self, name: strawberry.Maybe[str] = None) -> str: def test_maybe_list(): @strawberry.input class InputData: - items: strawberry.Maybe[list[str]] + items: strawberry.Maybe[Union[list[str], None]] @strawberry.type class Query: @@ -174,3 +174,828 @@ def test(self, data: InputData) -> str: test(data: InputData!): String! }""" ) + + +def test_maybe_str_rejects_explicit_nulls(): + """Test that Maybe[str] correctly rejects null values at Python validation level. + + BEHAVIOR: + - Maybe[str] generates 'String' (optional) in GraphQL schema + - Rejects null values at Python validation level, not GraphQL level + - This allows the GraphQL parser to accept null, but Python validation rejects it + """ + + @strawberry.input + class UpdateInput: + name: strawberry.Maybe[str] # Should be optional in schema but reject null + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def update(self, input: UpdateInput) -> str: + if input.name is not None: + return f"Updated to: {input.name.value}" + return "No change" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Generates optional field + schema_str = str(schema) + assert "name: String" in schema_str + assert "name: String!" not in schema_str + + # Test with explicit null fails at Python validation level + query = """ + mutation { + update(input: { name: null }) + } + """ + + result = schema.execute_sync(query) + assert result.errors + assert len(result.errors) == 1 + # The error should mention Python-level validation, not GraphQL schema validation + error_message = str(result.errors[0]) + assert "Expected value of type" in error_message + assert "found null" in error_message + + +def test_maybe_str_accepts_valid_values(): + """Test that Maybe[str] accepts valid string values.""" + + @strawberry.input + class UpdateInput: + name: strawberry.Maybe[str] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def update(self, input: UpdateInput) -> str: + if input.name is not None: + return f"Updated to: {input.name.value}" + return "No change" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test with valid string value should work + query = """ + mutation { + update(input: { name: "John" }) + } + """ + + result = schema.execute_sync(query) + assert not result.errors + assert result.data == {"update": "Updated to: John"} + + +def test_maybe_str_handles_absent_fields(): + """Test that Maybe[str] properly handles absent fields. + + BEHAVIOR: + - Absent fields are allowed and result in None value + """ + + @strawberry.input + class UpdateInput: + name: strawberry.Maybe[str] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def update(self, input: UpdateInput) -> str: + if input.name is not None: + return f"Updated to: {input.name.value}" + return "No change" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test with absent field should work and return "No change" + query = """ + mutation { + update(input: {}) + } + """ + + result = schema.execute_sync(query) + assert not result.errors + assert result.data == {"update": "No change"} + + +def test_maybe_str_error_messages(): + """Test that Maybe[str] provides error messages when null is rejected.""" + + @strawberry.input + class UpdateInput: + name: strawberry.Maybe[str] # Rejects null at Python validation level + phone: strawberry.Maybe[Union[str, None]] # Can accept null + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def update(self, input: UpdateInput) -> str: + return "Updated" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test that null to name field produces a validation error + query = """ + mutation { + update(input: { name: null, phone: null }) + } + """ + + result = schema.execute_sync(query) + assert result.errors + assert len(result.errors) == 1 + + # The error should be related to the name field, not phone field + error_message = str(result.errors[0]) + # Error message from Python validation + assert "Expected value of type" in error_message + assert "found null" in error_message + + +def test_mixed_maybe_field_behavior(): + """Test schema with both Maybe[T] and Maybe[T | None] behave differently.""" + + @strawberry.input + class UpdateUserInput: + # Should accept value or absent, reject null + username: strawberry.Maybe[str] + # Can accept null, value, or absent + bio: strawberry.Maybe[Union[str, None]] + # Can accept null, value, or absent + website: strawberry.Maybe[Union[str, None]] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def update_user(self, input: UpdateUserInput) -> str: + result = [] + + if input.username is not None: + result.append(f"username={input.username.value}") + else: + result.append("username=unchanged") + + if input.bio is not None: + bio_value = ( + input.bio.value if input.bio.value is not None else "cleared" + ) + result.append(f"bio={bio_value}") + else: + result.append("bio=unchanged") + + if input.website is not None: + website_value = ( + input.website.value + if input.website.value is not None + else "cleared" + ) + result.append(f"website={website_value}") + else: + result.append("website=unchanged") + + return ", ".join(result) + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test 1: Valid values for all fields + query1 = """ + mutation { + updateUser(input: { + username: "john", + bio: "Developer", + website: "example.com" + }) + } + """ + result1 = schema.execute_sync(query1) + assert not result1.errors + assert result1.data == { + "updateUser": "username=john, bio=Developer, website=example.com" + } + + # Test 2: Null for bio and website should work, but not for username + query2 = """ + mutation { + updateUser(input: { + username: null, + bio: null, + website: null + }) + } + """ + result2 = schema.execute_sync(query2) + assert result2.errors # Should fail due to username: null + + # Test 3: Valid bio/website nulls without username + query3 = """ + mutation { + updateUser(input: { + bio: null, + website: null + }) + } + """ + result3 = schema.execute_sync(query3) + assert not result3.errors + assert result3.data == { + "updateUser": "username=unchanged, bio=cleared, website=cleared" + } + + # Test 4: Absent fields should work + query4 = """ + mutation { + updateUser(input: {}) + } + """ + result4 = schema.execute_sync(query4) + assert not result4.errors + assert result4.data == { + "updateUser": "username=unchanged, bio=unchanged, website=unchanged" + } + + +def test_maybe_nested_types(): + """Test Maybe with nested types like lists.""" + + @strawberry.input + class UpdateItemsInput: + # Cannot accept null list - only valid list or absent + tags: strawberry.Maybe[list[str]] + # Can accept null, valid list, or absent + categories: strawberry.Maybe[Union[list[str], None]] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def update_items(self, input: UpdateItemsInput) -> str: + result = [] + + if input.tags is not None: + result.append(f"tags={input.tags.value}") + else: + result.append("tags=unchanged") + + if input.categories is not None: + cat_value = ( + input.categories.value + if input.categories.value is not None + else "cleared" + ) + result.append(f"categories={cat_value}") + else: + result.append("categories=unchanged") + + return ", ".join(result) + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test 1: Valid lists for both fields + query1 = """ + mutation { + updateItems(input: { + tags: ["python", "graphql"], + categories: ["tech", "web"] + }) + } + """ + result1 = schema.execute_sync(query1) + assert not result1.errors + assert result1.data == { + "updateItems": "tags=['python', 'graphql'], categories=['tech', 'web']" + } + + # Test 2: Null categories should work, but null tags should fail + query2 = """ + mutation { + updateItems(input: { + tags: null, + categories: null + }) + } + """ + result2 = schema.execute_sync(query2) + assert result2.errors # Should fail due to tags: null + + # Test 3: Valid categories null without tags + query3 = """ + mutation { + updateItems(input: { + categories: null + }) + } + """ + result3 = schema.execute_sync(query3) + assert not result3.errors + assert result3.data == {"updateItems": "tags=unchanged, categories=cleared"} + + # Test 4: Empty lists should work for both + query4 = """ + mutation { + updateItems(input: { + tags: [], + categories: [] + }) + } + """ + result4 = schema.execute_sync(query4) + assert not result4.errors + assert result4.data == {"updateItems": "tags=[], categories=[]"} + + +def test_maybe_resolver_arguments(): + """Test Maybe fields as resolver arguments.""" + + @strawberry.type + class Query: + @strawberry.field + def search( + self, + # Cannot accept null - only value or absent + query: strawberry.Maybe[str] = None, + # Can accept null, value, or absent + filter_by: strawberry.Maybe[Union[str, None]] = None, + ) -> str: + result = [] + + if query is not None: + result.append(f"query={query.value}") + else: + result.append("query=unset") + + if filter_by is not None: + filter_value = ( + filter_by.value if filter_by.value is not None else "cleared" + ) + result.append(f"filter={filter_value}") + else: + result.append("filter=unset") + + return ", ".join(result) + + schema = strawberry.Schema(query=Query) + + # Test 1: Valid values for both arguments + query1 = """ + query { + search(query: "python", filterBy: "category") + } + """ + result1 = schema.execute_sync(query1) + assert not result1.errors + assert result1.data == {"search": "query=python, filter=category"} + + # Test 2: Null filter should work, but null query should fail + query2 = """ + query { + search(query: null, filterBy: null) + } + """ + result2 = schema.execute_sync(query2) + assert result2.errors # Should fail due to query: null + + # Test 3: Valid filter null without query + query3 = """ + query { + search(filterBy: null) + } + """ + result3 = schema.execute_sync(query3) + assert not result3.errors + assert result3.data == {"search": "query=unset, filter=cleared"} + + # Test 4: No arguments + query4 = """ + query { + search + } + """ + result4 = schema.execute_sync(query4) + assert not result4.errors + assert result4.data == {"search": "query=unset, filter=unset"} + + +def test_maybe_graphql_schema_consistency(): + """Test GraphQL schema generation for Maybe types. + + BEHAVIOR: + - Maybe[str] generates String (optional) - allows absent but rejects null + - Maybe[str | None] generates String (optional) - allows absent and null + - Both generate the same GraphQL schema but have different validation + """ + + # Schema with Maybe[str] + @strawberry.input + class Input1: + field: strawberry.Maybe[str] + + @strawberry.type + class Query1: + @strawberry.field + def test(self, input: Input1) -> str: + return "test" + + schema1 = strawberry.Schema(query=Query1) + + # Schema with Maybe[str | None] + @strawberry.input + class Input2: + field: strawberry.Maybe[Union[str, None]] + + @strawberry.type + class Query2: + @strawberry.field + def test(self, input: Input2) -> str: + return "test" + + schema2 = strawberry.Schema(query=Query2) + + # Document new behavior + schema1_str = str(schema1) + schema2_str = str(schema2) + + # Both Maybe[str] and Maybe[str | None] generate String (optional) + assert "field: String" in schema1_str + assert "field: String!" not in schema1_str + assert "field: String" in schema2_str + assert "field: String!" not in schema2_str + + +def test_maybe_complex_types(): + """Test Maybe with complex custom types.""" + + @strawberry.input + class AddressInput: + street: str + city: str + zip_code: Optional[str] = None + + @strawberry.input + class UpdateProfileInput: + # Cannot accept null address - only valid address or absent + address: strawberry.Maybe[AddressInput] + # Can accept null, valid address, or absent + billing_address: strawberry.Maybe[Union[AddressInput, None]] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def update_profile(self, input: UpdateProfileInput) -> str: + result = [] + + if input.address is not None: + addr = input.address.value + result.append(f"address={addr.street}, {addr.city}") + else: + result.append("address=unchanged") + + if input.billing_address is not None: + if input.billing_address.value is not None: + addr = input.billing_address.value + result.append(f"billing={addr.street}, {addr.city}") + else: + result.append("billing=cleared") + else: + result.append("billing=unchanged") + + return ", ".join(result) + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test 1: Valid addresses for both fields + query1 = """ + mutation { + updateProfile(input: { + address: { street: "123 Main", city: "NYC" }, + billingAddress: { street: "456 Oak", city: "LA" } + }) + } + """ + result1 = schema.execute_sync(query1) + assert not result1.errors + assert result1.data == { + "updateProfile": "address=123 Main, NYC, billing=456 Oak, LA" + } + + # Test 2: Null billing should work, but null address should fail + query2 = """ + mutation { + updateProfile(input: { + address: null, + billingAddress: null + }) + } + """ + result2 = schema.execute_sync(query2) + assert result2.errors # Should fail due to address: null + + # Test 3: Valid billing null without address + query3 = """ + mutation { + updateProfile(input: { + billingAddress: null + }) + } + """ + result3 = schema.execute_sync(query3) + assert not result3.errors + assert result3.data == {"updateProfile": "address=unchanged, billing=cleared"} + + # Test 4: Absent fields + query4 = """ + mutation { + updateProfile(input: {}) + } + """ + result4 = schema.execute_sync(query4) + assert not result4.errors + assert result4.data == {"updateProfile": "address=unchanged, billing=unchanged"} + + +def test_maybe_union_with_none_works(): + """Test that Maybe[T | None] works correctly (this should pass).""" + + @strawberry.input + class TestInput: + # This should work correctly - can accept value, null, or absent + field: strawberry.Maybe[Union[str, None]] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test(self, input: TestInput) -> str: + if input.field is not None: + if input.field.value is not None: + return f"value={input.field.value}" + return "null" + return "absent" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Schema should show optional field + schema_str = str(schema) + assert "field: String" in schema_str + assert "field: String!" not in schema_str + + # Test valid value + result1 = schema.execute_sync('mutation { test(input: { field: "hello" }) }') + assert not result1.errors + assert result1.data == {"test": "value=hello"} + + # Test null value + result2 = schema.execute_sync("mutation { test(input: { field: null }) }") + assert not result2.errors + assert result2.data == {"test": "null"} + + # Test absent field + result3 = schema.execute_sync("mutation { test(input: {}) }") + assert not result3.errors + assert result3.data == {"test": "absent"} + + +def test_maybe_behavior_documented(): + """Document the behavior of Maybe[str] vs Maybe[str | None].""" + + @strawberry.input + class CompareInput: + # Generates String (optional) but rejects null at Python level + required_field: strawberry.Maybe[str] + # Generates String (optional) and accepts null + optional_field: strawberry.Maybe[Union[str, None]] + + @strawberry.type + class Query: + @strawberry.field + def compare(self, input: CompareInput) -> str: + return "test" + + schema = strawberry.Schema(query=Query) + schema_str = str(schema) + + # Document behavior - both generate optional fields + assert "requiredField: String" in schema_str + assert "requiredField: String!" not in schema_str + assert "optionalField: String" in schema_str + assert "optionalField: String!" not in schema_str + + +def test_maybe_schema_generation(): + """Test schema behavior - both Maybe[T] and Maybe[T | None] generate optional.""" + + @strawberry.input + class Input1: + field: strawberry.Maybe[str] + + @strawberry.input + class Input2: + field: strawberry.Maybe[Union[str, None]] + + @strawberry.type + class Query: + @strawberry.field + def test1(self, input: Input1) -> str: + return "test" + + @strawberry.field + def test2(self, input: Input2) -> str: + return "test" + + schema = strawberry.Schema(query=Query) + schema_str = str(schema) + + # Both Maybe[str] and Maybe[str | None] generate String (optional) + assert "field: String" in schema_str # Both Input1 and Input2 have optional fields + assert "field: String!" not in schema_str # No required fields + + +def test_maybe_validation(): + """Test that Maybe[T] rejects null at Python validation time, not GraphQL schema time.""" + + @strawberry.input + class TestInput: + field: strawberry.Maybe[ + str + ] # Should be optional in schema but reject null in validation + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test(self, input: TestInput) -> str: + return "test" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Schema should show optional field + schema_str = str(schema) + assert "field: String" in schema_str + assert "field: String!" not in schema_str + + # Null should be rejected during Python validation, not GraphQL parsing + result = schema.execute_sync("mutation { test(input: { field: null }) }") + assert result.errors + # Error should be a validation error, not a GraphQL parsing error + error_message = str(result.errors[0]) + assert "Expected value of type" in error_message + assert "found null" in error_message + + +def test_maybe_comprehensive_behavior_comparison(): + """Comprehensive test comparing Maybe[T] vs Maybe[T | None] behavior.""" + + @strawberry.input + class ComprehensiveInput: + # String (optional) - can be value or absent, but rejects null at Python level + strict_field: strawberry.Maybe[str] + # String (optional) - can be null, value, or absent + flexible_field: strawberry.Maybe[Union[str, None]] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test_comprehensive(self, input: ComprehensiveInput) -> str: + result = [] + + # This logic works for both current and intended behavior + if input.strict_field is not None: + result.append(f"strict={input.strict_field.value}") + else: + result.append("strict=absent") + + if input.flexible_field is not None: + if input.flexible_field.value is not None: + result.append(f"flexible={input.flexible_field.value}") + else: + result.append("flexible=null") + else: + result.append("flexible=absent") + + return ", ".join(result) + + schema = strawberry.Schema(query=Query, mutation=Mutation) + schema_str = str(schema) + + # Document schema generation - both are optional + assert "strictField: String" in schema_str + assert "strictField: String!" not in schema_str + assert "flexibleField: String" in schema_str + assert "flexibleField: String!" not in schema_str + + # Test 1: Valid values work for both + result1 = schema.execute_sync(""" + mutation { + testComprehensive(input: { + strictField: "hello", + flexibleField: "world" + }) + } + """) + assert not result1.errors + assert result1.data == {"testComprehensive": "strict=hello, flexible=world"} + + # Test 2: Only flexible field can be null + result2 = schema.execute_sync(""" + mutation { + testComprehensive(input: { + strictField: "hello", + flexibleField: null + }) + } + """) + assert not result2.errors + assert result2.data == {"testComprehensive": "strict=hello, flexible=null"} + + # Test 3: Strict field null causes Python validation error + result3 = schema.execute_sync(""" + mutation { + testComprehensive(input: { + strictField: null, + flexibleField: "world" + }) + } + """) + assert result3.errors # Python validation error - cannot pass null to Maybe[str] + + # Test 4: Both fields can be omitted now + result4 = schema.execute_sync(""" + mutation { + testComprehensive(input: { + strictField: "hello" + }) + } + """) + assert not result4.errors + assert result4.data == {"testComprehensive": "strict=hello, flexible=absent"} + + # Test 5: Strict field can now be omitted (both fields optional in schema) + result5 = schema.execute_sync(""" + mutation { + testComprehensive(input: { + flexibleField: "world" + }) + } + """) + assert not result5.errors # No error - both fields are optional in schema + assert result5.data == {"testComprehensive": "strict=absent, flexible=world"} diff --git a/tests/schema/test_one_of.py b/tests/schema/test_one_of.py index 57bee860f6..50e2a860cf 100644 --- a/tests/schema/test_one_of.py +++ b/tests/schema/test_one_of.py @@ -8,8 +8,8 @@ @strawberry.input(one_of=True) class ExampleInputTagged: - a: strawberry.Maybe[str] - b: strawberry.Maybe[int] + a: strawberry.Maybe[Union[str, None]] + b: strawberry.Maybe[Union[int, None]] @strawberry.type @@ -210,8 +210,8 @@ def test_works_with_camelcasing(): @strawberry.input(directives=[OneOf()]) class ExampleWithLongerNames: - a_field: strawberry.Maybe[str] - b_field: strawberry.Maybe[int] + a_field: strawberry.Maybe[Union[str, None]] + b_field: strawberry.Maybe[Union[int, None]] @strawberry.type class Result: diff --git a/tests/schema/validation_rules/__init__.py b/tests/schema/validation_rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/schema/validation_rules/test_maybe_null.py b/tests/schema/validation_rules/test_maybe_null.py new file mode 100644 index 0000000000..5a335ced48 --- /dev/null +++ b/tests/schema/validation_rules/test_maybe_null.py @@ -0,0 +1,264 @@ +from typing import Union + +import strawberry + + +def test_maybe_null_validation_rule_input_fields(): + """Test MaybeNullValidationRule validates input object fields correctly.""" + + @strawberry.input + class TestInput: + strict_field: strawberry.Maybe[str] # Should reject null + flexible_field: strawberry.Maybe[Union[str, None]] # Should allow null + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test(self, input: TestInput) -> str: + return "success" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test 1: Valid values should work + result = schema.execute_sync(""" + mutation { + test(input: { strictField: "hello", flexibleField: "world" }) + } + """) + assert not result.errors + assert result.data == {"test": "success"} + + # Test 2: Flexible field can be null + result = schema.execute_sync(""" + mutation { + test(input: { strictField: "hello", flexibleField: null }) + } + """) + assert not result.errors + assert result.data == {"test": "success"} + + # Test 3: Strict field cannot be null + result = schema.execute_sync(""" + mutation { + test(input: { strictField: null, flexibleField: "world" }) + } + """) + assert result.errors + assert len(result.errors) == 1 + error = result.errors[0] + assert "Expected value of type 'str', found null" in str(error) + assert "strictField" in str(error) + assert "cannot be explicitly set to null" in str(error) + assert "Use 'Maybe[str | None]'" in str(error) + + +def test_maybe_null_validation_rule_resolver_arguments(): + """Test MaybeNullValidationRule validates resolver arguments correctly.""" + + @strawberry.type + class Query: + @strawberry.field + def search( + self, + query: strawberry.Maybe[str] = None, # Should reject null + filter_by: strawberry.Maybe[Union[str, None]] = None, # Should allow null + ) -> str: + return "success" + + schema = strawberry.Schema(query=Query) + + # Test 1: Valid values should work + result = schema.execute_sync(""" + query { + search(query: "hello", filterBy: "world") + } + """) + assert not result.errors + assert result.data == {"search": "success"} + + # Test 2: Flexible argument can be null + result = schema.execute_sync(""" + query { + search(query: "hello", filterBy: null) + } + """) + assert not result.errors + assert result.data == {"search": "success"} + + # Test 3: Strict argument cannot be null + result = schema.execute_sync(""" + query { + search(query: null, filterBy: "world") + } + """) + assert result.errors + assert len(result.errors) == 1 + error = result.errors[0] + assert "Expected value of type 'str', found null" in str(error) + assert "query" in str(error) + assert "cannot be explicitly set to null" in str(error) + assert "Use 'Maybe[str | None]'" in str(error) + + +def test_maybe_null_validation_rule_multiple_errors(): + """Test that multiple null violations are all reported.""" + + @strawberry.input + class TestInput: + field1: strawberry.Maybe[str] + field2: strawberry.Maybe[int] + field3: strawberry.Maybe[Union[str, None]] # This one allows null + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test(self, input: TestInput) -> str: + return "success" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test with multiple nulls - should get multiple errors + result = schema.execute_sync(""" + mutation { + test(input: { field1: null, field2: null, field3: null }) + } + """) + assert result.errors + assert len(result.errors) == 2 # field1 and field2 should fail, field3 should pass + + error_messages = [str(error) for error in result.errors] + assert any("field1" in msg for msg in error_messages) + assert any("field2" in msg for msg in error_messages) + # field3 should NOT generate an error because it allows null + + +def test_maybe_null_validation_rule_nested_input(): + """Test validation works with nested input objects.""" + + @strawberry.input + class NestedInput: + value: strawberry.Maybe[str] + + @strawberry.input + class TestInput: + nested: NestedInput + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test(self, input: TestInput) -> str: + return "success" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test with null in nested input + result = schema.execute_sync(""" + mutation { + test(input: { nested: { value: null } }) + } + """) + assert result.errors + assert len(result.errors) == 1 + error = result.errors[0] + assert "Expected value of type 'str', found null" in str(error) + assert "value" in str(error) + + +def test_maybe_null_validation_rule_different_types(): + """Test validation works with different field types.""" + + @strawberry.input + class TestInput: + string_field: strawberry.Maybe[str] + int_field: strawberry.Maybe[int] + bool_field: strawberry.Maybe[bool] + list_field: strawberry.Maybe[list[str]] + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test(self, input: TestInput) -> str: + return "success" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test each field type with null + test_cases = [ + ("stringField", "str"), + ("intField", "int"), + ("boolField", "bool"), + ("listField", "list[str]"), + ] + + for field_name, type_name in test_cases: + result = schema.execute_sync(f""" + mutation {{ + test(input: {{ {field_name}: null }}) + }} + """) + assert result.errors + assert len(result.errors) == 1 + error = result.errors[0] + assert f"Expected value of type '{type_name}', found null" in str(error) + + +def test_maybe_null_validation_rule_custom_graphql_names(): + """Test validation works with custom GraphQL field names.""" + + @strawberry.input + class TestInput: + internal_name: strawberry.Maybe[str] = strawberry.field(name="customName") + + @strawberry.type + class Query: + @strawberry.field + def hello(self) -> str: + return "world" + + @strawberry.type + class Mutation: + @strawberry.mutation + def test(self, input: TestInput) -> str: + return "success" + + schema = strawberry.Schema(query=Query, mutation=Mutation) + + # Test with custom GraphQL name + result = schema.execute_sync(""" + mutation { + test(input: { customName: null }) + } + """) + assert result.errors + assert len(result.errors) == 1 + error = result.errors[0] + assert "customName" in str(error) + + +# TODO: Add test for auto_camel_case=False configuration +# This requires accessing the schema configuration from the validation rule context, +# which needs further investigation of the GraphQL validation context API. diff --git a/tests/typecheckers/test_maybe.py b/tests/typecheckers/test_maybe.py index b36195abe8..ee336254ca 100644 --- a/tests/typecheckers/test_maybe.py +++ b/tests/typecheckers/test_maybe.py @@ -31,13 +31,13 @@ def test_maybe() -> None: [ Result( type="information", - message='Type of "obj.foobar" is "Some[str | None] | None"', + message='Type of "obj.foobar" is "Some[str] | None"', line=12, column=13, ), Result( type="information", - message='Type of "obj.foobar" is "Some[str | None]"', + message='Type of "obj.foobar" is "Some[str]"', line=15, column=17, ), @@ -48,13 +48,13 @@ def test_maybe() -> None: [ Result( type="note", - message='Revealed type is "Union[strawberry.types.maybe.Some[Union[builtins.str, None]], None]"', + message='Revealed type is "Union[strawberry.types.maybe.Some[builtins.str], None]"', line=12, column=13, ), Result( type="note", - message='Revealed type is "strawberry.types.maybe.Some[Union[builtins.str, None]]"', + message='Revealed type is "strawberry.types.maybe.Some[builtins.str]"', line=15, column=17, ),