From 5c7ff13ffb40ead61fc5e7bdff228a74ef28b2f5 Mon Sep 17 00:00:00 2001 From: gazorby Date: Fri, 12 Jun 2026 19:13:05 +0200 Subject: [PATCH] feat: add field-level alias --- mise.toml | 18 +- src/strawchemy/dto/base.py | 4 - src/strawchemy/dto/types.py | 2 + src/strawchemy/mapper.py | 11 + src/strawchemy/schema/factories/_kwargs.py | 14 +- src/strawchemy/schema/factories/base.py | 118 ++++++++-- src/strawchemy/schema/factories/types.py | 10 +- src/strawchemy/schema/field.py | 4 + src/strawchemy/validation/pydantic.py | 2 - tests/unit/mapping/test_model_field.py | 222 ++++++++++++++++++ tests/unit/schemas/model_field/__init__.py | 0 .../schemas/model_field/duplicate_target.py | 12 + .../model_field/missing_model_field.py | 11 + 13 files changed, 392 insertions(+), 36 deletions(-) create mode 100644 tests/unit/mapping/test_model_field.py create mode 100644 tests/unit/schemas/model_field/__init__.py create mode 100644 tests/unit/schemas/model_field/duplicate_target.py create mode 100644 tests/unit/schemas/model_field/missing_model_field.py diff --git a/mise.toml b/mise.toml index 9ddb2445..fc2ac23f 100644 --- a/mise.toml +++ b/mise.toml @@ -82,7 +82,7 @@ flag "--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"] @@ -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"] diff --git a/src/strawchemy/dto/base.py b/src/strawchemy/dto/base.py index 467d80c6..7bc94bdd 100644 --- a/src/strawchemy/dto/base.py +++ b/src/strawchemy/dto/base.py @@ -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]: @@ -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__, diff --git a/src/strawchemy/dto/types.py b/src/strawchemy/dto/types.py index 75cddb11..0ca499ab 100644 --- a/src/strawchemy/dto/types.py +++ b/src/strawchemy/dto/types.py @@ -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( @@ -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, ) diff --git a/src/strawchemy/mapper.py b/src/strawchemy/mapper.py index 92eab240..366a8d7a 100644 --- a/src/strawchemy/mapper.py +++ b/src/strawchemy/mapper.py @@ -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, @@ -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, @@ -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, @@ -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. @@ -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, @@ -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, diff --git a/src/strawchemy/schema/factories/_kwargs.py b/src/strawchemy/schema/factories/_kwargs.py index f34fcb34..46e30a4d 100644 --- a/src/strawchemy/schema/factories/_kwargs.py +++ b/src/strawchemy/schema/factories/_kwargs.py @@ -31,6 +31,7 @@ "FactoryMethodKwargs", "ForwardedFactoryKwargs", "InputDecoratorKwargs", + "LegacyKwargs", "MakeInputKwargs", "RegistrationKwargs", "TypeDecoratorKwargs", @@ -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 @@ -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.""" diff --git a/src/strawchemy/schema/factories/base.py b/src/strawchemy/schema/factories/base.py index d9ec7aaf..b03f980a 100644 --- a/src/strawchemy/schema/factories/base.py +++ b/src/strawchemy/schema/factories/base.py @@ -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 @@ -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 @@ -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, @@ -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 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, @@ -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 @@ -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) @@ -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, diff --git a/src/strawchemy/schema/factories/types.py b/src/strawchemy/schema/factories/types.py index 4ad9e3b0..fca9591d 100644 --- a/src/strawchemy/schema/factories/types.py +++ b/src/strawchemy/schema/factories/types.py @@ -35,6 +35,7 @@ StrawchemyMappedFactory, UpsertConflictEnumBackend, ) +from strawchemy.schema.field import StrawchemyField from strawchemy.schema.mutation import ( RequiredToManyUpdateInput, RequiredToOneInput, @@ -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() ) diff --git a/src/strawchemy/schema/field.py b/src/strawchemy/schema/field.py index 6e939bd7..56f80583 100644 --- a/src/strawchemy/schema/field.py +++ b/src/strawchemy/schema/field.py @@ -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 @@ -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, @@ -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 diff --git a/src/strawchemy/validation/pydantic.py b/src/strawchemy/validation/pydantic.py index 59f69a0f..e351712a 100644 --- a/src/strawchemy/validation/pydantic.py +++ b/src/strawchemy/validation/pydantic.py @@ -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 = (), diff --git a/tests/unit/mapping/test_model_field.py b/tests/unit/mapping/test_model_field.py new file mode 100644 index 00000000..51118f63 --- /dev/null +++ b/tests/unit/mapping/test_model_field.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import re +import warnings +from importlib import import_module +from typing import TYPE_CHECKING + +import pytest + +from strawchemy.dto.types import DTOConfig, Purpose +from strawchemy.exceptions import StrawchemyFieldError +from strawchemy.schema.field import StrawchemyField +from tests.unit.models import Color, Fruit + +if TYPE_CHECKING: + from strawchemy.mapper import Strawchemy + + +def _make_class(**fields: object) -> type: + namespace = dict(fields) + namespace["__annotations__"] = dict.fromkeys(fields, str) + return type("Probe", (), namespace) + + +def test_field_stores_model_field(strawchemy: Strawchemy) -> None: + """Test that `field(model_field=...)` stores the target and marks the field non-root.""" + field = strawchemy.field(model_field="name") + assert isinstance(field, StrawchemyField) + assert field.model_field == "name" + # A mapped leaf is not a root field. + assert field.is_root_field is False + + +def test_field_model_field_defaults_to_none(strawchemy: Strawchemy) -> None: + """Test that `model_field` is None when not provided to `field()`.""" + field = strawchemy.field() + assert field.model_field is None + + +def test_model_field_forces_non_root_even_when_root_field_true(strawchemy: Strawchemy) -> None: + """Test that `model_field` overrides an explicit `root_field=True` to non-root.""" + field = strawchemy.field(model_field="name", root_field=True) + assert field.is_root_field is False + + +def test_collect_builds_alias_delta(strawchemy: Strawchemy) -> None: + """Test that the collector maps a model field name to its declared schema name.""" + probe = _make_class(full_name=strawchemy.field(model_field="name")) + delta = strawchemy.type_factory.collect_field_model_aliases(probe, Fruit, DTOConfig(Purpose.READ)) + assert delta == {"name": "full_name"} + + +def test_collect_ignores_plain_fields(strawchemy: Strawchemy) -> None: + """Test that fields without a `model_field` produce no alias entry.""" + probe = _make_class(plain=strawchemy.field()) + delta = strawchemy.type_factory.collect_field_model_aliases(probe, Fruit, DTOConfig(Purpose.READ)) + assert delta == {} + + +def test_collect_missing_model_field_raises(strawchemy: Strawchemy) -> None: + """Test that targeting a non-existent model field raises `StrawchemyFieldError`.""" + probe = _make_class(full_name=strawchemy.field(model_field="does_not_exist")) + with pytest.raises(StrawchemyFieldError, match="does_not_exist"): + strawchemy.type_factory.collect_field_model_aliases(probe, Fruit, DTOConfig(Purpose.READ)) + + +def test_collect_duplicate_target_raises(strawchemy: Strawchemy) -> None: + """Test that two schema fields targeting the same model field raise `StrawchemyFieldError`.""" + probe = _make_class( + full_name=strawchemy.field(model_field="name"), + other_name=strawchemy.field(model_field="name"), + ) + with pytest.raises(StrawchemyFieldError, match="name"): + strawchemy.type_factory.collect_field_model_aliases(probe, Fruit, DTOConfig(Purpose.READ)) + + +def test_collect_schema_name_shadowing_other_model_field_raises(strawchemy: Strawchemy) -> None: + """Test that a schema name shadowing a different model column raises `StrawchemyFieldError`.""" + # `sweetness` is itself a real Fruit column; using it as the schema name while + # pointing model_field at a different column must error, not silently mis-resolve. + probe = _make_class(sweetness=strawchemy.field(model_field="name")) + with pytest.raises(StrawchemyFieldError, match="shadows"): + strawchemy.type_factory.collect_field_model_aliases(probe, Fruit, DTOConfig(Purpose.READ)) + + +def test_type_renames_field_to_schema_name(strawchemy: Strawchemy) -> None: + """Test that `model_field` on a type renames the model column and keeps it linked.""" + + @strawchemy.type(Fruit) + class FruitType: + full_name: str = strawchemy.field(model_field="name") + + field_names = {f.name for f in FruitType.__strawberry_definition__.fields} + assert "full_name" in field_names + # The original model field name is not exposed. + assert "name" not in field_names + # The schema field must be genuinely linked to the model column `name`, + # not an unlinked standalone field. + field_defs = FruitType.__dto_field_definitions__ + assert "full_name" in field_defs + assert field_defs["full_name"].model_field_name == "name" + + +def test_type_declared_annotation_overrides_inferred_type(strawchemy: Strawchemy) -> None: + """Test that a declared annotation overrides the aliased column's inferred type.""" + + @strawchemy.type(Fruit) + class FruitType: + sweetness_label: str = strawchemy.field(model_field="sweetness") # sweetness is int on the model + + field = next(f for f in FruitType.__strawberry_definition__.fields if f.name == "sweetness_label") + # The declared `str` annotation wins over the model's int column type. + assert field.type is str + # The schema field is genuinely linked to the model column `sweetness`. + assert FruitType.__dto_field_definitions__["sweetness_label"].model_field_name == "sweetness" + + +def test_input_maps_and_round_trips(strawchemy: Strawchemy) -> None: + """Test that `model_field` on an input renames the field and round-trips via `to_mapped()`.""" + + @strawchemy.input(Fruit, mode="create_input") + class FruitCreate: + full_name: str = strawchemy.field(model_field="name") + sweetness: int + + field_names = {f.name for f in FruitCreate.__strawberry_definition__.fields} + assert "full_name" in field_names + assert "name" not in field_names + + instance = FruitCreate(full_name="apple", sweetness=3) + mapped = instance.to_mapped() + assert mapped.name == "apple" + # The input field definition links back to the real model column. + assert FruitCreate.__dto_field_definitions__["full_name"].model_field_name == "name" + + +def test_model_field_maps_relationship(strawchemy: Strawchemy) -> None: + """Test that `model_field` renames a relationship field and keeps it linked.""" + + @strawchemy.type(Fruit) + class FruitType: + name: str + + @strawchemy.type(Color) + class ColorType: + items: list[FruitType] = strawchemy.field(model_field="fruits") + + field_names = {f.name for f in ColorType.__strawberry_definition__.fields} + assert "items" in field_names + assert "fruits" not in field_names + assert ColorType.__dto_field_definitions__["items"].model_field_name == "fruits" + + +def test_missing_model_field_raises_on_import() -> None: + """Test that a missing `model_field` target raises at decoration (import) time.""" + with pytest.raises(StrawchemyFieldError, match=re.escape("Model field 'nope' not found on Fruit")): + import_module("tests.unit.schemas.model_field.missing_model_field") + + +def test_duplicate_target_raises_on_import() -> None: + """Test that duplicate `model_field` targets raise at decoration (import) time.""" + with pytest.raises(StrawchemyFieldError, match=re.escape("targeted by multiple schema fields")): + import_module("tests.unit.schemas.model_field.duplicate_target") + + +def test_type_aliases_param_deprecated(strawchemy: Strawchemy) -> None: + """Test that the type-level `aliases=` param still renames but emits a deprecation warning.""" + with pytest.warns(DeprecationWarning, match="model_field"): + + @strawchemy.type(Fruit, aliases={"name": "full_name"}, include={"name"}) + class FruitType: + pass + + field_names = {f.name for f in FruitType.__strawberry_definition__.fields} + assert "full_name" in field_names + assert "name" not in field_names + assert FruitType.__dto_field_definitions__["full_name"].model_field_name == "name" + + +def test_input_aliases_param_deprecated(strawchemy: Strawchemy) -> None: + """Test that the input-level `aliases=` param still renames but emits a deprecation warning.""" + with pytest.warns(DeprecationWarning, match="model_field"): + + @strawchemy.input(Fruit, mode="create_input", aliases={"name": "full_name"}, include={"name", "sweetness"}) + class FruitCreate: + pass + + field_names = {f.name for f in FruitCreate.__strawberry_definition__.fields} + assert "full_name" in field_names + assert "name" not in field_names + + mapped = FruitCreate(full_name="apple", sweetness=3).to_mapped() + assert mapped.name == "apple" + + +def test_alias_generator_not_deprecated(strawchemy: Strawchemy) -> None: + """Test that the `alias_generator=` param renames fields without a deprecation warning.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + @strawchemy.type(Fruit, alias_generator=str.upper, include={"name"}) + class FruitType: + pass + + assert not [w for w in caught if issubclass(w.category, DeprecationWarning)] + field_names = {f.name for f in FruitType.__strawberry_definition__.fields} + assert "NAME" in field_names + + +def test_type_level_aliases_declared_annotation_overrides_inferred_type(strawchemy: Strawchemy) -> None: + """Test that a declared annotation overrides the inferred type on the deprecated `aliases=` path.""" + # A divergent body annotation on a field renamed via the deprecated `aliases=` + # param still overrides the column's inferred type (consistent with model_field). + with pytest.warns(DeprecationWarning, match="model_field"): + + @strawchemy.type(Fruit, aliases={"sweetness": "sweetness_label"}) + class FruitType: + sweetness_label: str # `sweetness` is an int column on the model + + field = next(f for f in FruitType.__strawberry_definition__.fields if f.name == "sweetness_label") + assert field.type is str + assert FruitType.__dto_field_definitions__["sweetness_label"].model_field_name == "sweetness" diff --git a/tests/unit/schemas/model_field/__init__.py b/tests/unit/schemas/model_field/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/schemas/model_field/duplicate_target.py b/tests/unit/schemas/model_field/duplicate_target.py new file mode 100644 index 00000000..b776e7ed --- /dev/null +++ b/tests/unit/schemas/model_field/duplicate_target.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from strawchemy import Strawchemy +from tests.unit.models import Fruit + +strawchemy = Strawchemy("postgresql") + + +@strawchemy.type(Fruit) +class FruitType: + full_name: str = strawchemy.field(model_field="name") + also_name: str = strawchemy.field(model_field="name") diff --git a/tests/unit/schemas/model_field/missing_model_field.py b/tests/unit/schemas/model_field/missing_model_field.py new file mode 100644 index 00000000..7e7d3f45 --- /dev/null +++ b/tests/unit/schemas/model_field/missing_model_field.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from strawchemy import Strawchemy +from tests.unit.models import Fruit + +strawchemy = Strawchemy("postgresql") + + +@strawchemy.type(Fruit) +class FruitType: + bad: str = strawchemy.field(model_field="nope")