Files
prompts/docs/skills/async-fastapi-sqlmodel/references/crud.md
T
2026-07-31 22:15:51 -05:00

286 lines
12 KiB
Markdown

# Basic CRUD Repository and Functions
!!! info "Primary sources"
- [SQLModel create-data tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/multiple-models/)
- [SQLModel update-data tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/update-extra-data/)
- [SQLModel select tutorial](https://sqlmodel.tiangolo.com/tutorial/select/)
- [SQLAlchemy `AsyncSession` API](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncSession)
??? abstract "Decision metadata"
- Status: adopted
- Decision level: advisory
- Applies to: api-runtime, workers, tests
- Last reviewed: 2026-07-26
---
## Purpose
Show a small SQLModel CRUD layer in two forms:
- independent functions for convenient standalone or composed operations;
- a repository object that groups those functions behind one domain-oriented interface.
Every public operation accepts an optional `AsyncSession`. When omitted, reads resolve the cached session factory and own a short-lived session, while writes resolve the same factory and own a complete session-and-transaction scope. When supplied, reads borrow the session and writes borrow its already-active caller-owned transaction. The repository stores configuration and delegates to the same functions without changing those semantics.
Use the same vocabulary at every layer:
| Operation | Function | Repository method | Scope when session is omitted | Missing-row result |
|---|---|---|---|---|
| Create | `create_widget()` | `create()` | Owned transaction | Not applicable |
| Read one | `get_widget()` | `get()` | Owned session | `None` |
| Read many | `list_widgets()` | `list()` | Owned session | Empty list |
| Update | `update_widget()` | `update()` | Owned transaction | `None` |
| Delete | `delete_widget()` | `delete()` | Owned transaction | `None` |
Functions and repository methods both put domain arguments first. Database configuration and sessions are keyword-only infrastructure arguments. This keeps call sites analogous and makes ownership choices visible.
---
## Models
Start with one table model when the application does not need distinct persistence and API schemas.
```python
from sqlmodel import Field
from sqlmodel import SQLModel
class Widget(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
description: str | None = None
```
This reference uses direct field arguments and full-update semantics to keep the CRUD mechanics visible. Introduce separate create, update, or public schemas only when an API boundary needs different validation, field visibility, or partial-update behavior. See [SQLModel integration](sqlmodel.md) for that larger modeling pattern.
---
## Independent CRUD Functions
Functions are the simplest default when grouping state or behavior in an object adds no value. Keep them in the session-required data-access layer so transaction ownership remains external and several calls can compose under one boundary.
```python
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
async def create_widget(
session: AsyncSession,
name: str,
description: str | None = None,
) -> Widget:
widget = Widget(name=name, description=description)
session.add(widget)
await session.flush()
return widget
async def get_widget(
session: AsyncSession,
widget_id: int,
) -> Widget | None:
return await session.get(Widget, widget_id)
async def list_widgets(
session: AsyncSession,
*,
offset: int = 0,
limit: int = 100,
) -> list[Widget]:
if offset < 0:
raise ValueError("offset must be non-negative")
if not 1 <= limit <= 100:
raise ValueError("limit must be between 1 and 100")
statement = select(Widget).order_by(Widget.id).offset(offset).limit(limit)
return list(await session.scalars(statement))
async def update_widget(
session: AsyncSession,
widget_id: int,
name: str,
description: str | None,
) -> Widget | None:
widget = await session.get(Widget, widget_id)
if widget is None:
return None
widget.name = name
widget.description = description
await session.flush()
return widget
async def delete_widget(
session: AsyncSession,
widget_id: int,
) -> Widget | None:
widget = await session.get(Widget, widget_id)
if widget is None:
return None
await session.delete(widget)
await session.flush()
return widget
```
Update and delete load the row through the same session that mutates it. This avoids accepting detached instances from an earlier standalone read and gives both operations an explicit `None` result that the application layer can map to a domain or HTTP error. Delete returns the loaded object for callers that need its values, but that object represents a row scheduled for deletion and must not be reused as persistent state. List operations validate their bounds and order by the primary key so pagination is deterministic. Add a unique tiebreaker whenever ordering by a non-unique field.
`flush()` sends pending writes and populates ordinary generated primary keys. It does not itself commit. The caller's transaction retains commit and rollback ownership. Use `await session.refresh(widget)` only when the operation deliberately needs database-generated state that was not returned during the flush; an unconditional refresh adds another query.
---
## Repository Object
A repository can provide a stable domain-facing interface when several callers need the same grouped operations. It remains stateless here: every method requires a session and delegates to the analogous function.
```python
from sqlmodel.ext.asyncio.session import AsyncSession
class WidgetRepository:
async def create(
self,
session: AsyncSession,
name: str,
description: str | None = None,
) -> Widget:
return await create_widget(
session,
name,
description,
)
async def get(
self,
session: AsyncSession,
widget_id: int,
) -> Widget | None:
return await get_widget(session, widget_id)
async def list(
self,
session: AsyncSession,
*,
offset: int = 0,
limit: int = 100,
) -> list[Widget]:
return await list_widgets(
session,
offset=offset,
limit=limit,
)
async def update(
self,
session: AsyncSession,
widget_id: int,
name: str,
description: str | None,
) -> Widget | None:
return await update_widget(
session,
widget_id,
name,
description,
)
async def delete(
self,
session: AsyncSession,
widget_id: int,
) -> Widget | None:
return await delete_widget(session, widget_id)
```
The object is intentionally thin. Tests pass a transaction-scoped test session directly. The caller always owns that session and its transaction, and the repository never closes or commits it.
If a read participates in a later write, pass the same session and place both operations inside the explicit transaction. This avoids splitting one use case across sessions and keeps SQLAlchemy's autobegin behavior from obscuring transaction ownership. Add a repository only when its naming, shared query policy, dependency substitution, or domain boundary improves the application. Independent functions remain a valid and often clearer design.
---
## Transaction Ownership
Compose multiple calls under one use-case transaction. Only the complete use case accepts an optional session: a supplied session joins its caller's active transaction, while omitting the session creates and owns a standalone session and transaction. CRUD functions and repository methods simply use the resulting `active_session`.
```python
from sqlalchemy.ext.asyncio import async_sessionmaker
from .session import transaction_scope
type SessionFactory = async_sessionmaker[AsyncSession]
async def replace_widget(
session_factory: SessionFactory,
repository: WidgetRepository,
widget_id: int,
replacement_name: str,
replacement_description: str | None = None,
*,
session: AsyncSession | None = None,
) -> Widget | None:
async with transaction_scope(
session_factory,
session=session,
) as active_session:
deleted_widget = await repository.delete(
active_session,
widget_id,
)
if deleted_widget is None:
return None
return await repository.create(
active_session,
replacement_name,
replacement_description,
)
```
If creation fails, deletion rolls back with it. For a caller-owned transaction, wrap the call in `async with session.begin():` and pass that session. For a standalone use case, omit the session; the outer `transaction_scope()` commits on successful exit, rolls back on exception, and closes its owned session. Do not add direct `commit()` calls to CRUD functions or repository methods because that prevents callers from composing several operations atomically. See [transaction boundaries](transactions.md) and [session management](session.md) for ownership details.
---
## Anti-Patterns
- Storing one mutable `AsyncSession` on a long-lived repository object.
- Creating sessions or transactions inside CRUD functions and repository methods.
- Passing database configuration through every CRUD call instead of injecting a session at the data-access boundary.
- Accepting a supplied session for a write without requiring an active caller-owned transaction.
- Calling `commit()` or `rollback()` directly instead of expressing ownership through `transaction_scope()`.
- Accepting unbounded list queries.
- Accepting detached ORM instances for update or delete when an identifier can be resolved in the active session.
- Accessing unloaded attributes after a standalone repository read has closed its owned session.
---
## Operational Checks
- Every CRUD call receives a task-local `AsyncSession`.
- Standalone reads create and close a session at the service or application boundary.
- Standalone writes own commit, rollback, and session cleanup through `transaction_scope()` at the service or use-case boundary.
- Supplied write sessions already have an active caller-owned transaction.
- Each complete operation, service, or use-case boundary borrows an active transaction or owns a complete session-and-transaction scope.
- List operations have pagination and deterministic ordering where required.
- Update requires values for both mutable fields; passing `None` explicitly clears the nullable description.
- Get, update, and delete use the same identifier and missing-row semantics.
- Functions and repository methods take the session explicitly and do not accept database configuration.
- Standalone service reads load all state needed after their owned session closes.
- Repository objects hold query policy when useful, never database configuration or request-scoped session state.
---
## Testing Checks
- Create tests verify generated identifiers and persisted field values after commit.
- Get and list tests cover found, missing, pagination, and ordering behavior.
- List tests reject negative offsets and limits outside the supported range.
- Update tests cover replacement of both mutable fields, including clearing the nullable description.
- Update and delete tests cover missing identifiers without mutating the database.
- Delete tests verify the returned row and its absence after commit.
- Failure tests verify that a surrounding transaction rolls back all composed CRUD calls.
- Optional-session read tests verify borrowed sessions remain open and owned sessions close without committing.
- Optional-session write tests verify supplied transactions remain caller-owned and standalone transactions commit or roll back before closing.
- Composition tests pass one active session through several CRUD calls and verify one atomic commit or rollback.