diff --git a/pyproject.toml b/pyproject.toml index f6ff3df..af38425 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -312,8 +312,6 @@ exclude = [ "dist", "node_modules", "venv", - "tests/codegen/snapshots", - "alembic/* ", ] src = ["src/strawchemy", "tests"] diff --git a/src/strawchemy/dto/strawberry.py b/src/strawchemy/dto/strawberry.py index 78af0fb..5cf6e05 100644 --- a/src/strawchemy/dto/strawberry.py +++ b/src/strawchemy/dto/strawberry.py @@ -60,7 +60,7 @@ from strawchemy.utils.text import camel_to_snake if TYPE_CHECKING: - from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence + from collections.abc import Callable, Hashable, Iterator from sqlalchemy import ColumnElement @@ -112,33 +112,12 @@ def is_transform(self) -> bool: class StrawchemyDefinition: description: str = "GraphQL type" is_root_aggregation_type: bool = False - field_map: dict[DTOKey, GraphQLFieldDefinition] = dataclasses.field(default_factory=dict) query_hook: QueryHook[Any] | list[QueryHook[Any]] | None = None filter: type[Any] | None = None order_by: type[Any] | None = None distinct_on: type[Any] | None = None purpose: GraphQLPurpose | None = None - def __copy__(self) -> StrawchemyDefinition: - return dataclasses.replace(self, field_map=dict(self.field_map)) - - def populate_fields( - self, - key_source: type[Any] | DTOKey, - fields: Iterable[GraphQLFieldDefinition], - ) -> Self: - key = key_source if isinstance(key_source, DTOKey) else DTOKey([key_source]) - self.field_map = {key + f.name: f for f in fields} - return self - - def get_field(self, key: DTOKey, name: str | None = None) -> GraphQLFieldDefinition: - full_key = key + name if name else key - return self.field_map[full_key] - - def get_field_or_none(self, key: DTOKey, name: str | None = None) -> GraphQLFieldDefinition | None: - full_key = key + name if name else key - return self.field_map.get(full_key) - @property def query_hooks(self) -> list[QueryHook[Any]]: if self.query_hook is None: @@ -154,6 +133,7 @@ def is_update_purpose(self) -> bool: class StrawchemyObject: __strawchemy_definition__: ClassVar[StrawchemyDefinition] + __dto_field_definitions__: ClassVar[dict[str, GraphQLFieldDefinition]] def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) @@ -164,89 +144,6 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.__strawchemy_definition__ = copy(existing) -class _Key(Generic[T]): - """A class to represent a key with multiple components. - - The key is a sequence of components joined by a separator (default: ":"). - It can be constructed from a sequence of components or a single string. - Components can be of any type, but must be convertible to a string. - - The key can be extended with additional components using the `extend` or - `append` methods. The key can also be concatenated with another key or a - string using the `+` operator. - - The key can be converted to a string using the `str` function or the - `to_str` method. - - Subclasses should implement the `to_str` method to convert a component to a - string. - """ - - __slots__ = ("_key",) - - separator: ClassVar[str] = ":" - - def __init__(self, components: Sequence[T | str] | str | None = None) -> None: - self._key: str = "" - if isinstance(components, str): - self._key = components - elif components: - self._key = str(self.extend(components)) - - def _components_to_str(self, objects: Sequence[T | str]) -> Sequence[str]: - return [obj if isinstance(obj, str) else self.to_str(obj) for obj in objects] - - def to_str(self, obj: T) -> str: - raise NotImplementedError - - def append(self, component: T | str) -> Self: - return self.extend([component]) - - def extend(self, components: Sequence[T | str]) -> Self: - str_components = self._components_to_str(components) - self._key = self.separator.join([self._key, *str_components] if self._key else str_components) - return self - - def __add__(self, other: Self | str) -> Self: - if isinstance(other, str): - return self.__class__((self._key, other)) - return self.__class__((self._key, other._key)) - - @override - def __str__(self) -> str: - return self._key - - @override - def __hash__(self) -> int: - return hash(str(self)) - - @override - def __eq__(self, other: object) -> bool: - return hash(self) == hash(other) - - @override - def __ne__(self, other: object) -> bool: - return hash(self) != hash(other) - - -class DTOKey(_Key[type[Any]]): - @override - def to_str(self, obj: type[Any]) -> str: - return obj.__name__ - - @classmethod - def from_dto_node(cls, node: Node[Any, Any]) -> Self: - return cls([node.value.model]) - - @classmethod - def from_query_node(cls, node: QueryNodeType) -> Self: - if node.is_root: - return cls([node.value.model]) - if node.value.related_model: - return cls([node.value.related_model]) - return cls([node.value.model]) - - class OrderByRelationFilterDTO(RelationFilterDTO, Generic[OrderByDTOT], frozen=True): order_by: tuple[OrderByDTOT, ...] = field(default_factory=tuple) @@ -604,11 +501,10 @@ class AggregationFunctionFilterDTO(UnmappedStrawberryGraphQLDTO[DeclarativeBase] class OrderByDTO(GraphQLFilterDTO): def tree(self, _node: QueryNodeType | None = None) -> QueryNodeType: node = _node or QueryNode.root_node(self.__dto_model__) - key = DTOKey.from_query_node(node) for name in self.dto_set_fields: value: OrderByDTO | OrderByEnum = getattr(self, name) - field = self.__strawchemy_definition__.get_field(key, name) + field = self.__dto_field_definitions__[name] if isinstance(field, FunctionFieldDefinition) and not field.has_model_field: field.model_field = node.value.model_field if isinstance(value, OrderByDTO): @@ -627,7 +523,6 @@ class BooleanFilterDTO(GraphQLFilterDTO): def filters_tree(self, _node: QueryNodeType | None = None) -> tuple[QueryNodeType, Filter]: node = _node or QueryNode.root_node(self.__dto_model__) - key = DTOKey.from_query_node(node) query = Filter( and_=[and_val.filters_tree(node)[1] for and_val in self.and_], or_=[or_val.filters_tree(node)[1] for or_val in self.or_], @@ -635,7 +530,7 @@ def filters_tree(self, _node: QueryNodeType | None = None) -> tuple[QueryNodeTyp ) for name in self.dto_set_fields: value: EqualityComparison[Any] | BooleanFilterDTO | AggregateFilterDTO = getattr(self, name) - field = self.__strawchemy_definition__.get_field(key, name) + field = self.__dto_field_definitions__[name] if isinstance(value, BooleanFilterDTO): child, _ = node.upsert_child(field, match_on="value_equality") _, sub_query = value.filters_tree(child) diff --git a/src/strawchemy/repository/strawberry/base.py b/src/strawchemy/repository/strawberry/base.py index 6c2ae1a..4dcac68 100644 --- a/src/strawchemy/repository/strawberry/base.py +++ b/src/strawchemy/repository/strawberry/base.py @@ -18,14 +18,7 @@ from strawchemy.constants import JSON_PATH_KEY, ORDER_BY_KEY from strawchemy.dto.base import ModelT -from strawchemy.dto.strawberry import ( - DTOKey, - OrderByRelationFilterDTO, - QueryNode, - QueryNodeMetadata, - RelationFilterDTO, - StrawchemyObject, -) +from strawchemy.dto.strawberry import OrderByRelationFilterDTO, QueryNodeMetadata, RelationFilterDTO, StrawchemyObject from strawchemy.exceptions import StrawchemyError from strawchemy.repository.strawberry._node import StrawberryQueryNode from strawchemy.schema.mutation import error_type_names @@ -231,7 +224,6 @@ def _build( model_field_name = camel_to_snake(selection.name) if self.auto_snake_case else selection.name strawberry_field = next(field for field in strawberry_definition.fields if field.name == model_field_name) strawberry_field_type = strawberry_contained_user_type(strawberry_field.type) - dto_model = dto_model_from_type(selection_type) if (hooks := self._get_field_hooks(strawberry_field)) is not None: self._add_query_hooks([hooks] if isinstance(hooks, QueryHook) else hooks, node) @@ -243,9 +235,7 @@ def _build( raise StrawchemyError(msg) assert issubclass(dto, StrawchemyObject) - key = DTOKey.from_query_node(QueryNode.root_node(dto_model)) + strawberry_field.name - - field_definition = dto.__strawchemy_definition__.get_field_or_none(key) + field_definition = dto.__dto_field_definitions__.get(strawberry_field.name) if field_definition is None: continue diff --git a/src/strawchemy/schema/factories/_kwargs.py b/src/strawchemy/schema/factories/_kwargs.py index 46e30a4..0a28f58 100644 --- a/src/strawchemy/schema/factories/_kwargs.py +++ b/src/strawchemy/schema/factories/_kwargs.py @@ -19,7 +19,7 @@ from sqlalchemy.orm import QueryableAttribute from strawchemy.dto.base import DTOFieldDefinition - from strawchemy.dto.strawberry import BooleanFilterDTO, DTOKey, GraphQLFieldDefinition, OrderByDTO + from strawchemy.dto.strawberry import BooleanFilterDTO, OrderByDTO from strawchemy.dto.types import FieldSpec from strawchemy.schema.pagination import DefaultOffsetPagination from strawchemy.typing import GraphQLPurpose @@ -102,7 +102,6 @@ class ForwardedFactoryKwargs(TypedDict, total=False): tags: set[str] | None backend_kwargs: dict[str, Any] | None no_cache: bool - field_map: dict[DTOKey, GraphQLFieldDefinition] | None register_type: bool user_defined: bool diff --git a/src/strawchemy/schema/factories/aggregations.py b/src/strawchemy/schema/factories/aggregations.py index 9ff56f5..70dda55 100644 --- a/src/strawchemy/schema/factories/aggregations.py +++ b/src/strawchemy/schema/factories/aggregations.py @@ -11,11 +11,9 @@ from strawchemy.dto.backend.strawberry import StrawberrryDTOBackend from strawchemy.dto.strawberry import ( - DTOKey, EnumDTO, FilterFunctionInfo, FunctionArgFieldDefinition, - GraphQLFieldDefinition, OutputFunctionInfo, UnmappedStrawberryGraphQLDTO, ) @@ -99,13 +97,10 @@ def iter_field_definitions( node: Node[Relation[DeclarativeBase, UnmappedStrawberryGraphQLDTO[DeclarativeBase]], None], if_no_fields: Literal["raise", "skip"] = "skip", *, - field_map: dict[DTOKey, GraphQLFieldDefinition] | None = None, function: FunctionInfo | None = None, **kwargs: Any, ) -> Generator[DTOFieldDefinition[DeclarativeBase, QueryableAttribute[Any]]]: - for field_def in super().iter_field_definitions( - name, model, dto_config, base, node, if_no_fields, field_map=field_map, **kwargs - ): + for field_def in super().iter_field_definitions(name, model, dto_config, base, node, if_no_fields, **kwargs): yield ( FunctionArgFieldDefinition.from_field(field_def, function=function) if function is not None diff --git a/src/strawchemy/schema/factories/base.py b/src/strawchemy/schema/factories/base.py index b03f980..ab690d3 100644 --- a/src/strawchemy/schema/factories/base.py +++ b/src/strawchemy/schema/factories/base.py @@ -34,7 +34,6 @@ from strawchemy.dto.base import DTOBackend, DTOBase, DTOFactory, DTOFieldDefinition, Relation from strawchemy.dto.strawberry import ( BooleanFilterDTO, - DTOKey, EnumDTO, GraphQLFieldDefinition, MappedStrawberryGraphQLDTO, @@ -488,16 +487,10 @@ def iter_field_definitions( base: type[DTOBase[DeclarativeBase]] | None, node: Node[Relation[DeclarativeBase, GraphQLDTOT], None], if_no_fields: Literal["raise", "skip"] = "skip", - *, - field_map: dict[DTOKey, GraphQLFieldDefinition] | None = None, **kwargs: Any, ) -> Generator[DTOFieldDefinition[DeclarativeBase, QueryableAttribute[Any]]]: - field_map = field_map if field_map is not None else {} for field in super().iter_field_definitions(name, model, dto_config, base, node, if_no_fields, **kwargs): - key = DTOKey.from_dto_node(node) - graphql_field = GraphQLFieldDefinition.from_field(field) - yield graphql_field - field_map[key + field.name] = graphql_field + yield GraphQLFieldDefinition.from_field(field) @override def factory( @@ -520,7 +513,6 @@ def factory( user_defined: bool = False, **kwargs: Any, ) -> type[GraphQLDTOT]: - field_map: dict[DTOKey, GraphQLFieldDefinition] = {} if not user_defined and no_cache: name = self.root_dto_name(model, dto_config, current_node) if name is None else name name = self._mapper.registry.uniquify_name(self.graphql_type(dto_config), name) @@ -539,11 +531,8 @@ def factory( tags=tags, backend_kwargs=backend_kwargs, no_cache=no_cache, - field_map=field_map, **kwargs, ) - if not dto.__strawchemy_definition__.field_map: - dto.__strawchemy_definition__.field_map = field_map dto.__strawchemy_definition__.description = self.type_description() if register_type: diff --git a/src/strawchemy/schema/factories/inputs.py b/src/strawchemy/schema/factories/inputs.py index d4e7c5f..b4815b9 100644 --- a/src/strawchemy/schema/factories/inputs.py +++ b/src/strawchemy/schema/factories/inputs.py @@ -11,7 +11,6 @@ AggregateFilterDTO, AggregationFunctionFilterDTO, BooleanFilterDTO, - DTOKey, FilterFunctionInfo, FunctionArgFieldDefinition, FunctionFieldDefinition, @@ -107,24 +106,15 @@ def iter_field_definitions( if_no_fields: Literal["raise", "skip"] = "skip", *, aggregate_filters: bool = False, - field_map: dict[DTOKey, GraphQLFieldDefinition] | None = None, **kwargs: Any, ) -> Generator[DTOFieldDefinition[DeclarativeBase, QueryableAttribute[Any]]]: - field_map = field_map if field_map is not None else {} - for field in super().iter_field_definitions( - name, model, dto_config, base, node, if_no_fields, field_map=field_map, **kwargs - ): - key = DTOKey.from_dto_node(node) + for field in super().iter_field_definitions(name, model, dto_config, base, node, if_no_fields, **kwargs): if field.is_relation: field.type_ = Union[field.type_, None] if field.uselist and field.related_dto: field.type_ = Union[field.related_dto, None] # ty: ignore[invalid-type-form] if aggregate_filters: - aggregation_field = self._aggregation_field( - field, dto_config.copy_with(partial_default=UNSET, partial=True) - ) - field_map[key + aggregation_field.name] = aggregation_field - yield aggregation_field + yield self._aggregation_field(field, dto_config.copy_with(partial_default=UNSET, partial=True)) else: comparison_type = self._filter_type(field) field.type_ = Optional[comparison_type] # ty: ignore[invalid-type-form] @@ -247,11 +237,6 @@ def _aggregate_function_type( ), ], ) - fields = [ - FunctionArgFieldDefinition.from_field(field, function=aggregation) - for _, field in self.inspector.field_definitions(model, dto_config) - ] - dto.__strawchemy_definition__.populate_fields(model, fields) dto.__strawchemy_definition__.description = "Field filtering information" dto.__dto_function_info__ = aggregation return self._mapper.registry.register_type( @@ -304,7 +289,6 @@ def _factory( dto.__strawchemy_definition__.description = ( "Boolean expression to compare field aggregations. All fields are combined with logical 'AND'." ) - dto.__strawchemy_definition__.populate_fields(model, field_defs) return dto @@ -336,6 +320,7 @@ def _filter_type(self, field: DTOFieldDefinition[T, ModelFieldT]) -> type[OrderB def _order_by_aggregation_fields( self, aggregation: FilterFunctionInfo, model: type[Any], dto_config: DTOConfig ) -> type[OrderByDTO]: + model_fields = {field.name: field for _, field in self.inspector.field_definitions(model, dto_config)} field_defs = [ FunctionArgFieldDefinition( dto_config=dto_config, @@ -343,17 +328,13 @@ def _order_by_aggregation_fields( model_field_name=name.field_definition.name, type_hint=OrderByEnum, _function=aggregation, + _model_field=model_fields[name.field_definition.name].model_field, ) for name in aggregation.enum_fields ] name = f"{model.__name__}Aggregate{snake_to_camel(aggregation.aggregation_type)}FieldsOrderBy" dto = self.backend.build(name, model, field_defs) - fields = [ - FunctionArgFieldDefinition.from_field(field, function=aggregation) - for _, field in self.inspector.field_definitions(model, dto_config) - ] - dto.__strawchemy_definition__.populate_fields(model, fields) return self._mapper.registry.register_type( dto, dto_config=dto_config, @@ -386,7 +367,6 @@ def _order_by_aggregation(self, model: type[DeclarativeBase], dto_config: DTOCon ) dto = self.backend.build(f"{model.__name__}AggregateOrderBy", model, field_definitions) - dto.__strawchemy_definition__.populate_fields(model, field_definitions) return self._mapper.registry.register_type( dto, dto_config=dto_config, diff --git a/src/strawchemy/schema/factories/types.py b/src/strawchemy/schema/factories/types.py index 4f0c064..7379e21 100644 --- a/src/strawchemy/schema/factories/types.py +++ b/src/strawchemy/schema/factories/types.py @@ -16,7 +16,6 @@ from strawchemy.dto.strawberry import ( AggregateDTO, AggregateFieldDefinition, - DTOKey, EnumDTO, FunctionFieldDefinition, GraphQLFieldDefinition, @@ -237,7 +236,7 @@ def _add_fields_arguments( else set() ) - for field in dto.__strawchemy_definition__.field_map.values(): + for field in dto.__dto_field_definitions__.values(): if field.name in body_fields: # Drop the model-derived annotation so the resolver's own return type # drives the field type, rather than the column type. @@ -280,18 +279,11 @@ def iter_field_definitions( if_no_fields: Literal["raise", "skip"] = "skip", *, aggregations: bool = False, - field_map: dict[DTOKey, GraphQLFieldDefinition] | None = None, **kwargs: Any, ) -> Generator[DTOFieldDefinition[DeclarativeBase, QueryableAttribute[Any]]]: - field_map = field_map if field_map is not None else {} - for field in super().iter_field_definitions( - name, model, dto_config, base, node, if_no_fields, field_map=field_map, **kwargs - ): - key = DTOKey.from_dto_node(node) + for field in super().iter_field_definitions(name, model, dto_config, base, node, if_no_fields, **kwargs): if field.is_relation and field.uselist and aggregations: - aggregation_field = self._aggregation_field(field, dto_config) - field_map[key + aggregation_field.name] = aggregation_field - yield aggregation_field + yield self._aggregation_field(field, dto_config) yield field @override @@ -380,13 +372,10 @@ def iter_field_definitions( node: Node[Relation[DeclarativeBase, MappedGraphQLDTOT], None], if_no_fields: Literal["raise", "skip"] = "skip", aggregations: bool = False, - field_map: dict[DTOKey, GraphQLFieldDefinition] | None = None, **kwargs: Any, ) -> Generator[DTOFieldDefinition[DeclarativeBase, QueryableAttribute[Any]]]: if not node.is_root: yield from () - key = DTOKey.from_dto_node(node) - field_map = field_map if field_map is not None else {} nodes_dto = self._type_factory.factory(model, dto_config=dto_config, aggregations=aggregations) nodes = GraphQLFieldDefinition( dto_config=dto_config, @@ -403,8 +392,6 @@ def iter_field_definitions( is_relation=False, is_aggregate=True, ) - field_map[key + nodes.name] = nodes - field_map[key + aggregations_field.name] = aggregations_field yield from iter((nodes, aggregations_field)) @override @@ -455,10 +442,8 @@ def _factory( parent_field_def: DTOFieldDefinition[DeclarativeBase, QueryableAttribute[Any]] | None = None, if_no_fields: Literal["raise", "skip"] = "skip", backend_kwargs: dict[str, Any] | None = None, - field_map: dict[DTOKey, GraphQLFieldDefinition] | None = None, **kwargs: Any, ) -> type[AggregateDTOT]: - field_map = field_map if field_map is not None else {} model_field = parent_field_def.model_field if parent_field_def else None aggregate_config = dto_config.copy_with(partial=True, include="all") field_definitions: list[FunctionFieldDefinition] = [ @@ -474,8 +459,6 @@ def _factory( for aggregation in self._aggregation_builder.output_functions(model, aggregate_config) ] - root_key = DTOKey.from_dto_node(node) - field_map.update({root_key + field.model_field_name: field for field in field_definitions}) return self.backend.build(name, model, field_definitions, **(backend_kwargs or {})) @@ -737,7 +720,6 @@ def iter_field_definitions( if_no_fields: Literal["raise", "skip"] = "skip", *, aggregations: bool = False, - field_map: dict[DTOKey, GraphQLFieldDefinition] | None = None, **factory_kwargs: Unpack[_HasModeKwargs], ) -> Generator[DTOFieldDefinition[DeclarativeBase, QueryableAttribute[Any]]]: mode: GraphQLPurpose = factory_kwargs.pop("mode") @@ -750,7 +732,6 @@ def iter_field_definitions( if_no_fields, mode=mode, aggregations=aggregations, - field_map=field_map, **factory_kwargs, ): if mode == "update_by_pk_input" and self.inspector.is_primary_key(field.model_field): diff --git a/tests/unit/dto/test_dto.py b/tests/unit/dto/test_dto.py index 1bacfc4..8d36729 100644 --- a/tests/unit/dto/test_dto.py +++ b/tests/unit/dto/test_dto.py @@ -5,14 +5,11 @@ from uuid import UUID, uuid4 import pytest -from sqlalchemy import Integer -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from typing_extensions import Self from strawchemy import ALL, RELATIONSHIPS, SCALARS, StrawchemyConfig from strawchemy.dto import DTOConfig, Purpose, PurposeConfig, config, field from strawchemy.dto.constants import DTO_INFO_KEY -from strawchemy.dto.strawberry import DTOKey, GraphQLFieldDefinition, StrawchemyDefinition from strawchemy.dto.types import FieldSpec from strawchemy.dto.utils import DTOFieldConfig, read_all_config, write_all_config from strawchemy.exceptions import EmptyDTOError @@ -29,15 +26,6 @@ from tests.utils import DTOInspect, factory_iterator -class _PopulateFieldsBase(DeclarativeBase): - pass - - -class _PopulateFieldsModel(_PopulateFieldsBase): - __tablename__ = "populate_fields_model" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - - @pytest.mark.parametrize("model", [Tomato, TomatoDataclass]) @pytest.mark.parametrize("factory", factory_iterator()) def test_config_function_produces_same_default(factory: AnyFactory, model: type[Tomato | TomatoDataclass]) -> None: @@ -333,27 +321,6 @@ def test_named_exclude_with_include_all(factory: AnyFactory, model: type[Fruit | assert set(DTOInspect(dto).annotations()) == {"name", "sweetness", "id"} -@pytest.mark.parametrize( - "key_source", - [_PopulateFieldsModel, DTOKey([_PopulateFieldsModel])], - ids=["model-type", "dto-key"], -) -def test_strawchemy_definition_populate_fields(key_source: type[DeclarativeBase] | DTOKey) -> None: - field_def = GraphQLFieldDefinition( - config=DTOFieldConfig(), - dto_config=DTOConfig(Purpose.READ), - model=_PopulateFieldsModel, - model_field_name="id", - type_hint=int, - ) - - definition = StrawchemyDefinition() - result = definition.populate_fields(key_source, [field_def]) - - assert result is definition - assert definition.field_map == {DTOKey([_PopulateFieldsModel]) + "id": field_def} - - @pytest.mark.parametrize( "include_spec", [pytest.param("scalars", id="direct-string"), pytest.param([SCALARS], id="constant-list")], diff --git a/tests/unit/mapping/test_schemas.py b/tests/unit/mapping/test_schemas.py index 2212759..156d619 100644 --- a/tests/unit/mapping/test_schemas.py +++ b/tests/unit/mapping/test_schemas.py @@ -670,3 +670,20 @@ def test_default_order_by_on_non_list_field_raises() -> None: def test_default_order_by_wrong_model_column_raises() -> None: with pytest.raises(StrawchemyFieldError, match="not a column"): import_module("tests.unit.schemas.default_order_by_invalid") + + +def test_aggregation_order_by_aliased_column_no_key_error() -> None: + """Schema build must not raise KeyError for aggregatable columns with a field-level alias. + + Regression test for the bug introduced by the "centralize field_map" refactor. + `_order_by_aggregation_fields` was keying `model_fields` by `prop.key` (raw SQLAlchemy + attribute name) but looking up by `name.field_definition.name` (alias-aware). For any + aggregatable column with a Purpose.READ alias the subscript raised a KeyError. + """ + from tests.unit.schemas.order.order_by_aliased_aggregation import Query + + # Schema build must not raise KeyError. + schema = strawberry.Schema(query=Query, scalar_overrides=SCALAR_OVERRIDES) + schema_sdl = str(schema) + # The aggregate order-by input type for the aliased model must appear in the schema. + assert "AliasedItemAggregateOrderBy" in schema_sdl diff --git a/tests/unit/schemas/order/order_by_aliased_aggregation.py b/tests/unit/schemas/order/order_by_aliased_aggregation.py new file mode 100644 index 0000000..3063197 --- /dev/null +++ b/tests/unit/schemas/order/order_by_aliased_aggregation.py @@ -0,0 +1,53 @@ +# No `from __future__ import annotations` — SQLAlchemy needs concrete annotations. +from uuid import uuid4 + +import strawberry +from sqlalchemy import ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from strawchemy import Strawchemy +from strawchemy.dto.types import Purpose, PurposeConfig +from strawchemy.dto.utils import field as dto_field + + +class _AliasBase(DeclarativeBase): + __abstract__ = True + + id: Mapped[str] = mapped_column(primary_key=True, default=lambda: str(uuid4())) + + +class _AliasedContainer(_AliasBase): + __tablename__ = "aliased_container_regression" + + items: Mapped[list["_AliasedItem"]] = relationship("_AliasedItem", back_populates="parent") + + +class _AliasedItem(_AliasBase): + __tablename__ = "aliased_item_regression" + + # 'score' is aggregatable (float) and carries a READ-purpose alias 'rating'. + # Before the fix, _order_by_aggregation_fields keyed model_fields by prop.key + # ("score") but looked up by name.field_definition.name ("rating"), raising KeyError. + score: Mapped[float] = mapped_column(info=dto_field(configs={Purpose.READ: PurposeConfig(alias="rating")})) + parent_id: Mapped[str | None] = mapped_column(ForeignKey("aliased_container_regression.id"), nullable=True) + parent: Mapped[_AliasedContainer | None] = relationship("_AliasedContainer", back_populates="items") + + +strawchemy = Strawchemy("postgresql") + + +@strawchemy.type(_AliasedItem, include="all") +class AliasedItemType: ... + + +@strawchemy.type(_AliasedContainer, include="all") +class AliasedContainerType: ... + + +@strawchemy.order(_AliasedContainer, include="all") +class AliasedContainerOrderBy: ... + + +@strawberry.type +class Query: + containers: list[AliasedContainerType] = strawchemy.field(order_by_input=AliasedContainerOrderBy)