diff --git a/README.md b/README.md index ef7d21a1..55d1f017 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ class PostOrderBy: # Define GraphQL query fields @strawberry.type class Query: - users: list[UserType] = strawchemy.field(filter_input=UserFilter, order_by=UserOrderBy, pagination=True) - posts: list[PostType] = strawchemy.field(filter_input=PostFilter, order_by=PostOrderBy, pagination=True) + users: list[UserType] = strawchemy.field(filter_input=UserFilter, order_by_input=UserOrderBy, pagination=True) + posts: list[PostType] = strawchemy.field(filter_input=PostFilter, order_by_input=PostOrderBy, pagination=True) # Create schema @@ -451,7 +451,8 @@ class Query: # Simple field that returns a list of users users: list[UserType] = strawchemy.field() # Field with filtering, ordering, and pagination - filtered_users: list[UserType] = strawchemy.field(filter_input=UserFilter, order_by=UserOrderBy, pagination=True) + filtered_users: list[UserType] = strawchemy.field(filter_input=UserFilter, order_by_input=UserOrderBy, + pagination=True) # Field that returns a single user by ID user: UserType = strawchemy.field() ``` @@ -739,7 +740,7 @@ class UserOrderBy: @strawberry.type class Query: - users: list[UserType] = strawchemy.field(order_by=UserOrderBy) + users: list[UserType] = strawchemy.field(order_by_input=UserOrderBy) ``` Query with ordering: diff --git a/src/strawchemy/dto/strawberry.py b/src/strawchemy/dto/strawberry.py index 466899c9..56a96db8 100644 --- a/src/strawchemy/dto/strawberry.py +++ b/src/strawchemy/dto/strawberry.py @@ -35,12 +35,15 @@ import strawberry from msgspec import Struct, field, json -from sqlalchemy.orm import DeclarativeBase, QueryableAttribute +from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, QueryableAttribute +from sqlalchemy.sql import operators +from sqlalchemy.sql.elements import UnaryExpression from typing_extensions import Self, override from strawchemy.dto.backend.strawberry import MappedStrawberryDTO, StrawberryDTO from strawchemy.dto.base import DTOBase, DTOFieldDefinition, ModelFieldT, ModelT from strawchemy.dto.types import DTOConfig, DTOFieldConfig, DTOMissing, Purpose +from strawchemy.exceptions import StrawchemyFieldError from strawchemy.transpiler.hook import ( QueryHook, # noqa: TC001 msgspec does not support resolving references dynamically ) @@ -50,6 +53,7 @@ FunctionInfo, GraphQLPurpose, OrderByDTOT, + OrderByExpr, QueryNodeType, ) from strawchemy.utils.graph import AnyNode, GraphMetadata, MatchOn, Node, NodeMetadata, NodeT @@ -58,6 +62,8 @@ if TYPE_CHECKING: from collections.abc import Callable, Hashable, Iterable, Sequence + from sqlalchemy import ColumnElement + from strawchemy.schema.filters import EqualityComparison, GraphQLComparison T = TypeVar("T") @@ -102,7 +108,7 @@ def is_transform(self) -> bool: return bool(self.json_path) -@dataclass +@dataclass(slots=True) class StrawchemyDefinition: description: str = "GraphQL type" is_root_aggregation_type: bool = False @@ -470,6 +476,82 @@ class OrderByEnum(Enum): DESC_NULLS_LAST = "DESC_NULLS_LAST" +@dataclass(frozen=True, slots=True) +class _DecomposedOrderBy: + """A ``default_order_by`` expression broken into its column, direction and source element.""" + + key: str + """Attribute key of the ordered column.""" + order: OrderByEnum + """Ordering direction, including nulls placement.""" + element: InstrumentedAttribute[Any] | ColumnElement[Any] + """Underlying SQLAlchemy column element, with asc/desc/nulls modifiers stripped.""" + + @classmethod + def from_parts( + cls, + key: str, + descending: bool, + nulls: Literal["first", "last"] | None, + element: InstrumentedAttribute[Any] | ColumnElement[Any], + ) -> Self: + """Builds an instance, resolving the ``OrderByEnum`` from direction and nulls placement.""" + match (descending, nulls): + case (False, None): + order = OrderByEnum.ASC + case (False, "first"): + order = OrderByEnum.ASC_NULLS_FIRST + case (False, "last"): + order = OrderByEnum.ASC_NULLS_LAST + case (True, None): + order = OrderByEnum.DESC + case (True, "first"): + order = OrderByEnum.DESC_NULLS_FIRST + case _: # (True, "last") + order = OrderByEnum.DESC_NULLS_LAST + return cls(key=key, order=order, element=element) + + +def decompose_order_by(expr: OrderByExpr) -> _DecomposedOrderBy: + """Decomposes a SQLAlchemy ordering expression into its column, direction and source element. + + Supports bare columns and ``asc()``/``desc()`` optionally wrapped with + ``nulls_first()``/``nulls_last()``. + + Args: + expr: A root-model column or unary ordering expression derived from one. + + Returns: + The decomposed expression. + + Raises: + StrawchemyFieldError: If the expression uses an unsupported modifier or no + column can be resolved from it. + """ + descending = False + nulls: Literal["first", "last"] | None = None + element = expr + while isinstance(element, UnaryExpression): + modifier = element.modifier + if modifier is operators.asc_op: + descending = False + elif modifier is operators.desc_op: + descending = True + elif modifier is operators.nullsfirst_op: + nulls = "first" + elif modifier is operators.nullslast_op: + nulls = "last" + else: + msg = f"Unsupported ordering modifier in `default_order_by`: {modifier!r}" + raise StrawchemyFieldError(msg) + element = element.element + + if not element.key: + msg = f"Could not resolve a column from `default_order_by` expression: {expr!r}" + raise StrawchemyFieldError(msg) + return _DecomposedOrderBy.from_parts(element.key, descending, nulls, element) + + class EnumDTO(DTOBase[Any], Enum): __field_definitions__: dict[str, GraphQLFieldDefinition] diff --git a/src/strawchemy/mapper.py b/src/strawchemy/mapper.py index 366a8d7a..acc6bb70 100644 --- a/src/strawchemy/mapper.py +++ b/src/strawchemy/mapper.py @@ -48,7 +48,13 @@ from strawchemy.repository.typing import QueryHookCallable from strawchemy.schema.pagination import DefaultOffsetPagination from strawchemy.transpiler.hook import QueryHook - from strawchemy.typing import AnyRepositoryType, FilterStatementCallable, MappedGraphQLDTO, SupportedDialect + from strawchemy.typing import ( + AnyRepositoryType, + FilterStatementCallable, + MappedGraphQLDTO, + OrderByExpr, + SupportedDialect, + ) from strawchemy.validation.base import ValidationProtocol from strawchemy.validation.pydantic import PydanticMapper @@ -180,7 +186,8 @@ def field( resolver: Any, *, filter_input: type[BooleanFilterDTO] | bool | None = None, - order_by: FieldSpec | type[OrderByDTO] | None = None, + order_by_input: FieldSpec | type[OrderByDTO] | None = None, + default_order_by: Sequence[OrderByExpr] | OrderByExpr | None = None, pagination: bool | DefaultOffsetPagination | None = None, distinct_on: FieldSpec | type[EnumDTO] | None = None, arguments: list[StrawberryArgument] | None = None, @@ -209,7 +216,8 @@ def field( self, *, filter_input: type[BooleanFilterDTO] | bool | None = None, - order_by: FieldSpec | type[OrderByDTO] | None = None, + order_by_input: FieldSpec | type[OrderByDTO] | None = None, + default_order_by: Sequence[OrderByExpr] | OrderByExpr | None = None, pagination: bool | DefaultOffsetPagination | None = None, distinct_on: FieldSpec | type[EnumDTO] | None = None, arguments: list[StrawberryArgument] | None = None, @@ -238,7 +246,8 @@ def field( resolver: Any | None = None, *, filter_input: type[BooleanFilterDTO] | bool | None = None, - order_by: FieldSpec | type[OrderByDTO] | None = None, + order_by_input: FieldSpec | type[OrderByDTO] | None = None, + default_order_by: Sequence[OrderByExpr] | OrderByExpr | None = None, pagination: bool | DefaultOffsetPagination | None = None, distinct_on: FieldSpec | type[EnumDTO] | None = None, arguments: list[StrawberryArgument] | None = None, @@ -271,7 +280,12 @@ def field( resolver: The resolver function for the field. If not provided, Strawchemy will attempt to generate one based on the model. filter_input: The input type for filtering results. - order_by: The input type for ordering results. + order_by_input: The input type for ordering results. + default_order_by: Default ordering for a list field as one or more SQLAlchemy + column ordering expressions (e.g. ``Model.name.asc()``). Applied only when + the client supplies no ``order_by``. Overrides ``deterministic_ordering``: + when set, an ordering is always emitted; the primary-key tiebreaker is still + appended when ``deterministic_ordering`` is True. distinct_on: The enum type for 'distinct on' clauses (PostgreSQL). pagination: Enables pagination for the field. Can be True for default offset pagination or a DefaultOffsetPagination instance for customization. @@ -314,7 +328,8 @@ def field( filter_statement=filter_statement, execution_options=execution_options, filter_type=filter_input, - order_by=order_by, + order_by=order_by_input, + default_order_by=default_order_by, pagination=pagination, id_field_name=id_field_name, distinct_on=distinct_on, diff --git a/src/strawchemy/repository/sqlalchemy/_base.py b/src/strawchemy/repository/sqlalchemy/_base.py index 7731e35f..cdd1b9d9 100644 --- a/src/strawchemy/repository/sqlalchemy/_base.py +++ b/src/strawchemy/repository/sqlalchemy/_base.py @@ -26,7 +26,7 @@ from strawchemy.dto.strawberry import BooleanFilterDTO, EnumDTO, OrderByDTO from strawchemy.schema.mutation import Input, LevelInput, UpsertData from strawchemy.transpiler.hook import QueryHook - from strawchemy.typing import QueryNodeType, SupportedDialect + from strawchemy.typing import OrderByExpr, QueryNodeType, SupportedDialect __all__ = ("InsertData", "InsertOrUpdate", "MutationData", "RowLike", "SQLAlchemyGraphQLRepository") @@ -127,12 +127,14 @@ def __init__( statement: Select[tuple[DeclarativeT]] | None = None, execution_options: dict[str, Any] | None = None, deterministic_ordering: bool = False, + default_order_by: Sequence[OrderByExpr] | None = None, ) -> None: self.model = model self.session = session self.statement = statement self.execution_options = execution_options self.deterministic_ordering = deterministic_ordering + self.default_order_by: list[OrderByExpr] = list(default_order_by or []) self._dialect = session.get_bind().dialect # ty: ignore[invalid-argument-type] # get_bind() typing differs across sync/async Session stubs @@ -155,6 +157,7 @@ def _get_query_executor( query_hooks=query_hooks, statement=self.statement, deterministic_ordering=self.deterministic_ordering, + default_order_by=self.default_order_by, ) return transpiler.select_executor( selection_tree=selection, diff --git a/src/strawchemy/repository/strawberry/_async.py b/src/strawchemy/repository/strawberry/_async.py index 49a24ae6..6733be4d 100644 --- a/src/strawchemy/repository/strawberry/_async.py +++ b/src/strawchemy/repository/strawberry/_async.py @@ -6,7 +6,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, TypeVar from strawchemy.repository.sqlalchemy import SQLAlchemyGraphQLAsyncRepository @@ -22,6 +22,7 @@ from strawchemy.dto.strawberry import BooleanFilterDTO, EnumDTO, OrderByDTO from strawchemy.repository.typing import AnyAsyncSession, AsyncSessionGetter from strawchemy.schema.mutation import Input, InputModel + from strawchemy.typing import OrderByExpr __all__ = ("StrawchemyAsyncRepository",) @@ -56,6 +57,7 @@ class StrawchemyAsyncRepository(StrawchemyRepository[T]): filter_statement: Select[tuple[Any]] | None = None execution_options: dict[str, Any] | None = None deterministic_ordering: bool = False + default_order_by: builtins.list[OrderByExpr] = field(default_factory=list) def graphql_repository(self) -> SQLAlchemyGraphQLAsyncRepository[Any]: """Create and configure the underlying async SQLAlchemy GraphQL strawberry. @@ -69,6 +71,7 @@ def graphql_repository(self) -> SQLAlchemyGraphQLAsyncRepository[Any]: statement=self.filter_statement, execution_options=self.execution_options, deterministic_ordering=self.deterministic_ordering, + default_order_by=self.default_order_by, ) async def get_one_or_none( diff --git a/src/strawchemy/repository/strawberry/_sync.py b/src/strawchemy/repository/strawberry/_sync.py index dca5ade7..e96a87ae 100644 --- a/src/strawchemy/repository/strawberry/_sync.py +++ b/src/strawchemy/repository/strawberry/_sync.py @@ -8,7 +8,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, TypeVar from strawchemy.repository.sqlalchemy import SQLAlchemyGraphQLSyncRepository @@ -28,6 +28,7 @@ from strawchemy.dto.strawberry import BooleanFilterDTO, EnumDTO, OrderByDTO from strawchemy.repository.typing import AnySyncSession, SyncSessionGetter from strawchemy.schema.mutation import Input, InputModel + from strawchemy.typing import OrderByExpr __all__ = () @@ -62,6 +63,7 @@ class StrawchemySyncRepository(StrawchemyRepository[T]): filter_statement: Select[tuple[Any]] | None = None execution_options: dict[str, Any] | None = None deterministic_ordering: bool = False + default_order_by: builtins.list[OrderByExpr] = field(default_factory=list) def graphql_repository(self) -> SQLAlchemyGraphQLSyncRepository[Any]: """Create and configure the underlying async SQLAlchemy GraphQL strawberry. @@ -75,6 +77,7 @@ def graphql_repository(self) -> SQLAlchemyGraphQLSyncRepository[Any]: statement=self.filter_statement, execution_options=self.execution_options, deterministic_ordering=self.deterministic_ordering, + default_order_by=self.default_order_by, ) def get_one_or_none( diff --git a/src/strawchemy/schema/factories/types.py b/src/strawchemy/schema/factories/types.py index fca9591d..4f0c064a 100644 --- a/src/strawchemy/schema/factories/types.py +++ b/src/strawchemy/schema/factories/types.py @@ -168,7 +168,7 @@ def _relation_field( ): distinct_on_input = self._distinct_on_input_for_field(field) strawberry_field = self._mapper.field( - pagination=pagination, order_by=order_by_input, distinct_on=distinct_on_input, root_field=False + pagination=pagination, order_by_input=order_by_input, distinct_on=distinct_on_input, root_field=False ) return strawberry_field, type_annotation diff --git a/src/strawchemy/schema/field.py b/src/strawchemy/schema/field.py index 56f80583..0ee25215 100644 --- a/src/strawchemy/schema/field.py +++ b/src/strawchemy/schema/field.py @@ -5,6 +5,8 @@ from inspect import isclass from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, cast +from sqlalchemy.orm import InstrumentedAttribute +from sqlalchemy.sql.elements import UnaryExpression from strawberry.annotation import StrawberryAnnotation from strawberry.types import get_object_definition from strawberry.types.arguments import StrawberryArgument @@ -20,6 +22,7 @@ MappedStrawberryGraphQLDTO, OrderByDTO, StrawchemyObject, + decompose_order_by, ) from strawchemy.dto.types import DTOConfig, FieldSpec, Purpose from strawchemy.exceptions import EmptyDTOError, StrawchemyFieldError @@ -56,6 +59,7 @@ FilterStatementCallable, GetByIdResolverResult, ListResolverResult, + OrderByExpr, StrawchemyObjectWithStrawberryObjectDefinition, ) @@ -100,6 +104,7 @@ def __init__( id_field_name: str | None = None, arguments: list[StrawberryArgument] | None = None, model_field: str | None = None, + default_order_by: Sequence[OrderByExpr] | OrderByExpr | None = None, # Original StrawberryField args python_name: str | None = None, graphql_name: str | None = None, @@ -140,6 +145,13 @@ def __init__( self._filter_factory = filter_factory self._distinct_on_factory = distinct_on_factory + if default_order_by is None: + self._default_order_by: list[OrderByExpr] = [] + elif isinstance(default_order_by, (UnaryExpression, InstrumentedAttribute)): + self._default_order_by = [default_order_by] + else: + self._default_order_by = list(default_order_by) + super().__init__( python_name, graphql_name, @@ -181,6 +193,7 @@ def _get_repository(self, info: Info[Any, Any]) -> StrawchemySyncRepository[Any] filter_statement=self.filter_statement(info), execution_options=self._execution_options, deterministic_ordering=self._config.deterministic_ordering, + default_order_by=self._default_order_by, ) def _is_repo_async( @@ -226,6 +239,16 @@ def _list_resolver( return self._list_result_sync(repository.list(filter_input, order_by, distinct_on, limit, offset)) def _validate_type(self, type_: StrawberryType | builtins.type[WithStrawberryObjectDefinition] | Any) -> None: + """Validates the resolved field type against ``root_aggregations`` and ``default_order_by``. + + Args: + type_: The resolved type of the field from resolve_type. + + Raises: + StrawchemyFieldError: If ``root_aggregations`` is enabled on a non-root-aggregation + type, or if ``default_order_by`` is set on a non-list field or references a + column not belonging to the field's root model. + """ for inner_type in strawberry_contained_types(type_): if ( self.root_aggregations @@ -235,6 +258,16 @@ def _validate_type(self, type_: StrawberryType | builtins.type[WithStrawberryObj msg = f"The `{self.name}` field is defined with `root_aggregations` enabled but the field type is not a root aggregation type." raise StrawchemyFieldError(msg) + if self._default_order_by: + if not is_list(type_): + msg = f"`default_order_by` is only valid on a list field, but `{self.name}` is not a list field." + raise StrawchemyFieldError(msg) + model = dto_model_from_type(strawberry_contained_user_type(type_)) + for expr in self._default_order_by: + if decompose_order_by(expr).element.entity_namespace is not model: + msg = f"`default_order_by` expression is not a column of {model.__name__}" + raise StrawchemyFieldError(msg) + @classmethod def _is_strawchemy_type( cls, type_: Any @@ -449,6 +482,7 @@ def __copy__(self) -> Self: root_aggregations=self.root_aggregations, filter_type=self._filter, order_by=self._order_by, + default_order_by=self._default_order_by, distinct_on=self._distinct_on, pagination=self.pagination, registry_namespace=self.registry_namespace, diff --git a/src/strawchemy/transpiler/_transpiler.py b/src/strawchemy/transpiler/_transpiler.py index 0050b350..293abc6b 100644 --- a/src/strawchemy/transpiler/_transpiler.py +++ b/src/strawchemy/transpiler/_transpiler.py @@ -44,8 +44,9 @@ OrderByEnum, OrderByRelationFilterDTO, QueryNode, + decompose_order_by, ) -from strawchemy.exceptions import TranspilingError +from strawchemy.exceptions import StrawchemyFieldError, TranspilingError from strawchemy.repository.typing import DeclarativeT, OrderBySpec, QueryExecutorT from strawchemy.schema.filters import GraphQLComparison from strawchemy.transpiler._executor import SyncQueryExecutor @@ -72,7 +73,7 @@ from sqlalchemy.sql.elements import NamedColumn from strawchemy.transpiler.hook import QueryHook - from strawchemy.typing import QueryNodeType, SupportedDialect + from strawchemy.typing import OrderByExpr, QueryNodeType, SupportedDialect __all__ = ("QueryTranspiler",) @@ -88,6 +89,7 @@ def __init__( scope: QueryScope[DeclarativeT] | None = None, query_hooks: defaultdict[QueryNodeType, list[QueryHook[Any]]] | None = None, deterministic_ordering: bool = False, + default_order_by: Sequence[OrderByExpr] | None = None, ) -> None: """Initializes the QueryTranspiler. @@ -98,6 +100,8 @@ def __init__( scope: An optional existing QueryScope. query_hooks: Optional hooks to apply during query transpilation. deterministic_ordering: Whether to ensure deterministic ordering of results. + default_order_by: Default ordering applied when the client supplies no order. + Each expression must reference a mapped column of the root model; validated on first use. """ supported_dialect = cast("SupportedDialect", dialect.name) self._inspector = SQLAlchemyGraphQLInspector(supported_dialect, [model.registry]) @@ -105,6 +109,7 @@ def __init__( self._aggregation_joins: dict[QueryNodeType, AggregationJoin] = {} self._statement = statement self._deterministic_ordering = deterministic_ordering + self._default_order_by: list[OrderByExpr] = list(default_order_by or []) self._filter_in_subquery = False self.dialect = dialect self.scope = scope or QueryScope(model, supported_dialect, inspector=self._inspector) @@ -661,6 +666,33 @@ def _where(self, query_filter: Filter, allow_null: bool = False) -> Where: ], ) + def _default_order_columns(self) -> list[tuple[SQLColumnExpression[Any], OrderByEnum]]: + """Builds ORDER BY columns from the field's ``default_order_by`` expressions. + + Each expression's column is adapted to the active root alias so the ordering + is correct inside pagination/distinct subqueries. + + Returns: + A list of ``(aliased_column, OrderByEnum)`` tuples in declared order. + + Raises: + StrawchemyFieldError: If an expression references a column that is not a + column of the root model. + """ + alias_insp = inspect(self.scope.root_alias) + column_keys = {attr.key for attr in alias_insp.mapper.column_attrs} + columns: list[tuple[SQLColumnExpression[Any], OrderByEnum]] = [] + for expr in self._default_order_by: + decomposed = decompose_order_by(expr) + if ( + decomposed.key not in column_keys + ): # pragma: no cover # defensive: shadowed by StrawchemyField's stricter schema-build validation + msg = f"`default_order_by` column '{decomposed.key}' is not a column of {self.scope.model.__name__}" + raise StrawchemyFieldError(msg) + aliased_attribute = alias_insp.mapper.attrs[decomposed.key].class_attribute.adapt_to_entity(alias_insp) + columns.append((aliased_attribute, decomposed.order)) + return columns + def _order_by(self, order_by_nodes: list[QueryNodeType], existing_joins: list[Join]) -> OrderBy: """Creates ORDER BY expressions and joins from a list of nodes. @@ -697,7 +729,12 @@ def _order_by(self, order_by_nodes: list[QueryNodeType], existing_joins: list[Jo joins.append(new_join) else: columns.append((self.scope.aliased_attribute(node), node.metadata.data.order_by)) - if not columns and self._deterministic_ordering: + no_user_columns = not columns + # PK tiebreaker is appended after any default_order_by columns; no_user_columns + # (captured before the default block) prevents doubling up when the client ordered. + if no_user_columns and self._default_order_by: + columns.extend(self._default_order_columns()) + if no_user_columns and self._deterministic_ordering: pk_aliases = [ pk_attribute.adapt_to_entity(inspect(self.scope.root_alias)) for pk_attribute in self._inspector.pk_attributes(self.scope.model.__mapper__) @@ -761,7 +798,10 @@ def _use_distinct_rank(self, query_graph: QueryGraph[DeclarativeT]) -> bool: True if RANK() should be used for distinct operation, False otherwise. """ if self._inspector.db_features.supports_distinct_on: - return bool(query_graph.distinct_on and (query_graph.order_by_tree or self._deterministic_ordering)) + return bool( + query_graph.distinct_on + and (query_graph.order_by_tree or self._deterministic_ordering or self._default_order_by) + ) return bool(query_graph.distinct_on) def _relation_order_by(self, query_graph: QueryGraph[DeclarativeT], query: Query) -> list[OrderBySpec]: @@ -857,7 +897,7 @@ def _build_query( joins.extend(query.where.joins) subquery_join_nodes = {join.node for join in query.where.joins} - if query_graph.order_by_tree or self._deterministic_ordering: + if query_graph.order_by_tree or self._deterministic_ordering or self._default_order_by: query.order_by = self._order_by(query_graph.order_by_nodes, joins) joins.extend(query.order_by.joins) diff --git a/src/strawchemy/typing.py b/src/strawchemy/typing.py index 7391f396..bbe76272 100644 --- a/src/strawchemy/typing.py +++ b/src/strawchemy/typing.py @@ -7,6 +7,8 @@ from collections.abc import Callable, Sequence from sqlalchemy import Select + from sqlalchemy.orm import InstrumentedAttribute + from sqlalchemy.sql.elements import UnaryExpression from strawberry import Info from strawberry.types.base import WithStrawberryObjectDefinition @@ -48,6 +50,7 @@ "MappedGraphQLDTO", "OneOrManyResult", "OrderByDTOT", + "OrderByExpr", "QueryNodeType", "QueryObject", "StrawberryGraphQLDTO", @@ -57,6 +60,8 @@ UNION_TYPES = (Union, UnionType) +OrderByExpr: TypeAlias = "UnaryExpression[Any] | InstrumentedAttribute[Any]" + T = TypeVar("T", bound="Any") diff --git a/tests/integration/__snapshots__/test_default_order_by.ambr b/tests/integration/__snapshots__/test_default_order_by.ambr new file mode 100644 index 00000000..6ba5b1dd --- /dev/null +++ b/tests/integration/__snapshots__/test_default_order_by.ambr @@ -0,0 +1,381 @@ +# serializer version: 1 +# name: test_default_order_multi_column[session-tracked-async-aiosqlite_engine] + ''' + SELECT fruit.name, + fruit.sweetness, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.sweetness DESC, + fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_multi_column[session-tracked-async-asyncmy_engine] + ''' + SELECT fruit.name, + fruit.sweetness, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.sweetness DESC, + fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_multi_column[session-tracked-async-asyncpg_engine] + ''' + SELECT fruit.name, + fruit.sweetness, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.sweetness DESC, + fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_multi_column[session-tracked-async-psycopg_async_engine] + ''' + SELECT fruit.name, + fruit.sweetness, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.sweetness DESC, + fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_multi_column[session-tracked-sync-psycopg_engine] + ''' + SELECT fruit.name, + fruit.sweetness, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.sweetness DESC, + fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_multi_column[session-tracked-sync-sqlite_engine] + ''' + SELECT fruit.name, + fruit.sweetness, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.sweetness DESC, + fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_under_pagination[session-tracked-async-aiosqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT ? + OFFSET ? + ) AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT ? + OFFSET ? + ''' +# --- +# name: test_default_order_under_pagination[session-tracked-async-asyncmy_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT %s, + %s + ) AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT %s + ''' +# --- +# name: test_default_order_under_pagination[session-tracked-async-asyncpg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT $1::INTEGER + OFFSET $2::INTEGER + ) AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT $3::INTEGER + ''' +# --- +# name: test_default_order_under_pagination[session-tracked-async-psycopg_async_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT %(param_1)s::INTEGER + OFFSET %(param_2)s::INTEGER + ) AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT %(param_3)s::INTEGER + ''' +# --- +# name: test_default_order_under_pagination[session-tracked-sync-psycopg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT %(param_1)s::INTEGER + OFFSET %(param_2)s::INTEGER + ) AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT %(param_3)s::INTEGER + ''' +# --- +# name: test_default_order_under_pagination[session-tracked-sync-sqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT ? + OFFSET ? + ) AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + LIMIT ? + OFFSET ? + ''' +# --- +# name: test_default_order_with_distinct_on[session-tracked-async-aiosqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name, + row_number() OVER (PARTITION BY fruit.name ORDER BY fruit.name ASC, fruit.id ASC) AS __strawchemy_distinct_on_rank_0 + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ) AS fruit + WHERE fruit.__strawchemy_distinct_on_rank_0 = ? + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_distinct_on[session-tracked-async-asyncmy_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name, + row_number() OVER (PARTITION BY fruit.name ORDER BY fruit.name ASC, fruit.id ASC) AS __strawchemy_distinct_on_rank_0 + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ) AS fruit + WHERE fruit.__strawchemy_distinct_on_rank_0 = %s + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_distinct_on[session-tracked-async-asyncpg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name, + row_number() OVER (PARTITION BY fruit.name ORDER BY fruit.name ASC, fruit.id ASC) AS __strawchemy_distinct_on_rank_0 + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ) AS fruit + WHERE fruit.__strawchemy_distinct_on_rank_0 = $1::INTEGER + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_distinct_on[session-tracked-async-psycopg_async_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name, + row_number() OVER (PARTITION BY fruit.name ORDER BY fruit.name ASC, fruit.id ASC) AS __strawchemy_distinct_on_rank_0 + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ) AS fruit + WHERE fruit.__strawchemy_distinct_on_rank_0 = %(strawchemy_distinct_on_rank_0_1)s::INTEGER + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_distinct_on[session-tracked-sync-psycopg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name, + row_number() OVER (PARTITION BY fruit.name ORDER BY fruit.name ASC, fruit.id ASC) AS __strawchemy_distinct_on_rank_0 + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ) AS fruit + WHERE fruit.__strawchemy_distinct_on_rank_0 = %(strawchemy_distinct_on_rank_0_1)s::INTEGER + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_distinct_on[session-tracked-sync-sqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM ( + SELECT fruit.id AS id, + fruit.name AS name, + row_number() OVER (PARTITION BY fruit.name ORDER BY fruit.name ASC, fruit.id ASC) AS __strawchemy_distinct_on_rank_0 + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ) AS fruit + WHERE fruit.__strawchemy_distinct_on_rank_0 = ? + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_pk_tiebreaker[session-tracked-async-aiosqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_pk_tiebreaker[session-tracked-async-asyncmy_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_pk_tiebreaker[session-tracked-async-asyncpg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_pk_tiebreaker[session-tracked-async-psycopg_async_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_pk_tiebreaker[session-tracked-sync-psycopg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_with_pk_tiebreaker[session-tracked-sync-sqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC, + fruit.id ASC + ''' +# --- +# name: test_default_order_without_pk_tiebreaker[session-tracked-async-aiosqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC + ''' +# --- +# name: test_default_order_without_pk_tiebreaker[session-tracked-async-asyncmy_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC + ''' +# --- +# name: test_default_order_without_pk_tiebreaker[session-tracked-async-asyncpg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC + ''' +# --- +# name: test_default_order_without_pk_tiebreaker[session-tracked-async-psycopg_async_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC + ''' +# --- +# name: test_default_order_without_pk_tiebreaker[session-tracked-sync-psycopg_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC + ''' +# --- +# name: test_default_order_without_pk_tiebreaker[session-tracked-sync-sqlite_engine] + ''' + SELECT fruit.name, + fruit.id + FROM fruit AS fruit + ORDER BY fruit.name ASC + ''' +# --- diff --git a/tests/integration/test_default_order_by.py b/tests/integration/test_default_order_by.py new file mode 100644 index 00000000..aa58e740 --- /dev/null +++ b/tests/integration/test_default_order_by.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.integration.fixtures import QueryTracker +from tests.integration.typing import RawRecordData +from tests.typing import AnyQueryExecutor +from tests.utils import maybe_async + +if TYPE_CHECKING: + from syrupy.assertion import SnapshotAssertion + + from strawchemy import StrawchemyConfig + +pytestmark = [pytest.mark.integration] + + +async def test_default_order_by_applied_when_no_client_order( + any_query: AnyQueryExecutor, raw_fruits: RawRecordData +) -> None: + """Default ordering by name ASC is applied when the client sends no orderBy. + + The default-ordered result is compared against an explicit ``orderBy: { name: ASC }`` + query issued to the same database, so the assertion is independent of the database's + text collation (which differs between sqlite's binary ordering and Postgres/MySQL + locale-aware ordering). + """ + default = await maybe_async(any_query("{ fruitsDefaultOrderNoPagination { id name } }")) + explicit = await maybe_async(any_query("{ fruitsDefaultOrderNoPagination(orderBy: { name: ASC }) { id name } }")) + assert not default.errors + assert not explicit.errors + assert default.data + assert explicit.data + assert len(default.data["fruitsDefaultOrderNoPagination"]) == len(raw_fruits) + # No client order falls back to default_order_by (name ASC), matching explicit name ASC. + assert default.data["fruitsDefaultOrderNoPagination"] == explicit.data["fruitsDefaultOrderNoPagination"] + + +async def test_client_order_overrides_default(any_query: AnyQueryExecutor, raw_fruits: RawRecordData) -> None: + """A client orderBy fully overrides default_order_by. + + Compares the descending result against the reverse of the ascending result (names are + unique in the fixture), keeping the assertion independent of database text collation. + """ + asc = await maybe_async(any_query("{ fruitsDefaultOrderNoPagination(orderBy: { name: ASC }) { id name } }")) + desc = await maybe_async(any_query("{ fruitsDefaultOrderNoPagination(orderBy: { name: DESC }) { id name } }")) + assert not asc.errors + assert not desc.errors + assert asc.data + assert desc.data + assert len(desc.data["fruitsDefaultOrderNoPagination"]) == len(raw_fruits) + asc_names = [row["name"] for row in asc.data["fruitsDefaultOrderNoPagination"]] + desc_names = [row["name"] for row in desc.data["fruitsDefaultOrderNoPagination"]] + # Client DESC ordering is honored over the default ASC, producing the reverse order. + assert desc_names == list(reversed(asc_names)) + + +@pytest.mark.snapshot +async def test_default_order_with_pk_tiebreaker( + any_query: AnyQueryExecutor, + config: StrawchemyConfig, + query_tracker: QueryTracker, + sql_snapshot: SnapshotAssertion, +) -> None: + """With deterministic_ordering=True the PK is appended after the default column.""" + # True is the StrawchemyConfig default + config.deterministic_ordering = True + result = await maybe_async(any_query("{ fruitsDefaultOrderNoPagination { id name } }")) + assert not result.errors + assert query_tracker.query_count == 1 + assert query_tracker[0].statement_formatted == sql_snapshot + + +@pytest.mark.snapshot +async def test_default_order_without_pk_tiebreaker( + any_query: AnyQueryExecutor, + config: StrawchemyConfig, + query_tracker: QueryTracker, + sql_snapshot: SnapshotAssertion, +) -> None: + """With deterministic_ordering=False only the default column is emitted (no PK).""" + config.deterministic_ordering = False + result = await maybe_async(any_query("{ fruitsDefaultOrderNoPagination { id name } }")) + assert not result.errors + assert query_tracker.query_count == 1 + assert query_tracker[0].statement_formatted == sql_snapshot + + +@pytest.mark.snapshot +async def test_default_order_under_pagination( + any_query: AnyQueryExecutor, + query_tracker: QueryTracker, + sql_snapshot: SnapshotAssertion, +) -> None: + """Default ordering is adapted to the subquery alias when paginating (limit/offset).""" + result = await maybe_async(any_query("{ fruitsDefaultOrder(limit: 2, offset: 1) { id name } }")) + assert not result.errors + assert result.data + assert len(result.data["fruitsDefaultOrder"]) == 2 + assert query_tracker.query_count == 1 + assert query_tracker[0].statement_formatted == sql_snapshot + + +@pytest.mark.snapshot +async def test_default_order_multi_column( + any_query: AnyQueryExecutor, + raw_fruits: RawRecordData, + query_tracker: QueryTracker, + sql_snapshot: SnapshotAssertion, +) -> None: + """Multi-column default_order_by list is applied: sweetness DESC, name ASC, then PK tiebreaker.""" + result = await maybe_async(any_query("{ fruitsDefaultOrderMulti { id name sweetness } }")) + assert not result.errors + assert result.data + assert len(result.data["fruitsDefaultOrderMulti"]) == len(raw_fruits) + expected_names = [row["name"] for row in sorted(raw_fruits, key=lambda r: (-r["sweetness"], r["name"]))] + assert [row["name"] for row in result.data["fruitsDefaultOrderMulti"]] == expected_names + assert query_tracker.query_count == 1 + assert query_tracker[0].statement_formatted == sql_snapshot + + +@pytest.mark.snapshot +async def test_default_order_with_distinct_on( + any_query: AnyQueryExecutor, + query_tracker: QueryTracker, + sql_snapshot: SnapshotAssertion, +) -> None: + """distinct_on combined with default_order_by uses the default ordering inside the RANK window.""" + result = await maybe_async(any_query("{ fruitsDefaultOrderDistinct(distinctOn: [name]) { id name } }")) + assert not result.errors + assert result.data + assert len(result.data["fruitsDefaultOrderDistinct"]) > 0 + assert query_tracker.query_count == 1 + assert query_tracker[0].statement_formatted == sql_snapshot diff --git a/tests/integration/types/mysql.py b/tests/integration/types/mysql.py index 2cf82c2a..41b56571 100644 --- a/tests/integration/types/mysql.py +++ b/tests/integration/types/mysql.py @@ -194,6 +194,10 @@ class ColorOrder: ... class ColorDistinctOn: ... +@strawchemy.distinct_on(Fruit, include="all") +class FruitDistinctOn: ... + + @strawchemy.type(Color, include="all", paginate="all") class ColorTypeWithPagination: ... @@ -297,7 +301,7 @@ class DateTimeType: ... class AsyncQuery: # Fruit fruits: list[FruitType] = strawchemy.field( - filter_input=FruitFilter, order_by=FruitOrderBy, repository_type=StrawchemyAsyncRepository + filter_input=FruitFilter, order_by_input=FruitOrderBy, repository_type=StrawchemyAsyncRepository ) fruits_paginated: list[FruitTypeWithPaginationAndOrderBy] = strawchemy.field( filter_input=FruitFilter, @@ -318,6 +322,31 @@ class AsyncQuery: fruit_aggregations_paginated_limit_2: FruitAggregationType = strawchemy.field( root_aggregations=True, pagination=DefaultOffsetPagination(limit=2), repository_type=StrawchemyAsyncRepository ) + fruits_default_order: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=True, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_no_pagination: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_multi: list[FruitType] = strawchemy.field( + default_order_by=[Fruit.sweetness.desc(), Fruit.name.asc()], + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_distinct: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + distinct_on=FruitDistinctOn, + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) fruits_hooks: list[FruitTypeHooks] = strawchemy.field(repository_type=StrawchemyAsyncRepository) fruits_paginated_hooks: list[FruitTypeHooks] = strawchemy.field( repository_type=StrawchemyAsyncRepository, pagination=True @@ -338,7 +367,7 @@ class AsyncQuery: colors: list[ColorType] = strawchemy.field( filter_input=ColorFilter, distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, repository_type=StrawchemyAsyncRepository, ) colors_paginated: list[ColorTypeWithPagination] = strawchemy.field( @@ -349,14 +378,14 @@ class AsyncQuery: repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name == "Red") ) colors_filtered_paginated: list[ColorType] = strawchemy.field( - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), ) colors_filtered_distinct: list[ColorType] = strawchemy.field( distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), @@ -375,7 +404,7 @@ class AsyncQuery: user: UserType = strawchemy.field(repository_type=StrawchemyAsyncRepository) users: list[UserType] = strawchemy.field( filter_input=UserFilter, - order_by=UserOrderBy, + order_by_input=UserOrderBy, repository_type=StrawchemyAsyncRepository, distinct_on=UserDistinctOn, ) @@ -396,7 +425,7 @@ async def get_color(self, info: strawberry.Info, color: str) -> ColorType | None class SyncQuery: # Fruit fruits: list[FruitType] = strawchemy.field( - filter_input=FruitFilter, order_by=FruitOrderBy, repository_type=StrawchemySyncRepository + filter_input=FruitFilter, order_by_input=FruitOrderBy, repository_type=StrawchemySyncRepository ) fruits_paginated: list[FruitTypeWithPaginationAndOrderBy] = strawchemy.field( filter_input=FruitFilter, @@ -417,6 +446,31 @@ class SyncQuery: fruit_aggregations_paginated_limit_2: FruitAggregationType = strawchemy.field( root_aggregations=True, pagination=DefaultOffsetPagination(limit=2), repository_type=StrawchemySyncRepository ) + fruits_default_order: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=True, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_no_pagination: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_multi: list[FruitType] = strawchemy.field( + default_order_by=[Fruit.sweetness.desc(), Fruit.name.asc()], + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_distinct: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + distinct_on=FruitDistinctOn, + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) fruits_hooks: list[FruitTypeHooks] = strawchemy.field(repository_type=StrawchemySyncRepository) fruits_paginated_hooks: list[FruitTypeHooks] = strawchemy.field( repository_type=StrawchemySyncRepository, pagination=True @@ -437,7 +491,7 @@ class SyncQuery: colors: list[ColorType] = strawchemy.field( filter_input=ColorFilter, distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, repository_type=StrawchemySyncRepository, ) colors_paginated: list[ColorTypeWithPagination] = strawchemy.field( @@ -447,14 +501,14 @@ class SyncQuery: repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name == "Red") ) colors_filtered_paginated: list[ColorType] = strawchemy.field( - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), ) colors_filtered_distinct: list[ColorType] = strawchemy.field( distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), @@ -473,7 +527,7 @@ class SyncQuery: user: UserType = strawchemy.field(repository_type=StrawchemySyncRepository) users: list[UserType] = strawchemy.field( filter_input=UserFilter, - order_by=UserOrderBy, + order_by_input=UserOrderBy, repository_type=StrawchemySyncRepository, distinct_on=UserDistinctOn, ) diff --git a/tests/integration/types/postgres.py b/tests/integration/types/postgres.py index 84e4f3e3..112b78ce 100644 --- a/tests/integration/types/postgres.py +++ b/tests/integration/types/postgres.py @@ -202,6 +202,10 @@ class ColorOrder: ... class ColorDistinctOn: ... +@strawchemy.distinct_on(Fruit, include="all") +class FruitDistinctOn: ... + + @strawchemy.type(Color, include="all", paginate="all") class ColorTypeWithPagination: ... @@ -316,7 +320,7 @@ class DateTimeType: ... class AsyncQuery: # Fruit fruits: list[FruitType] = strawchemy.field( - filter_input=FruitFilter, order_by=FruitOrderBy, repository_type=StrawchemyAsyncRepository + filter_input=FruitFilter, order_by_input=FruitOrderBy, repository_type=StrawchemyAsyncRepository ) fruits_paginated: list[FruitTypeWithPaginationAndOrderBy] = strawchemy.field( filter_input=FruitFilter, @@ -337,6 +341,31 @@ class AsyncQuery: fruit_aggregations_paginated_limit_2: FruitAggregationType = strawchemy.field( root_aggregations=True, pagination=DefaultOffsetPagination(limit=2), repository_type=StrawchemyAsyncRepository ) + fruits_default_order: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=True, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_no_pagination: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_multi: list[FruitType] = strawchemy.field( + default_order_by=[Fruit.sweetness.desc(), Fruit.name.asc()], + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_distinct: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + distinct_on=FruitDistinctOn, + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) fruits_hooks: list[FruitTypeHooks] = strawchemy.field(repository_type=StrawchemyAsyncRepository) fruits_paginated_hooks: list[FruitTypeHooks] = strawchemy.field( repository_type=StrawchemyAsyncRepository, pagination=True @@ -357,7 +386,7 @@ class AsyncQuery: colors: list[ColorType] = strawchemy.field( filter_input=ColorFilter, distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, repository_type=StrawchemyAsyncRepository, ) colors_paginated: list[ColorTypeWithPagination] = strawchemy.field( @@ -368,14 +397,14 @@ class AsyncQuery: repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name == "Red") ) colors_filtered_paginated: list[ColorType] = strawchemy.field( - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), ) colors_filtered_distinct: list[ColorType] = strawchemy.field( distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), @@ -394,7 +423,7 @@ class AsyncQuery: user: UserType = strawchemy.field(repository_type=StrawchemyAsyncRepository) users: list[UserType] = strawchemy.field( filter_input=UserFilter, - order_by=UserOrderBy, + order_by_input=UserOrderBy, repository_type=StrawchemyAsyncRepository, distinct_on=UserDistinctOn, ) @@ -415,7 +444,7 @@ async def get_color(self, info: strawberry.Info, color: str) -> ColorType | None class SyncQuery: # Fruit fruits: list[FruitType] = strawchemy.field( - filter_input=FruitFilter, order_by=FruitOrderBy, repository_type=StrawchemySyncRepository + filter_input=FruitFilter, order_by_input=FruitOrderBy, repository_type=StrawchemySyncRepository ) fruits_paginated: list[FruitTypeWithPaginationAndOrderBy] = strawchemy.field( filter_input=FruitFilter, @@ -436,6 +465,31 @@ class SyncQuery: fruit_aggregations_paginated_limit_2: FruitAggregationType = strawchemy.field( root_aggregations=True, pagination=DefaultOffsetPagination(limit=2), repository_type=StrawchemySyncRepository ) + fruits_default_order: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=True, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_no_pagination: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_multi: list[FruitType] = strawchemy.field( + default_order_by=[Fruit.sweetness.desc(), Fruit.name.asc()], + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_distinct: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + distinct_on=FruitDistinctOn, + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) fruits_hooks: list[FruitTypeHooks] = strawchemy.field(repository_type=StrawchemySyncRepository) fruits_paginated_hooks: list[FruitTypeHooks] = strawchemy.field( repository_type=StrawchemySyncRepository, pagination=True @@ -456,7 +510,7 @@ class SyncQuery: colors: list[ColorType] = strawchemy.field( filter_input=ColorFilter, distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, repository_type=StrawchemySyncRepository, ) colors_paginated: list[ColorTypeWithPagination] = strawchemy.field( @@ -466,14 +520,14 @@ class SyncQuery: repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name == "Red") ) colors_filtered_paginated: list[ColorType] = strawchemy.field( - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), ) colors_filtered_distinct: list[ColorType] = strawchemy.field( distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), @@ -492,7 +546,7 @@ class SyncQuery: user: UserType = strawchemy.field(repository_type=StrawchemySyncRepository) users: list[UserType] = strawchemy.field( filter_input=UserFilter, - order_by=UserOrderBy, + order_by_input=UserOrderBy, repository_type=StrawchemySyncRepository, distinct_on=UserDistinctOn, ) diff --git a/tests/integration/types/sqlite.py b/tests/integration/types/sqlite.py index 9f479686..405e8fe6 100644 --- a/tests/integration/types/sqlite.py +++ b/tests/integration/types/sqlite.py @@ -193,6 +193,10 @@ class ColorOrder: ... class ColorDistinctOn: ... +@strawchemy.distinct_on(Fruit, include="all") +class FruitDistinctOn: ... + + @strawchemy.type(Color, include="all", paginate="all") class ColorTypeWithPagination: ... @@ -296,7 +300,7 @@ class DateTimeType: ... class AsyncQuery: # Fruit fruits: list[FruitType] = strawchemy.field( - filter_input=FruitFilter, order_by=FruitOrderBy, repository_type=StrawchemyAsyncRepository + filter_input=FruitFilter, order_by_input=FruitOrderBy, repository_type=StrawchemyAsyncRepository ) fruits_paginated: list[FruitTypeWithPaginationAndOrderBy] = strawchemy.field( filter_input=FruitFilter, @@ -317,6 +321,31 @@ class AsyncQuery: fruit_aggregations_paginated_limit_2: FruitAggregationType = strawchemy.field( root_aggregations=True, pagination=DefaultOffsetPagination(limit=2), repository_type=StrawchemyAsyncRepository ) + fruits_default_order: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=True, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_no_pagination: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_multi: list[FruitType] = strawchemy.field( + default_order_by=[Fruit.sweetness.desc(), Fruit.name.asc()], + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) + fruits_default_order_distinct: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + distinct_on=FruitDistinctOn, + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemyAsyncRepository, + ) fruits_hooks: list[FruitTypeHooks] = strawchemy.field(repository_type=StrawchemyAsyncRepository) fruits_paginated_hooks: list[FruitTypeHooks] = strawchemy.field( repository_type=StrawchemyAsyncRepository, pagination=True @@ -337,7 +366,7 @@ class AsyncQuery: colors: list[ColorType] = strawchemy.field( filter_input=ColorFilter, distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, repository_type=StrawchemyAsyncRepository, ) colors_paginated: list[ColorTypeWithPagination] = strawchemy.field( @@ -348,14 +377,14 @@ class AsyncQuery: repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name == "Red") ) colors_filtered_paginated: list[ColorType] = strawchemy.field( - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), ) colors_filtered_distinct: list[ColorType] = strawchemy.field( distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemyAsyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), @@ -374,7 +403,7 @@ class AsyncQuery: user: UserType = strawchemy.field(repository_type=StrawchemyAsyncRepository) users: list[UserType] = strawchemy.field( filter_input=UserFilter, - order_by=UserOrderBy, + order_by_input=UserOrderBy, repository_type=StrawchemyAsyncRepository, distinct_on=UserDistinctOn, ) @@ -395,7 +424,7 @@ async def get_color(self, info: strawberry.Info, color: str) -> ColorType | None class SyncQuery: # Fruit fruits: list[FruitType] = strawchemy.field( - filter_input=FruitFilter, order_by=FruitOrderBy, repository_type=StrawchemySyncRepository + filter_input=FruitFilter, order_by_input=FruitOrderBy, repository_type=StrawchemySyncRepository ) fruits_paginated: list[FruitTypeWithPaginationAndOrderBy] = strawchemy.field( filter_input=FruitFilter, @@ -416,6 +445,31 @@ class SyncQuery: fruit_aggregations_paginated_limit_2: FruitAggregationType = strawchemy.field( root_aggregations=True, pagination=DefaultOffsetPagination(limit=2), repository_type=StrawchemySyncRepository ) + fruits_default_order: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=True, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_no_pagination: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_multi: list[FruitType] = strawchemy.field( + default_order_by=[Fruit.sweetness.desc(), Fruit.name.asc()], + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) + fruits_default_order_distinct: list[FruitType] = strawchemy.field( + default_order_by=Fruit.name.asc(), + distinct_on=FruitDistinctOn, + order_by_input=FruitOrderBy, + pagination=False, + repository_type=StrawchemySyncRepository, + ) fruits_hooks: list[FruitTypeHooks] = strawchemy.field(repository_type=StrawchemySyncRepository) fruits_paginated_hooks: list[FruitTypeHooks] = strawchemy.field( repository_type=StrawchemySyncRepository, pagination=True @@ -436,7 +490,7 @@ class SyncQuery: colors: list[ColorType] = strawchemy.field( filter_input=ColorFilter, distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, repository_type=StrawchemySyncRepository, ) colors_paginated: list[ColorTypeWithPagination] = strawchemy.field( @@ -446,14 +500,14 @@ class SyncQuery: repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name == "Red") ) colors_filtered_paginated: list[ColorType] = strawchemy.field( - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), ) colors_filtered_distinct: list[ColorType] = strawchemy.field( distinct_on=ColorDistinctOn, - order_by=ColorOrder, + order_by_input=ColorOrder, pagination=True, repository_type=StrawchemySyncRepository, filter_statement=lambda _: select(Color).where(Color.name.in_(["Red", "Green", "Pink"])), @@ -472,7 +526,7 @@ class SyncQuery: user: UserType = strawchemy.field(repository_type=StrawchemySyncRepository) users: list[UserType] = strawchemy.field( filter_input=UserFilter, - order_by=UserOrderBy, + order_by_input=UserOrderBy, repository_type=StrawchemySyncRepository, distinct_on=UserDistinctOn, ) diff --git a/tests/unit/mapping/test_schemas.py b/tests/unit/mapping/test_schemas.py index dcaa6d2b..22127594 100644 --- a/tests/unit/mapping/test_schemas.py +++ b/tests/unit/mapping/test_schemas.py @@ -660,3 +660,13 @@ class ColorNode: fruit_fields = set(DTOInspect(FruitNode).annotations()) assert "color" not in fruit_fields assert {"id", "name", "sweetness", "color_id"} <= fruit_fields + + +def test_default_order_by_on_non_list_field_raises() -> None: + with pytest.raises(StrawchemyFieldError, match="list field"): + import_module("tests.unit.schemas.default_order_by_non_list") + + +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") diff --git a/tests/unit/schemas/default_order_by_invalid.py b/tests/unit/schemas/default_order_by_invalid.py new file mode 100644 index 00000000..cd0d48fe --- /dev/null +++ b/tests/unit/schemas/default_order_by_invalid.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import strawberry + +from strawchemy import Strawchemy +from tests.unit.models import Color, Fruit + +strawchemy = Strawchemy("postgresql") + + +@strawchemy.type(Fruit, include=["name"]) +class FruitType: + id: strawberry.auto + + +@strawchemy.type(Color, include=["name"]) +class ColorType: + id: strawberry.auto + + +@strawberry.type +class WrongModelQuery: + fruits: list[FruitType] = strawchemy.field(default_order_by=Color.name.asc()) diff --git a/tests/unit/schemas/default_order_by_non_list.py b/tests/unit/schemas/default_order_by_non_list.py new file mode 100644 index 00000000..e5e7bc5d --- /dev/null +++ b/tests/unit/schemas/default_order_by_non_list.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import strawberry + +from strawchemy import Strawchemy +from tests.unit.models import Fruit + +strawchemy = Strawchemy("postgresql") + + +@strawchemy.type(Fruit, include=["name"]) +class FruitType: + id: strawberry.auto + + +@strawberry.type +class NonListQuery: + fruit: FruitType = strawchemy.field(default_order_by=Fruit.name.asc()) diff --git a/tests/unit/schemas/include/all_order_by.py b/tests/unit/schemas/include/all_order_by.py index 85fa06b0..81f99a38 100644 --- a/tests/unit/schemas/include/all_order_by.py +++ b/tests/unit/schemas/include/all_order_by.py @@ -20,4 +20,4 @@ class FruitOrderBy: @strawberry.type class Query: - fruit: list[FruitType] = strawchemy.field(order_by=FruitOrderBy) + fruit: list[FruitType] = strawchemy.field(order_by_input=FruitOrderBy) diff --git a/tests/unit/schemas/order/field_order_by.py b/tests/unit/schemas/order/field_order_by.py index 4cb8bb1f..cd02f100 100644 --- a/tests/unit/schemas/order/field_order_by.py +++ b/tests/unit/schemas/order/field_order_by.py @@ -18,4 +18,4 @@ class SQLDataTypesType: ... @strawberry.type class Query: - sql_data_types: list[SQLDataTypesType] = strawchemy.field(order_by=SQLDataTypesOrderBy) + sql_data_types: list[SQLDataTypesType] = strawchemy.field(order_by_input=SQLDataTypesOrderBy) diff --git a/tests/unit/schemas/order/field_order_by_all.py b/tests/unit/schemas/order/field_order_by_all.py index 9aec1336..58ab795f 100644 --- a/tests/unit/schemas/order/field_order_by_all.py +++ b/tests/unit/schemas/order/field_order_by_all.py @@ -15,4 +15,4 @@ class FruitType: @strawberry.type class Query: - fruits: list[FruitType] = strawchemy.field(order_by="all") + fruits: list[FruitType] = strawchemy.field(order_by_input="all") diff --git a/tests/unit/schemas/order/field_order_by_specific_fields.py b/tests/unit/schemas/order/field_order_by_specific_fields.py index 91c9a19a..195a4d40 100644 --- a/tests/unit/schemas/order/field_order_by_specific_fields.py +++ b/tests/unit/schemas/order/field_order_by_specific_fields.py @@ -15,4 +15,4 @@ class ContainerType: @strawberry.type class Query: - containers: list[ContainerType] = strawchemy.field(order_by=["fruits", "vegetables"]) + containers: list[ContainerType] = strawchemy.field(order_by_input=["fruits", "vegetables"]) diff --git a/tests/unit/schemas/order/order_config_all_with_field_override.py b/tests/unit/schemas/order/order_config_all_with_field_override.py index d71294b0..8f27cafc 100644 --- a/tests/unit/schemas/order/order_config_all_with_field_override.py +++ b/tests/unit/schemas/order/order_config_all_with_field_override.py @@ -15,4 +15,4 @@ class FruitType: @strawberry.type class Query: - fruits: list[FruitType] = strawchemy.field(order_by=["name"]) + fruits: list[FruitType] = strawchemy.field(order_by_input=["name"]) diff --git a/tests/unit/schemas/order/order_config_with_field_override.py b/tests/unit/schemas/order/order_config_with_field_override.py index 448c573f..c04e9271 100644 --- a/tests/unit/schemas/order/order_config_with_field_override.py +++ b/tests/unit/schemas/order/order_config_with_field_override.py @@ -15,4 +15,4 @@ class FruitType: @strawberry.type class Query: - fruits: list[FruitType] = strawchemy.field(order_by=["sweetness"]) + fruits: list[FruitType] = strawchemy.field(order_by_input=["sweetness"]) diff --git a/tests/unit/schemas/override/override_argument.py b/tests/unit/schemas/override/override_argument.py index fc76c887..34d81130 100644 --- a/tests/unit/schemas/override/override_argument.py +++ b/tests/unit/schemas/override/override_argument.py @@ -20,4 +20,4 @@ class FruitOrderBy: @strawberry.type class Query: - fruits: list[FruitType] = strawchemy.field(order_by=FruitOrderBy) + fruits: list[FruitType] = strawchemy.field(order_by_input=FruitOrderBy) diff --git a/tests/unit/test_order_by_expr.py b/tests/unit/test_order_by_expr.py new file mode 100644 index 00000000..ed8f0859 --- /dev/null +++ b/tests/unit/test_order_by_expr.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from sqlalchemy import func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from strawchemy.dto.strawberry import OrderByEnum, decompose_order_by +from strawchemy.exceptions import StrawchemyFieldError + + +class Base(DeclarativeBase): ... + + +class Widget(Base): + __tablename__ = "widget" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column() + + +@pytest.mark.parametrize( + ("expr", "expected"), + [ + (Widget.name, ("name", OrderByEnum.ASC)), + (Widget.name.asc(), ("name", OrderByEnum.ASC)), + (Widget.name.desc(), ("name", OrderByEnum.DESC)), + (Widget.name.asc().nulls_first(), ("name", OrderByEnum.ASC_NULLS_FIRST)), + (Widget.name.asc().nulls_last(), ("name", OrderByEnum.ASC_NULLS_LAST)), + (Widget.name.desc().nulls_first(), ("name", OrderByEnum.DESC_NULLS_FIRST)), + (Widget.name.desc().nulls_last(), ("name", OrderByEnum.DESC_NULLS_LAST)), + ], +) +def test_decompose_order_by(expr: Any, expected: tuple[str, OrderByEnum]) -> None: + result = decompose_order_by(expr) + assert (result.key, result.order) == expected + + +def test_decompose_order_by_rejects_unsupported() -> None: + with pytest.raises(StrawchemyFieldError): + decompose_order_by(Widget.name.distinct()) + + +def test_decompose_order_by_rejects_non_column() -> None: + """An ordering expression with no resolvable column key is rejected.""" + with pytest.raises(StrawchemyFieldError, match="Could not resolve a column"): + decompose_order_by(func.now().asc())