From 7489b23591ad24e89c1e7f7430c19cd6c4f61d5b Mon Sep 17 00:00:00 2001 From: gazorby Date: Wed, 10 Jun 2026 20:32:37 +0200 Subject: [PATCH] feat(dto): make database-resolved defaults optional in GraphQL inputs test: add model and mutation for SQL expression defaults --- mise.toml | 15 +-- src/strawchemy/dto/backend/strawberry.py | 7 +- src/strawchemy/dto/inspectors/sqlalchemy.py | 96 ++++++++++++++----- src/strawchemy/schema/factories/base.py | 15 ++- ...tation_schemas[sql_expression_default].gql | 25 +++++ tests/unit/mapping/test_schemas.py | 1 + tests/unit/models.py | 15 ++- .../mutations/sql_expression_default.py | 21 ++++ 8 files changed, 158 insertions(+), 37 deletions(-) create mode 100644 tests/unit/mapping/__snapshots__/test_schemas/test_mutation_schemas[sql_expression_default].gql create mode 100644 tests/unit/schemas/mutations/sql_expression_default.py diff --git a/mise.toml b/mise.toml index 287b9b5a..71eb323e 100644 --- a/mise.toml +++ b/mise.toml @@ -10,6 +10,7 @@ actionlint = "latest" [vars] local_pytest_options = "-n=auto -vv" +uv_run = "uv run --frozen" pytest_coverage_options = "--cov-config=./pyproject.toml --cov=src --cov-report=html" ci_uv_run_options = " --frozen --no-default-groups --only-group=nox" ci_pytest_coverage_options = "--cov-config=./pyproject.toml --cov=src --junit-xml=./junit.xml -o junit_family=legacy" @@ -227,31 +228,31 @@ run = "uv run {{vars.ci_uv_run_options}} nox --json -t tests -l | jq 'map(.name) [tasks."ruff:check"] description = "Check ruff formatting" -run = "uv run ruff --version && uv run ruff check" +run = "{{vars.uv_run}} ruff --version && {{vars.uv_run}} ruff check" [tasks."ruff:fix"] description = "Fix ruff errors" -run = "uv run ruff check --fix --unsafe-fixes" +run = "{{vars.uv_run}} ruff check --fix --unsafe-fixes" [tasks."ruff:format"] description = "Format code" -run = "uv run ruff format" +run = "{{vars.uv_run}} ruff format" [tasks."ruff:format:check"] description = "Format code" -run = "uv run ruff --version && uv run ruff format --check" +run = "{{vars.uv_run}} ruff --version && {{vars.uv_run}} ruff format --check" [tasks.tombi] description = "Run tombi" -run = "uv run tombi format" +run = "{{vars.uv_run}} tombi format" [tasks.ty] description = "Run ty" -run = "uv run ty --version && uv run ty check" +run = "{{vars.uv_run}} ty --version && {{vars.uv_run}} ty check" [tasks.vulture] description = "Run vulture" -run = "uv run vulture --version && uv run vulture" +run = "{{vars.uv_run}} vulture --version && uv run vulture" [tasks.format] description = "Lint the code" diff --git a/src/strawchemy/dto/backend/strawberry.py b/src/strawchemy/dto/backend/strawberry.py index 45ef1d73..118007ea 100644 --- a/src/strawchemy/dto/backend/strawberry.py +++ b/src/strawchemy/dto/backend/strawberry.py @@ -11,7 +11,7 @@ from strawchemy.dto import Purpose from strawchemy.dto.base import DTOBackend, DTOBase, MappedDTO, ModelFieldT, ModelT -from strawchemy.dto.types import DTOMissing +from strawchemy.dto.types import DTOMissing, DTOUnset from strawchemy.utils.annotation import get_annotations if TYPE_CHECKING: @@ -57,7 +57,10 @@ def _construct_field_info(self, field_def: DTOFieldDefinition[ModelT, ModelField else: strawberry_field = strawberry.field(default=strawberry.UNSET) if field_def.default is not DTOMissing: - strawberry_field = strawberry.field(default=field_def.default) + # DTOUnset marks a database-resolved default (sequence or SQL expression); + # render it as UNSET so the field is optional in write inputs. + default = strawberry.UNSET if field_def.default is DTOUnset else field_def.default + strawberry_field = strawberry.field(default=default) if strawberry_field: return FieldInfo(field_def.name, field_def.type_, strawberry_field) return FieldInfo(field_def.name, field_def.type_) diff --git a/src/strawchemy/dto/inspectors/sqlalchemy.py b/src/strawchemy/dto/inspectors/sqlalchemy.py index 2feea89c..bda85e59 100644 --- a/src/strawchemy/dto/inspectors/sqlalchemy.py +++ b/src/strawchemy/dto/inspectors/sqlalchemy.py @@ -13,6 +13,7 @@ from sqlalchemy import ( ARRAY, Column, + ColumnDefault, ColumnElement, PrimaryKeyConstraint, Sequence, @@ -68,6 +69,7 @@ from shapely import Geometry from sqlalchemy.orm import MapperProperty + from sqlalchemy.schema import DefaultGenerator from sqlalchemy.sql.schema import ColumnCollectionConstraint from strawchemy.repository.typing import FilterMap @@ -224,6 +226,53 @@ def _column_or_relationship( except KeyError: return attribute.parent.mapper.relationships[attribute.key] + @classmethod + def _column_default( + cls, default: DefaultGenerator, default_factory: Callable[..., Any] | type[DTOMissing] + ) -> tuple[Any | type[DTOMissing], Callable[..., Any] | type[DTOMissing]]: + """Resolve a SQLAlchemy column ``default`` into a DTO (default, default_factory) pair. + + Args: + default: The column's ``ColumnDefault`` (already known to be set and non-None). + default_factory: The factory resolved so far, used as fallback. + + Returns: + The (resolved_default, default_factory) pair to apply to the DTO field. + + Raises: + ValueError: If the default is of an unsupported type. + """ + resolved_default: Any = default + if isinstance(default, Sequence): + resolved_default = DTOUnset + elif default.is_clause_element: + # SQL-expression default (e.g. func.now()) is resolved by the + # database at INSERT, like a server-side sequence above; there is no + # Python value/factory to mirror, so the field is left unset and + # becomes optional in write inputs. + resolved_default = DTOUnset + elif isinstance(default, ColumnDefault): + if default.is_scalar: + resolved_default = default.arg + elif default.is_callable: + default_callable = default.arg.__func__ if isinstance(default.arg, staticmethod) else default.arg + if ( + # Eager test because inspect.signature() does not + # recognize builtins + hasattr(builtins, default_callable.__name__) + # If present, context contains information about the current + # statement and can be used to access values from other columns. + # As we can't reproduce such context in DTO, we don't want + # include a default_factory in that case. + or "context" not in signature(default_callable).parameters + ): + default_arg = default.arg + default_factory = lambda: default_arg({}) # noqa: E731 + else: + msg = "Unexpected default type" + raise ValueError(msg) + return resolved_default, default_factory + @classmethod def _defaults( cls, attribute: MapperProperty[Any] @@ -242,31 +291,13 @@ def _defaults( default_factory = ( getattr(element, "default_factory", DTOMissing) if default_factory is DTOMissing else default_factory ) - default = getattr(element, "default", DTOMissing) if default is DTOMissing else default + default: DefaultGenerator | type[DTOMissing] | None = ( + getattr(element, "default", DTOMissing) if default is DTOMissing else default + ) if isinstance(element, Column): if default is not DTOMissing and default is not None: - if default.is_scalar: - default = default.arg - elif default.is_callable: - default_callable = default.arg.__func__ if isinstance(default.arg, staticmethod) else default.arg - if ( - # Eager test because inspect.signature() does not - # recognize builtins - hasattr(builtins, default_callable.__name__) - # If present, context contains information about the current - # statement and can be used to access values from other columns. - # As we can't reproduce such context in Pydantic, we don't want - # include a default_factory in that case. - or "context" not in signature(default_callable).parameters - ): - default_arg = default.arg - default_factory = lambda: default_arg({}) # noqa: E731 - elif isinstance(default, Sequence): - default = DTOUnset - else: - msg = "Unexpected default type" - raise ValueError(msg) + default, default_factory = cls._column_default(default, default_factory) elif default is None and not element.nullable: default = DTOMissing elif isinstance(element, RelationshipProperty) and default is DTOMissing and element.uselist: @@ -444,6 +475,27 @@ def relation_cycle( def has_default(self, model_field: QueryableAttribute[Any]) -> bool: return any(default is not DTOMissing for default in self._defaults(model_field.property)) + def has_db_resolved_default(self, model_field: QueryableAttribute[Any]) -> bool: + """Whether the column value is generated by the database when omitted. + + True for columns whose default is a SQL expression (e.g. ``func.now()``) + or a sequence: the database fills the value at INSERT, so the column is + optional in write inputs. + + Args: + model_field: The model attribute to inspect. + + Returns: + ``True`` if the column has a database-resolved default. + """ + if not self._is_column(model_field.property): + return False + return any( + (default := getattr(column, "default", DTOMissing)) is not DTOMissing + and (getattr(default, "is_clause_element", False) or isinstance(default, Sequence)) + for column in model_field.property.columns + ) + @override def required(self, model_field: QueryableAttribute[Any]) -> bool: if self._is_column(model_field.property): diff --git a/src/strawchemy/schema/factories/base.py b/src/strawchemy/schema/factories/base.py index 7496f303..d9ec7aaf 100644 --- a/src/strawchemy/schema/factories/base.py +++ b/src/strawchemy/schema/factories/base.py @@ -495,11 +495,16 @@ def _root_input_config(self, model: builtins.type[Any], dto_config: DTOConfig, m exclude_defaults = True if mode in {"update_by_pk_input", "update_by_filter_input"}: partial = True - # Exclude default generated PKs for create inputs, if not explicitly included - else: - for name, field in id_fields: - # Exclude rules are deliberately ignored: default-generated PKs stay as optional inputs. - if field in dto_config.included_fields and self.inspector.has_default(field.model_field): + # Make create-input columns optional when their value can be omitted: + # a primary key with a default (generated PK), or any column whose value + # is generated by the database (SQL expression or sequence default). + elif dto_config.include == "all": + for name, field in self.inspector.field_definitions(model, dto_config): + model_field = field.model_field + if field in dto_config.included_fields and ( + self.inspector.has_db_resolved_default(model_field) + or (self.inspector.is_primary_key(model_field) and self.inspector.has_default(model_field)) + ): annotations_overrides[name] = Optional[field.type_hint] return dto_config.copy_with( annotation_overrides=annotations_overrides, diff --git a/tests/unit/mapping/__snapshots__/test_schemas/test_mutation_schemas[sql_expression_default].gql b/tests/unit/mapping/__snapshots__/test_schemas/test_mutation_schemas[sql_expression_default].gql new file mode 100644 index 00000000..e14e6d30 --- /dev/null +++ b/tests/unit/mapping/__snapshots__/test_schemas/test_mutation_schemas[sql_expression_default].gql @@ -0,0 +1,25 @@ +''' +"""Date with time (isoformat)""" +scalar DateTime + +type Mutation { + """Fetch object from the TimestampedRecordType collection by id""" + createRecord(data: TimestampedRecordCreateInput!): TimestampedRecordType! +} + +type Query { + hello: String! +} + +"""Create input""" +input TimestampedRecordCreateInput { + id: Int! + createdAt: DateTime +} + +"""GraphQL type""" +type TimestampedRecordType { + id: Int! + createdAt: DateTime! +} +''' \ No newline at end of file diff --git a/tests/unit/mapping/test_schemas.py b/tests/unit/mapping/test_schemas.py index 04754336..dcaa6d2b 100644 --- a/tests/unit/mapping/test_schemas.py +++ b/tests/unit/mapping/test_schemas.py @@ -343,6 +343,7 @@ def test_geo_schemas(path: str, graphql_snapshot: SnapshotAssertion) -> None: pytest.param("delete.Mutation", id="delete_mutation"), pytest.param("create_no_id.Mutation", id="create_no_id"), pytest.param("upsert.Mutation", id="upsert"), + pytest.param("sql_expression_default.Mutation", id="sql_expression_default"), ], ) @pytest.mark.snapshot diff --git a/tests/unit/models.py b/tests/unit/models.py index a9580c9d..3e03d1a3 100644 --- a/tests/unit/models.py +++ b/tests/unit/models.py @@ -8,7 +8,7 @@ from typing import Any from uuid import UUID, uuid4 -from sqlalchemy import VARCHAR, Column, DateTime, Enum, ForeignKey, Table, Text, UniqueConstraint +from sqlalchemy import VARCHAR, Column, DateTime, Enum, ForeignKey, Table, Text, UniqueConstraint, func from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import DeclarativeBase, Mapped, column_property, mapped_column, relationship @@ -212,6 +212,19 @@ class Container(UUIDBase): colors: Mapped[list[Color]] = relationship("Color", primaryjoin="Container.id == foreign(Color.id)", viewonly=True) +class SQLDefaultBase(DeclarativeBase): + __abstract__ = True + + +class TimestampedRecord(SQLDefaultBase): + """Model exercising a client-side SQL-expression default (func.now()).""" + + __tablename__ = "timestamped_record" + + id: Mapped[int] = mapped_column(primary_key=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + # Geo if GEO_INSTALLED: diff --git a/tests/unit/schemas/mutations/sql_expression_default.py b/tests/unit/schemas/mutations/sql_expression_default.py new file mode 100644 index 00000000..ed69bed8 --- /dev/null +++ b/tests/unit/schemas/mutations/sql_expression_default.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import strawberry + +from strawchemy import Strawchemy +from tests.unit.models import TimestampedRecord + +strawchemy = Strawchemy("postgresql") + + +@strawchemy.type(TimestampedRecord, include="all") +class TimestampedRecordType: ... + + +@strawchemy.create_input(TimestampedRecord, include="all") +class TimestampedRecordCreateInput: ... + + +@strawberry.type +class Mutation: + create_record: TimestampedRecordType = strawchemy.create(TimestampedRecordCreateInput)