Skip to main content

Generate GraphQL API from SQLAlchemy models

Project description

Strawchemy

🔂 Tests and linting codecov PyPI Downloads

Generates GraphQL types, inputs, queries and resolvers directly from SQLAlchemy models.

Features

  • 🔄 Automatic Type Generation: Generate strawberry types from SQLAlchemy models

  • 🧠 Smart Resolvers: Automatically generates single, optimized database queries for a given GraphQL request

  • 🔍 Comprehensive Filtering: Rich filtering capabilities on most data types, including PostGIS geo columns

  • 📄 Pagination Support: Built-in offset-based pagination

  • 📊 Aggregation Queries: Support for aggregation functions like count, sum, avg, min, max, and statistical functions

  • Sync/Async Support: Works with both synchronous and asynchronous SQLAlchemy sessions

  • 🛢 Database Support: Currently only PostgreSQL is officially supported and tested (using asyncpg or psycopg3 sync/async)

Table of Contents

Installation

Strawchemy is available on PyPi

pip install strawchemy

Strawchemy has the following optional dependencies:

To install these dependencies along with strawchemy:

pip install strawchemy[geo]

Quick Start

import strawberry
from strawchemy import Strawchemy
from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

# Initialize the strawchemy mapper
strawchemy = Strawchemy()


# Define SQLAlchemy models
class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    posts: Mapped[list["Post"]] = relationship("Post", back_populates="author")


class Post(Base):
    __tablename__ = "post"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    content: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
    author: Mapped[User] = relationship("User", back_populates="posts")


# Map models to GraphQL types
@strawchemy.type(User, include="all")
class UserType:
    pass


@strawchemy.type(Post, include="all")
class PostType:
    pass


# Create filter inputs
@strawchemy.filter_input(User, include="all")
class UserFilter:
    pass


@strawchemy.filter_input(Post, include="all")
class PostFilter:
    pass


# Create order by inputs
@strawchemy.order_by_input(User, include="all")
class UserOrderBy:
    pass


@strawchemy.order_by_input(Post, include="all")
class PostOrderBy:
    pass


# 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)

# Create schema
schema = strawberry.Schema(query=Query)
{
  # Users with pagination, filtering, and ordering
  users(
    offset: 0
    limit: 10
    filter: { name: { contains: "John" } }
    orderBy: { name: ASC }
  ) {
    id
    name
    posts {
      id
      title
      content
    }
  }

  # Posts with exact title match
  posts(filter: { title: { eq: "Introduction to GraphQL" } }) {
    id
    title
    content
    author {
      id
      name
    }
  }
}

Mapping SQLAlchemy Models

Strawchemy provides an easy way to map SQLAlchemy models to GraphQL types using the @strawchemy.type decorator. You can include/exclude specific fields or have strawchemy map all columns/relationships of the model and it's children.

Mapping example

Include columns and relationships

import strawberry
from strawchemy import Strawchemy

# Assuming these models are defined as in the Quick Start example
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey

strawchemy = Strawchemy()


class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "user"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    posts: Mapped[list["Post"]] = relationship("Post", back_populates="author")


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

Including/excluding specific fields

class User(Base):
    __tablename__ = "user"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    password: Mapped[str]


# Include specific fields
@strawchemy.type(User, include=["id", "name"])
class UserType:
    pass


# Exclude specific fields
@strawchemy.type(User, exclude=["password"])
class UserType:
    pass


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

Add a custom fields

from strawchemy import ModelInstance

class User(Base):
    __tablename__ = "user"

    id: Mapped[int] = mapped_column(primary_key=True)
    first_name: Mapped[str]
    last_name: Mapped[str]


@strawchemy.type(User, include="all")
class UserType:
    instance: ModelInstance[User]

    @strawchemy.field
    def full_name(self) -> str:
        return f"{self.instance.first_name} {self.instance.last_name}"

See the custom resolvers for more details

Type override

By default, strawchemy generates strawberry types when visiting the model and the following relationships, but only if you have not already defined a type with the same name using the @strawchemy.type decorator, otherwise you will see an error.

