diff --git a/tests/unit/dc_models.py b/tests/unit/dc_models.py index fe66deed..6261f182 100644 --- a/tests/unit/dc_models.py +++ b/tests/unit/dc_models.py @@ -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 @@ -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) diff --git a/tests/unit/dto/test_dto.py b/tests/unit/dto/test_dto.py index 455de40a..cf530bb6 100644 --- a/tests/unit/dto/test_dto.py +++ b/tests/unit/dto/test_dto.py @@ -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 @@ -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 diff --git a/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[nullable_fields].gql b/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[nullable_fields].gql new file mode 100644 index 00000000..c6f4b9c4 --- /dev/null +++ b/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[nullable_fields].gql @@ -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 +''' \ No newline at end of file diff --git a/tests/unit/mapping/test_schemas.py b/tests/unit/mapping/test_schemas.py index ae1d08f6..43fb347b 100644 --- a/tests/unit/mapping/test_schemas.py +++ b/tests/unit/mapping/test_schemas.py @@ -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 diff --git a/tests/unit/models.py b/tests/unit/models.py index c2832440..d4d8307d 100644 --- a/tests/unit/models.py +++ b/tests/unit/models.py @@ -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: diff --git a/tests/unit/schemas/nullable_fields.py b/tests/unit/schemas/nullable_fields.py new file mode 100644 index 00000000..a16df22c --- /dev/null +++ b/tests/unit/schemas/nullable_fields.py @@ -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