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
26 changes: 25 additions & 1 deletion tests/unit/dc_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from uuid import UUID, uuid4

from sqlalchemy import ForeignKey
from sqlalchemy import VARCHAR, ForeignKey
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, column_property, mapped_column, relationship

Expand Down Expand Up @@ -82,3 +82,27 @@ class UserWithGreetingDataclass(UUIDBase):
@hybrid_property
def greeting_hybrid_property(self) -> str:
return f"Hello, {self.name}"


class NullableTestModelDataclass(UUIDBase):
__tablename__ = "nullable_test_dataclass"

# Style 3: Type hint NOT optional, no explicit nullable (infers nullable=False, both agree)
non_optional_nullable_not_set: Mapped[str] = mapped_column(VARCHAR(255))

# Style 6: Type hint NOT optional + explicit nullable=False (both agree)
non_optional_nullable_false: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)

# Style 1: Type hint optional + explicit nullable=True (both agree)
optional_nullable_true: Mapped[str | None] = mapped_column(VARCHAR(255), nullable=True, default=None)

# Style 2: Type hint NOT optional + explicit nullable=True (MISMATCH)
non_optional_nullable_true: Mapped[str] = mapped_column(VARCHAR(255), nullable=True, default="")

# Style 4: Type hint optional, no explicit nullable (infers nullable=True)
optional_nullable_not_set: Mapped[str | None] = mapped_column(VARCHAR(255), default=None)

# Style 5: Type hint optional + explicit nullable=False (MISMATCH opposite)
optional_nullable_false: Mapped[str | None] = mapped_column(VARCHAR(255), nullable=False, default="")

id: Mapped[UUID] = mapped_column(primary_key=True, default_factory=uuid4)
50 changes: 49 additions & 1 deletion tests/unit/dto/test_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,23 @@
AdminDataclass,
ColorDataclass,
FruitDataclass,
NullableTestModelDataclass,
SponsoredUserDataclass,
TomatoDataclass,
UserWithGreetingDataclass,
)
from tests.unit.models import Admin, Book, Color, Fruit, SponsoredUser, Tag, Tomato, UserWithGreeting
from tests.unit.models import (
Admin,
Book,
Color,
Department,
Fruit,
NullableTestModel,
SponsoredUser,
Tag,
Tomato,
UserWithGreeting,
)
from tests.utils import DTOInspect, factory_iterator


Expand Down Expand Up @@ -266,3 +278,39 @@ def test_forward_refs_resolved(name: str, sqlalchemy_pydantic_factory: MappedPyd
],
}
)


@pytest.mark.parametrize("model", [NullableTestModel, NullableTestModelDataclass])
@pytest.mark.parametrize("factory", factory_iterator())
def test_nullable_declaration_styles(
factory: AnyFactory, model: type[NullableTestModel | NullableTestModelDataclass]
) -> None:
dto = factory.factory(model, read_all_config)

# Style 1: Mapped[str | None] + nullable=True (both agree)
# DB nullable=True -> Expected: str | None
assert DTOInspect(dto).field_type("optional_nullable_true") == str | None

# Style 2: Mapped[str] + nullable=True (MISMATCH - type hint says required, DB says nullable)
# DB nullable=True -> Expected: str | None
assert DTOInspect(dto).field_type("non_optional_nullable_true") is str
# IMO we should look to the sqlalchemy declaration and be str | None, like the commented assert below does
# assert DTOInspect(dto).field_type("non_optional_nullable_true") == str | None

# Style 3: Mapped[str] (infers nullable=False)
# DB nullable=False -> Expected: str
assert DTOInspect(dto).field_type("non_optional_nullable_not_set") is str

# Style 4: Mapped[str | None] (infers nullable=True)
# DB nullable=True -> Expected: str | None
assert DTOInspect(dto).field_type("optional_nullable_not_set") == str | None

# Style 5: Mapped[str | None] + nullable=False (MISMATCH - type hint says optional, DB says required)
# DB nullable=False -> Expected: str
assert DTOInspect(dto).field_type("optional_nullable_false") == str | None
# IMO we should look to the sqlalchemy declaration and be str, like the commented assert below does
# assert DTOInspect(dto).field_type("optional_nullable_false") is str

# Style 6: Mapped[str] + nullable=False (both agree)
# DB nullable=False -> Expected: str
assert DTOInspect(dto).field_type("non_optional_nullable_false") is str
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'''
"""GraphQL type"""
type NullableTestModelType {
optionalNullableTrue: String
nonOptionalNullableTrue: String!
nonOptionalNullableNotSet: String!
optionalNullableNotSet: String
optionalNullableFalse: String
nonOptionalNullableFalse: String!
id: UUID!
}

type Query {
nullableTest: NullableTestModelType!
}

scalar UUID
'''
1 change: 1 addition & 0 deletions tests/unit/mapping/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ def test_update_mutation_by_filter_type_not_list_fail() -> None:
pytest.param("scope.schema_before.Query", id="scope_schema_before"),
pytest.param("scope.schema_after.Query", id="scope_schema_after"),
pytest.param("scope.schema_in_the_middle.Query", id="scope_schema_in_the_middle"),
pytest.param("nullable_fields.Query", id="nullable_fields"),
],
)
@pytest.mark.snapshot
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,28 @@ class SQLDataTypes(UUIDBase):
array_str_col: Mapped[list[str]] = mapped_column(postgresql.ARRAY(Text), default=list)


class NullableTestModel(UUIDBase):
__tablename__ = "nullable_test"

# Style 1: Type hint optional + explicit nullable=True (both agree)
optional_nullable_true: Mapped[str | None] = mapped_column(VARCHAR(255), nullable=True)

# Style 2: Type hint NOT optional + explicit nullable=True (MISMATCH)
non_optional_nullable_true: Mapped[str] = mapped_column(VARCHAR(255), nullable=True)

# Style 3: Type hint NOT optional, no explicit nullable (infers nullable=False)
non_optional_nullable_not_set: Mapped[str] = mapped_column(VARCHAR(255))

# Style 4: Type hint optional, no explicit nullable (infers nullable=True)
optional_nullable_not_set: Mapped[str | None] = mapped_column(VARCHAR(255))

# Style 5: Type hint optional + explicit nullable=False (MISMATCH opposite)
optional_nullable_false: Mapped[str | None] = mapped_column(VARCHAR(255), nullable=False)

# Style 6: Type hint NOT optional + explicit nullable=False (both agree)
non_optional_nullable_false: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)


# Geo

if GEO_INSTALLED:
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/schemas/nullable_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from __future__ import annotations

from strawchemy import Strawchemy

import strawberry
from tests.unit.models import NullableTestModel

strawchemy = Strawchemy("postgresql")


@strawchemy.type(NullableTestModel, include="all")
class NullableTestModelType:
pass


@strawberry.type
class Query:
nullable_test: NullableTestModelType
Loading