To explicitly tell strawchemy to use your type, you need to define it with @strawchemy.type(override=True).

Using the Override Parameter
from strawchemy import Strawchemy

strawchemy = Strawchemy()

# Define models
class Color(Base):
    __tablename__ = "color"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    fruits: Mapped[list["Fruit"]] = relationship("Fruit", back_populates="color")

class Fruit(Base):
    __tablename__ = "fruit"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    color_id: Mapped[int] = mapped_column(ForeignKey("color.id"))
    color: Mapped[Color] = relationship("Color", back_populates="fruits")

# Define a type with override=True
@strawchemy.type(Color, include="all", override=True)
class ColorType:
    fruits: auto
    name: int

# Define another type that uses the same model
@strawchemy.type(Fruit, include="all", override=True)
class FruitType:
    name: int
    color: auto  # This will use the ColorType defined above

# Define a query that uses these types
@strawberry.type
class Query:
    fruit: FruitType = strawchemy.field()

The override parameter is useful in the following scenarios:

  1. Type Reuse: When you need to use the same type in multiple places where the same model is referenced.
  2. Auto-generated Type Override: When you want to override the default auto-generated type for a model.
  3. Custom Type Names: When you want to use a custom name for your type but still have it recognized as the type for a specific model.

Without setting override=True, you would get an error like:

Type `FruitType` cannot be auto generated because it's already declared.
You may want to set `override=True` on the existing type to use it everywhere.

This happens when Strawchemy tries to auto-generate a type for a model that already has a type defined for it.

You can also use override=True with input types:

@strawchemy.order_by_input(Fruit, include="all", override=True)
class FruitOrderBy:
    # Custom order by fields
    override: bool = True

Resolver Generation

Strawchemy automatically generates resolvers for your GraphQL fields. You can use the strawchemy.field() function to generate fields that query your database

Resolvers example
@strawberry.type
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)
    # Field that returns a single user by ID
    user: UserType = strawchemy.field()

Custom Resolvers

While Strawchemy automatically generates resolvers for most use cases, you can also create custom resolvers for more complex scenarios. There are two main approaches to creating custom resolvers:

1. Using Repository Directly

When using strawchemy.field() as a function, strawchemy creates a resolver that delegates data fetching to the StrawchemySyncRepository or StrawchemyAsyncRepository classes depending on the SQLAlchemy session type. You can create custom resolvers by using the @strawchemy.field as a decorator and working directly with the repository:

Custom resolvers using repository
from sqlalchemy import select, true
from strawchemy import StrawchemySyncRepository

@strawberry.type
class Query:
    @strawchemy.field
    def red_color(self, info: strawberry.Info) -> ColorType:
        # Create a repository with a predefined filter
        repo = StrawchemySyncRepository(ColorType, info, filter_statement=select(Color).where(Color.name == "Red"))
        # Return a single result (will raise an exception if not found)
        return repo.get_one()

    @strawchemy.field
    def get_color_by_name(self, info: strawberry.Info, color: str) -> ColorType | None:
        # Create a repository with a custom filter statement
        repo = StrawchemySyncRepository(ColorType, info, filter_statement=select(Color).where(Color.name == color))
        # Return a single result or None if not found
        return repo.get_one_or_none()

    @strawchemy.field
    def get_color_by_id(self, info: strawberry.Info, id: str) -> ColorType | None:
        repo = StrawchemySyncRepository(ColorType, info)
        # Return a single result or None if not found
        return repo.get_by_id(id=id)

    @strawchemy.field
    def public_colors(self, info: strawberry.Info) -> ColorType:
        repo = StrawchemySyncRepository(ColorType, info, filter_statement=select(Color).where(Color.public.is_(true())))
        # Return a list of results
        return repo.list()

For async resolvers, use StrawchemyAsyncRepository which is the async variant of StrawchemySyncRepository:

from strawchemy import StrawchemyAsyncRepository

@strawberry.type
class Query:
    @strawchemy.field
    async def get_color(self, info: strawberry.Info, color: str) -> ColorType | None:
        repo = StrawchemyAsyncRepository(ColorType, info, filter_statement=select(Color).where(Color.name == color))
        return await repo.get_one_or_none()

