Skip to content
Merged
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
15 changes: 8 additions & 7 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions src/strawchemy/dto/backend/strawberry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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_)
Expand Down
96 changes: 74 additions & 22 deletions src/strawchemy/dto/inspectors/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from sqlalchemy import (
ARRAY,
Column,
ColumnDefault,
ColumnElement,
PrimaryKeyConstraint,
Sequence,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
15 changes: 10 additions & 5 deletions src/strawchemy/schema/factories/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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!
}
'''
1 change: 1 addition & 0 deletions tests/unit/mapping/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion tests/unit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/schemas/mutations/sql_expression_default.py
Original file line number Diff line number Diff line change
@@ -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)
Loading