diff --git a/docs/skills/async-fastapi-sqlmodel/SKILL.md b/docs/skills/async-fastapi-sqlmodel/SKILL.md index 6b90c1b..be17243 100644 --- a/docs/skills/async-fastapi-sqlmodel/SKILL.md +++ b/docs/skills/async-fastapi-sqlmodel/SKILL.md @@ -122,6 +122,7 @@ See [observability and resilience](references/observability.md). | Implicit I/O control in ORM | [Implicit I/O reference](references/implicit_io.md) | | Observability and resilience | [Observability reference](references/observability.md) | | SQLModel-first modeling | [SQLModel integration reference](references/sqlmodel.md) | +| CRUD repository and standalone functions | [Basic CRUD reference](references/crud.md) | ## Canonical Composition Pattern diff --git a/docs/skills/async-fastapi-sqlmodel/references/crud.md b/docs/skills/async-fastapi-sqlmodel/references/crud.md new file mode 100644 index 0000000..82ab9dc --- /dev/null +++ b/docs/skills/async-fastapi-sqlmodel/references/crud.md @@ -0,0 +1,357 @@ +# 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, factory overrides, 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. Each function is a complete operation boundary: it can run standalone by resolving the cached factory from `database_url`, or compose into a caller-owned scope through `session`. + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlmodel import select + +from .session import session_scope +from .session import transaction_scope + + +async def create_widget( + name: str, + description: str | None = None, + *, + database_url: str, + session: AsyncSession | None = None, + session_factory: async_sessionmaker[AsyncSession] | None = None, +) -> Widget: + async with transaction_scope( + database_url=database_url, + session=session, + session_factory=session_factory, + ) as active_session: + widget = Widget(name=name, description=description) + active_session.add(widget) + await active_session.flush() + return widget + + +async def get_widget( + widget_id: int, + *, + database_url: str, + session: AsyncSession | None = None, + session_factory: async_sessionmaker[AsyncSession] | None = None, +) -> Widget | None: + async with session_scope( + database_url=database_url, + session=session, + session_factory=session_factory, + ) as active_session: + return await active_session.get(Widget, widget_id) + + +async def list_widgets( + *, + database_url: str, + offset: int = 0, + limit: int = 100, + session: AsyncSession | None = None, + session_factory: async_sessionmaker[AsyncSession] | None = None, +) -> 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") + + async with session_scope( + database_url=database_url, + session=session, + session_factory=session_factory, + ) as active_session: + statement = select(Widget).order_by(Widget.id).offset(offset).limit(limit) + return list(await active_session.scalars(statement)) + + +async def update_widget( + widget_id: int, + name: str, + description: str | None, + *, + database_url: str, + session: AsyncSession | None = None, + session_factory: async_sessionmaker[AsyncSession] | None = None, +) -> Widget | None: + async with transaction_scope( + database_url=database_url, + session=session, + session_factory=session_factory, + ) as active_session: + widget = await active_session.get(Widget, widget_id) + if widget is None: + return None + + widget.name = name + widget.description = description + await active_session.flush() + return widget + + +async def delete_widget( + widget_id: int, + *, + database_url: str, + session: AsyncSession | None = None, + session_factory: async_sessionmaker[AsyncSession] | None = None, +) -> Widget | None: + async with transaction_scope( + database_url=database_url, + session=session, + session_factory=session_factory, + ) as active_session: + widget = await active_session.get(Widget, widget_id) + if widget is None: + return None + + await active_session.delete(widget) + await active_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. For a standalone write, the surrounding owned `transaction_scope()` commits after the function body succeeds. For a supplied session, the caller's outer transaction retains commit and rollback ownership. Use `await active_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 stores repeatable database configuration and an optional factory override, never a mutable session. Every method delegates to the analogous function and exposes the same optional-session contract. + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import async_sessionmaker + +class WidgetRepository: + def __init__( + self, + database_url: str, + session_factory: async_sessionmaker[AsyncSession] | None = None, + ) -> None: + self.database_url = database_url + self.session_factory = session_factory + + async def create( + self, + name: str, + description: str | None = None, + *, + session: AsyncSession | None = None, + ) -> Widget: + return await create_widget( + name, + description, + database_url=self.database_url, + session=session, + session_factory=self.session_factory, + ) + + async def get( + self, + widget_id: int, + *, + session: AsyncSession | None = None, + ) -> Widget | None: + return await get_widget( + widget_id, + database_url=self.database_url, + session=session, + session_factory=self.session_factory, + ) + + async def list( + self, + *, + offset: int = 0, + limit: int = 100, + session: AsyncSession | None = None, + ) -> list[Widget]: + return await list_widgets( + database_url=self.database_url, + offset=offset, + limit=limit, + session=session, + session_factory=self.session_factory, + ) + + async def update( + self, + widget_id: int, + name: str, + description: str | None, + *, + session: AsyncSession | None = None, + ) -> Widget | None: + return await update_widget( + widget_id, + name, + description, + database_url=self.database_url, + session=session, + session_factory=self.session_factory, + ) + + async def delete( + self, + widget_id: int, + *, + session: AsyncSession | None = None, + ) -> Widget | None: + return await delete_widget( + widget_id, + database_url=self.database_url, + session=session, + session_factory=self.session_factory, + ) +``` + +The object is intentionally thin. The factory override lets tests supply a maker bound to a test engine without FastAPI startup. A caller-provided session always wins and remains open after the method returns. A standalone operation closes its owned session before returning, so returned objects are detached; load every required scalar, deferred column, and relationship explicitly before the scope exits, and do not mutate those objects expecting persistence. + +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. At this boundary, a supplied session joins its already-active caller-owned transaction, while omitting the session creates a standalone session and transaction. Each nested CRUD write receives `active_session`, detects that transaction, and borrows it instead of committing independently. + +The scope names describe exactly what they own: `session_scope()` manages session lifetime but never commits, while `transaction_scope()` manages a complete transaction only when it also creates the session. Both yield the name `active_session` because downstream CRUD code does not need to know whether the session was borrowed or owned. + +```python +from .session import transaction_scope + + +async def replace_widget( + repository: WidgetRepository, + widget_id: int, + replacement_name: str, + replacement_description: str | None = None, + *, + session: AsyncSession | None = None, +) -> Widget | None: + async with transaction_scope( + database_url=repository.database_url, + session=session, + session_factory=repository.session_factory, + ) as active_session: + deleted_widget = await repository.delete( + widget_id, + session=active_session, + ) + if deleted_widget is None: + return None + + return await repository.create( + replacement_name, + replacement_description, + session=active_session, + ) +``` + +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. +- Constructing ad hoc factories or sessions instead of resolving the cached factory through the scope helpers. +- Using `session_scope()` for an optional write, which would close an owned session without committing. +- 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 resolve the cached factory by database URL and close their owned session. +- Standalone writes resolve the cached factory and own commit, rollback, and session cleanup through `transaction_scope()`. +- 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 use domain arguments first and keyword-only infrastructure arguments consistently. +- Standalone reads load all state needed after their owned session closes. +- Repository objects hold configuration or policy, never 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. \ No newline at end of file diff --git a/docs/skills/async-fastapi-sqlmodel/references/index.md b/docs/skills/async-fastapi-sqlmodel/references/index.md index 2b9f942..1066fbf 100644 --- a/docs/skills/async-fastapi-sqlmodel/references/index.md +++ b/docs/skills/async-fastapi-sqlmodel/references/index.md @@ -14,6 +14,7 @@ Purpose: concept registry for the principles, mechanics, and implementation guid | Implicit ORM I/O under asyncio | [implicit_io.md](implicit_io.md) | adopted | advisory | platform/backend | 2026-06-17 | | Observability and resilience | [observability.md](observability.md) | adopted | mandatory | platform/backend | 2026-06-17 | | SQLModel modeling and async boundaries | [sqlmodel.md](sqlmodel.md) | adopted | mandatory | platform/backend | 2026-07-26 | +| Basic CRUD repository and functions | [crud.md](crud.md) | adopted | advisory | platform/backend | 2026-07-26 | --- diff --git a/docs/skills/async-fastapi-sqlmodel/references/session.md b/docs/skills/async-fastapi-sqlmodel/references/session.md index d196e2e..7b73cb0 100644 --- a/docs/skills/async-fastapi-sqlmodel/references/session.md +++ b/docs/skills/async-fastapi-sqlmodel/references/session.md @@ -36,6 +36,7 @@ Define one canonical session model for FastAPI + SQLAlchemy asyncio: - Do not share AsyncSession across `asyncio.gather()` or parallel tasks. - Prefer direct dependency injection over global scoped-session patterns in new code. - Use explicit transaction boundaries (`async with session.begin():`) for writes. +- When a use case accepts an optional session, borrow only an active caller-owned transaction or own the complete session-and-transaction scope. --- @@ -132,6 +133,54 @@ Do not turn this into an implicit unit-of-work helper that sometimes commits. Wh --- +## Optional Transaction Ownership + +Use a separate context manager when a service or use-case function must support both a caller-owned transaction and a standalone transaction. A supplied session must already be inside a transaction; otherwise the helper creates a session and transaction together with `async_sessionmaker.begin()`: + +```python +@asynccontextmanager +async def transaction_scope( + *, + database_url: str, + session: AsyncSession | None = None, + session_factory: async_sessionmaker[AsyncSession] | None = None, +) -> AsyncIterator[AsyncSession]: + if session is not None: + if not session.in_transaction(): + raise RuntimeError("A supplied session must have an active transaction") + yield session + return + + active_factory = session_factory or get_session_factory(database_url) + + async with active_factory.begin() as owned_session: + yield owned_session +``` + +This helper makes transaction ownership follow the same explicit borrow-or-own mechanics as session ownership: + +- A supplied session and its active transaction remain caller-owned. The helper does not commit, roll back, or close them. +- Without a supplied session, the helper owns the session and transaction. Successful exit commits; exceptional exit rolls back; either path closes the session. +- A supplied factory overrides cached resolution only when the helper must create a session. +- Use this helper only at a complete operation, service, or use-case boundary. A public CRUD function or repository method may be such a boundary when its optional-session contract explicitly states that omitting the session owns and commits one transaction. Never use it inside a lower-level session-required helper. +- Do not silently begin a transaction on a supplied session. That would make commit ownership depend on hidden helper behavior. + +Callers that supply a session make their ownership visible with an outer transaction: + +```python +async with session_factory() as session: + async with session.begin(): + await run_use_case(..., session=session) +``` + +Standalone callers omit the session and let the use case own the complete unit of work: + +```python +await run_use_case(...) +``` + +--- + ## Repository and Function Boundaries Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and `session_scope()` resolves the cached factory when a standalone operation needs a session. An optional factory override keeps tests independent: @@ -270,6 +319,7 @@ async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict: - Hidden session creation in lower access functions with no caller control. - Closing or committing a session supplied by the caller. - Starting a new transaction inside a helper that may receive a session already in a transaction. +- Silently starting or committing a transaction on a supplied session. - Mixing commit/rollback ownership across layers without a declared boundary. --- @@ -290,6 +340,8 @@ async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict: - Session-taking access functions accept a transaction-scoped test session directly. - Optional-session tests verify that borrowed sessions remain open and created sessions close. - Optional-session tests verify that neither path commits implicitly. +- Optional-transaction tests verify supplied sessions require an active transaction and remain caller-owned. +- Optional-transaction tests verify owned transactions commit on success, roll back on failure, and close their sessions. - Cache tests clear `get_session_factory` before and after replacing engines. - Dependency override exists for the FastAPI session factory. - Rollback behavior is verified for failed write units. diff --git a/docs/skills/async-fastapi-sqlmodel/references/transactions.md b/docs/skills/async-fastapi-sqlmodel/references/transactions.md index 93b95dd..95635ca 100644 --- a/docs/skills/async-fastapi-sqlmodel/references/transactions.md +++ b/docs/skills/async-fastapi-sqlmodel/references/transactions.md @@ -30,7 +30,8 @@ Define consistent transaction demarcation for async SQLAlchemy so write behavior - Every mutating use case must run inside an explicit transaction boundary. - Prefer `async with session.begin():` for write units. -- Keep transaction ownership at service/use-case boundary, not deep in helper internals. +- Keep transaction ownership at a service, use-case, or explicitly documented complete-operation boundary, not deep in helper internals. +- An optional-session write may own one transaction when omitting the session clearly means standalone execution; a supplied session must remain caller-owned. - Read paths should not auto-upgrade into hidden write behavior. - On exception in a transaction block, rely on rollback semantics and propagate or map exceptions intentionally. @@ -82,7 +83,7 @@ Use nested transactions only when partial failure semantics are explicitly requi ## Anti-Patterns - Multiple commits scattered across one logical use case. -- Helper functions that commit/rollback without caller awareness. +- Helper functions that commit or roll back without an explicit ownership contract. - Mixing implicit and explicit transaction styles in confusing ways. - Using savepoints as a default pattern rather than a targeted tool. @@ -90,8 +91,8 @@ Use nested transactions only when partial failure semantics are explicitly requi ## Operational Checks -- All mutating service functions declare one clear transaction boundary. -- No repository/helper performs hidden commit calls. +- All mutating services and complete operations declare one clear transaction boundary. +- No repository or helper performs hidden direct commit calls; standalone ownership is expressed through a documented transaction scope. - Transaction style is consistent across handlers and workers. ---