engine/session updates
This commit is contained in:
@@ -58,194 +58,143 @@ 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. 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`.
|
||||
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
|
||||
|
||||
from .session import session_scope
|
||||
from .session import transaction_scope
|
||||
|
||||
|
||||
async def create_widget(
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
*,
|
||||
database_url: str,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget:
|
||||
async with transaction_scope(
|
||||
database_url=database_url,
|
||||
session=session,
|
||||
) as active_session:
|
||||
widget = Widget(name=name, description=description)
|
||||
active_session.add(widget)
|
||||
await active_session.flush()
|
||||
return widget
|
||||
widget = Widget(name=name, description=description)
|
||||
session.add(widget)
|
||||
await session.flush()
|
||||
return widget
|
||||
|
||||
|
||||
async def get_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
database_url: str,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
async with session_scope(
|
||||
database_url=database_url,
|
||||
session=session,
|
||||
) as active_session:
|
||||
return await active_session.get(Widget, widget_id)
|
||||
return await session.get(Widget, widget_id)
|
||||
|
||||
|
||||
async def list_widgets(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
database_url: str,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
session: 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,
|
||||
) as active_session:
|
||||
statement = select(Widget).order_by(Widget.id).offset(offset).limit(limit)
|
||||
return list(await active_session.scalars(statement))
|
||||
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,
|
||||
*,
|
||||
database_url: str,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
async with transaction_scope(
|
||||
database_url=database_url,
|
||||
session=session,
|
||||
) as active_session:
|
||||
widget = await active_session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
widget = await session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
|
||||
widget.name = name
|
||||
widget.description = description
|
||||
await active_session.flush()
|
||||
return widget
|
||||
widget.name = name
|
||||
widget.description = description
|
||||
await session.flush()
|
||||
return widget
|
||||
|
||||
|
||||
async def delete_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
database_url: str,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
async with transaction_scope(
|
||||
database_url=database_url,
|
||||
session=session,
|
||||
) as active_session:
|
||||
widget = await active_session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
widget = await session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
|
||||
await active_session.delete(widget)
|
||||
await active_session.flush()
|
||||
return widget
|
||||
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. 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.
|
||||
`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 stores repeatable database configuration, never a mutable session. Every method delegates to the analogous function and exposes the same optional-session contract.
|
||||
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:
|
||||
def __init__(self, database_url: str) -> None:
|
||||
self.database_url = database_url
|
||||
|
||||
async def create(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget:
|
||||
return await create_widget(
|
||||
session,
|
||||
name,
|
||||
description,
|
||||
database_url=self.database_url,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
return await get_widget(
|
||||
widget_id,
|
||||
database_url=self.database_url,
|
||||
session=session,
|
||||
)
|
||||
return await get_widget(session, widget_id)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
session: AsyncSession | None = None,
|
||||
) -> list[Widget]:
|
||||
return await list_widgets(
|
||||
database_url=self.database_url,
|
||||
session,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
name: str,
|
||||
description: str | None,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
return await update_widget(
|
||||
session,
|
||||
widget_id,
|
||||
name,
|
||||
description,
|
||||
database_url=self.database_url,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
return await delete_widget(
|
||||
widget_id,
|
||||
database_url=self.database_url,
|
||||
session=session,
|
||||
)
|
||||
return await delete_widget(session, widget_id)
|
||||
```
|
||||
|
||||
The object is intentionally thin. Tests can construct it with a test database URL or pass a transaction-scoped test session to individual methods. 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.
|
||||
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.
|
||||
|
||||
@@ -253,15 +202,18 @@ 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. 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.
|
||||
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,
|
||||
@@ -270,20 +222,20 @@ async def replace_widget(
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
async with transaction_scope(
|
||||
database_url=repository.database_url,
|
||||
session_factory,
|
||||
session=session,
|
||||
) as active_session:
|
||||
deleted_widget = await repository.delete(
|
||||
active_session,
|
||||
widget_id,
|
||||
session=active_session,
|
||||
)
|
||||
if deleted_widget is None:
|
||||
return None
|
||||
|
||||
return await repository.create(
|
||||
active_session,
|
||||
replacement_name,
|
||||
replacement_description,
|
||||
session=active_session,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -294,8 +246,8 @@ If creation fails, deletion rolls back with it. For a caller-owned transaction,
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -307,16 +259,16 @@ If creation fails, deletion rolls back with it. For a caller-owned transaction,
|
||||
## 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()`.
|
||||
- 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 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user