Skip to content

feat(schema): semantic nullability draft #3722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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: 22 additions & 2 deletions strawberry/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
Union,
cast,
)
from typing_extensions import Annotated, Self, get_args, get_origin
from typing_extensions import Annotated, Required, Self, get_args, get_origin

from strawberry.types.base import (
StrawberryList,
StrawberryObjectDefinition,
StrawberryOptional,
StrawberryRequired,
StrawberryTypeVar,
get_object_definition,
has_object_definition,
Expand All @@ -41,7 +42,6 @@
from strawberry.types.field import StrawberryField
from strawberry.types.union import StrawberryUnion


ASYNC_TYPES = (
abc.AsyncGenerator,
abc.AsyncIterable,
Expand Down Expand Up @@ -160,6 +160,8 @@
return self.create_enum(evaled_type)
elif self._is_optional(evaled_type, args):
return self.create_optional(evaled_type)
elif self._is_required(evaled_type):
return self.create_required(evaled_type)

Check warning on line 164 in strawberry/annotation.py

View check run for this annotation

Codecov / codecov/patch

strawberry/annotation.py#L164

Added line #L164 was not covered by tests
elif self._is_union(evaled_type, args):
return self.create_union(evaled_type, args)
elif is_type_var(evaled_type) or evaled_type is Self:
Expand Down Expand Up @@ -221,6 +223,18 @@

return StrawberryOptional(of_type)

def create_required(self, evaled_type: Any) -> StrawberryRequired:
types = get_args(evaled_type)

Check warning on line 227 in strawberry/annotation.py

View check run for this annotation

Codecov / codecov/patch

strawberry/annotation.py#L227

Added line #L227 was not covered by tests

child_type = Union[types] # type: ignore

Check warning on line 229 in strawberry/annotation.py

View check run for this annotation

Codecov / codecov/patch

strawberry/annotation.py#L229

Added line #L229 was not covered by tests

of_type = StrawberryAnnotation(

Check warning on line 231 in strawberry/annotation.py

View check run for this annotation

Codecov / codecov/patch

strawberry/annotation.py#L231

Added line #L231 was not covered by tests
annotation=child_type,
namespace=self.namespace,
).resolve()

return StrawberryRequired(of_type)

Check warning on line 236 in strawberry/annotation.py

View check run for this annotation

Codecov / codecov/patch

strawberry/annotation.py#L236

Added line #L236 was not covered by tests

def create_type_var(self, evaled_type: TypeVar) -> StrawberryTypeVar:
return StrawberryTypeVar(evaled_type)

Expand Down Expand Up @@ -300,6 +314,12 @@
# A Union to be optional needs to have at least one None type
return any(x is type(None) for x in types)

@classmethod
def _is_required(cls, annotation: Any) -> bool:
"""Returns True if the annotation is Required[SomeType]."""
# check if the annotation is typing.Required
return annotation is Required

@classmethod
def _is_list(cls, annotation: Any) -> bool:
"""Returns True if annotation is a List."""
Expand Down
1 change: 1 addition & 0 deletions strawberry/schema/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class StrawberryConfig:
default_resolver: Callable[[Any, str], object] = getattr
relay_max_results: int = 100
disable_field_suggestions: bool = False
semantic_nullability_beta: bool = False
info_class: type[Info] = Info

def __post_init__(
Expand Down
4 changes: 3 additions & 1 deletion strawberry/schema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ class Query:
if has_object_definition(type_):
if type_.__strawberry_definition__.is_graphql_generic:
type_ = StrawberryAnnotation(type_).resolve() # noqa: PLW2901
graphql_type = self.schema_converter.from_maybe_optional(type_)
graphql_type = self.schema_converter.from_maybe_optional_or_required(
type_
)
if isinstance(graphql_type, GraphQLNonNull):
graphql_type = graphql_type.of_type
if not isinstance(graphql_type, GraphQLNamedType):
Expand Down
26 changes: 21 additions & 5 deletions strawberry/schema/schema_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
StrawberryList,
StrawberryObjectDefinition,
StrawberryOptional,
StrawberryRequired,
StrawberryType,
get_object_definition,
has_object_definition,
Expand All @@ -72,6 +73,7 @@
from strawberry.utils.await_maybe import await_maybe

from ..extensions.field_extension import build_field_extension_resolvers
from ..types.semantic_non_null import SemanticNonNull
from . import compat
from .types.concrete_type import ConcreteType

Expand Down Expand Up @@ -252,7 +254,7 @@

def from_argument(self, argument: StrawberryArgument) -> GraphQLArgument:
argument_type = cast(
"GraphQLInputType", self.from_maybe_optional(argument.type)
"GraphQLInputType", self.from_maybe_optional_or_required(argument.type)
)
default_value = Undefined if argument.default is UNSET else argument.default

Expand Down Expand Up @@ -367,6 +369,9 @@
},
)

def create_semantic_non_null_directive(self) -> SemanticNonNull:
return SemanticNonNull()

Check warning on line 373 in strawberry/schema/schema_converter.py

View check run for this annotation

Codecov / codecov/patch

strawberry/schema/schema_converter.py#L373

Added line #L373 was not covered by tests

def from_field(
self,
field: StrawberryField,
Expand All @@ -378,12 +383,19 @@
resolver = self.from_resolver(field)
field_type = cast(
"GraphQLOutputType",
self.from_maybe_optional(
self.from_maybe_optional_or_required(
field.resolve_type(type_definition=type_definition)
),
)
subscribe = None

if (
self.config.semantic_nullability_beta
and isinstance(field_type, GraphQLNonNull)
and not getattr(field_type, "strawberry_semantic_required_non_null", False)
):
field.directives.append(self.create_semantic_non_null_directive())

Check warning on line 397 in strawberry/schema/schema_converter.py

View check run for this annotation

Codecov / codecov/patch

strawberry/schema/schema_converter.py#L397

Added line #L397 was not covered by tests

if field.is_subscription:
subscribe = resolver
resolver = lambda event, *_, **__: event # noqa: E731
Expand Down Expand Up @@ -413,7 +425,7 @@
) -> GraphQLInputField:
field_type = cast(
"GraphQLInputType",
self.from_maybe_optional(
self.from_maybe_optional_or_required(
field.resolve_type(type_definition=type_definition)
),
)
Expand Down Expand Up @@ -586,7 +598,7 @@
return graphql_interface

def from_list(self, type_: StrawberryList) -> GraphQLList:
of_type = self.from_maybe_optional(type_.of_type)
of_type = self.from_maybe_optional_or_required(type_.of_type)

return GraphQLList(of_type)

Expand Down Expand Up @@ -804,14 +816,18 @@

return implementation

def from_maybe_optional(
def from_maybe_optional_or_required(
self, type_: Union[StrawberryType, type]
) -> Union[GraphQLNullableType, GraphQLNonNull]:
# ) -> Union[GraphQLNullableType, GraphQLNonNull, GraphQLSemanticNonNull]: TODO in the future this will include graphql semantic non null
NoneType = type(None)
if type_ is None or type_ is NoneType:
return self.from_type(type_)
elif isinstance(type_, StrawberryOptional):
return self.from_type(type_.of_type)
elif isinstance(type_, StrawberryRequired):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Missing return statement in StrawberryRequired branch

The code sets strawberry_semantic_required_non_null but doesn't return graphql_core_type, causing it to fall through to the else clause. Add 'return graphql_core_type' after setting the attribute.

graphql_core_type = GraphQLNonNull(self.from_type(type_))
graphql_core_type.strawberry_semantic_required_non_null = True

Check warning on line 830 in strawberry/schema/schema_converter.py

View check run for this annotation

Codecov / codecov/patch

strawberry/schema/schema_converter.py#L829-L830

Added lines #L829 - L830 were not covered by tests
else:
return GraphQLNonNull(self.from_type(type_))

Expand Down
3 changes: 3 additions & 0 deletions strawberry/types/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ class StrawberryList(StrawberryContainer): ...
class StrawberryOptional(StrawberryContainer): ...


class StrawberryRequired(StrawberryContainer): ...


class StrawberryTypeVar(StrawberryType):
def __init__(self, type_var: TypeVar) -> None:
self.type_var = type_var
Expand Down
18 changes: 18 additions & 0 deletions strawberry/types/semantic_non_null.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from strawberry.schema_directive import Location, schema_directive

from .field import field


@schema_directive(
locations=[
Location.FIELD_DEFINITION,
Location.OBJECT,
Location.INTERFACE,
Location.SCALAR,
Location.ENUM,
],
name="semanticNonNull",
print_definition=True,
)
class SemanticNonNull:
levels: list[int] = field(default_factory=lambda: [1])
63 changes: 63 additions & 0 deletions tests/schema/test_semantic_nullability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import textwrap
from typing import List, Optional, Required

import strawberry
from strawberry.schema.config import StrawberryConfig


def test_entities_type_when_no_type_has_keys():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): Test name doesn't match the actual test content

The test name suggests it's testing entity types without keys, but it appears to be testing semantic nullability behavior. Consider renaming the test to something like test_semantic_nullability_schema_generation or test_semantic_nullability_with_required_and_optional_fields to better reflect its purpose.

@strawberry.type()
class Product:
upc: str
name: str
price: Required[int]
weight: Optional[int]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Missing test cases for semantic nullability behavior

The test only verifies SDL generation but doesn't test the runtime behavior of Required and Optional fields. Consider adding test cases that verify: 1) Required fields actually enforce non-null values at runtime, 2) Optional fields accept null values, 3) Error cases when null is provided for Required fields.

Suggested implementation:

def test_entities_type_when_no_type_has_keys():
    """Test SDL generation for types with Required and Optional fields"""
=======

def test_required_field_rejects_null():
    """Test that Required fields raise an error when null is provided"""
    @strawberry.type
    class Product:
        upc: str
        name: str
        price: Required[int]
        weight: Optional[int]

    schema = strawberry.Schema(
        query=Product,
        config=StrawberryConfig(semantic_nullability_beta=True)
    )

    with pytest.raises(ValueError) as exc:
        Product(upc="123", name="test", price=None, weight=10)
    assert "price cannot be null" in str(exc.value)

def test_optional_field_accepts_null():
    """Test that Optional fields accept null values"""
    @strawberry.type
    class Product:
        upc: str
        name: str
        price: Required[int]
        weight: Optional[int]

    # Should not raise any errors
    product = Product(upc="123", name="test", price=100, weight=None)
    assert product.weight is None

def test_required_field_accepts_value():
    """Test that Required fields accept non-null values"""
    @strawberry.type
    class Product:
        upc: str
        name: str
        price: Required[int]
        weight: Optional[int]

    # Should not raise any errors
    product = Product(upc="123", name="test", price=100, weight=10)
    assert product.price == 100

You'll need to:

  1. Add import pytest at the top of the file if not already present
  2. Make sure the Required and Optional types are properly imported from wherever they are defined in your codebase
  3. Consider adding more edge cases based on your specific requirements (e.g., testing with different types, testing inheritance behavior, etc.)


@strawberry.federation.type(extend=True)
class Query:
@strawberry.field
def top_products(self, first: int) -> List[Product]:
return []

Check warning on line 20 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L16-L20

Added lines #L16 - L20 were not covered by tests

schema = strawberry.Schema(

Check warning on line 22 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L22

Added line #L22 was not covered by tests
query=Query, config=StrawberryConfig(semantic_nullability_beta=True)
)

expected_sdl = textwrap.dedent("""

Check warning on line 26 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L26

Added line #L26 was not covered by tests
type Product {
upc: String!
name: String
price: Int
weight: Int
}

extend type Query {
_service: _Service!
topProducts(first: Int!): [Product!]!
}

scalar _Any

type _Service {
sdl: String!
}
""").strip()

assert str(schema) == expected_sdl

Check warning on line 46 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L46

Added line #L46 was not covered by tests

query = """

Check warning on line 48 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L48

Added line #L48 was not covered by tests
query {
__type(name: "_Entity") {
kind
possibleTypes {
name
}
}
}
"""

result = schema.execute_sync(query)

Check warning on line 59 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L59

Added line #L59 was not covered by tests

assert not result.errors

Check warning on line 61 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L61

Added line #L61 was not covered by tests

assert result.data == {"__type": None}

Check warning on line 63 in tests/schema/test_semantic_nullability.py

View check run for this annotation

Codecov / codecov/patch

tests/schema/test_semantic_nullability.py#L63

Added line #L63 was not covered by tests
Loading