Skip to content

RFC: Pluggable extension points for pagination strategies and custom filter resolvers #157

Description

@adiberk

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:

  1. Pagination shape — I need Relay-style cursor connections (edges { cursor node } + pageInfo + totalCount); strawchemy ships offset pagination only.
  2. 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+

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions