# CRUD Generic async CRUD operations for SQLAlchemy models with search, pagination, and many-to-many support. !!! info This module has been coded and tested to be compatible with PostgreSQL only. ## Overview The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), an abstract base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model. ## Creating a CRUD class ```python from fastapi_toolsets.crud import CrudFactory from myapp.models import User UserCrud = CrudFactory(model=User) ``` [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model. ## Basic operations ```python # Create user = await UserCrud.create(session=session, obj=UserCreateSchema(username="alice")) # Get one (raises NotFoundError if not found) user = await UserCrud.get(session=session, filters=[User.id == user_id]) # Get first or None user = await UserCrud.first(session=session, filters=[User.email == email]) # Get multiple users = await UserCrud.get_multi(session=session, filters=[User.is_active == True]) # Update user = await UserCrud.update(session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id]) # Delete await UserCrud.delete(session=session, filters=[User.id == user_id]) # Count / exists count = await UserCrud.count(session=session, filters=[User.is_active == True]) exists = await UserCrud.exists(session=session, filters=[User.email == email]) ``` ## Pagination !!! info "Added in `v1.1` (only offset_pagination via `paginate` if ` PaginatedResponse[UserRead]: return await UserCrud.offset_paginate( session=session, page=page, filter_by=filter_by, ) ``` Both single-value and multi-value query parameters work: ``` GET /users?status=active → filter_by={"status": ["active"]} GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]} GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause) ``` ## Sorting !!! info "Added in `v1.3`" Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters. ```python UserCrud = CrudFactory( model=User, order_fields=[ User.name, User.created_at, ], ) ``` Call [`order_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.order_params) to generate a FastAPI dependency that maps the query parameters to an [`OrderByClause`](../reference/crud.md#fastapi_toolsets.crud.factory.OrderByClause) expression: ```python from typing import Annotated from fastapi import Depends from fastapi_toolsets.crud import OrderByClause @router.get("") async def list_users( session: SessionDep, order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())], ) -> PaginatedResponse[UserRead]: return await UserCrud.offset_paginate(session=session, order_by=order_by) ``` The dependency adds two query parameters to the endpoint: | Parameter | Type | | ---------- | --------------- | | `order_by` | `str | null` | | `order` | `asc` or `desc` | ``` GET /users?order_by=name&order=asc → ORDER BY users.name ASC GET /users?order_by=name&order=desc → ORDER BY users.name DESC ``` An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422). You can also pass `order_fields` directly to `order_params()` to override the class-level defaults without modifying them: ```python UserOrderParams = UserCrud.order_params(order_fields=[User.name]) ``` ## Relationship loading !!! info "Added in `v1.1`" By default, SQLAlchemy relationships are not loaded unless explicitly requested. Instead of using `lazy="selectin"` on model definitions (which is implicit and applies globally), define a `default_load_options` on the CRUD class to control loading strategy explicitly. !!! warning Avoid using `lazy="selectin"` on model relationships. It fires silently on every query, cannot be disabled per-call, and can cause unexpected cascading loads through deep relationship chains. Use `default_load_options` instead. ```python from sqlalchemy.orm import selectinload ArticleCrud = CrudFactory( model=Article, default_load_options=[ selectinload(Article.category), selectinload(Article.tags), ], ) ``` `default_load_options` applies automatically to all read operations (`get`, `first`, `get_multi`, `offset_paginate`, `cursor_paginate`). When `load_options` is passed at call-site, it **fully replaces** `default_load_options` for that query — giving you precise per-call control: ```python # Only loads category, tags are not loaded article = await ArticleCrud.get( session=session, filters=[Article.id == article_id], load_options=[selectinload(Article.category)], ) # Loads nothing — useful for write-then-refresh flows or lightweight checks articles = await ArticleCrud.get_multi(session=session, load_options=[]) ``` ## Many-to-many relationships Use `m2m_fields` to map schema fields containing lists of IDs to SQLAlchemy relationships. The CRUD class resolves and validates all IDs before persisting: ```python PostCrud = CrudFactory( model=Post, m2m_fields={"tag_ids": Post.tags}, ) post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3])) ``` ## Upsert Atomic `INSERT ... ON CONFLICT DO UPDATE` using PostgreSQL: ```python await UserCrud.upsert( session=session, obj=UserCreateSchema(email="alice@example.com", username="alice"), index_elements=[User.email], set_={"username"}, ) ``` ## Response serialization !!! info "Added in `v1.1`" Pass a Pydantic schema class to `create`, `get`, `update`, or `offset_paginate` to serialize the result directly into that schema and wrap it in a [`Response[schema]`](../reference/schemas.md#fastapi_toolsets.schemas.Response) or [`PaginatedResponse[schema]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse): ```python class UserRead(PydanticBase): id: UUID username: str @router.get( "/{uuid}", responses=generate_error_responses(NotFoundError), ) async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]: return await crud.UserCrud.get( session=session, filters=[User.id == uuid], schema=UserRead, ) @router.get("") async def list_users(session: SessionDep, page: int = 1) -> PaginatedResponse[UserRead]: return await crud.UserCrud.offset_paginate( session=session, page=page, schema=UserRead, ) ``` The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances. !!! warning "Deprecated: `as_response`" The `as_response=True` parameter is **deprecated** and will be removed in **v2.0**. Replace it with `schema=YourSchema`. --- [:material-api: API Reference](../reference/crud.md)