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
18 changes: 9 additions & 9 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ flag "--fail-under <fail_under>" default="100"
arg "[test]" var=#true
'''
run = [
"{{vars.uv_run}} diff-cover coverage.xml --compare-branch=${usage_branch} --fail-under=${usage_fail_under}",
"{{vars.uv_run}} diff-cover coverage.xml --compare-branch=${usage_branch} --fail-under=${usage_fail_under}",
]

[tasks."test:unit"]
Expand Down Expand Up @@ -301,14 +301,14 @@ description = "Clean working directory"
alias = "c"
confirm = "Are you sure you want to clean the working directory? This will remove test caches, build artifacts, and other temporary files."
run = [
"rm -rf {{vars.cleanable_paths}} >/dev/null 2>&1",
"find . -name '*.egg-info' -exec rm -rf {} + >/dev/null 2>&1",
"find . -type f -name '*.egg' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '*.pyc' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '*.pyo' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '*~' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '__pycache__' -exec rm -rf {} + >/dev/null 2>&1",
"find . -name '.ipynb_checkpoints' -exec rm -rf {} + >/dev/null 2>&1",
"rm -rf {{vars.cleanable_paths}} >/dev/null 2>&1",
"find . -name '*.egg-info' -exec rm -rf {} + >/dev/null 2>&1",
"find . -type f -name '*.egg' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '*.pyc' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '*.pyo' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '*~' -exec rm -f {} + >/dev/null 2>&1",
"find . -name '__pycache__' -exec rm -rf {} + >/dev/null 2>&1",
"find . -name '.ipynb_checkpoints' -exec rm -rf {} + >/dev/null 2>&1",
]

[tasks."render:usage"]
Expand Down
4 changes: 0 additions & 4 deletions src/strawchemy/dto/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,8 +694,6 @@ def decorator(
exclude: FieldSpec | None = None,
partial: bool | None = None,
type_map: Mapping[Any, Any] | None = None,
aliases: Mapping[str, str] | None = None,
alias_generator: Callable[[str], str] | None = None,
**kwargs: Any,
) -> Callable[[type[Any]], type[DTOBaseT]]:
def wrapper(class_: type[Any]) -> type[DTOBaseT]:
Expand All @@ -707,8 +705,6 @@ def wrapper(class_: type[Any]) -> type[DTOBaseT]:
exclude=exclude,
partial=partial,
type_map=type_map,
aliases=aliases,
alias_generator=alias_generator,
),
base=class_,
name=class_.__name__,
Expand Down
2 changes: 2 additions & 0 deletions src/strawchemy/dto/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ def union(self, other: DTOConfig) -> DTOConfig:
global_exclude = FieldSet(self.global_exclude) | other.global_exclude
type_overrides = dict(self.type_overrides) | dict(other.type_overrides)
annotation_overrides = self.annotation_overrides | other.annotation_overrides
aliases = {**self.aliases, **other.aliases}
tags = self.tags | other.tags

return self.copy_with(
Expand All @@ -334,6 +335,7 @@ def union(self, other: DTOConfig) -> DTOConfig:
global_exclude=global_exclude,
type_overrides=type_overrides,
annotation_overrides=annotation_overrides,
aliases=aliases,
tags=tags,
)

Expand Down
11 changes: 11 additions & 0 deletions src/strawchemy/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ def field(
pagination: bool | DefaultOffsetPagination | None = None,
distinct_on: FieldSpec | type[EnumDTO] | None = None,
arguments: list[StrawberryArgument] | None = None,
model_field: str | None = None,
id_field_name: str | None = None,
root_aggregations: bool = False,
filter_statement: FilterStatementCallable | None = None,
Expand Down Expand Up @@ -212,6 +213,7 @@ def field(
pagination: bool | DefaultOffsetPagination | None = None,
distinct_on: FieldSpec | type[EnumDTO] | None = None,
arguments: list[StrawberryArgument] | None = None,
model_field: str | None = None,
id_field_name: str | None = None,
root_aggregations: bool = False,
filter_statement: FilterStatementCallable | None = None,
Expand Down Expand Up @@ -240,6 +242,7 @@ def field(
pagination: bool | DefaultOffsetPagination | None = None,
distinct_on: FieldSpec | type[EnumDTO] | None = None,
arguments: list[StrawberryArgument] | None = None,
model_field: str | None = None,
id_field_name: str | None = None,
root_aggregations: bool = False,
filter_statement: FilterStatementCallable | None = None,
Expand Down Expand Up @@ -273,6 +276,10 @@ def field(
pagination: Enables pagination for the field. Can be True for default
offset pagination or a DefaultOffsetPagination instance for customization.
arguments: A list of additional StrawberryArgument instances for the field.
model_field: Name of the model attribute this field maps to. Lets a
schema field use a different name than the underlying model field.
Raises StrawchemyFieldError at decoration time if the named model
field does not exist.
id_field_name: The name of the ID field, used for certain operations.
root_aggregations: If True, enables root-level aggregations for the field.
filter_statement: A callable to generate a filter statement for the query.
Expand All @@ -297,6 +304,9 @@ def field(
namespace = self._annotation_namespace()
type_annotation = StrawberryAnnotation.from_annotation(graphql_type, namespace) if graphql_type else None

if model_field is not None:
root_field = False

field = StrawchemyField(
config=self.config,
repository_type=repository_type,
Expand All @@ -310,6 +320,7 @@ def field(
distinct_on=distinct_on,
root_aggregations=root_aggregations,
query_hook=query_hook,
model_field=model_field,
python_name=None,
graphql_name=name,
type_annotation=type_annotation,
Expand Down
14 changes: 12 additions & 2 deletions src/strawchemy/schema/factories/_kwargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"FactoryMethodKwargs",
"ForwardedFactoryKwargs",
"InputDecoratorKwargs",
"LegacyKwargs",
"MakeInputKwargs",
"RegistrationKwargs",
"TypeDecoratorKwargs",
Expand All @@ -45,6 +46,15 @@ class DTOConfigKwargs(TypedDict, total=False):
exclude: FieldSpec | None
partial: bool | None
type_map: Mapping[Any, Any] | None


class LegacyKwargs(TypedDict, total=False):
"""Legacy aliasing args, exposed only on the type/input decorators.

``aliases`` is deprecated in favour of field-level
``strawchemy.field(model_field=...)``; ``alias_generator`` remains supported.
"""

aliases: Mapping[str, str] | None
alias_generator: Callable[[str], str] | None

Expand Down Expand Up @@ -101,11 +111,11 @@ class DecoratorKwargs(DTOConfigKwargs, RegistrationKwargs, total=False):
"""Composite kwargs for plain ``.decorator()`` / ``.input()`` on enum factories."""


class TypeDecoratorKwargs(DTOConfigKwargs, RegistrationKwargs, TypeWrapperKwargs, total=False):
class TypeDecoratorKwargs(DTOConfigKwargs, LegacyKwargs, RegistrationKwargs, TypeWrapperKwargs, total=False):
"""Composite kwargs for public ``.type()`` decorator."""


class InputDecoratorKwargs(DTOConfigKwargs, RegistrationKwargs, total=False):
class InputDecoratorKwargs(DTOConfigKwargs, LegacyKwargs, RegistrationKwargs, total=False):
"""Composite kwargs for public ``.input()`` decorator."""


Expand Down
118 changes: 101 additions & 17 deletions src/strawchemy/schema/factories/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import warnings
from enum import Enum
from functools import cached_property
from inspect import getmembers
from typing import TYPE_CHECKING, Any, ForwardRef, Literal, Optional, TypeAlias, TypeVar, get_type_hints

from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
Expand All @@ -43,8 +44,9 @@
)
from strawchemy.dto.types import DTOAuto, DTOConfig, DTOScope, DTOSkip, Purpose, is_fields_iterable
from strawchemy.dto.utils import config
from strawchemy.exceptions import EmptyDTOError, StrawchemyError
from strawchemy.exceptions import EmptyDTOError, StrawchemyError, StrawchemyFieldError
from strawchemy.instance import MapperModelInstance
from strawchemy.schema.field import StrawchemyField
from strawchemy.transpiler import hook
from strawchemy.typing import GraphQLDTOT, GraphQLPurpose, GraphQLType, MappedGraphQLDTO
from strawchemy.utils.annotation import get_annotations, inner_types, try_resolve_forwardref
Expand All @@ -55,7 +57,7 @@

from strawchemy import Strawchemy
from strawchemy.dto.inspectors import SQLAlchemyGraphQLInspector
from strawchemy.dto.types import FieldSpec
from strawchemy.dto.types import FieldSelector, FieldSpec
from strawchemy.schema.factories._kwargs import (
InputDecoratorKwargs,
MakeInputKwargs,
Expand Down Expand Up @@ -168,9 +170,77 @@ def _resolve_config(self, dto_config: DTOConfig, base: type[Any]) -> DTOConfig:
if type_has_annotation(annotation, StrawberryAuto):
config.annotation_overrides[name] = DTOAuto
base_annotations_copy.pop(name)
# Reverse-map model_field aliases so the DTO factory finds the annotation
# override under the model field name and includes the aliased field.
# A `model_field` declaration always wins: the aliased model field is added
# to the include set even if it appears in an explicit `exclude`.
reverse_aliases = {schema_name: model_name for model_name, schema_name in config.aliases.items()}
extra_include: set[FieldSelector] = set()
for schema_name, model_name in reverse_aliases.items():
if schema_name in base_annotations and model_name not in config.annotation_overrides:
config.annotation_overrides[model_name] = base_annotations[schema_name]
extra_include.add(model_name)
if extra_include:
config = config | DTOConfig(config.purpose, include=extra_include)
base.__annotations__ = base_annotations_copy
Comment thread
gazorby marked this conversation as resolved.
return config

def collect_field_model_aliases(
self,
class_: type[Any],
model: type[DeclarativeBase],
dto_config: DTOConfig,
) -> dict[str, str]:
"""Build an alias delta from a class body's ``model_field`` declarations.

Scans ``class_`` for ``StrawchemyField``s that carry a ``model_field`` and
maps each model field name to the schema attribute name it is declared
under, so the existing alias machinery renders the field under its schema
name while preserving the model linkage for data resolution.

Args:
class_: The decorated class whose body is scanned for declared fields.
model: The SQLAlchemy model the type maps to.
dto_config: Config used to enumerate the model's fields (via the
inspector).

Returns:
A mapping of model field name to schema field name for every declared
field that carries a ``model_field``.

Raises:
StrawchemyFieldError: If a ``model_field`` target is not a mapped
attribute of ``model``, or if two declared fields target the same
model field.
"""
valid_names = {name for name, _ in self.inspector.field_definitions(model, dto_config)}
alias_delta: dict[str, str] = {}

for attr_name, value in getmembers(class_):
if not isinstance(value, StrawchemyField):
continue
target = value.model_field
if target is None:
continue
if target not in valid_names:
msg = f"Model field '{target}' not found on {model.__name__}"
raise StrawchemyFieldError(msg)
if attr_name in valid_names and attr_name != target:
msg = (
f"Schema field '{attr_name}' shadows a different model field on "
f"{model.__name__}; rename the schema field or alias that column too"
)
raise StrawchemyFieldError(msg)
if target in alias_delta:
msg = (
f"Model field '{target}' is targeted by multiple schema fields: "
f"'{alias_delta[target]}' and '{attr_name}'"
)
raise StrawchemyFieldError(msg)
alias_delta[target] = attr_name

return alias_delta

def _config(
self,
purpose: Purpose,
Expand All @@ -182,21 +252,34 @@ def _config(
alias_generator: Callable[[str], str] | None = None,
scope: DTOScope | None = None,
tags: set[str] | None = None,
model: type[DeclarativeBase] | None = None,
class_: type[Any] | None = None,
) -> DTOConfig:
return (
if aliases is not None:
warnings.warn(
"The `aliases` parameter is deprecated; use field-level `strawchemy.field(model_field=...)` instead.",
DeprecationWarning,
stacklevel=2,
)
dto_config = (
config(
purpose,
include=include,
exclude=exclude,
partial=partial,
type_map=type_map,
alias_generator=alias_generator,
aliases=aliases,
alias_generator=alias_generator,
scope=scope,
tags=tags,
)
| self._mapper.config.field_config
)
if model is not None and class_ is not None:
delta = self.collect_field_model_aliases(class_, model, dto_config)
if delta:
dto_config = dto_config | DTOConfig(dto_config.purpose, aliases=delta)
return dto_config

def _type_order_by(
self, model: type[DeclarativeBase], include: FieldSpec | type[OrderByDTO] | None = None
Expand Down Expand Up @@ -270,19 +353,18 @@ def _type_wrapper(
scope: DTOScope | None = None,
) -> Callable[[type[Any]], type[GraphQLDTOT]]:
def wrapper(class_: type[Any]) -> type[GraphQLDTOT]:
dto_config = (
config(
purpose,
include=include,
exclude=exclude,
partial=partial,
type_map=type_map,
alias_generator=alias_generator,
aliases=aliases,
scope=scope,
tags={mode},
)
| self._mapper.config.field_config
dto_config = self._config(
purpose,
include=include,
exclude=exclude,
partial=partial,
type_map=type_map,
aliases=aliases,
alias_generator=alias_generator,
scope=scope,
tags={mode},
model=model,
class_=class_,
)

order_by_input = self._type_order_by(model, order)
Expand Down Expand Up @@ -348,6 +430,8 @@ def wrapper(class_: type[Any]) -> type[GraphQLDTOT]:
aliases=aliases,
scope=scope,
tags={mode},
model=model,
class_=class_,
)
return self.make_input(
model=model,
Expand Down
10 changes: 8 additions & 2 deletions src/strawchemy/schema/factories/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
StrawchemyMappedFactory,
UpsertConflictEnumBackend,
)
from strawchemy.schema.field import StrawchemyField
from strawchemy.schema.mutation import (
RequiredToManyUpdateInput,
RequiredToOneInput,
Expand Down Expand Up @@ -224,9 +225,14 @@ def _add_fields_arguments(
distinct_on_config = DTOConfig.from_include(distinct_on)

# Make sure Class-body `@strawberry.field` resolvers take precedence over auto-derived
# JSON-path projection and relation arguments.
# JSON-path projection and relation arguments. Exclude model_field alias declarations
# — those are alias mappings, not resolvers, and must keep their annotations.
body_fields = (
{name for name, _ in inspect.getmembers(base, lambda v: isinstance(v, StrawberryField))}
{
name
for name, field_ in inspect.getmembers(base, lambda v: isinstance(v, StrawberryField))
if not (isinstance(field_, StrawchemyField) and field_.model_field is not None)
}
if base is not None
else set()
)
Expand Down
4 changes: 4 additions & 0 deletions src/strawchemy/schema/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class StrawchemyField(StrawberryField):
Attributes:
arguments: A list of StrawberryArgument instances representing the arguments
that the resolver function accepts.
model_field: Name of the SQLAlchemy model attribute this schema field maps
to, or None if the field uses its own name.
"""

@override
Expand All @@ -97,6 +99,7 @@ def __init__(
execution_options: dict[str, Any] | None = None,
id_field_name: str | None = None,
arguments: list[StrawberryArgument] | None = None,
model_field: str | None = None,
# Original StrawberryField args
python_name: str | None = None,
graphql_name: str | None = None,
Expand All @@ -120,6 +123,7 @@ def __init__(
self.is_root_field = root_field
self.root_aggregations = root_aggregations
self.query_hook = query_hook
self.model_field = model_field

self.id_field_name = config.default_id_field_name if id_field_name is None else id_field_name

Expand Down
2 changes: 0 additions & 2 deletions src/strawchemy/validation/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ def input(
exclude: FieldSpec | None = None,
partial: bool | None = None,
type_map: Mapping[Any, Any] | None = None,
aliases: Mapping[str, str] | None = None,
alias_generator: Callable[[str], str] | None = None,
name: str | None = None,
description: str | None = None,
directives: Sequence[object] | None = (),
Expand Down
Loading