Strawchemy and Strawberry
Strawchemy is built on top of Strawberry, not beside it. It reads your SQLAlchemy models and produces ordinary Strawberry types, inputs, and fields. The schema, the server integration, the extensions, and the permissions all stay Strawberry's. You can therefore adopt it in an existing Strawberry codebase without a rewrite, one field at a time.
Strawberry compatibility
A Strawchemy type is a Strawberry type: your own @strawberry.type classes sit beside the generated ones, and so do plain resolvers that never touch a mapped model. Query and Mutation are ordinary Strawberry classes too, and the fields Strawchemy builds sit beside your own.
strawchemy.field() and the mutation factories accept the strawberry.field arguments description, deprecation_reason, directives, permission_classes, and extensions, and pass them to the generated field unchanged:
posts: list[PostType] = strawchemy.field(
filter_input=PostFilter,
description="Published posts",
deprecation_reason="use postsConnection instead",
)Both show up on the generated field exactly as passed:
type Query {
"""Published posts"""
posts(filter: PostFilter = null): [PostType!]! @deprecated(reason: "use postsConnection instead")
}Since the result is an ordinary strawberry.Schema, any Strawberry server integration serves it. The quickstart app plugs it into Litestar by passing schema to make_graphql_controller.
Migrating an existing schema
Strawchemy starts resolving fields while the rest of your schema stays as it is. A typical migration takes four steps:
Give it the session you already have. By default Strawchemy reads
sessionfrominfo.context, then frominfo.context.request; a context that already carries one needs no setup. Otherwise, setsession_getteronStrawchemyConfig. An async session also needsrepository_type, as async sessions shows.Map one model and add one field. The new field joins the hand-written ones on the same
Query:pythonstrawchemy = Strawchemy(StrawchemyConfig("postgresql")) @strawchemy.type(Post, include="all") class PostType: ... @strawberry.type class Query: @strawberry.field def users(self, info: strawberry.Info) -> list[UserType]: ... # existing resolver, untouched posts: list[PostType] = strawchemy.field()Replace hand-written resolvers one at a time. For a mapped model, one declaration replaces the resolver, the session-fetching code, and the arguments it reads:
pythonusers: list[UserType] = strawchemy.field(filter_input=UserFilter, order_by_input=UserOrderBy, pagination=True)This field accepts
filter,orderBy,limit, andoffset. Filtering, ordering, and pagination describe these arguments; architecture describes the generated resolver. Mutation fields work the same way, with generated input types in place of hand-written ones.Keep the resolvers no generated field can replace. Decorate such a resolver with
@strawchemy.fieldand fetch its data through a repository; Strawchemy still builds the query. Custom resolvers shows how.
Each step leaves a working schema; you can stop after any of them.
Accepted field types
A field Strawchemy resolves itself builds its query from a Strawchemy type. On a strawchemy.field() or mutation field without a resolver of its own, the first member of the annotation that isn't an error type must be one; plain Strawberry types may follow it in a union:
@strawberry.type
class Query:
user_or_plain: UserType | Plain = strawchemy.field()
plain: list[Plain] = strawchemy.field() strawchemy.exceptions.StrawchemyFieldError: The `plain` field has no resolver but its type `Plain` is not a strawchemy type.Plain | UserType fails the same way, and an annotation made only of error types, such as list[ValidationErrorType], raises "…its type only contains error types." A method decorated with @strawchemy.field may return any type — a plain Strawberry type, a scalar, error types alone — but default_order_by still needs a Strawchemy type, and root_aggregations an aggregation type, resolver or not.
These checks run when the class holding the field is decorated, or at strawberry.Schema(...) for an annotation naming a type declared further down the module, where Strawberry wraps the StrawchemyFieldError in a TypeError. Under from __future__ import annotations, a name Strawchemy has registered takes precedence over a module-level name when the annotation resolves.
Replacing dataloaders
Dataloaders batch a relationship across the N+1 resolver calls a naive implementation would make. Strawchemy leaves a dataloader nothing to batch: it compiles the whole selection set into one SQL statement before running it, and a nested posts selection becomes a join in that statement. A three-level selection plus a relationship aggregate still compiles to one statement:
{
users {
name
postsAggregate {
count
}
posts {
title
tags {
name
}
}
}
}Architecture describes the execution model behind this. Your existing dataloaders keep serving the fields Strawchemy leaves to hand-written resolvers.
Generating relationship inputs
strawberry.auto changes meaning on a Strawchemy input. In plain Strawberry, it means "infer this field's type." On a Strawchemy input it means "generate the relationship input for this field," built from up to four operations — a to-many field gets all four, a to-one field all but add:
set— link an existing related record by idadd— attach an existing related record alongside any created here (to-many only)create— create a new related recordupsert— create a new related record, or update the existing one it conflicts with
@strawchemy.create_input(Post, include=["title", "content"])
class PostCreateWithAuthorInput:
author: autoSee create mutations with relationships for what each operation accepts.
Adding computed fields
A Strawchemy type can also hold fields you write. Add a ModelInstance attribute to reach the underlying SQLAlchemy instance, then expose a computed field as a method decorated with @strawchemy.field:
@strawchemy.type(User, include="all")
class UserType:
instance: ModelInstance[User]
@strawchemy.field
def post_count(self) -> int:
return len(self.instance.posts)post_count reads instance.posts, but the generated query never loads relationships onto the instance, even those the client selects. A QueryHook adds that load to the statement Strawchemy already builds:
@strawchemy.field(query_hook=QueryHook(load=[User.posts]))
def post_count(self) -> int:
return len(self.instance.posts)See mapping models for ModelInstance and computed fields, and loading extra data for the rest of what load can do.