template updates
This commit is contained in:
@@ -5,12 +5,13 @@
|
||||
- [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)
|
||||
- [`nicegui-db` service functions](https://forgejo.john-stream.com/john/nicegui-db/src/commit/126bc26ad8635a86bacf684d7bda409230347597/src/nicegui_db/services/my_table.py)
|
||||
|
||||
??? abstract "Decision metadata"
|
||||
- Status: adopted
|
||||
- Decision level: advisory
|
||||
- Applies to: api-runtime, workers, tests
|
||||
- Last reviewed: 2026-07-26
|
||||
- Last reviewed: 2026-08-06
|
||||
|
||||
---
|
||||
|
||||
@@ -21,17 +22,17 @@ 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.
|
||||
Template-style public functions use `@with_session` and accept an optional `AsyncSession`. When the argument is omitted, the decorator resolves the cached session factory and owns a short-lived session. When supplied, the function borrows the session without controlling its lifetime or transaction. The decorator does not commit, so standalone writes need a visible transaction strategy; repository methods remain explicit-session operations for predictable composition.
|
||||
|
||||
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 |
|
||||
| Create | `create_widget()` | `create()` | Owned session; no implicit commit | 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` |
|
||||
| Update | `update_widget()` | `update()` | Owned session; no implicit commit | `None` |
|
||||
| Delete | `delete_widget()` | `delete()` | Owned session; no implicit commit | `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.
|
||||
|
||||
@@ -58,36 +59,47 @@ This reference uses direct field arguments and full-update semantics to keep the
|
||||
|
||||
## 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.
|
||||
Functions are the simplest default when grouping state or behavior in an object adds no value. Decorate public service functions when both standalone reads and explicit composition are useful. The assertion narrows the optional type after decorator injection and catches accidental explicit `None` calls.
|
||||
|
||||
```python
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .session import with_session
|
||||
|
||||
|
||||
@with_session
|
||||
async def create_widget(
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
widget = Widget(name=name, description=description)
|
||||
session.add(widget)
|
||||
await session.flush()
|
||||
return widget
|
||||
|
||||
|
||||
@with_session
|
||||
async def get_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
return await session.get(Widget, widget_id)
|
||||
|
||||
|
||||
@with_session
|
||||
async def list_widgets(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
session: AsyncSession | None = None,
|
||||
) -> list[Widget]:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
if offset < 0:
|
||||
raise ValueError("offset must be non-negative")
|
||||
if not 1 <= limit <= 100:
|
||||
@@ -97,12 +109,15 @@ async def list_widgets(
|
||||
return list(await session.scalars(statement))
|
||||
|
||||
|
||||
@with_session
|
||||
async def update_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
name: str,
|
||||
description: str | None,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
widget = await session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
@@ -113,10 +128,13 @@ async def update_widget(
|
||||
return widget
|
||||
|
||||
|
||||
@with_session
|
||||
async def delete_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
widget = await session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
@@ -128,7 +146,7 @@ async def delete_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.
|
||||
`flush()` sends pending writes and populates ordinary generated primary keys. It does not itself commit. A decorated write called without a session will therefore roll back when its owned session closes unless the function explicitly commits. Prefer passing a transaction-scoped session so several writes compose atomically. 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,9 +165,9 @@ class WidgetRepository:
|
||||
description: str | None = None,
|
||||
) -> Widget:
|
||||
return await create_widget(
|
||||
session,
|
||||
name,
|
||||
description,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def get(
|
||||
@@ -157,7 +175,7 @@ class WidgetRepository:
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
) -> Widget | None:
|
||||
return await get_widget(session, widget_id)
|
||||
return await get_widget(widget_id, session=session)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
@@ -167,9 +185,9 @@ class WidgetRepository:
|
||||
limit: int = 100,
|
||||
) -> list[Widget]:
|
||||
return await list_widgets(
|
||||
session,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def update(
|
||||
@@ -180,10 +198,10 @@ class WidgetRepository:
|
||||
description: str | None,
|
||||
) -> Widget | None:
|
||||
return await update_widget(
|
||||
session,
|
||||
widget_id,
|
||||
name,
|
||||
description,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def delete(
|
||||
@@ -191,7 +209,7 @@ class WidgetRepository:
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
) -> Widget | None:
|
||||
return await delete_widget(session, widget_id)
|
||||
return await delete_widget(widget_id, session=session)
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -202,29 +220,19 @@ If a read participates in a later write, pass the same session and place both op
|
||||
|
||||
## 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`.
|
||||
Compose multiple calls under one use-case transaction. `db_transaction_scope()` owns the standalone engine, factory, session, and transaction lifetimes. Decorated CRUD functions detect the supplied session and borrow it; repository methods receive it directly.
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from .session import transaction_scope
|
||||
|
||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||
from .session import db_transaction_scope
|
||||
|
||||
|
||||
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:
|
||||
async with db_transaction_scope() as active_session:
|
||||
deleted_widget = await repository.delete(
|
||||
active_session,
|
||||
widget_id,
|
||||
@@ -239,17 +247,17 @@ async def replace_widget(
|
||||
)
|
||||
```
|
||||
|
||||
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.
|
||||
If creation fails, deletion rolls back with it. Inside an already-running application, prefer `async with session_factory.begin()` or `async with session.begin()` over `db_transaction_scope()` so the application-owned engine and factory remain in use. 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.
|
||||
- Creating sessions manually inside functions already using `@with_session`.
|
||||
- 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()`.
|
||||
- Assuming decorator-owned write sessions commit on close.
|
||||
- Forwarding explicit `session=None` when decorator injection was intended.
|
||||
- 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.
|
||||
@@ -260,13 +268,13 @@ If creation fails, deletion rolls back with it. For a caller-owned transaction,
|
||||
|
||||
- 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.
|
||||
- Standalone reads may omit `session`; decorated writes receive a transaction-scoped session or explicitly own their commit policy.
|
||||
- Supplied write sessions remain caller-owned.
|
||||
- Each complete write operation declares a visible transaction boundary.
|
||||
- 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.
|
||||
- Decorated functions accept an optional keyword-only session; repository methods require one explicitly.
|
||||
- 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.
|
||||
|
||||
@@ -282,5 +290,5 @@ If creation fails, deletion rolls back with it. For a caller-owned transaction,
|
||||
- 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.
|
||||
- Decorated write tests verify supplied transactions remain caller-owned and omitted sessions do not imply a commit.
|
||||
- Composition tests pass one active session through several CRUD calls and verify one atomic commit or rollback.
|
||||
Reference in New Issue
Block a user