The repository provides several methods for fetching data:

  • get_one(): Returns a single result, raises an exception if not found
  • get_one_or_none(): Returns a single result or None if not found
  • get_by_id(): Returns a single result filtered on primary key
  • list(): Returns a list of results

2. Using Query Hooks

Strawchemy provides query hooks that allow you to customize query behavior. These hooks can be applied at both the field level and the type level:

Using query hooks
from strawchemy import ModelInstance, QueryHook
from strawchemy.sqlalchemy.hook import QueryHookResult
from sqlalchemy import Select
from sqlalchemy.orm.util import AliasedClass
from strawberry import Info

@strawchemy.type(Fruit, exclude={"color"})
class FruitTypeWithDescription:
    instance: ModelInstance[Fruit]

    # Use QueryHook with load_columns parameter to ensure columns are loaded
    @strawchemy.field(query_hook=QueryHook(load_columns=[Fruit.name, Fruit.adjectives]))
    def description(self) -> str:
        return f"The {self.instance.name} is {', '.join(self.instance.adjectives)}"

# Custom query hook function
def user_fruit_filter(
    statement: Select[tuple[Fruit]], alias: AliasedClass[Fruit], info: Info
) -> QueryHookResult[Fruit]:
    # Add a custom filter based on context
    if info.context.role == "user":
        return QueryHookResult(statement=statement.where(alias.name == "Apple"))
    return QueryHookResult(statement=statement)

# Type-level query hook
@strawchemy.type(Fruit, exclude={"color"}, query_hook=user_fruit_filter)
class FilteredFruitType:
    pass

You must set a ModelInstance typed attribute if you want to access the model instance values. The instance attribute is matched by the ModelInstance[Fruit] type hint, so you can give it any name you want.

Query hooks provide powerful ways to:

  • Ensure specific columns are always loaded, even if not directly requested in the GraphQL query. (useful to expose hybrid properties in the schema)
  • Apply custom filters based on context (e.g., user role)
  • Modify the underlying SQLAlchemy query for optimization or security

Pagination

Strawchemy supports offset-based pagination out of the box.

Pagination example:

Enable pagination on fields:

from strawchemy.types import DefaultOffsetPagination

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

In your GraphQL queries, you can use the offset and limit parameters:

{
  users(offset: 0, limit: 10) {
    id
    name
  }
}

You can also enable pagination for nested relationships:

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

Then in your GraphQL queries:

{
  users {
    id
    name
    posts(offset: 0, limit: 5) {
      id
      title
    }
  }
}

Filtering

Strawchemy provides powerful filtering capabilities.

Filtering example

First, create a filter input type:

@strawchemy.filter_input(User, include="all")
class UserFilter:
    pass

Then use it in your field:

@strawberry.type
class Query:
    users: list[UserType] = strawchemy.field(filter_input=UserFilter)

Now you can use various filter operations in your GraphQL queries:

{
  # Equality filter
  users(filter: { name: { eq: "John" } }) {
    id
    name
  }

  # Comparison filters
  users(filter: { age: { gt: 18, lte: 30 } }) {
    id
    name
    age
  }

  # String filters
  users(filter: { name: { contains: "oh", ilike: "%OHN%" } }) {
    id
    name
  }

  # Logical operators
  users(filter: { _or: [{ name: { eq: "John" } }, { name: { eq: "Jane" } }] }) {
    id
    name
  }
  # Nested filters
  users(filter: { posts: { title: { contains: "GraphQL" } } }) {
    id
    name
    posts {
      id
      title
    }
  }

  # Compare interval component
  tasks(filter: { duration: { days: { gt: 2 } } }) {
    id
    name
    duration
  }

  # Direct interval comparison
  tasks(filter: { duration: { gt: "P2DT5H" } }) {
    id
    name
    duration
  }
}

Strawchemy supports a wide range of filter operations:

