Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
```
Expand Down Expand Up @@ -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:
Expand Down
86 changes: 84 additions & 2 deletions src/strawchemy/dto/strawberry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -50,6 +53,7 @@
FunctionInfo,
GraphQLPurpose,
OrderByDTOT,
OrderByExpr,
QueryNodeType,
)
from strawchemy.utils.graph import AnyNode, GraphMetadata, MatchOn, Node, NodeMetadata, NodeT
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down
27 changes: 21 additions & 6 deletions src/strawchemy/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/strawchemy/repository/sqlalchemy/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/strawchemy/repository/strawberry/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",)

Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion src/strawchemy/repository/strawberry/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__ = ()

Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/strawchemy/schema/factories/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading