Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
192 changes: 172 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Generates GraphQL types, inputs, queries and resolvers directly from SQLAlchemy
- [Mapping SQLAlchemy Models](#mapping-sqlalchemy-models)
- [Resolver Generation](#resolver-generation)
- [Pagination](#pagination)
- [Ordering](#ordering)
- [Filtering](#filtering)
- [Aggregations](#aggregations)
- [Mutations](#mutations)
Expand Down Expand Up @@ -567,20 +568,25 @@ def farms(self) -> str:
Strawchemy supports offset-based pagination out of the box.

<details>
<summary>Pagination example:</summary>
<summary>Pagination examples</summary>

Enable pagination on fields:
### Field-Level Pagination

Enable pagination on specific fields:

```python
from strawchemy.schema.pagination import DefaultOffsetPagination


@strawberry.type
class Query:
# Enable pagination with default settings
# Enable pagination with default settings (limit=100, offset=0)
users: list[UserType] = strawchemy.field(pagination=True)
# Customize pagination defaults
users_custom_pagination: list[UserType] = strawchemy.field(pagination=DefaultOffsetPagination(limit=20))

# Customize pagination defaults for this specific field
users_custom: list[UserType] = strawchemy.field(
pagination=DefaultOffsetPagination(limit=20, offset=10)
)
```

In your GraphQL queries, you can use the `offset` and `limit` parameters:
Expand All @@ -594,10 +600,40 @@ In your GraphQL queries, you can use the `offset` and `limit` parameters:
}
```

You can also enable pagination for nested relationships:
### Config-Level Pagination

Enable pagination globally for all list fields:

```python
@strawchemy.type(User, include="all", child_pagination=True)
from strawchemy import Strawchemy, StrawchemyConfig

strawchemy = Strawchemy(
StrawchemyConfig(
"postgresql",
pagination="all", # Enable on all list fields
pagination_default_limit=100, # Default limit
pagination_default_offset=0, # Default offset
)
)


@strawchemy.type(User, include="all")
class UserType:
pass


@strawberry.type
class Query:
# This field automatically has pagination enabled
users: list[UserType] = strawchemy.field()
```

### Type-level pagination

Enable pagination for nested relationships from a specific type:

```python
@strawchemy.type(User, include="all", paginate="all")
class UserType:
pass
```
Expand All @@ -619,6 +655,118 @@ Then in your GraphQL queries:

</details>

## Ordering

Strawchemy provides flexible ordering capabilities for query results.

<details>
<summary>Ordering examples</summary>

### Field-Level Ordering

Define ordering inputs and use them on specific fields:

```python
# Create order by input
@strawchemy.order(User, include="all")
class UserOrderBy:
pass


@strawberry.type
class Query:
users: list[UserType] = strawchemy.field(order_by=UserOrderBy)
```

Query with ordering:

```graphql
{
users(orderBy: [{ name: ASC }, { createdAt: DESC }]) {
id
name
createdAt
}
}
```

Available ordering options:

- `ASC` - Ascending order
- `DESC` - Descending order
- `ASC_NULLS_FIRST` - Ascending with nulls first
- `ASC_NULLS_LAST` - Ascending with nulls last
- `DESC_NULLS_FIRST` - Descending with nulls first
- `DESC_NULLS_LAST` - Descending with nulls last

### Type-Level Ordering

Enable ordering automatically on a type:

```python
@strawchemy.type(User, include="all", order="all")
class UserType:
pass
```

This automatically generates and applies an order by input for all fields using this type.

### Config-Level Ordering

Enable ordering globally for all list fields:

```python
from strawchemy import Strawchemy, StrawchemyConfig

strawchemy = Strawchemy(
StrawchemyConfig(
"postgresql",
order_by="all", # Enable ordering on all list fields
)
)


@strawchemy.type(User, include="all")
class UserType:
pass


@strawberry.type
class Query:
# This field automatically has ordering enabled
users: list[UserType] = strawchemy.field()
```

With this configuration, all list fields will automatically have an `orderBy` argument without needing to specify it per
field.

### Nested Relationship Ordering

Order nested relationships:

```python
@strawchemy.type(User, include="all", order="all")
class UserType:
pass
```

Query with nested ordering:

```graphql
{
users(orderBy: [{ name: ASC }]) {
id
name
posts(orderBy: [{ title: ASC }]) {
id
title
}
}
}
```

</details>

## Filtering

Strawchemy provides powerful filtering capabilities.
Expand Down Expand Up @@ -1950,18 +2098,20 @@ Configuration is made by passing a `StrawchemyConfig` to the `Strawchemy` instan

### Configuration Options

| Option | Type | Default | Description |
|----------------------------|-------------------------------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `dialect` | `SupportedDialect` | | Database dialect to use. Supported dialects are "postgresql", "mysql", "sqlite". |
| `session_getter` | `Callable[[Info], Session]` | `default_session_getter` | Function to retrieve SQLAlchemy session from strawberry `Info` object. By default, it retrieves the session from `info.context.session`. |
| `auto_snake_case` | `bool` | `True` | Automatically convert snake cased names to camel case in GraphQL schema. |
| `repository_type` | `type[Repository] \| StrawchemySyncRepository` | `StrawchemySyncRepository` | Repository class to use for auto resolvers. |
| `filter_overrides` | `OrderedDict[tuple[type, ...], type[SQLAlchemyFilterBase]]` | `None` | Override default filters with custom filters. This allows you to provide custom filter implementations for specific column types. |
| `execution_options` | `dict[str, Any]` | `None` | SQLAlchemy execution options for repository operations. These options are passed to the SQLAlchemy `execution_options()` method. |
| `pagination_default_limit` | `int` | `100` | Default pagination limit when `pagination=True`. |
| `pagination` | `bool` | `False` | Enable/disable pagination on list resolvers by default. |
| `default_id_field_name` | `str` | `"id"` | Name for primary key fields arguments on primary key resolvers. |
| `deterministic_ordering` | `bool` | `True` | Force deterministic ordering for list resolvers. |
| Option | Type | Default | Description |
|-----------------------------|-------------------------------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `dialect` | `SupportedDialect` | | Database dialect to use. Supported dialects are "postgresql", "mysql", "sqlite". |
| `session_getter` | `Callable[[Info], Session]` | `default_session_getter` | Function to retrieve SQLAlchemy session from strawberry `Info` object. By default, it retrieves the session from `info.context.session`. |
| `auto_snake_case` | `bool` | `True` | Automatically convert snake cased names to camel case in GraphQL schema. |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix hyphenation in configuration table.

"snake cased names" should be hyphenated as "snake-cased names" for proper English grammar.

🔎 Proposed fix
-| `auto_snake_case`           | `bool`                                                      | `True`                     | Automatically convert snake cased names to camel case in GraphQL schema.                                                                 |
+| `auto_snake_case`           | `bool`                                                      | `True`                     | Automatically convert snake-cased names to camel case in GraphQL schema.                                                                 |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| `auto_snake_case` | `bool` | `True` | Automatically convert snake cased names to camel case in GraphQL schema. |
| `auto_snake_case` | `bool` | `True` | Automatically convert snake-cased names to camel case in GraphQL schema. |
🧰 Tools
🪛 LanguageTool

[grammar] ~2105-~2105: Use a hyphen to join words.
Context: ... | Automatically convert snake cased names to camel case in GraphQL sch...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
In README.md around line 2105, the phrase "snake cased names" in the
configuration table should be corrected to "snake-cased names"; update the table
cell text to use the hyphenated form "snake-cased names" so the grammar is
correct while preserving the rest of the sentence and formatting.

| `repository_type` | `type[Repository] \| StrawchemySyncRepository` | `StrawchemySyncRepository` | Repository class to use for auto resolvers. |
| `filter_overrides` | `OrderedDict[tuple[type, ...], type[SQLAlchemyFilterBase]]` | `None` | Override default filters with custom filters. This allows you to provide custom filter implementations for specific column types. |
| `execution_options` | `dict[str, Any]` | `None` | SQLAlchemy execution options for repository operations. These options are passed to the SQLAlchemy `execution_options()` method. |
| `default_id_field_name` | `str` | `"id"` | Name for primary key fields arguments on primary key resolvers. |
| `deterministic_ordering` | `bool` | `True` | Force deterministic ordering for list resolvers. |
| `pagination` | `Literal["all"] \| None` | `None` | Enable/disable pagination on list resolvers by default. Set to `"all"` to enable pagination on all list fields. |
| `order_by` | `Literal["all"] \| None` | `None` | Enable/disable order by on list resolvers by default. Set to `"all"` to enable ordering on all list fields. |
| `pagination_default_limit` | `int` | `100` | Default pagination limit when `pagination=True`. |
| `pagination_default_offset` | `int` | `0` | Default pagination offset when `pagination=True`. |

### Example

Expand All @@ -1980,8 +2130,10 @@ strawchemy = Strawchemy(
"postgresql",
session_getter=get_session_from_context,
auto_snake_case=True,
pagination=True,
pagination="all",
pagination_default_limit=50,
pagination_default_offset=0,
order_by="all",
default_id_field_name="pk",
)
)
Expand Down
14 changes: 9 additions & 5 deletions src/strawchemy/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal

from strawchemy.dto.inspectors import SQLAlchemyGraphQLInspector
from strawchemy.repository.strawberry import StrawchemySyncRepository
Expand Down Expand Up @@ -43,14 +43,18 @@ class StrawchemyConfig:
"""Override default filters with custom filters."""
execution_options: dict[str, Any] | None = None
"""SQLAlchemy execution options for strawberry operations."""
pagination_default_limit: int = 100
"""Default pagination limit when `pagination=True`."""
pagination: bool = False
"""Enable/disable pagination on list resolvers."""
default_id_field_name: str = "id"
"""Name for primary key fields arguments on primary key resolvers."""
deterministic_ordering: bool = True
"""Force deterministic ordering for list resolvers."""
pagination: Literal["all"] | None = None
"""Enable/disable pagination on list resolvers."""
order_by: Literal["all"] | None = None
"""Enable/disable order by on list resolvers."""
pagination_default_limit: int = 100
"""Default pagination limit when `pagination=True`."""
pagination_default_offset: int = 0
"""Default pagination offset when `pagination=True`."""
Comment on lines +62 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inline docstrings reference outdated API.

The docstrings on lines 55 and 57 mention pagination=True, but the new API uses pagination="all".

🔎 Proposed fix
     pagination_default_limit: int = 100
-    """Default pagination limit when `pagination=True`."""
+    """Default pagination limit when pagination is enabled."""
     pagination_default_offset: int = 0
-    """Default pagination offset when `pagination=True`."""
+    """Default pagination offset when pagination is enabled."""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pagination_default_limit: int = 100
"""Default pagination limit when `pagination=True`."""
pagination_default_offset: int = 0
"""Default pagination offset when `pagination=True`."""
pagination_default_limit: int = 100
"""Default pagination limit when pagination is enabled."""
pagination_default_offset: int = 0
"""Default pagination offset when pagination is enabled."""
🤖 Prompt for AI Agents
In src/strawchemy/config/base.py around lines 54 to 57, the inline docstrings
reference the outdated API value `pagination=True`; update both docstrings to
reference the new API value `pagination="all"` (e.g., change `when
`pagination=True`` to `when `pagination="all"``) so they accurately reflect the
current parameter semantics and quoting style.

Comment on lines +52 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix documentation errors in field docstrings.

There are two issues in the docstrings:

  1. Line 54: The exclude field docstring says "Globally included fields" but should describe excluded fields
  2. Line 60: Typo "onelist" should be "on list"
📝 Proposed fix
     include: IncludeFields = "all"
     """Globally included fields."""
     exclude: FieldIterable | None = None
-    """Globally included fields."""
+    """Globally excluded fields."""
     pagination: IncludeFields | None = None
     """Enable/disable pagination on list resolvers."""
     order_by: IncludeFields | None = None
     """Enable/disable order by on list resolvers."""
     distinct_on: IncludeFields | None = None
-    """Enable/disable order by onelist resolvers."""
+    """Enable/disable distinct on for list resolvers."""
     pagination_default_limit: int = 100
     """Default pagination limit when `pagination=True`."""
     pagination_default_offset: int = 0
     """Default pagination offset when `pagination=True`."""
🤖 Prompt for AI Agents
In @src/strawchemy/config/base.py around lines 51 - 64, Update the field
docstrings to correct their descriptions: change the docstring for the exclude
field (symbol: exclude: FieldIterable | None) from "Globally included fields."
to something like "Globally excluded fields." and fix the typo in the
distinct_on field docstring (symbol: distinct_on: IncludeFields | None) from
"Enable/disable order by onelist resolvers." to "Enable/disable order by on list
resolvers."


inspector: SQLAlchemyGraphQLInspector = field(init=False)

Expand Down
4 changes: 2 additions & 2 deletions src/strawchemy/dto/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
DTOMissing,
DTOSkip,
DTOUnset,
ExcludeFields,
FieldIterable,
IncludeFields,
Purpose,
PurposeConfig,
Expand Down Expand Up @@ -685,7 +685,7 @@ def decorator(
model: type[ModelT],
purpose: Purpose,
include: IncludeFields | None = None,
exclude: ExcludeFields | None = None,
exclude: FieldIterable | None = None,
partial: bool | None = None,
type_map: Mapping[Any, Any] | None = None,
aliases: Mapping[str, str] | None = None,
Expand Down
56 changes: 50 additions & 6 deletions src/strawchemy/dto/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from enum import Enum
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, final, get_type_hints

from typing_extensions import override
from typing_extensions import Self, override

from strawchemy.utils.annotation import get_annotations

Expand All @@ -23,15 +23,15 @@
"DTOScope",
"DTOSkip",
"DTOUnset",
"ExcludeFields",
"FieldIterable",
"IncludeFields",
"Purpose",
"PurposeConfig",
)

DTOScope: TypeAlias = Literal["global", "dto"]
IncludeFields: TypeAlias = "list[str] | set[str] | Literal['all']"
ExcludeFields: TypeAlias = "list[str] | set[str]"
FieldIterable: TypeAlias = "list[str] | set[str] | frozenset[str] | tuple[str, ...]"
IncludeFields: TypeAlias = "FieldIterable | Literal['all']"


@final
Expand Down Expand Up @@ -160,7 +160,7 @@ class DTOConfig:
"""Configure the DTO for "read" or "write" operations."""
include: IncludeFields = field(default_factory=set)
"""Explicitly include fields from the generated DTO."""
exclude: ExcludeFields = field(default_factory=set)
exclude: FieldIterable = field(default_factory=set)
"""Explicitly exclude fields from the generated DTO. Implies `include="all"`."""
partial: bool | None = None
"""Make all field optional."""
Expand All @@ -185,11 +185,34 @@ def __post_init__(self) -> None:
if self.exclude:
self.include = "all"

@classmethod
def from_include(cls, include: IncludeFields | None = None, purpose: Purpose = Purpose.READ) -> Self:
"""Create a DTOConfig from an include specification.

Factory method for creating a DTOConfig with a simplified interface, converting
an `IncludeFields` specification into a complete configuration object. This is
useful for building configs when only the include/exclude specification matters.

Args:
include: The field inclusion specification. Can be:
- None: Include no fields (converted to empty set)
- "all": Include all fields
- list or set of field names: Include only these specific fields
Defaults to None.
purpose: The purpose of the DTO being configured (READ, WRITE, or COMPLETE).
Defaults to Purpose.READ.

Returns:
A new DTOConfig instance with the specified include and purpose settings.
All other configuration parameters use their defaults.
"""
return cls(purpose, include=set() if include is None else include)
Comment on lines +199 to +220
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider the past suggestion to make include keyword-only.

A previous review recommended making the include parameter keyword-only to avoid potential misuse with boolean positional arguments. While the current implementation is functional, the suggestion remains valid for improved API safety.

🔎 Reference to past review

The past review suggested:

@classmethod
def from_include(cls, *, include: IncludeFields | None = None, purpose: Purpose = Purpose.READ) -> Self:

This would require callers to explicitly use include=... when calling the method.

🤖 Prompt for AI Agents
In src/strawchemy/dto/types.py around lines 188 to 209, change the classmethod
signature to make include a keyword-only parameter to prevent accidental
positional misuse: update the signature to accept a leading "*" (e.g., def
from_include(cls, *, include: IncludeFields | None = None, purpose: Purpose =
Purpose.READ) -> Self), keep the existing logic that converts include None to an
empty set, and ensure any call sites are updated to pass include=... (and
purpose=... if needed).


def copy_with(
self,
purpose: Purpose | type[DTOUnset] = DTOUnset,
include: IncludeFields | None = None,
exclude: ExcludeFields | None = None,
exclude: FieldIterable | None = None,
partial: bool | None | type[DTOUnset] = DTOUnset,
unset_sentinel: Any | type[DTOUnset] = DTOUnset,
type_overrides: Mapping[Any, Any] | type[DTOUnset] = DTOUnset,
Expand Down Expand Up @@ -265,3 +288,24 @@ def alias(self, name: str) -> str | None:
if self.alias_generator is not None:
return self.alias_generator(name)
return None

def is_field_included(self, name: str) -> bool:
"""Check if a field should be included based on this configuration.

Evaluates field inclusion using the following rules:
1. If include="all": the field is included unless explicitly excluded
2. If include is a specific list/set: the field is included only if named
3. If include is empty: the field is never included
4. Regardless of include, the field is excluded if it's in the exclude set

This method is used during DTO factory operations to determine which fields
from the source model should be included in the generated DTO.

Args:
name: The field name to check for inclusion.

Returns:
True if the field should be included based on the include/exclude rules,
False otherwise.
"""
return (name in self.include or self.include == "all") and name not in self.exclude
Loading
Loading