2026-02-09 16:23:41 +05:30
|
|
|
"""Generic asynchronous CRUD helpers for SQLModel entities."""
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
from typing import TYPE_CHECKING, Any, TypeVar
|
2026-02-06 16:12:04 +05:30
|
|
|
|
2026-02-09 02:17:34 +05:30
|
|
|
from sqlalchemy import delete as sql_delete
|
|
|
|
|
from sqlalchemy import update as sql_update
|
2026-02-09 02:24:16 +05:30
|
|
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
2026-02-06 16:12:04 +05:30
|
|
|
from sqlmodel import SQLModel, select
|
2026-02-09 16:23:41 +05:30
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import Iterable, Mapping
|
|
|
|
|
|
|
|
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
|
from sqlmodel.sql.expression import SelectOfScalar
|
2026-02-06 16:12:04 +05:30
|
|
|
|
|
|
|
|
ModelT = TypeVar("ModelT", bound=SQLModel)
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
class DoesNotExistError(LookupError):
|
|
|
|
|
"""Raised when a query expected one row but found none."""
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
class MultipleObjectsReturnedError(LookupError):
|
|
|
|
|
"""Raised when a query expected one row but found many."""
|
2026-02-06 16:12:04 +05:30
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
|
|
|
|
|
DoesNotExist = DoesNotExistError
|
|
|
|
|
MultipleObjectsReturned = MultipleObjectsReturnedError
|
2026-02-06 16:12:04 +05:30
|
|
|
|
|
|
|
|
|
2026-02-09 02:17:34 +05:30
|
|
|
async def _flush_or_rollback(session: AsyncSession) -> None:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Flush changes and rollback on SQLAlchemy errors."""
|
2026-02-09 02:17:34 +05:30
|
|
|
try:
|
|
|
|
|
await session.flush()
|
2026-02-09 02:24:16 +05:30
|
|
|
except SQLAlchemyError:
|
2026-02-09 02:17:34 +05:30
|
|
|
await session.rollback()
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _commit_or_rollback(session: AsyncSession) -> None:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Commit transaction and rollback on SQLAlchemy errors."""
|
2026-02-09 02:17:34 +05:30
|
|
|
try:
|
|
|
|
|
await session.commit()
|
2026-02-09 02:24:16 +05:30
|
|
|
except SQLAlchemyError:
|
2026-02-09 02:17:34 +05:30
|
|
|
await session.rollback()
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
def _lookup_statement(
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
lookup: Mapping[str, Any],
|
|
|
|
|
) -> SelectOfScalar[ModelT]:
|
|
|
|
|
"""Build a select statement with equality filters from lookup values."""
|
2026-02-09 00:51:26 +05:30
|
|
|
stmt = select(model)
|
|
|
|
|
for key, value in lookup.items():
|
|
|
|
|
stmt = stmt.where(getattr(model, key) == value)
|
|
|
|
|
return stmt
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
async def get_by_id(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
obj_id: object,
|
|
|
|
|
) -> ModelT | None:
|
|
|
|
|
"""Fetch one model instance by id or return None."""
|
2026-02-09 02:04:14 +05:30
|
|
|
stmt = _lookup_statement(model, {"id": obj_id}).limit(1)
|
|
|
|
|
return (await session.exec(stmt)).first()
|
2026-02-06 16:12:04 +05:30
|
|
|
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
async def get(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
**lookup: object,
|
|
|
|
|
) -> ModelT:
|
|
|
|
|
"""Fetch exactly one model instance by lookup values."""
|
2026-02-09 00:51:26 +05:30
|
|
|
stmt = _lookup_statement(model, lookup).limit(2)
|
2026-02-06 16:12:04 +05:30
|
|
|
items = (await session.exec(stmt)).all()
|
|
|
|
|
if not items:
|
2026-02-09 16:23:41 +05:30
|
|
|
message = f"{model.__name__} matching query does not exist."
|
|
|
|
|
raise DoesNotExist(message)
|
2026-02-06 16:12:04 +05:30
|
|
|
if len(items) > 1:
|
2026-02-09 16:23:41 +05:30
|
|
|
message = f"Multiple {model.__name__} objects returned for lookup {lookup!r}."
|
|
|
|
|
raise MultipleObjectsReturned(message)
|
2026-02-06 16:12:04 +05:30
|
|
|
return items[0]
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
async def get_one_by(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
**lookup: object,
|
|
|
|
|
) -> ModelT | None:
|
|
|
|
|
"""Fetch the first model instance matching lookup values."""
|
2026-02-09 00:51:26 +05:30
|
|
|
stmt = _lookup_statement(model, lookup)
|
2026-02-06 16:12:04 +05:30
|
|
|
return (await session.exec(stmt)).first()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
*,
|
|
|
|
|
commit: bool = True,
|
|
|
|
|
refresh: bool = True,
|
2026-02-09 16:23:41 +05:30
|
|
|
**data: object,
|
2026-02-06 16:12:04 +05:30
|
|
|
) -> ModelT:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Create, flush, optionally commit, and optionally refresh an object."""
|
2026-02-06 16:12:04 +05:30
|
|
|
obj = model.model_validate(data)
|
|
|
|
|
session.add(obj)
|
2026-02-09 02:17:34 +05:30
|
|
|
await _flush_or_rollback(session)
|
2026-02-06 16:12:04 +05:30
|
|
|
if commit:
|
2026-02-09 02:17:34 +05:30
|
|
|
await _commit_or_rollback(session)
|
2026-02-06 16:12:04 +05:30
|
|
|
if refresh:
|
|
|
|
|
await session.refresh(obj)
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def save(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
obj: ModelT,
|
|
|
|
|
*,
|
|
|
|
|
commit: bool = True,
|
|
|
|
|
refresh: bool = True,
|
|
|
|
|
) -> ModelT:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Persist an existing object with optional commit and refresh."""
|
2026-02-06 16:12:04 +05:30
|
|
|
session.add(obj)
|
2026-02-09 02:17:34 +05:30
|
|
|
await _flush_or_rollback(session)
|
2026-02-06 16:12:04 +05:30
|
|
|
if commit:
|
2026-02-09 02:17:34 +05:30
|
|
|
await _commit_or_rollback(session)
|
2026-02-06 16:12:04 +05:30
|
|
|
if refresh:
|
|
|
|
|
await session.refresh(obj)
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 20:40:17 +05:30
|
|
|
async def delete(session: AsyncSession, obj: SQLModel, *, commit: bool = True) -> None:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Delete an object with optional commit."""
|
2026-02-06 16:12:04 +05:30
|
|
|
await session.delete(obj)
|
|
|
|
|
if commit:
|
2026-02-09 02:17:34 +05:30
|
|
|
await _commit_or_rollback(session)
|
2026-02-06 16:12:04 +05:30
|
|
|
|
|
|
|
|
|
2026-02-09 00:51:26 +05:30
|
|
|
async def list_by(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
*,
|
|
|
|
|
order_by: Iterable[Any] = (),
|
|
|
|
|
limit: int | None = None,
|
|
|
|
|
offset: int | None = None,
|
2026-02-09 16:23:41 +05:30
|
|
|
**lookup: object,
|
2026-02-09 00:51:26 +05:30
|
|
|
) -> list[ModelT]:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""List objects by lookup values with optional ordering and pagination."""
|
2026-02-09 00:51:26 +05:30
|
|
|
stmt = _lookup_statement(model, lookup)
|
|
|
|
|
for ordering in order_by:
|
|
|
|
|
stmt = stmt.order_by(ordering)
|
|
|
|
|
if offset is not None:
|
|
|
|
|
stmt = stmt.offset(offset)
|
|
|
|
|
if limit is not None:
|
|
|
|
|
stmt = stmt.limit(limit)
|
|
|
|
|
return list(await session.exec(stmt))
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
async def exists(session: AsyncSession, model: type[ModelT], **lookup: object) -> bool:
|
|
|
|
|
"""Return whether any object exists for lookup values."""
|
2026-02-09 20:44:05 +05:30
|
|
|
return (await session.exec(_lookup_statement(model, lookup).limit(1))).first() is not None
|
2026-02-09 00:51:26 +05:30
|
|
|
|
|
|
|
|
|
2026-02-09 16:23:41 +05:30
|
|
|
def _criteria_statement(
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
criteria: tuple[Any, ...],
|
|
|
|
|
) -> SelectOfScalar[ModelT]:
|
|
|
|
|
"""Build a select statement from variadic where criteria."""
|
2026-02-09 02:17:34 +05:30
|
|
|
stmt = select(model)
|
|
|
|
|
if criteria:
|
|
|
|
|
stmt = stmt.where(*criteria)
|
|
|
|
|
return stmt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_where(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
2026-02-09 16:23:41 +05:30
|
|
|
*criteria: object,
|
2026-02-09 02:17:34 +05:30
|
|
|
order_by: Iterable[Any] = (),
|
|
|
|
|
) -> list[ModelT]:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""List objects filtered by explicit SQL criteria."""
|
2026-02-09 02:17:34 +05:30
|
|
|
stmt = _criteria_statement(model, criteria)
|
|
|
|
|
for ordering in order_by:
|
|
|
|
|
stmt = stmt.order_by(ordering)
|
|
|
|
|
return list(await session.exec(stmt))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_where(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
2026-02-09 16:23:41 +05:30
|
|
|
*criteria: object,
|
2026-02-09 02:17:34 +05:30
|
|
|
commit: bool = False,
|
|
|
|
|
) -> int:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Delete rows matching criteria and return affected row count."""
|
2026-02-09 02:24:49 +05:30
|
|
|
stmt: Any = sql_delete(model)
|
2026-02-09 02:17:34 +05:30
|
|
|
if criteria:
|
|
|
|
|
stmt = stmt.where(*criteria)
|
2026-02-09 02:24:49 +05:30
|
|
|
result = await session.exec(stmt)
|
2026-02-09 02:17:34 +05:30
|
|
|
if commit:
|
|
|
|
|
await _commit_or_rollback(session)
|
|
|
|
|
rowcount = getattr(result, "rowcount", None)
|
|
|
|
|
return int(rowcount) if isinstance(rowcount, int) else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_where(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
2026-02-09 16:23:41 +05:30
|
|
|
*criteria: object,
|
2026-02-09 02:17:34 +05:30
|
|
|
updates: Mapping[str, Any] | None = None,
|
2026-02-09 16:23:41 +05:30
|
|
|
**options: object,
|
2026-02-09 02:17:34 +05:30
|
|
|
) -> int:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Apply bulk updates by criteria and return affected row count."""
|
|
|
|
|
commit = bool(options.pop("commit", False))
|
|
|
|
|
exclude_none = bool(options.pop("exclude_none", False))
|
|
|
|
|
allowed_fields_raw = options.pop("allowed_fields", None)
|
2026-02-09 20:44:05 +05:30
|
|
|
allowed_fields = allowed_fields_raw if isinstance(allowed_fields_raw, set) else None
|
2026-02-09 02:17:34 +05:30
|
|
|
source_updates: dict[str, Any] = {}
|
|
|
|
|
if updates:
|
|
|
|
|
source_updates.update(dict(updates))
|
2026-02-09 16:23:41 +05:30
|
|
|
if options:
|
|
|
|
|
source_updates.update(options)
|
2026-02-09 02:17:34 +05:30
|
|
|
|
|
|
|
|
values: dict[str, Any] = {}
|
|
|
|
|
for key, value in source_updates.items():
|
|
|
|
|
if allowed_fields is not None and key not in allowed_fields:
|
|
|
|
|
continue
|
|
|
|
|
if exclude_none and value is None:
|
|
|
|
|
continue
|
|
|
|
|
values[key] = value
|
|
|
|
|
if not values:
|
|
|
|
|
return 0
|
|
|
|
|
|
2026-02-09 02:24:49 +05:30
|
|
|
stmt: Any = sql_update(model).values(**values)
|
2026-02-09 02:17:34 +05:30
|
|
|
if criteria:
|
|
|
|
|
stmt = stmt.where(*criteria)
|
2026-02-09 02:24:49 +05:30
|
|
|
result = await session.exec(stmt)
|
2026-02-09 02:17:34 +05:30
|
|
|
if commit:
|
|
|
|
|
await _commit_or_rollback(session)
|
|
|
|
|
rowcount = getattr(result, "rowcount", None)
|
|
|
|
|
return int(rowcount) if isinstance(rowcount, int) else 0
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 00:51:26 +05:30
|
|
|
def apply_updates(
|
|
|
|
|
obj: ModelT,
|
|
|
|
|
updates: Mapping[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
exclude_none: bool = False,
|
|
|
|
|
allowed_fields: set[str] | None = None,
|
|
|
|
|
) -> ModelT:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Apply a mapping of field updates onto an object."""
|
2026-02-09 00:51:26 +05:30
|
|
|
for key, value in updates.items():
|
|
|
|
|
if allowed_fields is not None and key not in allowed_fields:
|
|
|
|
|
continue
|
|
|
|
|
if exclude_none and value is None:
|
|
|
|
|
continue
|
|
|
|
|
setattr(obj, key, value)
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def patch(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
obj: ModelT,
|
|
|
|
|
updates: Mapping[str, Any],
|
2026-02-09 16:23:41 +05:30
|
|
|
**options: object,
|
2026-02-09 00:51:26 +05:30
|
|
|
) -> ModelT:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Apply partial updates and persist object."""
|
|
|
|
|
exclude_none = bool(options.pop("exclude_none", False))
|
|
|
|
|
allowed_fields_raw = options.pop("allowed_fields", None)
|
2026-02-09 20:44:05 +05:30
|
|
|
allowed_fields = allowed_fields_raw if isinstance(allowed_fields_raw, set) else None
|
2026-02-09 16:23:41 +05:30
|
|
|
commit = bool(options.pop("commit", True))
|
|
|
|
|
refresh = bool(options.pop("refresh", True))
|
2026-02-09 00:51:26 +05:30
|
|
|
apply_updates(
|
|
|
|
|
obj,
|
|
|
|
|
updates,
|
|
|
|
|
exclude_none=exclude_none,
|
|
|
|
|
allowed_fields=allowed_fields,
|
|
|
|
|
)
|
|
|
|
|
return await save(session, obj, commit=commit, refresh=refresh)
|
|
|
|
|
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
async def get_or_create(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
model: type[ModelT],
|
|
|
|
|
*,
|
|
|
|
|
defaults: Mapping[str, Any] | None = None,
|
|
|
|
|
commit: bool = True,
|
|
|
|
|
refresh: bool = True,
|
2026-02-09 16:23:41 +05:30
|
|
|
**lookup: object,
|
2026-02-06 16:12:04 +05:30
|
|
|
) -> tuple[ModelT, bool]:
|
2026-02-09 16:23:41 +05:30
|
|
|
"""Get one object by lookup, or create it with defaults."""
|
2026-02-09 00:51:26 +05:30
|
|
|
stmt = _lookup_statement(model, lookup)
|
2026-02-06 16:12:04 +05:30
|
|
|
|
|
|
|
|
existing = (await session.exec(stmt)).first()
|
|
|
|
|
if existing is not None:
|
|
|
|
|
return existing, False
|
|
|
|
|
|
|
|
|
|
payload: dict[str, Any] = dict(lookup)
|
|
|
|
|
if defaults:
|
|
|
|
|
for key, value in defaults.items():
|
|
|
|
|
payload.setdefault(key, value)
|
|
|
|
|
|
|
|
|
|
obj = model.model_validate(payload)
|
|
|
|
|
session.add(obj)
|
|
|
|
|
try:
|
|
|
|
|
await session.flush()
|
|
|
|
|
if commit:
|
|
|
|
|
await session.commit()
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
# If another concurrent request inserted the same unique row, surface that row.
|
|
|
|
|
await session.rollback()
|
|
|
|
|
existing = (await session.exec(stmt)).first()
|
|
|
|
|
if existing is not None:
|
|
|
|
|
return existing, False
|
|
|
|
|
raise
|
2026-02-09 02:24:16 +05:30
|
|
|
except SQLAlchemyError:
|
2026-02-09 02:17:34 +05:30
|
|
|
await session.rollback()
|
|
|
|
|
raise
|
2026-02-06 16:12:04 +05:30
|
|
|
|
|
|
|
|
if refresh:
|
|
|
|
|
await session.refresh(obj)
|
|
|
|
|
return obj, True
|