Data Type/Category Filter Operations
Common to most types eq, neq, isNull, in, nin
Numeric types (Int, Float, Decimal) gt, gte, lt, lte
String order filter, plus like, nlike, ilike, nilike, regexp, iregexp, nregexp, inregexp, startswith, endswith, contains, istartswith, iendswith, icontains
JSON contains, containedIn, hasKey, hasKeyAll, hasKeyAny
Array contains, containedIn, overlap
Date order filters on plain dates, plus year, month, day, weekDay, week, quarter, isoYear and isoWeekDay filters
DateTime All Date filters plus hour, minute, second
Time order filters on plain times, plus hour, minute and second filters
Interval order filters on plain intervals, plus days, hours, minutes and seconds filters
Logical _and, _or, _not

Geo Filters

Strawchemy supports spatial filtering capabilities for geometry fields using GeoJSON. To use geo filters, you need to have PostGIS installed and enabled in your PostgreSQL database.

Geo filters example

Define models and types:

class GeoModel(Base):
    __tablename__ = "geo"

    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    # Define geometry columns using GeoAlchemy2
    point: Mapped[WKBElement | None] = mapped_column(Geometry("POINT", srid=4326), nullable=True)
    polygon: Mapped[WKBElement | None] = mapped_column(Geometry("POLYGON", srid=4326), nullable=True)

@strawchemy.type(GeoModel, include="all")
class GeoType: ...

@strawchemy.filter_input(GeoModel, include="all")
class GeoFieldsFilter: ...

@strawberry.type
class Query:
geo: list[GeoType] = strawchemy.field(filter_input=GeoFieldsFilter)

Then you can use the following geo filter operations in your GraphQL queries:

{
  # Find geometries that contain a point
  geo(
    filter: {
      polygon: { containsGeometry: { type: "Point", coordinates: [0.5, 0.5] } }
    }
  ) {
    id
    polygon
  }

  # Find geometries that are within a polygon
  geo(
    filter: {
      point: {
        withinGeometry: {
          type: "Polygon"
          coordinates: [[[0, 0], [0, 2], [2, 2], [2, 0], [0, 0]]]
        }
      }
    }
  ) {
    id
    point
  }

  # Find records with null geometry
  geo(filter: { point: { isNull: true } }) {
    id
  }
}

Strawchemy supports the following geo filter operations:

  • containsGeometry: Filters for geometries that contain the specified GeoJSON geometry
  • withinGeometry: Filters for geometries that are within the specified GeoJSON geometry
  • isNull: Filters for null or non-null geometry values

These filters work with all geometry types supported by PostGIS, including:

  • Point
  • LineString
  • Polygon
  • MultiPoint
  • MultiLineString
  • MultiPolygon
  • Geometry (generic geometry type)

Aggregations

Strawchemy automatically exposes aggregation fields for list relationships.

When you define a model with a list relationship, the corresponding GraphQL type will include an aggregation field for that relationship, named <field_name>Aggregate.

Basic aggregation example:

With the folliing model definitions:

class User(Base):
    __tablename__ = "user"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    posts: Mapped[list["Post"]] = relationship("Post", back_populates="author")


class Post(Base):
    __tablename__ = "post"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    content: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
    author: Mapped[User] = relationship("User", back_populates="posts")

And the corresponding GraphQL types:

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


@strawchemy.type(Post, include="all")
class PostType:
    pass

You can query aggregations on the posts relationship:

{
  users {
    id
    name
    postsAggregate {
      count
      min {
        title
      }
      max {
        title
      }
      # Other aggregation functions are also available
    }
  }
}

Filtering by relationship aggregations

You can also filter entities based on aggregations of their related entities.

Aggregation filtering example

Define types with filters:

@strawchemy.filter_input(User, include="all")
class UserFilter:
    pass


@strawberry.type
class Query:
    users: list[UserType] = strawchemy.field(filter_input=UserFilter)

For example, to find users who have more than 5 posts:

{
  users(
    filter: {
      postsAggregate: { count: { arguments: [id], predicate: { gt: 5 } } }
    }
  ) {
    id
    name
    postsAggregate {
      count
    }
  }
}

You can use various predicates for filtering:

# Users with exactly 3 posts
users(filter: {
  postsAggregate: {
    count: {
      arguments: [id]
      predicate: { eq: 3 }
    }
  }
})

