RFC: Pluggable extension points for pagination strategies and custom filter resolvers
Repository: https://github.com/gazorby/strawchemy
Disclosure: This issue was drafted with the help of an AI assistant. I'm migrating a large production codebase from Flask + Graphene 2 to FastAPI + Strawberry + strawchemy and have hit a couple of seams where I wanted to plug in company-specific behavior but had to bypass sc.field() / @sc.filter entirely to do so. Filing as an RFC because in both cases the right answer feels like "let users plug in", not "add this specific feature." Happy to contribute a PR for either if the direction sounds right.
TL;DR
Two places where I had to bypass strawchemy's high-level decorators entirely instead of customizing them:
- Pagination shape — I need Relay-style cursor connections (
edges { cursor node } + pageInfo + totalCount); strawchemy ships offset pagination only.
- Custom filter resolvers — I need filter fields whose logic runs Python with access to
info (e.g., isSelf, hasUnreadMessages); @sc.filter is column-derived only.
For both, my codebase ended up reimplementing the surrounding machinery (~700 LOC of custom_connection + custom_filter_resolver) just to get past two missing seams. Proposing extension points instead of specific features so the API stays opinionated but escapable.
Context
This is part of a Flask/Graphene 2 → FastAPI/Strawberry migration of a multi-tenant production codebase (~150 SA models, ~30 GraphQL connection fields, custom row-level access control via a policy engine). I'm trying to keep the migration close to upstream strawchemy where I can — and the places where I had to fork the most are the two below.
Proposal 1 — Pluggable pagination strategy
Current state
sc.field(pagination: bool | DefaultOffsetPagination | None = None, ...)
Only offset/limit is supported. The argument shape, response envelope, and SQL slicing are all baked in.
What I need
A Relay-style cursor connection:
usersPreferences(first: Int, after: String, filter: ..., orderBy: [...]) {
totalCount
pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
edges { cursor node { id ... } }
}
Today this requires hand-writing the entire resolver because sc.field() doesn't expose where to plug in a different shape.
Proposed API — pagination as a strategy
from strawchemy.pagination import PaginationStrategy
class RelayCursorPagination(PaginationStrategy):
def arguments(self) -> list[StrawberryArgument]:
"""SDL arguments to emit on the field (first, after, ...)."""
...
def apply(self, query: Select, args: dict) -> Select:
"""Slice the query given the resolver kwargs."""
...
def envelope(self, rows: list, total: int, args: dict) -> Any:
"""Wrap results in the per-strategy response shape."""
...
sc.field(pagination=RelayCursorPagination(default_first=50), ...)
DefaultOffsetPagination becomes one implementation of this protocol; users register their own. This subsumes most of what I built in custom_connection and would let me drop ~400 LOC.
Why an extension point vs. a specific feature
Cursor pagination is the obvious next ask, but the underlying problem is that pagination shape is opinionated and there's no escape. A strategy hook handles Relay cursors, OpenAPI-style _links pagination, custom envelopes that include aggregate fields, and anything else without strawchemy needing to ship each one.
Proposal 2 — Custom filter resolver hook on @sc.filter
Current state
@sc.filter generates column comparators from SA metadata. Adding a custom Python-evaluated filter field requires going around the decorator entirely — and QueryTranspiler.filter_expressions(dto) then aliases the model in a way that doesn't cleanly compose with query.filter(custom_expr) on the unaliased model, so the typical workaround is to walk the DTO yourself (which is what I ended up doing).
What I need
A way to declare custom filter fields on a strawchemy filter class that receive (info, query, value) and return SA expressions to AND-merge:
@sc.filter(UserPreferences, include=["id", "user_id"])
class UserPreferencesFilter:
# Auto-generated column comparators land via strawchemy: ✅
is_self: bool | None = strawberry.UNSET # custom field
@sc.filter_resolver # ← new decorator
def is_self_filter(info, query, value):
if not value:
return None
return UserPreferences.user_id == info.context.user.id
Prior art: graphene-sqlalchemy-filter's {field_name}_filter static method convention, which has been shipping this for years.
Proposed semantics
- Decorator registers the method as the resolver for the field with matching name (stripping a
_filter suffix, matching graphene-sqlalchemy-filter).
- During filter translation, strawchemy invokes registered resolvers with
(info, query, value) after the auto-generated column comparators.
- Return contract:
None → no-op (lets resolvers opt out for falsy/empty values)
ColumnOperators → AND-merged into the WHERE clause
tuple[Query, ColumnOperators | None] → for filters that need to mutate the query (joins, subqueries)
- UNSET-default fields preserve the omitted/null/value three-state distinction.
This is exactly the surface my company needs and would let me drop my custom apply_custom_filters() walker (~150 LOC).
Why an extension point vs. a specific feature
Every nontrivial filter eventually needs request context — user role, viewer ID, computed predicates over loaders. Without a hook, every project rewrites the same wrapper. A documented (info, query, value) resolver hook covers the case and matches what graphene-sqlalchemy-filter and many other GraphQL/SQLAlchemy bridges already do.
Why one issue for two proposals
They're the same architectural pattern — "expose a seam." Both are places where the high-level decorator works for 90% of the use case and the missing 10% forces complete bypass. Filing together so the maintainer sees the broader shape and can decide if there's a unifying API.
Happy to split if preferred — but the motivations and migration context are intertwined and felt easier to read in one place.
Workaround
For now I've reimplemented both — a custom_connection(...) helper (Relay shape + auth + dual filter surface) and @custom_filter_resolver (custom filter fields). Total ~700 LOC of bypass code for two missing seams. Will share if useful as reference.
Environment
- strawchemy current main
- strawberry-graphql current main
- SQLAlchemy 2.x
- Python 3.11+
RFC: Pluggable extension points for pagination strategies and custom filter resolvers
Repository: https://github.com/gazorby/strawchemy
TL;DR
Two places where I had to bypass strawchemy's high-level decorators entirely instead of customizing them:
edges { cursor node }+pageInfo+totalCount); strawchemy ships offset pagination only.info(e.g.,isSelf,hasUnreadMessages);@sc.filteris column-derived only.For both, my codebase ended up reimplementing the surrounding machinery (~700 LOC of
custom_connection+custom_filter_resolver) just to get past two missing seams. Proposing extension points instead of specific features so the API stays opinionated but escapable.Context
This is part of a Flask/Graphene 2 → FastAPI/Strawberry migration of a multi-tenant production codebase (~150 SA models, ~30 GraphQL connection fields, custom row-level access control via a policy engine). I'm trying to keep the migration close to upstream strawchemy where I can — and the places where I had to fork the most are the two below.
Proposal 1 — Pluggable pagination strategy
Current state
Only offset/limit is supported. The argument shape, response envelope, and SQL slicing are all baked in.
What I need
A Relay-style cursor connection:
Today this requires hand-writing the entire resolver because
sc.field()doesn't expose where to plug in a different shape.Proposed API — pagination as a strategy
DefaultOffsetPaginationbecomes one implementation of this protocol; users register their own. This subsumes most of what I built incustom_connectionand would let me drop ~400 LOC.Why an extension point vs. a specific feature
Cursor pagination is the obvious next ask, but the underlying problem is that pagination shape is opinionated and there's no escape. A strategy hook handles Relay cursors, OpenAPI-style
_linkspagination, custom envelopes that include aggregate fields, and anything else without strawchemy needing to ship each one.Proposal 2 — Custom filter resolver hook on
@sc.filterCurrent state
@sc.filtergenerates column comparators from SA metadata. Adding a custom Python-evaluated filter field requires going around the decorator entirely — andQueryTranspiler.filter_expressions(dto)then aliases the model in a way that doesn't cleanly compose withquery.filter(custom_expr)on the unaliased model, so the typical workaround is to walk the DTO yourself (which is what I ended up doing).What I need
A way to declare custom filter fields on a strawchemy filter class that receive
(info, query, value)and return SA expressions to AND-merge:Prior art:
graphene-sqlalchemy-filter's{field_name}_filterstatic method convention, which has been shipping this for years.Proposed semantics
_filtersuffix, matchinggraphene-sqlalchemy-filter).(info, query, value)after the auto-generated column comparators.None→ no-op (lets resolvers opt out for falsy/empty values)ColumnOperators→ AND-merged into the WHERE clausetuple[Query, ColumnOperators | None]→ for filters that need to mutate the query (joins, subqueries)This is exactly the surface my company needs and would let me drop my custom
apply_custom_filters()walker (~150 LOC).Why an extension point vs. a specific feature
Every nontrivial filter eventually needs request context — user role, viewer ID, computed predicates over loaders. Without a hook, every project rewrites the same wrapper. A documented
(info, query, value)resolver hook covers the case and matches whatgraphene-sqlalchemy-filterand many other GraphQL/SQLAlchemy bridges already do.Why one issue for two proposals
They're the same architectural pattern — "expose a seam." Both are places where the high-level decorator works for 90% of the use case and the missing 10% forces complete bypass. Filing together so the maintainer sees the broader shape and can decide if there's a unifying API.
Happy to split if preferred — but the motivations and migration context are intertwined and felt easier to read in one place.
Workaround
For now I've reimplemented both — a
custom_connection(...)helper (Relay shape + auth + dual filter surface) and@custom_filter_resolver(custom filter fields). Total ~700 LOC of bypass code for two missing seams. Will share if useful as reference.Environment