# Users with posts containing "GraphQL" in the title
users(filter: {
  postsAggregate: {
    maxString: {
      arguments: [title]
      predicate: { contains: "GraphQL" }
    }
  }
})

# Users with an average post length greater than 1000 characters
users(filter: {
  postsAggregate: {
    avg: {
      arguments: [contentLength]
      predicate: { gt: 1000 }
    }
  }
})

Distinct aggregations

Distinct aggregation filtering example

You can also use the distinct parameter to count only distinct values:

{
  users(
    filter: {
      postsAggregate: {
        count: { arguments: [category], predicate: { gt: 2 }, distinct: true }
      }
    }
  ) {
    id
    name
  }
}

This would find users who have posts in more than 2 distinct categories.

Root aggregations

Strawchemy supports query level aggregations.

Root aggregations example:

First, create an aggregation type:

@strawchemy.aggregation_type(User, include="all")
class UserAggregationType:
    pass

Then set up the root aggregations on the field:

@strawberry.type
class Query:
    users_aggregations: UserAggregationType = strawchemy.field(root_aggregations=True)

Now you can use aggregation functions on the result of your query:

{
  usersAggregations {
    aggregations {
      # Basic aggregations
      count

      sum {
        age
      }

      avg {
        age
      }

      min {
        age
        createdAt
      }
      max {
        age
        createdAt
      }

      # Statistical aggregations
      stddev {
        age
      }
      variance {
        age
      }
    }
    # Access the actual data
    nodes {
      id
      name
      age
    }
  }
}

Configuration

Strawchemy can be configured when initializing the mapper.

Configuration Options

Option Type Default Description
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] | "auto" "auto" Repository class to use for auto resolvers. When set to "auto", Strawchemy will automatically choose between sync and async repositories based on the session type.
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.
dialect Literal["postgresql"] "postgresql" Database dialect to use. Currently, only PostgreSQL is supported.

Example

from strawchemy import Strawchemy

# Custom session getter function
def get_session_from_context(info):
    return info.context.db_session

# Initialize with custom configuration
strawchemy = Strawchemy(
    session_getter=get_session_from_context,
    auto_snake_case=True,
    pagination=True,
    pagination_default_limit=50,
    default_id_field_name="pk",
)

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for details on how to contribute to this project.

License

This project is licensed under the terms of the license included in the LICENCE file.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

strawchemy-0.10.0.tar.gz (415.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

strawchemy-0.10.0-py3-none-any.whl (115.2 kB view details)

Uploaded Python 3

File details

Details for the file strawchemy-0.10.0.tar.gz.

File metadata

  • Download URL: strawchemy-0.10.0.tar.gz
  • Upload date:
  • Size: 415.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.12.9

File hashes

Hashes for strawchemy-0.10.0.tar.gz
Algorithm Hash digest
SHA256 7bb031484cf628c9c9353433aa882564a4da6c0a861912a61c541a65759b415b
MD5 61c300f7dcc6581e79c53b02f00ab7f3
BLAKE2b-256 af58103aa58b57425be96db93310e853d18955bb5126c7dc8bfa45f2e0e71a75

See more details on using hashes here.

Provenance

The following attestation bundles were made for strawchemy-0.10.0.tar.gz:

Publisher: publish.yaml on gazorby/strawchemy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file strawchemy-0.10.0-py3-none-any.whl.

File metadata

  • Download URL: strawchemy-0.10.0-py3-none-any.whl
  • Upload date:
  • Size: 115.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.12.9

File hashes

Hashes for strawchemy-0.10.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d819d839bf7f13d011f62860e518f34d1937c8df223692b207cbeecea674c4f3
MD5 d59468e0b2168fefb130a4b90af9090a
BLAKE2b-256 d109679627bbec3724cc85b8123b930c7a3378a699ab16c636c1ae4523820c33

See more details on using hashes here.

Provenance

The following attestation bundles were made for strawchemy-0.10.0-py3-none-any.whl:

Publisher: publish.yaml on gazorby/strawchemy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page