sqlmodel_crud_utils

SQLModel CRUD Utilities

A set of CRUD utilities to expedite operations with SQLModel, providing both synchronous and asynchronous support for common database operations.

New in v0.2.0 (see CHANGELOG.md for later releases): - Custom exception hierarchy for better error handling - Transaction context managers for safe operations - Audit trail mixins (created_at, updated_at tracking) - Soft delete support (is_deleted flag) - Public API exports for easier imports

  1"""
  2SQLModel CRUD Utilities
  3
  4A set of CRUD utilities to expedite operations with SQLModel, providing both
  5synchronous and asynchronous support for common database operations.
  6
  7New in v0.2.0 (see CHANGELOG.md for later releases):
  8    - Custom exception hierarchy for better error handling
  9    - Transaction context managers for safe operations
 10    - Audit trail mixins (created_at, updated_at tracking)
 11    - Soft delete support (is_deleted flag)
 12    - Public API exports for easier imports
 13"""
 14
 15__version__ = "0.4.0"
 16
 17# Import asynchronous functions with a_ prefix
 18from sqlmodel_crud_utils.a_sync import AsyncQueryBuilder
 19from sqlmodel_crud_utils.a_sync import bulk_update_rows as a_bulk_update_rows
 20from sqlmodel_crud_utils.a_sync import (
 21    bulk_upsert_mappings as a_bulk_upsert_mappings,
 22)
 23from sqlmodel_crud_utils.a_sync import delete_row as a_delete_row
 24from sqlmodel_crud_utils.a_sync import (
 25    delete_rows_within_id_list as a_delete_rows_within_id_list,
 26)
 27from sqlmodel_crud_utils.a_sync import (
 28    get_change_history as a_get_change_history,
 29)
 30from sqlmodel_crud_utils.a_sync import get_one_or_create as a_get_one_or_create
 31from sqlmodel_crud_utils.a_sync import (
 32    get_result_from_query as a_get_result_from_query,
 33)
 34from sqlmodel_crud_utils.a_sync import get_row as a_get_row
 35from sqlmodel_crud_utils.a_sync import get_rows as a_get_rows
 36from sqlmodel_crud_utils.a_sync import (
 37    get_rows_within_id_list as a_get_rows_within_id_list,
 38)
 39from sqlmodel_crud_utils.a_sync import insert_data_rows as a_insert_data_rows
 40from sqlmodel_crud_utils.a_sync import update_row as a_update_row
 41from sqlmodel_crud_utils.a_sync import write_row as a_write_row
 42
 43# Import cache
 44from sqlmodel_crud_utils.cache import (
 45    CacheBackend,
 46    InMemoryCache,
 47    RedisCache,
 48    configure_cache,
 49    invalidate_cache,
 50)
 51
 52# Import exceptions
 53from sqlmodel_crud_utils.exceptions import (
 54    BulkOperationError,
 55    MultipleRecordsError,
 56    RecordNotFoundError,
 57    SQLModelCRUDError,
 58    TransactionError,
 59    ValidationError,
 60)
 61
 62# Import mixins
 63from sqlmodel_crud_utils.mixins import (
 64    AuditMixin,
 65    SoftDeleteMixin,
 66    TimestampMixin,
 67)
 68
 69# Import synchronous functions
 70from sqlmodel_crud_utils.sync import (
 71    QueryBuilder,
 72    bulk_update_rows,
 73    bulk_upsert_mappings,
 74    delete_row,
 75    delete_rows_within_id_list,
 76    get_change_history,
 77    get_one_or_create,
 78    get_result_from_query,
 79    get_row,
 80    get_rows,
 81    get_rows_within_id_list,
 82    insert_data_rows,
 83    update_row,
 84    write_row,
 85)
 86
 87# Import change tracking
 88from sqlmodel_crud_utils.tracking import (
 89    HISTORY_METADATA,
 90    TrackChangesMixin,
 91    register_change_tracking,
 92)
 93
 94# Import transaction managers
 95from sqlmodel_crud_utils.transactions import a_transaction, transaction
 96
 97__all__ = [
 98    # Version
 99    "__version__",
100    # Synchronous functions
101    "bulk_update_rows",
102    "bulk_upsert_mappings",
103    "delete_row",
104    "delete_rows_within_id_list",
105    "get_change_history",
106    "get_one_or_create",
107    "get_result_from_query",
108    "get_row",
109    "get_rows",
110    "get_rows_within_id_list",
111    "insert_data_rows",
112    "update_row",
113    "write_row",
114    # Asynchronous functions
115    "a_bulk_update_rows",
116    "a_bulk_upsert_mappings",
117    "a_delete_row",
118    "a_delete_rows_within_id_list",
119    "a_get_change_history",
120    "a_get_one_or_create",
121    "a_get_result_from_query",
122    "a_get_row",
123    "a_get_rows",
124    "a_get_rows_within_id_list",
125    "a_insert_data_rows",
126    "a_update_row",
127    "a_write_row",
128    # Query builders
129    "QueryBuilder",
130    "AsyncQueryBuilder",
131    # Exceptions
132    "SQLModelCRUDError",
133    "RecordNotFoundError",
134    "MultipleRecordsError",
135    "ValidationError",
136    "BulkOperationError",
137    "TransactionError",
138    # Transaction managers
139    "transaction",
140    "a_transaction",
141    # Mixins
142    "AuditMixin",
143    "SoftDeleteMixin",
144    "TimestampMixin",
145    # Caching
146    "CacheBackend",
147    "InMemoryCache",
148    "RedisCache",
149    "configure_cache",
150    "invalidate_cache",
151    # Change tracking
152    "HISTORY_METADATA",
153    "TrackChangesMixin",
154    "register_change_tracking",
155]
__version__ = '0.4.0'
def bulk_update_rows( id_str_list: list[str | int], data: dict, session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id', chunk_size: int = 500) -> tuple[bool, int]:
620def bulk_update_rows(
621    id_str_list: list[str | int],
622    data: dict,
623    session_inst: Session,
624    model: type[SQLModel],
625    pk_field: str = "id",
626    chunk_size: int = 500,
627) -> tuple[bool, int]:
628    """
629    Applies the same column values to every row whose primary key is within
630    the provided list and commits once. IDs with no matching row are ignored.
631
632    This is a Core ``UPDATE``, so ORM-level hooks and change tracking
633    (ADR-0011) do not see it. Column-level ``onupdate`` defaults do fire, so
634    ``updated_at`` is refreshed for both ``TimestampMixin`` and
635    ``AuditMixin``. Nothing else is filled in: ``AuditMixin.updated_by`` is
636    only set when the caller includes it in ``data``, and
637    ``SoftDeleteMixin`` fields (``is_deleted``, ``deleted_at``,
638    ``deleted_by``) are only changed if named in ``data``. The ID list is
639    split into ``chunk_size`` batches. One ``UPDATE ... WHERE pk IN (...)``
640    statement is sent per batch (never per row), and all batches share one
641    transaction.
642
643    :param id_str_list: List of primary key values to update.
644    :param data: Mapping of column name to the new value for every row. Must
645        be non-empty and may not name a primary-key column.
646    :param session_inst: SQLAlchemy Session instance.
647    :param model: SQLModel class representing the table.
648    :param pk_field: Primary-key column to match against (default: "id").
649    :param chunk_size: Maximum IDs per UPDATE statement (default: 500).
650    :return: Tuple[bool, int]: ``True`` and the number of rows matched by the
651        update. The count is 0 when nothing matched or the list was empty.
652    :raises ValueError: If ``pk_field`` is not a primary-key column,
653        ``data`` is empty or names an unknown or primary-key column, or
654        ``chunk_size`` is not positive.
655    :raises Exception: Any database error, after the transaction has been
656        rolled back and the error logged.
657    """
658    validate_primary_key_field(model, pk_field)
659    validate_update_columns(model, data)
660    updated = 0
661    try:
662        for chunk in chunked(id_str_list, chunk_size):
663            stmnt = (
664                update(model)
665                .where(getattr(model, pk_field).in_(chunk))
666                .values(**data)
667            )
668            result = session_inst.exec(stmnt)
669            updated += max(result.rowcount, 0)
670        session_inst.commit()
671    except Exception as e:
672        session_inst.rollback()
673        logger.error(f"Failed to bulk update rows: {type(e)}, {e}")
674        raise
675
676    invalidate_written(session_inst, model)
677    return True, updated

Applies the same column values to every row whose primary key is within the provided list and commits once. IDs with no matching row are ignored.

This is a Core UPDATE, so ORM-level hooks and change tracking (ADR-0011) do not see it. Column-level onupdate defaults do fire, so updated_at is refreshed for both TimestampMixin and AuditMixin. Nothing else is filled in: AuditMixin.updated_by is only set when the caller includes it in data, and SoftDeleteMixin fields (is_deleted, deleted_at, deleted_by) are only changed if named in data. The ID list is split into chunk_size batches. One UPDATE ... WHERE pk IN (...) statement is sent per batch (never per row), and all batches share one transaction.

Parameters
  • id_str_list: List of primary key values to update.
  • data: Mapping of column name to the new value for every row. Must be non-empty and may not name a primary-key column.
  • session_inst: SQLAlchemy Session instance.
  • model: SQLModel class representing the table.
  • pk_field: Primary-key column to match against (default: "id").
  • chunk_size: Maximum IDs per UPDATE statement (default: 500).
Returns

Tuple[bool, int]: True and the number of rows matched by the update. The count is 0 when nothing matched or the list was empty.

Raises
  • ValueError: If pk_field is not a primary-key column, data is empty or names an unknown or primary-key column, or chunk_size is not positive.
  • Exception: Any database error, after the transaction has been rolled back and the error logged.
def bulk_upsert_mappings( payload: list, session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], pk_fields: list[str] | None = None, update_columns: Optional[Sequence[str]] = None):
718def bulk_upsert_mappings(
719    payload: list,
720    session_inst: Session,
721    model: type[SQLModel],
722    pk_fields: list[str] | None = None,
723    update_columns: Sequence[str] | None = None,
724):
725    """
726    Insert or update a batch of rows in a single ``INSERT ... ON CONFLICT DO
727    UPDATE ... RETURNING`` statement.
728
729    The ``RETURNING`` rows are fully read (``.all()``) before ``commit()``.
730    SQLite refuses to commit while a result cursor is still unread
731    ("cannot commit transaction - SQL statements in progress"); Postgres
732    tolerates it. The upsert is also executed exactly once rather than once
733    bare and once more to fetch the rows.
734
735    Returned instances are expired by ``commit()`` when the session uses
736    ``expire_on_commit=True`` (the default), so read their attributes while
737    the session is open or create the session with ``expire_on_commit=False``.
738
739    :param payload: Row mappings to upsert; the first mapping's keys decide
740        which columns are overwritten on conflict.
741    :param session_inst: Active session.
742    :param model: Table model being upserted.
743    :param pk_fields: Conflict-target columns. Defaults to ``["id"]``.
744    :param update_columns: Restrict the columns written on conflict to
745        these (the ``SET`` clause of ``ON CONFLICT DO UPDATE``, ADR-0016).
746        Defaults to ``None``: every column in the first mapping is
747        overwritten. New rows are always inserted with every provided
748        column. Names are table column keys, as in ``payload``; duplicates
749        are ignored.
750    :return: ``(True, rows)`` where ``rows`` are the upserted model instances.
751    :raises ValueError: Before any statement is sent, if ``update_columns``
752        is empty or a bare string, names an unknown column, a primary-key
753        column or a ``pk_fields`` column, or names a column the first
754        mapping of ``payload`` does not provide.
755    """
756    if not pk_fields:
757        pk_fields = ["id"]
758    if update_columns is not None:
759        update_columns = validate_upsert_update_columns(
760            model, payload, pk_fields, update_columns
761        )
762    set_columns = (
763        payload[0].keys() if update_columns is None else update_columns
764    )
765    stmnt = upsert(model).values(payload)
766    stmnt = stmnt.on_conflict_do_update(
767        index_elements=[getattr(model, x) for x in pk_fields],
768        set_={k: getattr(stmnt.excluded, k) for k in set_columns},
769    )
770    results = (
771        session_inst.scalars(
772            stmnt.returning(model),
773            execution_options={"populate_existing": True},
774        )
775    ).all()
776
777    session_inst.commit()
778    invalidate_written(session_inst, model)
779
780    return True, results

Insert or update a batch of rows in a single INSERT ... ON CONFLICT DO UPDATE ... RETURNING statement.

The RETURNING rows are fully read (.all()) before commit(). SQLite refuses to commit while a result cursor is still unread ("cannot commit transaction - SQL statements in progress"); Postgres tolerates it. The upsert is also executed exactly once rather than once bare and once more to fetch the rows.

Returned instances are expired by commit() when the session uses expire_on_commit=True (the default), so read their attributes while the session is open or create the session with expire_on_commit=False.

Parameters
  • payload: Row mappings to upsert; the first mapping's keys decide which columns are overwritten on conflict.
  • session_inst: Active session.
  • model: Table model being upserted.
  • pk_fields: Conflict-target columns. Defaults to ["id"].
  • update_columns: Restrict the columns written on conflict to these (the SET clause of ON CONFLICT DO UPDATE, ADR-0016). Defaults to None: every column in the first mapping is overwritten. New rows are always inserted with every provided column. Names are table column keys, as in payload; duplicates are ignored.
Returns

(True, rows) where rows are the upserted model instances.

Raises
  • ValueError: Before any statement is sent, if update_columns is empty or a bare string, names an unknown column, a primary-key column or a pk_fields column, or names a column the first mapping of payload does not provide.
def delete_row( id_str: str | int, session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id'):
680def delete_row(
681    id_str: str | int,
682    session_inst: Session,
683    model: type[SQLModel],
684    pk_field: str = "id",
685):
686    """
687
688    :param id_str:
689    :param session_inst:
690    :param model:
691    :param pk_field:
692    :return:
693    """
694    success = False
695    stmnt = select(model).where(getattr(model, pk_field) == id_str)
696    results = session_inst.exec(stmnt)
697
698    row = results.one_or_none()
699
700    if not row:
701        pass
702    else:
703        try:
704            session_inst.delete(row)
705            session_inst.commit()
706            invalidate_written(session_inst, model)
707            success = True
708        except Exception as e:
709            logger.error(
710                f"Failed to delete data row. Please see error messages here: "
711                f"{type(e), e, e.args}"
712            )
713            session_inst.rollback()
714
715    return success
Parameters
  • id_str:
  • session_inst:
  • model:
  • pk_field:
Returns
def delete_rows_within_id_list( id_str_list: list[str | int], session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id', chunk_size: int = 500, soft: bool = False, deleted_by: str | None = None) -> tuple[bool, int]:
542def delete_rows_within_id_list(
543    id_str_list: list[str | int],
544    session_inst: Session,
545    model: type[SQLModel],
546    pk_field: str = "id",
547    chunk_size: int = 500,
548    soft: bool = False,
549    deleted_by: str | None = None,
550) -> tuple[bool, int]:
551    """
552    Deletes every row whose primary key is within the provided list and
553    commits once. IDs with no matching row are ignored.
554
555    By default this is a hard SQL ``DELETE``: rows are removed, even for a
556    model that uses ``SoftDeleteMixin`` (nothing is marked with
557    ``deleted_at``), and ORM-level hooks do not run. Pass ``soft=True`` to
558    mark the rows deleted instead, exactly as ``SoftDeleteMixin.soft_delete``
559    does: ``is_deleted`` becomes true, ``deleted_at`` is the current UTC time
560    (one timestamp for the whole call) and ``deleted_by`` is the given user.
561    The soft path is a Core ``UPDATE``, so the same bulk-write rules as
562    ``bulk_update_rows`` apply: column-level ``onupdate`` defaults fire (for
563    example ``updated_at`` of ``TimestampMixin`` and ``AuditMixin``), while
564    ``updated_by`` is not set and ORM events and change tracking do not see
565    the write. Rows already soft-deleted are left untouched (their original
566    ``deleted_at`` and ``deleted_by`` are kept) and are not counted.
567
568    The ID list is split into ``chunk_size`` batches to stay under backend
569    bind-parameter limits. One ``DELETE`` (or ``UPDATE``) ``... WHERE pk IN
570    (...)`` statement is sent per batch (never per row), so 1,200 IDs at the
571    default size is 3 statements. All batches share one transaction, so the
572    call is all-or-nothing. The model's cache entries are invalidated after
573    the commit in both modes.
574
575    :param id_str_list: List of primary key values to delete.
576    :param session_inst: SQLAlchemy Session instance.
577    :param model: SQLModel class representing the table.
578    :param pk_field: Primary-key column to match against (default: "id").
579    :param chunk_size: Maximum IDs per statement (default: 500).
580    :param soft: When ``True``, soft-delete instead of removing the rows.
581        The model must have the ``SoftDeleteMixin`` columns ``is_deleted``,
582        ``deleted_at`` and ``deleted_by`` (default: ``False``).
583    :param deleted_by: User recorded in ``deleted_by`` on soft-deleted rows.
584        Only valid with ``soft=True`` (default: ``None``).
585    :return: Tuple[bool, int]: ``True`` and the number of rows affected: rows
586        removed for a hard delete, or rows newly marked deleted for a soft
587        delete (rows that were already soft-deleted are not counted). The
588        count is 0 when nothing matched or the list was empty.
589    :raises ValueError: If ``pk_field`` is not a primary-key column of
590        ``model``, ``chunk_size`` is not positive, ``soft=True`` is used on a
591        model without the ``SoftDeleteMixin`` columns (the message names the
592        missing ones), or ``deleted_by`` is given without ``soft=True``.
593    :raises Exception: Any database error, after the transaction has been
594        rolled back and the error logged.
595    """
596    validate_primary_key_field(model, pk_field)
597    validate_soft_delete_args(model, soft, deleted_by)
598    deleted_at = soft_delete_timestamp() if soft else None
599    deleted = 0
600    try:
601        for chunk in chunked(id_str_list, chunk_size):
602            if soft:
603                stmnt = soft_delete_statement(
604                    model, pk_field, chunk, deleted_at, deleted_by
605                )
606            else:
607                stmnt = delete(model).where(getattr(model, pk_field).in_(chunk))
608            result = session_inst.exec(stmnt)
609            deleted += max(result.rowcount, 0)
610        session_inst.commit()
611    except Exception as e:
612        session_inst.rollback()
613        logger.error(f"Failed to bulk delete rows: {type(e)}, {e}")
614        raise
615
616    invalidate_written(session_inst, model)
617    return True, deleted

Deletes every row whose primary key is within the provided list and commits once. IDs with no matching row are ignored.

By default this is a hard SQL DELETE: rows are removed, even for a model that uses SoftDeleteMixin (nothing is marked with deleted_at), and ORM-level hooks do not run. Pass soft=True to mark the rows deleted instead, exactly as SoftDeleteMixin.soft_delete does: is_deleted becomes true, deleted_at is the current UTC time (one timestamp for the whole call) and deleted_by is the given user. The soft path is a Core UPDATE, so the same bulk-write rules as bulk_update_rows apply: column-level onupdate defaults fire (for example updated_at of TimestampMixin and AuditMixin), while updated_by is not set and ORM events and change tracking do not see the write. Rows already soft-deleted are left untouched (their original deleted_at and deleted_by are kept) and are not counted.

The ID list is split into chunk_size batches to stay under backend bind-parameter limits. One DELETE (or UPDATE) ... WHERE pk IN (...) statement is sent per batch (never per row), so 1,200 IDs at the default size is 3 statements. All batches share one transaction, so the call is all-or-nothing. The model's cache entries are invalidated after the commit in both modes.

Parameters
  • id_str_list: List of primary key values to delete.
  • session_inst: SQLAlchemy Session instance.
  • model: SQLModel class representing the table.
  • pk_field: Primary-key column to match against (default: "id").
  • chunk_size: Maximum IDs per statement (default: 500).
  • soft: When True, soft-delete instead of removing the rows. The model must have the SoftDeleteMixin columns is_deleted, deleted_at and deleted_by (default: False).
  • deleted_by: User recorded in deleted_by on soft-deleted rows. Only valid with soft=True (default: None).
Returns

Tuple[bool, int]: True and the number of rows affected: rows removed for a hard delete, or rows newly marked deleted for a soft delete (rows that were already soft-deleted are not counted). The count is 0 when nothing matched or the list was empty.

Raises
  • ValueError: If pk_field is not a primary-key column of model, chunk_size is not positive, soft=True is used on a model without the SoftDeleteMixin columns (the message names the missing ones), or deleted_by is given without soft=True.
  • Exception: Any database error, after the transaction has been rolled back and the error logged.
def get_change_history( session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], id_str: int | str):
823def get_change_history(
824    session_inst: Session,
825    model: type[SQLModel],
826    id_str: int | str,
827):
828    """
829    Return the recorded change history of one row, oldest first.
830
831    Requires ``register_change_tracking()`` and a model inheriting
832    ``TrackChangesMixin`` (ADR-0011). Changes made by the bulk helpers are not
833    recorded, so they do not appear here.
834
835    :param session_inst: Session
836    :param model: type[SQLModel]
837    :param id_str: primary key value of the row; a tuple or list in
838        column order for a composite key
839    :return: Tuple[bool, list[dict]]
840    """
841    stmnt = history_select(model, id_str)
842    try:
843        result = session_inst.connection(
844            bind_arguments={"mapper": model}
845        ).execute(stmnt)
846        return True, [dict(r) for r in result.mappings().all()]
847    except Exception as e:
848        logger.error(
849            f"Reading change history failed. See error message: "
850            f"{type(e), e, e.args}"
851        )
852        session_inst.rollback()
853        return False, []

Return the recorded change history of one row, oldest first.

Requires register_change_tracking() and a model inheriting TrackChangesMixin (ADR-0011). Changes made by the bulk helpers are not recorded, so they do not appear here.

Parameters
  • session_inst: Session
  • model: type[SQLModel]
  • id_str: primary key value of the row; a tuple or list in column order for a composite key
Returns

Tuple[bool, list[dict]]

def get_one_or_create( session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], create_method_kwargs: dict | None = None, selectin: bool = False, select_in_key: str | None = None, **kwargs):
 68def get_one_or_create(
 69    session_inst: Session,
 70    model: type[SQLModel],
 71    create_method_kwargs: dict | None = None,
 72    selectin: bool = False,
 73    select_in_key: str | None = None,
 74    **kwargs,
 75):
 76    """
 77    This function either returns an existing data row from the database or
 78    creates a new instance and saves it to the DB.
 79
 80    :param session_inst: Session
 81    :param model: SQLModel ORM
 82    :param create_method_kwargs: dict
 83    :param selectin: bool
 84    :param select_in_key: str | None
 85    :param kwargs: keyword args
 86    :return: Tuple[Row, bool]
 87    """
 88
 89    def _get_entry(sqlmodel, **key_args):
 90        stmnt = select(sqlmodel).filter_by(**key_args)
 91        results = get_result_from_query(query=stmnt, session=session_inst)
 92
 93        if results:
 94            if selectin and select_in_key:
 95                stmnt = stmnt.options(
 96                    selectinload(getattr(sqlmodel, select_in_key))
 97                )
 98                results = get_result_from_query(
 99                    query=stmnt, session=session_inst
100                )
101            return results, True
102        else:
103            return results, False
104
105    results, exists = _get_entry(model, **kwargs)
106    if results:
107        return results, exists
108    else:
109        kwargs.update(create_method_kwargs or {})
110        created = model()
111        [setattr(created, k, v) for k, v in kwargs.items()]
112        session_inst.add(created)
113        session_inst.commit()
114        invalidate_written(session_inst, model)
115        return created, False

This function either returns an existing data row from the database or creates a new instance and saves it to the DB.

Parameters
  • session_inst: Session
  • model: SQLModel ORM
  • create_method_kwargs: dict
  • selectin: bool
  • select_in_key: str | None
  • kwargs: keyword args
Returns

Tuple[Row, bool]

def get_result_from_query( query: sqlmodel.sql._expression_select_cls.SelectOfScalar, session: sqlmodel.orm.session.Session):
47def get_result_from_query(query: SelectOfScalar, session: Session):
48    """
49    Processes an SQLModel query object and returns a singular result from the
50    return payload. If more than one row is returned, then only the first row is
51    returned. If no rows are available, then a null value is returned.
52
53    :param query: SelectOfScalar
54    :param session: Session
55
56    :return: Row
57    """
58    results = session.exec(query)
59    try:
60        results = results.one_or_none()
61    except MultipleResultsFound:
62        results = session.exec(query)
63        results = results.first()
64
65    return results

Processes an SQLModel query object and returns a singular result from the return payload. If more than one row is returned, then only the first row is returned. If no rows are available, then a null value is returned.

Parameters
  • query: SelectOfScalar
  • session: Session
Returns

Row

def get_row( id_str: str | int, session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], selectin: bool = False, select_in_keys: list[str] | None = None, lazy: bool = False, lazy_load_keys: list[str] | None = None, pk_field: str = 'id', use_cache: bool = False, cache_ttl: float | None = None):
183def get_row(
184    id_str: str | int,
185    session_inst: Session,
186    model: type[SQLModel],
187    selectin: bool = False,
188    select_in_keys: list[str] | None = None,
189    lazy: bool = False,
190    lazy_load_keys: list[str] | None = None,
191    pk_field: str = "id",
192    use_cache: bool = False,
193    cache_ttl: float | None = None,
194):
195    """
196    Fetch a single row by primary key.
197
198    The optional parameters follow the same order as ``a_sync.get_row`` and
199    both ``get_rows`` functions, so positional calls bind identically in the
200    sync and async APIs.
201
202    :param id_str: Primary key value to look up.
203    :param session_inst: SQLModel Session instance.
204    :param model: SQLModel class representing the table.
205    :param selectin: Apply ``selectinload`` for ``select_in_keys``.
206    :param select_in_keys: Relationship name(s) to eager load.
207    :param lazy: Apply ``lazyload`` for ``lazy_load_keys``.
208    :param lazy_load_keys: Relationship name(s) to lazy load.
209    :param pk_field: Primary key field name (default: "id").
210    :param use_cache: Read from and fill the cache set by ``configure_cache``
211        (ADR-0012). Bypassed with ``selectin`` or ``lazy``.
212    :param cache_ttl: Seconds the entry lives; backend default when None.
213    :return: Tuple[bool, SQLModel | None]: Success flag and the row, if found.
214    """
215    backend = active_backend(
216        use_cache
217        and not (selectin and select_in_keys)
218        and not (lazy and lazy_load_keys)
219    )
220    if backend:
221        namespace = namespace_for(model)
222        cache_key = make_key(
223            session_inst, model, "get_row", pk_field=pk_field, id=id_str
224        )
225        hit = lookup(backend, namespace, cache_key)
226        row = load_row(model, hit) if hit is not None else None
227        if row is not None:
228            return True, row
229    stmnt = select(model).where(getattr(model, pk_field) == id_str)
230    if selectin and select_in_keys:
231        if isinstance(select_in_keys, list) is False:
232            select_in_keys = [select_in_keys]
233
234        for key in select_in_keys:
235            stmnt = stmnt.options(selectinload(getattr(model, key)))
236    if lazy and lazy_load_keys:
237        if isinstance(lazy_load_keys, list) is False:
238            lazy_load_keys = [lazy_load_keys]
239        for key in lazy_load_keys:
240            stmnt = stmnt.options(lazyload(getattr(model, key)))
241    results = session_inst.exec(stmnt)
242
243    row = results.one_or_none()
244
245    if not row:
246        success = False
247    else:
248        success = True
249        if backend:
250            store(backend, namespace, cache_key, dump_row(row), cache_ttl)
251
252    return success, row

Fetch a single row by primary key.

The optional parameters follow the same order as a_sync.get_row and both get_rows functions, so positional calls bind identically in the sync and async APIs.

Parameters
  • id_str: Primary key value to look up.
  • session_inst: SQLModel Session instance.
  • model: SQLModel class representing the table.
  • selectin: Apply selectinload for select_in_keys.
  • select_in_keys: Relationship name(s) to eager load.
  • lazy: Apply lazyload for lazy_load_keys.
  • lazy_load_keys: Relationship name(s) to lazy load.
  • pk_field: Primary key field name (default: "id").
  • use_cache: Read from and fill the cache set by configure_cache (ADR-0012). Bypassed with selectin or lazy.
  • cache_ttl: Seconds the entry lives; backend default when None.
Returns

Tuple[bool, SQLModel | None]: Success flag and the row, if found.

def get_rows( session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], selectin: bool = False, select_in_keys: list[str] | None = None, lazy: bool = False, lazy_load_keys: list[str] | None = None, page_size: int = 100, page: int = 1, text_field: str | None = None, stmnt: sqlmodel.sql._expression_select_cls.SelectOfScalar | None = None, use_cache: bool = False, cache_ttl: float | None = None, **kwargs):
255def get_rows(
256    session_inst: Session,
257    model: type[SQLModel],
258    selectin: bool = False,
259    select_in_keys: list[str] | None = None,
260    lazy: bool = False,
261    lazy_load_keys: list[str] | None = None,
262    page_size: int = 100,
263    page: int = 1,
264    text_field: str | None = None,
265    stmnt: SelectOfScalar | None = None,
266    use_cache: bool = False,
267    cache_ttl: float | None = None,
268    **kwargs,
269):
270    """
271    Retrieve rows for ``model``, building the query from ``kwargs`` or
272    running a caller-supplied statement.
273
274    Pagination applies only to statements built internally (``stmnt`` is
275    ``None``): ``page`` and ``page_size`` translate to OFFSET and LIMIT. A
276    caller-supplied ``stmnt`` is executed exactly as given; the caller owns
277    its pagination, so ``page``, ``page_size`` and any filter, sort or
278    loading arguments are ignored. Add ``.limit()``/``.offset()`` to the
279    statement yourself when you want a bounded result.
280
281    .. versionchanged:: Unreleased
282        A custom ``stmnt`` is no longer silently capped at ``page_size``
283        rows (issue #11).
284
285    :param session_inst: Active session used to execute the query.
286    :param model: SQLModel table class to query.
287    :param selectin: Enable ``selectinload`` for ``select_in_keys``.
288    :param select_in_keys: Relationship names to eager load via selectin.
289    :param lazy: Enable ``lazyload`` for ``lazy_load_keys``.
290    :param lazy_load_keys: Relationship names to lazy load.
291    :param page_size: Rows per page; ignored when ``stmnt`` is supplied.
292    :param page: 1-based page number; ignored when ``stmnt`` is supplied.
293    :param text_field: Field matched with a text search from ``kwargs``.
294    :param stmnt: Custom statement, executed as-is and never paginated.
295    :param use_cache: Read from and fill the cache set by ``configure_cache``
296        (ADR-0012). Bypassed with ``stmnt``, ``selectin`` or ``lazy``.
297    :param cache_ttl: Seconds the entry lives; backend default when None.
298    :param kwargs: Filters and sort options for the built query.
299    :return: ``(success, rows)`` where ``success`` is True if rows exist.
300    """
301    paginate = stmnt is None
302    backend = active_backend(
303        use_cache
304        and stmnt is None
305        and not (selectin and select_in_keys)
306        and not (lazy and lazy_load_keys)
307    )
308    if backend:
309        namespace = namespace_for(model)
310        cache_key = make_key(
311            session_inst,
312            model,
313            "get_rows",
314            page=page,
315            page_size=page_size,
316            text_field=text_field,
317            filters=repr(sorted(kwargs.items(), key=lambda kv: kv[0])),
318        )
319        hit = lookup(backend, namespace, cache_key)
320        rows = load_rows(model, hit) if hit is not None else None
321        if rows is not None:
322            return True, rows
323    # Inside get_rows (sync and async versions)
324
325    # ... existing code ...
326    if stmnt is None:
327        stmnt = select(model)
328        if kwargs:
329            # Separate special filter keys from exact match keys
330            exact_match_kwargs = {}
331            special_filters = {}
332
333            keys_to_process = list(kwargs.keys())  # Iterate over a copy
334
335            for key in keys_to_process:
336                val = kwargs[key]
337                if "__like" in key:
338                    model_key = key.replace("__like", "")
339                    special_filters[key] = (
340                        getattr(model, model_key).like,
341                        f"%{val}%",
342                    )  # Adapt for like
343                elif "__gte" in key:
344                    model_key = key.replace("__gte", "")
345                    parsed_val = (
346                        date_parse(val)
347                        if "date" in key
348                        and isinstance(val, str)
349                        and is_date(val, fuzzy=False)
350                        else (
351                            int(val)
352                            if isinstance(val, str) and val.isdigit()
353                            else val
354                        )
355                    )
356                    special_filters[key] = (
357                        getattr(model, model_key).__ge__,
358                        parsed_val,
359                    )
360                elif "__lte" in key:
361                    model_key = key.replace("__lte", "")
362                    parsed_val = (
363                        date_parse(val)
364                        if "date" in key
365                        and isinstance(val, str)
366                        and is_date(val, fuzzy=False)
367                        else (
368                            int(val)
369                            if isinstance(val, str) and val.isdigit()
370                            else val
371                        )
372                    )
373                    special_filters[key] = (
374                        getattr(model, model_key).__le__,
375                        parsed_val,
376                    )
377                elif "__gt" in key:  # Add __gt if needed
378                    model_key = key.replace("__gt", "")
379                    parsed_val = (
380                        date_parse(val)
381                        if "date" in key
382                        and isinstance(val, str)
383                        and is_date(val, fuzzy=False)
384                        else (
385                            int(val)
386                            if isinstance(val, str) and val.isdigit()
387                            else val
388                        )
389                    )
390                    special_filters[key] = (
391                        getattr(model, model_key).__gt__,
392                        parsed_val,
393                    )
394                elif "__lt" in key:  # Add __lt if needed
395                    model_key = key.replace("__lt", "")
396                    parsed_val = (
397                        date_parse(val)
398                        if "date" in key
399                        and isinstance(val, str)
400                        and is_date(val, fuzzy=False)
401                        else (
402                            int(val)
403                            if isinstance(val, str) and val.isdigit()
404                            else val
405                        )
406                    )
407                    special_filters[key] = (
408                        getattr(model, model_key).__lt__,
409                        parsed_val,
410                    )
411                elif "__in" in key:  # Add __in if needed
412                    model_key = key.replace("__in", "")
413                    if isinstance(val, list):
414                        special_filters[key] = (
415                            getattr(model, model_key).in_,
416                            val,
417                        )
418                    else:
419                        logger.warning(
420                            f"Value for __in filter '{key}' is not a list, "
421                            f"skipping."
422                        )
423                elif key not in ("sort_desc", "sort_field") and (
424                    not text_field or key != text_field
425                ):
426                    # Collect keys for filter_by, excluding sort/text search
427                    # keys
428                    exact_match_kwargs[key] = val
429
430            # Apply special filters using filter()
431            for _filter_key, (
432                filter_method,
433                filter_value,
434            ) in special_filters.items():
435                stmnt = stmnt.filter(filter_method(filter_value))
436
437            # Apply sorting
438            sort_desc = kwargs.get("sort_desc")
439            sort_field = kwargs.get("sort_field")
440            if sort_field:
441                sort_attr = getattr(model, sort_field)
442                stmnt = stmnt.order_by(
443                    sort_attr.desc() if sort_desc else sort_attr
444                )
445
446            # Apply text search if applicable (assuming .match() is correct)
447            if text_field and text_field in kwargs:
448                search_val = kwargs[text_field]
449                stmnt = stmnt.where(
450                    getattr(model, text_field).match(search_val)
451                )
452                # Remove from exact_match_kwargs if it ended up there
453                exact_match_kwargs.pop(text_field, None)
454
455            # Apply exact matches using filter_by()
456            if exact_match_kwargs:
457                stmnt = stmnt.filter_by(**exact_match_kwargs)
458
459        # Apply relationship loading options (Check if key is a relationship
460        # first - simplified check)
461        if selectin and select_in_keys:
462            for key in select_in_keys:
463                # Basic check: Does the attribute exist and is it likely a
464                # relationship?
465                # A more robust check might involve inspecting
466                # model.__sqlmodel_relationships__
467                attr = getattr(model, key, None)
468                if (
469                    attr is not None
470                    and hasattr(attr, "property")
471                    and hasattr(attr.property, "mapper")
472                ):
473                    stmnt = stmnt.options(selectinload(attr))
474                else:
475                    logger.warning(
476                        f"Skipping selectinload for non-relationship "
477                        f"attribute '{key}' on model {model.__name__}"
478                    )
479
480        if lazy and lazy_load_keys:
481            for key in lazy_load_keys:
482                attr = getattr(model, key, None)
483                if (
484                    attr is not None
485                    and hasattr(attr, "property")
486                    and hasattr(attr.property, "mapper")
487                ):
488                    stmnt = stmnt.options(lazyload(attr))
489                else:
490                    logger.warning(
491                        f"Skipping lazyload for non-relationship attribute  "
492                        f"'{key}' on model {model.__name__}"
493                    )
494
495    if paginate:
496        stmnt = stmnt.offset((page - 1) * page_size).limit(page_size)
497
498    _result = session_inst.exec(stmnt)
499    results = _result.all()
500    success = True if len(results) > 0 else False
501    if success and backend:
502        store(
503            backend,
504            namespace,
505            cache_key,
506            dump_rows(results),
507            cache_ttl,
508        )
509
510    return success, results

Retrieve rows for model, building the query from kwargs or running a caller-supplied statement.

Pagination applies only to statements built internally (stmnt is None): page and page_size translate to OFFSET and LIMIT. A caller-supplied stmnt is executed exactly as given; the caller owns its pagination, so page, page_size and any filter, sort or loading arguments are ignored. Add .limit()/.offset() to the statement yourself when you want a bounded result.

Changed in version Unreleased: A custom stmnt is no longer silently capped at page_size rows (issue #11).

Parameters
  • session_inst: Active session used to execute the query.
  • model: SQLModel table class to query.
  • selectin: Enable selectinload for select_in_keys.
  • select_in_keys: Relationship names to eager load via selectin.
  • lazy: Enable lazyload for lazy_load_keys.
  • lazy_load_keys: Relationship names to lazy load.
  • page_size: Rows per page; ignored when stmnt is supplied.
  • page: 1-based page number; ignored when stmnt is supplied.
  • text_field: Field matched with a text search from kwargs.
  • stmnt: Custom statement, executed as-is and never paginated.
  • use_cache: Read from and fill the cache set by configure_cache (ADR-0012). Bypassed with stmnt, selectin or lazy.
  • cache_ttl: Seconds the entry lives; backend default when None.
  • kwargs: Filters and sort options for the built query.
Returns

(success, rows) where success is True if rows exist.

def get_rows_within_id_list( id_str_list: list[str | int], session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id'):
513def get_rows_within_id_list(
514    id_str_list: list[str | int],
515    session_inst: Session,
516    model: type[SQLModel],
517    pk_field: str = "id",
518):
519    """
520    Retrieves rows from the database whose primary key is within the provided
521    list.
522
523    :param id_str_list: List of primary key values to fetch.
524    :param session_inst: SQLAlchemy Session instance.
525    :param model: SQLModel class representing the table.
526    :param pk_field: Name of the primary key field (default: "id").
527    :return: Tuple[bool, list[SQLModel]]: A tuple containing a success flag
528             (True if rows were found, False otherwise) and a list of the
529             found model instances.
530    """
531    if not id_str_list:  # Handle empty input list
532        return False, []
533
534    stmnt = select(model).where(getattr(model, pk_field).in_(id_str_list))
535    results = session_inst.exec(stmnt).all()  # Fetch all results into a list
536
537    success = len(results) > 0  # Success is true only if results were found
538
539    return success, results

Retrieves rows from the database whose primary key is within the provided list.

Parameters
  • id_str_list: List of primary key values to fetch.
  • session_inst: SQLAlchemy Session instance.
  • model: SQLModel class representing the table.
  • pk_field: Name of the primary key field (default: "id").
Returns

Tuple[bool, list[SQLModel]]: A tuple containing a success flag (True if rows were found, False otherwise) and a list of the found model instances.

def insert_data_rows(data_rows, session_inst: sqlmodel.orm.session.Session):
143def insert_data_rows(data_rows, session_inst: Session):
144    """
145
146    :param data_rows:
147    :param session_inst:
148    :return:
149    """
150    try:
151        session_inst.add_all(data_rows)
152        session_inst.commit()
153        invalidate_written(session_inst, *{type(r) for r in data_rows})
154
155        return True, data_rows
156
157    except Exception as e:
158        logger.error(
159            f"Writing data rows to table failed. See error message: "
160            f"{type(e), e, e.args}"
161        )
162        logger.info(
163            "Attempting to write individual entries. This can be a "
164            "bit taxing, so please consider your payload to the DB"
165        )
166
167        session_inst.rollback()
168        processed_rows, failed_rows = [], []
169        for row in data_rows:
170            success, processed_row = write_row(row, session_inst=session_inst)
171            if not success:
172                failed_rows.append(row)
173            else:
174                processed_rows.append(row)
175
176        if processed_rows:
177            status = True
178        else:
179            status = (False,)
180        return status, {"success": processed_rows, "failed": failed_rows}
Parameters
  • data_rows:
  • session_inst:
Returns
def update_row( id_str: int | str, data: dict, session_inst: sqlmodel.orm.session.Session, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id'):
783def update_row(
784    id_str: int | str,
785    data: dict,
786    session_inst: Session,
787    model: type[SQLModel],
788    pk_field: str = "id",
789):
790    """
791
792    :param id_str:
793    :param data:
794    :param session_inst:
795    :param model:
796    :param pk_field:
797    :return:
798    """
799    success = False
800    stmnt = select(model).where(getattr(model, pk_field) == id_str)
801    results = session_inst.exec(stmnt)
802
803    row = results.one_or_none()
804
805    if row:
806        [setattr(row, k, v) for k, v in data.items()]
807        try:
808            session_inst.add(row)
809            session_inst.commit()
810            invalidate_written(session_inst, model)
811            success = True
812        except Exception as e:
813            session_inst.rollback()
814            logger.error(
815                f"Updating the data row failed. See error messages: "
816                f"{type(e), e, e.args}"
817            )
818        return success, row
819    else:
820        return success, None
Parameters
  • id_str:
  • data:
  • session_inst:
  • model:
  • pk_field:
Returns
def write_row( data_row: sqlmodel.main.SQLModel, session_inst: sqlmodel.orm.session.Session):
118def write_row(data_row: SQLModel, session_inst: Session):
119    """
120    Writes a new instance of an SQLModel ORM model to the database, with an
121    exception catch that rolls back the session in the event of failure.
122
123    :param data_row: SQLModel
124    :param session_inst: Session
125    :return: Tuple[bool, ScalarResult]
126    """
127    try:
128        session_inst.add(data_row)
129        session_inst.commit()
130        invalidate_written(session_inst, type(data_row))
131
132        return True, data_row
133    except Exception as e:
134        session_inst.rollback()
135        logger.error(
136            f"Writing data row to table failed. See error message: "
137            f"{type(e), e, e.args}"
138        )
139
140        return False, None

Writes a new instance of an SQLModel ORM model to the database, with an exception catch that rolls back the session in the event of failure.

Parameters
  • data_row: SQLModel
  • session_inst: Session
Returns

Tuple[bool, ScalarResult]

async def a_bulk_update_rows( id_str_list: list[str | int], data: dict, session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id', chunk_size: int = 500) -> tuple[bool, int]:
616async def bulk_update_rows(
617    id_str_list: list[str | int],
618    data: dict,
619    session_inst: AsyncSession,
620    model: type[SQLModel],
621    pk_field: str = "id",
622    chunk_size: int = 500,
623) -> tuple[bool, int]:
624    """
625    Applies the same column values to every row whose primary key is within
626    the provided list and commits once. IDs with no matching row are ignored.
627
628    This is a Core ``UPDATE``, so ORM-level hooks and change tracking
629    (ADR-0011) do not see it. Column-level ``onupdate`` defaults do fire, so
630    ``updated_at`` is refreshed for both ``TimestampMixin`` and
631    ``AuditMixin``. Nothing else is filled in: ``AuditMixin.updated_by`` is
632    only set when the caller includes it in ``data``, and
633    ``SoftDeleteMixin`` fields (``is_deleted``, ``deleted_at``,
634    ``deleted_by``) are only changed if named in ``data``. The ID list is
635    split into ``chunk_size`` batches. One ``UPDATE ... WHERE pk IN (...)``
636    statement is sent per batch (never per row), and all batches share one
637    transaction.
638
639    :param id_str_list: List of primary key values to update.
640    :param data: Mapping of column name to the new value for every row. Must
641        be non-empty and may not name a primary-key column.
642    :param session_inst: SQLModel AsyncSession instance.
643    :param model: SQLModel class representing the table.
644    :param pk_field: Primary-key column to match against (default: "id").
645    :param chunk_size: Maximum IDs per UPDATE statement (default: 500).
646    :return: Tuple[bool, int]: ``True`` and the number of rows matched by the
647        update. The count is 0 when nothing matched or the list was empty.
648    :raises ValueError: If ``pk_field`` is not a primary-key column,
649        ``data`` is empty or names an unknown or primary-key column, or
650        ``chunk_size`` is not positive.
651    :raises Exception: Any database error, after the transaction has been
652        rolled back and the error logged.
653    """
654    validate_primary_key_field(model, pk_field)
655    validate_update_columns(model, data)
656    updated = 0
657    try:
658        for chunk in chunked(id_str_list, chunk_size):
659            stmnt = (
660                update(model)
661                .where(getattr(model, pk_field).in_(chunk))
662                .values(**data)
663            )
664            result = await session_inst.exec(stmnt)
665            updated += max(result.rowcount, 0)
666        await session_inst.commit()
667    except Exception as e:
668        await session_inst.rollback()
669        logger.error(f"Failed to bulk update rows: {type(e)}, {e}")
670        raise
671
672    await a_invalidate_written(session_inst, model)
673    return True, updated

Applies the same column values to every row whose primary key is within the provided list and commits once. IDs with no matching row are ignored.

This is a Core UPDATE, so ORM-level hooks and change tracking (ADR-0011) do not see it. Column-level onupdate defaults do fire, so updated_at is refreshed for both TimestampMixin and AuditMixin. Nothing else is filled in: AuditMixin.updated_by is only set when the caller includes it in data, and SoftDeleteMixin fields (is_deleted, deleted_at, deleted_by) are only changed if named in data. The ID list is split into chunk_size batches. One UPDATE ... WHERE pk IN (...) statement is sent per batch (never per row), and all batches share one transaction.

Parameters
  • id_str_list: List of primary key values to update.
  • data: Mapping of column name to the new value for every row. Must be non-empty and may not name a primary-key column.
  • session_inst: SQLModel AsyncSession instance.
  • model: SQLModel class representing the table.
  • pk_field: Primary-key column to match against (default: "id").
  • chunk_size: Maximum IDs per UPDATE statement (default: 500).
Returns

Tuple[bool, int]: True and the number of rows matched by the update. The count is 0 when nothing matched or the list was empty.

Raises
  • ValueError: If pk_field is not a primary-key column, data is empty or names an unknown or primary-key column, or chunk_size is not positive.
  • Exception: Any database error, after the transaction has been rolled back and the error logged.
async def a_bulk_upsert_mappings( payload: list, session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], pk_fields: list[str] | None = None, update_columns: Optional[Sequence[str]] = None):
714async def bulk_upsert_mappings(
715    payload: list,
716    session_inst: AsyncSession,
717    model: type[SQLModel],
718    pk_fields: list[str] | None = None,
719    update_columns: Sequence[str] | None = None,
720):
721    """
722    Insert or update a batch of rows in a single ``INSERT ... ON CONFLICT DO
723    UPDATE ... RETURNING`` statement.
724
725    The ``RETURNING`` rows are fully read (``.all()``) before ``commit()``.
726    SQLite refuses to commit while a result cursor is still unread
727    ("cannot commit transaction - SQL statements in progress"); Postgres
728    tolerates it. The upsert is also executed exactly once rather than once
729    bare and once more to fetch the rows.
730
731    Returned instances are expired by ``commit()`` when the session uses
732    ``expire_on_commit=True`` (the default), so read their attributes while
733    the session is open or create the session with ``expire_on_commit=False``.
734
735    :param payload: Row mappings to upsert; the first mapping's keys decide
736        which columns are overwritten on conflict.
737    :param session_inst: Active session.
738    :param model: Table model being upserted.
739    :param pk_fields: Conflict-target columns. Defaults to ``["id"]``.
740    :param update_columns: Restrict the columns written on conflict to
741        these (the ``SET`` clause of ``ON CONFLICT DO UPDATE``, ADR-0016).
742        Defaults to ``None``: every column in the first mapping is
743        overwritten. New rows are always inserted with every provided
744        column. Names are table column keys, as in ``payload``; duplicates
745        are ignored.
746    :return: ``(True, rows)`` where ``rows`` are the upserted model instances.
747    :raises ValueError: Before any statement is sent, if ``update_columns``
748        is empty or a bare string, names an unknown column, a primary-key
749        column or a ``pk_fields`` column, or names a column the first
750        mapping of ``payload`` does not provide.
751    """
752    if not pk_fields:
753        pk_fields = ["id"]
754    if update_columns is not None:
755        update_columns = validate_upsert_update_columns(
756            model, payload, pk_fields, update_columns
757        )
758    set_columns = (
759        payload[0].keys() if update_columns is None else update_columns
760    )
761    stmnt = upsert(model).values(payload)
762    stmnt = stmnt.on_conflict_do_update(
763        index_elements=[getattr(model, x) for x in pk_fields],
764        set_={k: getattr(stmnt.excluded, k) for k in set_columns},
765    )
766    results = (
767        await session_inst.scalars(
768            stmnt.returning(model),
769            execution_options={"populate_existing": True},
770        )
771    ).all()
772
773    await session_inst.commit()
774    await a_invalidate_written(session_inst, model)
775
776    return True, results

Insert or update a batch of rows in a single INSERT ... ON CONFLICT DO UPDATE ... RETURNING statement.

The RETURNING rows are fully read (.all()) before commit(). SQLite refuses to commit while a result cursor is still unread ("cannot commit transaction - SQL statements in progress"); Postgres tolerates it. The upsert is also executed exactly once rather than once bare and once more to fetch the rows.

Returned instances are expired by commit() when the session uses expire_on_commit=True (the default), so read their attributes while the session is open or create the session with expire_on_commit=False.

Parameters
  • payload: Row mappings to upsert; the first mapping's keys decide which columns are overwritten on conflict.
  • session_inst: Active session.
  • model: Table model being upserted.
  • pk_fields: Conflict-target columns. Defaults to ["id"].
  • update_columns: Restrict the columns written on conflict to these (the SET clause of ON CONFLICT DO UPDATE, ADR-0016). Defaults to None: every column in the first mapping is overwritten. New rows are always inserted with every provided column. Names are table column keys, as in payload; duplicates are ignored.
Returns

(True, rows) where rows are the upserted model instances.

Raises
  • ValueError: Before any statement is sent, if update_columns is empty or a bare string, names an unknown column, a primary-key column or a pk_fields column, or names a column the first mapping of payload does not provide.
async def a_delete_row( id_str: str | int, session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id'):
676async def delete_row(
677    id_str: str | int,
678    session_inst: AsyncSession,
679    model: type[SQLModel],
680    pk_field: str = "id",
681):
682    """
683
684    :param id_str:
685    :param session_inst:
686    :param model:
687    :param pk_field:
688    :return:
689    """
690    success = False
691    stmnt = select(model).where(getattr(model, pk_field) == id_str)
692    results = await session_inst.exec(stmnt)
693
694    row = results.one_or_none()
695
696    if not row:
697        pass
698    else:
699        try:
700            await session_inst.delete(row)
701            await session_inst.commit()
702            await a_invalidate_written(session_inst, model)
703            success = True
704        except Exception as e:
705            logger.error(
706                f"Failed to delete data row. Please see error messages here: "
707                f"{type(e), e, e.args}"
708            )
709            await session_inst.rollback()
710
711    return success
Parameters
  • id_str:
  • session_inst:
  • model:
  • pk_field:
Returns
async def a_delete_rows_within_id_list( id_str_list: list[str | int], session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id', chunk_size: int = 500, soft: bool = False, deleted_by: str | None = None) -> tuple[bool, int]:
538async def delete_rows_within_id_list(
539    id_str_list: list[str | int],
540    session_inst: AsyncSession,
541    model: type[SQLModel],
542    pk_field: str = "id",
543    chunk_size: int = 500,
544    soft: bool = False,
545    deleted_by: str | None = None,
546) -> tuple[bool, int]:
547    """
548    Deletes every row whose primary key is within the provided list and
549    commits once. IDs with no matching row are ignored.
550
551    By default this is a hard SQL ``DELETE``: rows are removed, even for a
552    model that uses ``SoftDeleteMixin`` (nothing is marked with
553    ``deleted_at``), and ORM-level hooks do not run. Pass ``soft=True`` to
554    mark the rows deleted instead, exactly as ``SoftDeleteMixin.soft_delete``
555    does: ``is_deleted`` becomes true, ``deleted_at`` is the current UTC time
556    (one timestamp for the whole call) and ``deleted_by`` is the given user.
557    The soft path is a Core ``UPDATE``, so the same bulk-write rules as
558    ``bulk_update_rows`` apply: column-level ``onupdate`` defaults fire (for
559    example ``updated_at`` of ``TimestampMixin`` and ``AuditMixin``), while
560    ``updated_by`` is not set and ORM events and change tracking do not see
561    the write. Rows already soft-deleted are left untouched (their original
562    ``deleted_at`` and ``deleted_by`` are kept) and are not counted.
563
564    The ID list is split into ``chunk_size`` batches to stay under backend
565    bind-parameter limits. One ``DELETE`` (or ``UPDATE``) ``... WHERE pk IN
566    (...)`` statement is sent per batch (never per row), so 1,200 IDs at the
567    default size is 3 statements. All batches share one transaction, so the
568    call is all-or-nothing. The model's cache entries are invalidated after
569    the commit in both modes.
570
571    :param id_str_list: List of primary key values to delete.
572    :param session_inst: SQLModel AsyncSession instance.
573    :param model: SQLModel class representing the table.
574    :param pk_field: Primary-key column to match against (default: "id").
575    :param chunk_size: Maximum IDs per statement (default: 500).
576    :param soft: When ``True``, soft-delete instead of removing the rows.
577        The model must have the ``SoftDeleteMixin`` columns ``is_deleted``,
578        ``deleted_at`` and ``deleted_by`` (default: ``False``).
579    :param deleted_by: User recorded in ``deleted_by`` on soft-deleted rows.
580        Only valid with ``soft=True`` (default: ``None``).
581    :return: Tuple[bool, int]: ``True`` and the number of rows affected: rows
582        removed for a hard delete, or rows newly marked deleted for a soft
583        delete (rows that were already soft-deleted are not counted). The
584        count is 0 when nothing matched or the list was empty.
585    :raises ValueError: If ``pk_field`` is not a primary-key column of
586        ``model``, ``chunk_size`` is not positive, ``soft=True`` is used on a
587        model without the ``SoftDeleteMixin`` columns (the message names the
588        missing ones), or ``deleted_by`` is given without ``soft=True``.
589    :raises Exception: Any database error, after the transaction has been
590        rolled back and the error logged.
591    """
592    validate_primary_key_field(model, pk_field)
593    validate_soft_delete_args(model, soft, deleted_by)
594    deleted_at = soft_delete_timestamp() if soft else None
595    deleted = 0
596    try:
597        for chunk in chunked(id_str_list, chunk_size):
598            if soft:
599                stmnt = soft_delete_statement(
600                    model, pk_field, chunk, deleted_at, deleted_by
601                )
602            else:
603                stmnt = delete(model).where(getattr(model, pk_field).in_(chunk))
604            result = await session_inst.exec(stmnt)
605            deleted += max(result.rowcount, 0)
606        await session_inst.commit()
607    except Exception as e:
608        await session_inst.rollback()
609        logger.error(f"Failed to bulk delete rows: {type(e)}, {e}")
610        raise
611
612    await a_invalidate_written(session_inst, model)
613    return True, deleted

Deletes every row whose primary key is within the provided list and commits once. IDs with no matching row are ignored.

By default this is a hard SQL DELETE: rows are removed, even for a model that uses SoftDeleteMixin (nothing is marked with deleted_at), and ORM-level hooks do not run. Pass soft=True to mark the rows deleted instead, exactly as SoftDeleteMixin.soft_delete does: is_deleted becomes true, deleted_at is the current UTC time (one timestamp for the whole call) and deleted_by is the given user. The soft path is a Core UPDATE, so the same bulk-write rules as bulk_update_rows apply: column-level onupdate defaults fire (for example updated_at of TimestampMixin and AuditMixin), while updated_by is not set and ORM events and change tracking do not see the write. Rows already soft-deleted are left untouched (their original deleted_at and deleted_by are kept) and are not counted.

The ID list is split into chunk_size batches to stay under backend bind-parameter limits. One DELETE (or UPDATE) ... WHERE pk IN (...) statement is sent per batch (never per row), so 1,200 IDs at the default size is 3 statements. All batches share one transaction, so the call is all-or-nothing. The model's cache entries are invalidated after the commit in both modes.

Parameters
  • id_str_list: List of primary key values to delete.
  • session_inst: SQLModel AsyncSession instance.
  • model: SQLModel class representing the table.
  • pk_field: Primary-key column to match against (default: "id").
  • chunk_size: Maximum IDs per statement (default: 500).
  • soft: When True, soft-delete instead of removing the rows. The model must have the SoftDeleteMixin columns is_deleted, deleted_at and deleted_by (default: False).
  • deleted_by: User recorded in deleted_by on soft-deleted rows. Only valid with soft=True (default: None).
Returns

Tuple[bool, int]: True and the number of rows affected: rows removed for a hard delete, or rows newly marked deleted for a soft delete (rows that were already soft-deleted are not counted). The count is 0 when nothing matched or the list was empty.

Raises
  • ValueError: If pk_field is not a primary-key column of model, chunk_size is not positive, soft=True is used on a model without the SoftDeleteMixin columns (the message names the missing ones), or deleted_by is given without soft=True.
  • Exception: Any database error, after the transaction has been rolled back and the error logged.
async def a_get_change_history( session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], id_str: int | str):
819async def get_change_history(
820    session_inst: AsyncSession,
821    model: type[SQLModel],
822    id_str: int | str,
823):
824    """
825    Return the recorded change history of one row, oldest first.
826
827    Requires ``register_change_tracking()`` and a model inheriting
828    ``TrackChangesMixin`` (ADR-0011). Changes made by the bulk helpers are not
829    recorded, so they do not appear here.
830
831    :param session_inst: AsyncSession
832    :param model: type[SQLModel]
833    :param id_str: primary key value of the row; a tuple or list in
834        column order for a composite key
835    :return: Tuple[bool, list[dict]]
836    """
837    stmnt = history_select(model, id_str)
838    try:
839        conn = await session_inst.connection(bind_arguments={"mapper": model})
840        result = await conn.execute(stmnt)
841        return True, [dict(r) for r in result.mappings().all()]
842    except Exception as e:
843        logger.error(
844            f"Reading change history failed. See error message: "
845            f"{type(e), e, e.args}"
846        )
847        await session_inst.rollback()
848        return False, []

Return the recorded change history of one row, oldest first.

Requires register_change_tracking() and a model inheriting TrackChangesMixin (ADR-0011). Changes made by the bulk helpers are not recorded, so they do not appear here.

Parameters
  • session_inst: AsyncSession
  • model: type[SQLModel]
  • id_str: primary key value of the row; a tuple or list in column order for a composite key
Returns

Tuple[bool, list[dict]]

async def a_get_one_or_create( session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], create_method_kwargs: dict | None = None, selectin: bool = False, select_in_key: str | None = None, **kwargs):
 69async def get_one_or_create(
 70    session_inst: AsyncSession,
 71    model: type[SQLModel],
 72    create_method_kwargs: dict | None = None,
 73    selectin: bool = False,
 74    select_in_key: str | None = None,
 75    **kwargs,
 76):
 77    """
 78    This function either returns an existing data row from the database or
 79    creates a new instance and saves it to the DB.
 80
 81    :param session_inst: AsyncSession
 82    :param model: SQLModel ORM
 83    :param create_method_kwargs: dict
 84    :param selectin: bool
 85    :param select_in_key: str | None
 86    :param kwargs: keyword args
 87    :return: Tuple[Row, bool]
 88    """
 89
 90    async def _get_entry(sqlmodel, **key_args):
 91        stmnt = select(sqlmodel).filter_by(**key_args)
 92        results = await get_result_from_query(query=stmnt, session=session_inst)
 93
 94        if results:
 95            if selectin and select_in_key:
 96                stmnt = stmnt.options(
 97                    selectinload(getattr(sqlmodel, select_in_key))
 98                )
 99                results = await get_result_from_query(
100                    query=stmnt, session=session_inst
101                )
102            return results, True
103        else:
104            return results, False
105
106    results, exists = await _get_entry(model, **kwargs)
107    if results:
108        return results, exists
109    else:
110        kwargs.update(create_method_kwargs or {})
111        created = model()
112        [setattr(created, k, v) for k, v in kwargs.items()]
113        session_inst.add(created)
114        await session_inst.commit()
115        await a_invalidate_written(session_inst, model)
116        return created, False

This function either returns an existing data row from the database or creates a new instance and saves it to the DB.

Parameters
  • session_inst: AsyncSession
  • model: SQLModel ORM
  • create_method_kwargs: dict
  • selectin: bool
  • select_in_key: str | None
  • kwargs: keyword args
Returns

Tuple[Row, bool]

async def a_get_result_from_query( query: sqlmodel.sql._expression_select_cls.SelectOfScalar, session: sqlmodel.ext.asyncio.session.AsyncSession):
48async def get_result_from_query(query: SelectOfScalar, session: AsyncSession):
49    """
50    Processes an SQLModel query object and returns a singular result from the
51    return payload. If more than one row is returned, then only the first row is
52    returned. If no rows are available, then a null value is returned.
53
54    :param query: SelectOfScalar
55    :param session: AsyncSession
56
57    :return: Row
58    """
59    results = await session.exec(query)
60    try:
61        results = results.one_or_none()
62    except MultipleResultsFound:
63        results = await session.exec(query)
64        results = results.first()
65
66    return results

Processes an SQLModel query object and returns a singular result from the return payload. If more than one row is returned, then only the first row is returned. If no rows are available, then a null value is returned.

Parameters
  • query: SelectOfScalar
  • session: AsyncSession
Returns

Row

async def a_get_row( id_str: str | int, session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], selectin: bool = False, select_in_keys: list[str] | None = None, lazy: bool = False, lazy_load_keys: list[str] | None = None, pk_field: str = 'id', use_cache: bool = False, cache_ttl: float | None = None):
186async def get_row(
187    id_str: str | int,
188    session_inst: AsyncSession,
189    model: type[SQLModel],
190    selectin: bool = False,
191    select_in_keys: list[str] | None = None,
192    lazy: bool = False,
193    lazy_load_keys: list[str] | None = None,
194    pk_field: str = "id",
195    use_cache: bool = False,
196    cache_ttl: float | None = None,
197):
198    """
199
200    :param id_str:
201    :param session_inst:
202    :param model:
203    :param selectin:
204    :param select_in_keys:
205    :param lazy:
206    :param lazy_load_keys:
207    :param pk_field:
208    :param use_cache: Read from and fill the cache set by ``configure_cache``
209        (ADR-0012). Bypassed with ``selectin`` or ``lazy``.
210    :param cache_ttl: Seconds the entry lives; backend default when None.
211    :return:
212    """
213    backend = active_backend(
214        use_cache
215        and not (selectin and select_in_keys)
216        and not (lazy and lazy_load_keys)
217    )
218    if backend:
219        namespace = namespace_for(model)
220        cache_key = make_key(
221            session_inst, model, "get_row", pk_field=pk_field, id=id_str
222        )
223        hit = await a_lookup(backend, namespace, cache_key)
224        row = load_row(model, hit) if hit is not None else None
225        if row is not None:
226            return True, row
227    stmnt = select(model).where(getattr(model, pk_field) == id_str)
228    if selectin and select_in_keys:
229        if isinstance(select_in_keys, list) is False:
230            select_in_keys = [select_in_keys]
231
232        for key in select_in_keys:
233            stmnt = stmnt.options(selectinload(getattr(model, key)))
234    if lazy and lazy_load_keys:
235        if isinstance(lazy_load_keys, list) is False:
236            lazy_load_keys = [lazy_load_keys]
237        for key in lazy_load_keys:
238            stmnt = stmnt.options(lazyload(getattr(model, key)))
239    results = await session_inst.exec(stmnt)
240
241    row = results.one_or_none()
242
243    if not row:
244        success = False
245    else:
246        success = True
247        if backend:
248            await a_store(
249                backend, namespace, cache_key, dump_row(row), cache_ttl
250            )
251
252    return success, row
Parameters
  • id_str:
  • session_inst:
  • model:
  • selectin:
  • select_in_keys:
  • lazy:
  • lazy_load_keys:
  • pk_field:
  • use_cache: Read from and fill the cache set by configure_cache (ADR-0012). Bypassed with selectin or lazy.
  • cache_ttl: Seconds the entry lives; backend default when None.
Returns
async def a_get_rows( session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], selectin: bool = False, select_in_keys: list[str] | None = None, lazy: bool = False, lazy_load_keys: list[str] | None = None, page_size: int = 100, page: int = 1, text_field: str | None = None, stmnt: sqlmodel.sql._expression_select_cls.SelectOfScalar | None = None, use_cache: bool = False, cache_ttl: float | None = None, **kwargs):
255async def get_rows(
256    session_inst: AsyncSession,
257    model: type[SQLModel],
258    selectin: bool = False,
259    select_in_keys: list[str] | None = None,
260    lazy: bool = False,
261    lazy_load_keys: list[str] | None = None,
262    page_size: int = 100,
263    page: int = 1,
264    text_field: str | None = None,
265    stmnt: SelectOfScalar | None = None,
266    use_cache: bool = False,
267    cache_ttl: float | None = None,
268    **kwargs,
269):
270    """
271    Retrieve rows for ``model``, building the query from ``kwargs`` or
272    running a caller-supplied statement.
273
274    Pagination applies only to statements built internally (``stmnt`` is
275    ``None``): ``page`` and ``page_size`` translate to OFFSET and LIMIT. A
276    caller-supplied ``stmnt`` is executed exactly as given; the caller owns
277    its pagination, so ``page``, ``page_size`` and any filter, sort or
278    loading arguments are ignored. Add ``.limit()``/``.offset()`` to the
279    statement yourself when you want a bounded result.
280
281    .. versionchanged:: Unreleased
282        A custom ``stmnt`` is no longer silently capped at ``page_size``
283        rows (issue #11).
284
285    :param session_inst: Active session used to execute the query.
286    :param model: SQLModel table class to query.
287    :param selectin: Enable ``selectinload`` for ``select_in_keys``.
288    :param select_in_keys: Relationship names to eager load via selectin.
289    :param lazy: Enable ``lazyload`` for ``lazy_load_keys``.
290    :param lazy_load_keys: Relationship names to lazy load.
291    :param page_size: Rows per page; ignored when ``stmnt`` is supplied.
292    :param page: 1-based page number; ignored when ``stmnt`` is supplied.
293    :param text_field: Field matched with a text search from ``kwargs``.
294    :param stmnt: Custom statement, executed as-is and never paginated.
295    :param use_cache: Read from and fill the cache set by ``configure_cache``
296        (ADR-0012). Bypassed with ``stmnt``, ``selectin`` or ``lazy``.
297    :param cache_ttl: Seconds the entry lives; backend default when None.
298    :param kwargs: Filters and sort options for the built query.
299    :return: ``(success, rows)`` where ``success`` is True if rows exist.
300    """
301    paginate = stmnt is None
302    backend = active_backend(
303        use_cache
304        and stmnt is None
305        and not (selectin and select_in_keys)
306        and not (lazy and lazy_load_keys)
307    )
308    if backend:
309        namespace = namespace_for(model)
310        cache_key = make_key(
311            session_inst,
312            model,
313            "get_rows",
314            page=page,
315            page_size=page_size,
316            text_field=text_field,
317            filters=repr(sorted(kwargs.items(), key=lambda kv: kv[0])),
318        )
319        hit = await a_lookup(backend, namespace, cache_key)
320        rows = load_rows(model, hit) if hit is not None else None
321        if rows is not None:
322            return True, rows
323    # kwargs = {k: v for k, v in kwargs.items() if v}
324    # Inside get_rows (sync and async versions)
325
326    # ... existing code ...
327    if stmnt is None:
328        stmnt = select(model)
329        if kwargs:
330            # Separate special filter keys from exact match keys
331            exact_match_kwargs = {}
332            special_filters = {}
333
334            keys_to_process = list(kwargs.keys())  # Iterate over a copy
335
336            for key in keys_to_process:
337                val = kwargs[key]
338                if "__like" in key:
339                    model_key = key.replace("__like", "")
340                    special_filters[key] = (
341                        getattr(model, model_key).like,
342                        f"%{val}%",
343                    )  # Adapt for like
344                elif "__gte" in key:
345                    model_key = key.replace("__gte", "")
346                    parsed_val = (
347                        date_parse(val)
348                        if "date" in key
349                        and isinstance(val, str)
350                        and is_date(val, fuzzy=False)
351                        else (
352                            int(val)
353                            if isinstance(val, str) and val.isdigit()
354                            else val
355                        )
356                    )
357                    special_filters[key] = (
358                        getattr(model, model_key).__ge__,
359                        parsed_val,
360                    )
361                elif "__lte" in key:
362                    model_key = key.replace("__lte", "")
363                    parsed_val = (
364                        date_parse(val)
365                        if "date" in key
366                        and isinstance(val, str)
367                        and is_date(val, fuzzy=False)
368                        else (
369                            int(val)
370                            if isinstance(val, str) and val.isdigit()
371                            else val
372                        )
373                    )
374                    special_filters[key] = (
375                        getattr(model, model_key).__le__,
376                        parsed_val,
377                    )
378                elif "__gt" in key:  # Add __gt if needed
379                    model_key = key.replace("__gt", "")
380                    parsed_val = (
381                        date_parse(val)
382                        if "date" in key
383                        and isinstance(val, str)
384                        and is_date(val, fuzzy=False)
385                        else (
386                            int(val)
387                            if isinstance(val, str) and val.isdigit()
388                            else val
389                        )
390                    )
391                    special_filters[key] = (
392                        getattr(model, model_key).__gt__,
393                        parsed_val,
394                    )
395                elif "__lt" in key:  # Add __lt if needed
396                    model_key = key.replace("__lt", "")
397                    parsed_val = (
398                        date_parse(val)
399                        if "date" in key
400                        and isinstance(val, str)
401                        and is_date(val, fuzzy=False)
402                        else (
403                            int(val)
404                            if isinstance(val, str) and val.isdigit()
405                            else val
406                        )
407                    )
408                    special_filters[key] = (
409                        getattr(model, model_key).__lt__,
410                        parsed_val,
411                    )
412                elif "__in" in key:  # Add __in if needed
413                    model_key = key.replace("__in", "")
414                    if isinstance(val, list):
415                        special_filters[key] = (
416                            getattr(model, model_key).in_,
417                            val,
418                        )
419                    else:
420                        logger.warning(
421                            f"Value for __in filter '{key}' is not a list, "
422                            f"skipping."
423                        )
424                elif key not in ("sort_desc", "sort_field") and (
425                    not text_field or key != text_field
426                ):
427                    # Collect keys for filter_by, excluding sort/text search
428                    # keys
429                    exact_match_kwargs[key] = val
430
431            # Apply special filters using filter()
432            for _filter_key, (
433                filter_method,
434                filter_value,
435            ) in special_filters.items():
436                stmnt = stmnt.filter(filter_method(filter_value))
437
438            # Apply sorting
439            sort_desc = kwargs.get("sort_desc")
440            sort_field = kwargs.get("sort_field")
441            if sort_field:
442                sort_attr = getattr(model, sort_field)
443                stmnt = stmnt.order_by(
444                    sort_attr.desc() if sort_desc else sort_attr
445                )
446
447            # Apply text search if applicable (assuming .match() is correct)
448            if text_field and text_field in kwargs:
449                search_val = kwargs[text_field]
450                stmnt = stmnt.where(
451                    getattr(model, text_field).match(search_val)
452                )
453                # Remove from exact_match_kwargs if it ended up there
454                exact_match_kwargs.pop(text_field, None)
455
456            # Apply exact matches using filter_by()
457            if exact_match_kwargs:
458                stmnt = stmnt.filter_by(**exact_match_kwargs)
459
460        # Apply relationship loading options (Check if key is a relationship
461        # first - simplified check)
462        if selectin and select_in_keys:
463            for key in select_in_keys:
464                # Basic check: Does the attribute exist and is it likely a
465                # relationship?
466                # A more robust check might involve inspecting
467                # model.__sqlmodel_relationships__
468                attr = getattr(model, key, None)
469                if (
470                    attr is not None
471                    and hasattr(attr, "property")
472                    and hasattr(attr.property, "mapper")
473                ):
474                    stmnt = stmnt.options(selectinload(attr))
475                else:
476                    logger.warning(
477                        f"Skipping selectinload for non-relationship "
478                        f"attribute '{key}' on model {model.__name__}"
479                    )
480
481        if lazy and lazy_load_keys:
482            for key in lazy_load_keys:
483                attr = getattr(model, key, None)
484                if (
485                    attr is not None
486                    and hasattr(attr, "property")
487                    and hasattr(attr.property, "mapper")
488                ):
489                    stmnt = stmnt.options(lazyload(attr))
490                else:
491                    logger.warning(
492                        f"Skipping lazyload for non-relationship attribute "
493                        f"'{key}' on model {model.__name__}"
494                    )
495
496    if paginate:
497        stmnt = stmnt.offset((page - 1) * page_size).limit(page_size)
498    _result = await session_inst.exec(stmnt)
499    results = _result.all()
500    success = True if len(results) > 0 else False
501    if success and backend:
502        await a_store(
503            backend,
504            namespace,
505            cache_key,
506            dump_rows(results),
507            cache_ttl,
508        )
509
510    return success, results

Retrieve rows for model, building the query from kwargs or running a caller-supplied statement.

Pagination applies only to statements built internally (stmnt is None): page and page_size translate to OFFSET and LIMIT. A caller-supplied stmnt is executed exactly as given; the caller owns its pagination, so page, page_size and any filter, sort or loading arguments are ignored. Add .limit()/.offset() to the statement yourself when you want a bounded result.

Changed in version Unreleased: A custom stmnt is no longer silently capped at page_size rows (issue #11).

Parameters
  • session_inst: Active session used to execute the query.
  • model: SQLModel table class to query.
  • selectin: Enable selectinload for select_in_keys.
  • select_in_keys: Relationship names to eager load via selectin.
  • lazy: Enable lazyload for lazy_load_keys.
  • lazy_load_keys: Relationship names to lazy load.
  • page_size: Rows per page; ignored when stmnt is supplied.
  • page: 1-based page number; ignored when stmnt is supplied.
  • text_field: Field matched with a text search from kwargs.
  • stmnt: Custom statement, executed as-is and never paginated.
  • use_cache: Read from and fill the cache set by configure_cache (ADR-0012). Bypassed with stmnt, selectin or lazy.
  • cache_ttl: Seconds the entry lives; backend default when None.
  • kwargs: Filters and sort options for the built query.
Returns

(success, rows) where success is True if rows exist.

async def a_get_rows_within_id_list( id_str_list: list[str | int], session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id'):
513async def get_rows_within_id_list(
514    id_str_list: list[str | int],
515    session_inst: AsyncSession,
516    model: type[SQLModel],
517    pk_field: str = "id",
518):
519    """
520
521    :param id_str_list:
522    :param session_inst:
523    :param model:
524    :param pk_field:
525    :return:
526    """
527    stmnt = select(model).where(getattr(model, pk_field).in_(id_str_list))
528    results = await session_inst.exec(stmnt)
529
530    if results:
531        success = True
532    else:
533        success = False
534
535    return success, results
Parameters
  • id_str_list:
  • session_inst:
  • model:
  • pk_field:
Returns
async def a_insert_data_rows(data_rows, session_inst: sqlmodel.ext.asyncio.session.AsyncSession):
144async def insert_data_rows(data_rows, session_inst: AsyncSession):
145    """
146
147    :param data_rows:
148    :param session_inst:
149    :return:
150    """
151    try:
152        session_inst.add_all(data_rows)
153        await session_inst.commit()
154        await a_invalidate_written(session_inst, *{type(r) for r in data_rows})
155
156        return True, data_rows
157
158    except Exception as e:
159        logger.error(
160            f"Writing data rows to table failed. See error message: "
161            f"{type(e), e, e.args}"
162        )
163        logger.info(
164            "Attempting to write individual entries. This can be a "
165            "bit taxing, so please consider your payload to the DB"
166        )
167
168        await session_inst.rollback()
169        processed_rows, failed_rows = [], []
170        for row in data_rows:
171            success, processed_row = await write_row(
172                row, session_inst=session_inst
173            )
174            if not success:
175                failed_rows.append(row)
176            else:
177                processed_rows.append(row)
178
179        if processed_rows:
180            status = True
181        else:
182            status = (False,)
183        return status, {"success": processed_rows, "failed": failed_rows}
Parameters
  • data_rows:
  • session_inst:
Returns
async def a_update_row( id_str: int | str, data: dict, session_inst: sqlmodel.ext.asyncio.session.AsyncSession, model: type[sqlmodel.main.SQLModel], pk_field: str = 'id'):
779async def update_row(
780    id_str: int | str,
781    data: dict,
782    session_inst: AsyncSession,
783    model: type[SQLModel],
784    pk_field: str = "id",
785):
786    """
787
788    :param id_str:
789    :param data:
790    :param session_inst:
791    :param model:
792    :param pk_field:
793    :return:
794    """
795    success = False
796    stmnt = select(model).where(getattr(model, pk_field) == id_str)
797    results = await session_inst.exec(stmnt)
798
799    row = results.one_or_none()
800
801    if row:
802        [setattr(row, k, v) for k, v in data.items()]
803        try:
804            session_inst.add(row)
805            await session_inst.commit()
806            await a_invalidate_written(session_inst, model)
807            success = True
808        except Exception as e:
809            await session_inst.rollback()
810            logger.error(
811                f"Updating the data row failed. See error messages: "
812                f"{type(e), e, e.args}"
813            )
814        return success, row
815    else:
816        return success, None
Parameters
  • id_str:
  • data:
  • session_inst:
  • model:
  • pk_field:
Returns
async def a_write_row( data_row: sqlmodel.main.SQLModel, session_inst: sqlmodel.ext.asyncio.session.AsyncSession):
119async def write_row(data_row: SQLModel, session_inst: AsyncSession):
120    """
121    Writes a new instance of an SQLModel ORM model to the database, with an
122    exception catch that rolls back the session in the event of failure.
123
124    :param data_row: SQLModel
125    :param session_inst: AsyncSession
126    :return: Tuple[bool, ScalarResult]
127    """
128    try:
129        session_inst.add(data_row)
130        await session_inst.commit()
131        await a_invalidate_written(session_inst, type(data_row))
132
133        return True, data_row
134    except Exception as e:
135        await session_inst.rollback()
136        logger.error(
137            f"Writing data row to table failed. See error message: "
138            f"{type(e), e, e.args}"
139        )
140
141        return False, None

Writes a new instance of an SQLModel ORM model to the database, with an exception catch that rolls back the session in the event of failure.

Parameters
  • data_row: SQLModel
  • session_inst: AsyncSession
Returns

Tuple[bool, ScalarResult]

class QueryBuilder(sqlmodel_crud_utils.utils.QueryBuilderBase):
856class QueryBuilder(QueryBuilderBase):
857    """
858    Immutable fluent query builder bound to a ``Session`` (ADR-0010).
859
860    Chain ``where``, ``order_by``, ``limit`` and ``offset``, then finish with
861    ``all``, ``first`` or ``count``. Terminals return the library's
862    ``(success, data)`` tuple where ``success`` is True when rows exist.
863    Chain ``cached()`` to read from and fill the cache (ADR-0015).
864    """
865
866    def _slot(self, op, stmnt):
867        return query_slot(
868            self._session,
869            self._model,
870            op,
871            stmnt,
872            self._cache_eligible(),
873        )
874
875    def all(self):
876        """
877        :return: ``(success, rows)``; ``rows`` is an empty list when nothing
878            matched.
879        """
880        slot = self._slot("qb.all", self._stmnt)
881        if slot:
882            hit = lookup(*slot)
883            rows = load_rows(self._model, hit) if hit is not None else None
884            if rows is not None:
885                return True, rows
886        rows = self._session.exec(self._stmnt).unique().all()
887        if slot and rows:
888            store(*slot, dump_rows(rows), self._cache_ttl)
889        return len(rows) > 0, rows
890
891    def first(self):
892        """
893        :return: ``(success, row)``; ``row`` is ``None`` when nothing matched.
894        """
895        stmnt = self._first_stmnt()
896        slot = self._slot("qb.first", stmnt)
897        if slot:
898            hit = lookup(*slot)
899            row = load_row(self._model, hit) if hit is not None else None
900            if row is not None:
901                return True, row
902        row = self._session.exec(stmnt).first()
903        if slot and row is not None:
904            store(*slot, dump_row(row), self._cache_ttl)
905        return row is not None, row
906
907    def count(self):
908        """
909        Count rows of the composed statement, honoring limit and offset.
910
911        :return: ``(success, count)`` with ``success`` True when count > 0.
912        """
913        stmnt = self._count_stmnt()
914        slot = self._slot("qb.count", stmnt)
915        if slot:
916            hit = lookup(*slot)
917            if isinstance(hit, dict) and isinstance(hit.get("count"), int):
918                return True, hit["count"]
919        total = self._session.exec(stmnt).one()
920        if slot and total > 0:
921            store(*slot, {"count": total}, self._cache_ttl)
922        return total > 0, total

Immutable fluent query builder bound to a Session (ADR-0010).

Chain where, order_by, limit and offset, then finish with all, first or count. Terminals return the library's (success, data) tuple where success is True when rows exist. Chain cached() to read from and fill the cache (ADR-0015).

def all(self):
875    def all(self):
876        """
877        :return: ``(success, rows)``; ``rows`` is an empty list when nothing
878            matched.
879        """
880        slot = self._slot("qb.all", self._stmnt)
881        if slot:
882            hit = lookup(*slot)
883            rows = load_rows(self._model, hit) if hit is not None else None
884            if rows is not None:
885                return True, rows
886        rows = self._session.exec(self._stmnt).unique().all()
887        if slot and rows:
888            store(*slot, dump_rows(rows), self._cache_ttl)
889        return len(rows) > 0, rows
Returns

(success, rows); rows is an empty list when nothing matched.

def first(self):
891    def first(self):
892        """
893        :return: ``(success, row)``; ``row`` is ``None`` when nothing matched.
894        """
895        stmnt = self._first_stmnt()
896        slot = self._slot("qb.first", stmnt)
897        if slot:
898            hit = lookup(*slot)
899            row = load_row(self._model, hit) if hit is not None else None
900            if row is not None:
901                return True, row
902        row = self._session.exec(stmnt).first()
903        if slot and row is not None:
904            store(*slot, dump_row(row), self._cache_ttl)
905        return row is not None, row
Returns

(success, row); row is None when nothing matched.

def count(self):
907    def count(self):
908        """
909        Count rows of the composed statement, honoring limit and offset.
910
911        :return: ``(success, count)`` with ``success`` True when count > 0.
912        """
913        stmnt = self._count_stmnt()
914        slot = self._slot("qb.count", stmnt)
915        if slot:
916            hit = lookup(*slot)
917            if isinstance(hit, dict) and isinstance(hit.get("count"), int):
918                return True, hit["count"]
919        total = self._session.exec(stmnt).one()
920        if slot and total > 0:
921            store(*slot, {"count": total}, self._cache_ttl)
922        return total > 0, total

Count rows of the composed statement, honoring limit and offset.

Returns

(success, count) with success True when count > 0.

class AsyncQueryBuilder(sqlmodel_crud_utils.utils.QueryBuilderBase):
851class AsyncQueryBuilder(QueryBuilderBase):
852    """
853    Immutable fluent query builder bound to an ``AsyncSession`` (ADR-0010).
854
855    Chaining methods are synchronous; the terminals ``all``, ``first`` and
856    ``count`` are coroutines returning the library's ``(success, data)``
857    tuple where ``success`` is True when rows exist. Chain ``cached()`` to
858    read from and fill the cache (ADR-0015).
859    """
860
861    def _slot(self, op, stmnt):
862        return query_slot(
863            self._session,
864            self._model,
865            op,
866            stmnt,
867            self._cache_eligible(),
868        )
869
870    async def all(self):
871        """
872        :return: ``(success, rows)``; ``rows`` is an empty list when nothing
873            matched.
874        """
875        slot = self._slot("qb.all", self._stmnt)
876        if slot:
877            hit = await a_lookup(*slot)
878            rows = load_rows(self._model, hit) if hit is not None else None
879            if rows is not None:
880                return True, rows
881        rows = (await self._session.exec(self._stmnt)).unique().all()
882        if slot and rows:
883            await a_store(*slot, dump_rows(rows), self._cache_ttl)
884        return len(rows) > 0, rows
885
886    async def first(self):
887        """
888        :return: ``(success, row)``; ``row`` is ``None`` when nothing matched.
889        """
890        stmnt = self._first_stmnt()
891        slot = self._slot("qb.first", stmnt)
892        if slot:
893            hit = await a_lookup(*slot)
894            row = load_row(self._model, hit) if hit is not None else None
895            if row is not None:
896                return True, row
897        row = (await self._session.exec(stmnt)).first()
898        if slot and row is not None:
899            await a_store(*slot, dump_row(row), self._cache_ttl)
900        return row is not None, row
901
902    async def count(self):
903        """
904        Count rows of the composed statement, honoring limit and offset.
905
906        :return: ``(success, count)`` with ``success`` True when count > 0.
907        """
908        stmnt = self._count_stmnt()
909        slot = self._slot("qb.count", stmnt)
910        if slot:
911            hit = await a_lookup(*slot)
912            if isinstance(hit, dict) and isinstance(hit.get("count"), int):
913                return True, hit["count"]
914        total = (await self._session.exec(stmnt)).one()
915        if slot and total > 0:
916            await a_store(*slot, {"count": total}, self._cache_ttl)
917        return total > 0, total

Immutable fluent query builder bound to an AsyncSession (ADR-0010).

Chaining methods are synchronous; the terminals all, first and count are coroutines returning the library's (success, data) tuple where success is True when rows exist. Chain cached() to read from and fill the cache (ADR-0015).

async def all(self):
870    async def all(self):
871        """
872        :return: ``(success, rows)``; ``rows`` is an empty list when nothing
873            matched.
874        """
875        slot = self._slot("qb.all", self._stmnt)
876        if slot:
877            hit = await a_lookup(*slot)
878            rows = load_rows(self._model, hit) if hit is not None else None
879            if rows is not None:
880                return True, rows
881        rows = (await self._session.exec(self._stmnt)).unique().all()
882        if slot and rows:
883            await a_store(*slot, dump_rows(rows), self._cache_ttl)
884        return len(rows) > 0, rows
Returns

(success, rows); rows is an empty list when nothing matched.

async def first(self):
886    async def first(self):
887        """
888        :return: ``(success, row)``; ``row`` is ``None`` when nothing matched.
889        """
890        stmnt = self._first_stmnt()
891        slot = self._slot("qb.first", stmnt)
892        if slot:
893            hit = await a_lookup(*slot)
894            row = load_row(self._model, hit) if hit is not None else None
895            if row is not None:
896                return True, row
897        row = (await self._session.exec(stmnt)).first()
898        if slot and row is not None:
899            await a_store(*slot, dump_row(row), self._cache_ttl)
900        return row is not None, row
Returns

(success, row); row is None when nothing matched.

async def count(self):
902    async def count(self):
903        """
904        Count rows of the composed statement, honoring limit and offset.
905
906        :return: ``(success, count)`` with ``success`` True when count > 0.
907        """
908        stmnt = self._count_stmnt()
909        slot = self._slot("qb.count", stmnt)
910        if slot:
911            hit = await a_lookup(*slot)
912            if isinstance(hit, dict) and isinstance(hit.get("count"), int):
913                return True, hit["count"]
914        total = (await self._session.exec(stmnt)).one()
915        if slot and total > 0:
916            await a_store(*slot, {"count": total}, self._cache_ttl)
917        return total > 0, total

Count rows of the composed statement, honoring limit and offset.

Returns

(success, count) with success True when count > 0.

class SQLModelCRUDError(builtins.Exception):
34class SQLModelCRUDError(Exception):
35    """
36    Base exception for all sqlmodel-crud-utils errors.
37
38    This is the root exception class for the library. All other exceptions
39    inherit from this class, allowing users to catch all library-specific
40    errors with a single except clause.
41
42    Example:
43        >>> try:
44        ...     # Any library operation
45        ...     pass
46        ... except SQLModelCRUDError as e:
47        ...     # Handle any library error
48        ...     logger.error(f"CRUD operation failed: {e}")
49    """
50
51    pass

Base exception for all sqlmodel-crud-utils errors.

This is the root exception class for the library. All other exceptions inherit from this class, allowing users to catch all library-specific errors with a single except clause.

Example:

try: ... # Any library operation ... pass ... except SQLModelCRUDError as e: ... # Handle any library error ... logger.error(f"CRUD operation failed: {e}")

class RecordNotFoundError(sqlmodel_crud_utils.SQLModelCRUDError):
 54class RecordNotFoundError(SQLModelCRUDError):
 55    """
 56    Raised when a requested database record is not found.
 57
 58    This exception is raised when attempting to retrieve, update, or delete
 59    a record that doesn't exist in the database. It stores contextual
 60    information about the model, ID value, and primary key field for
 61    better debugging.
 62
 63    Attributes:
 64        model: The SQLModel class that was being queried.
 65        id_value: The primary key value that was searched for.
 66        pk_field: The name of the primary key field (default: "id").
 67
 68    Example:
 69        >>> from models import User
 70        >>> raise RecordNotFoundError(
 71        ...     model=User,
 72        ...     id_value=123,
 73        ...     pk_field="user_id"
 74        ... )
 75        RecordNotFoundError: User with user_id=123 not found
 76    """
 77
 78    def __init__(
 79        self,
 80        model: type,
 81        id_value: Any,
 82        pk_field: str = "id",
 83    ):
 84        """
 85        Initialize RecordNotFoundError.
 86
 87        Args:
 88            model: The SQLModel class being queried.
 89            id_value: The primary key value that wasn't found.
 90            pk_field: Name of the primary key field (default: "id").
 91        """
 92        self.model = model
 93        self.id_value = id_value
 94        self.pk_field = pk_field
 95
 96        # Create a helpful error message
 97        model_name = (
 98            model.__name__ if hasattr(model, "__name__") else str(model)
 99        )
100        super().__init__(f"{model_name} with {pk_field}={id_value!r} not found")

Raised when a requested database record is not found.

This exception is raised when attempting to retrieve, update, or delete a record that doesn't exist in the database. It stores contextual information about the model, ID value, and primary key field for better debugging.

Attributes: model: The SQLModel class that was being queried. id_value: The primary key value that was searched for. pk_field: The name of the primary key field (default: "id").

Example:

from models import User raise RecordNotFoundError( ... model=User, ... id_value=123, ... pk_field="user_id" ... ) RecordNotFoundError: User with user_id=123 not found

RecordNotFoundError(model: type, id_value: Any, pk_field: str = 'id')
 78    def __init__(
 79        self,
 80        model: type,
 81        id_value: Any,
 82        pk_field: str = "id",
 83    ):
 84        """
 85        Initialize RecordNotFoundError.
 86
 87        Args:
 88            model: The SQLModel class being queried.
 89            id_value: The primary key value that wasn't found.
 90            pk_field: Name of the primary key field (default: "id").
 91        """
 92        self.model = model
 93        self.id_value = id_value
 94        self.pk_field = pk_field
 95
 96        # Create a helpful error message
 97        model_name = (
 98            model.__name__ if hasattr(model, "__name__") else str(model)
 99        )
100        super().__init__(f"{model_name} with {pk_field}={id_value!r} not found")

Initialize RecordNotFoundError.

Args: model: The SQLModel class being queried. id_value: The primary key value that wasn't found. pk_field: Name of the primary key field (default: "id").

model
id_value
pk_field
class MultipleRecordsError(sqlmodel_crud_utils.SQLModelCRUDError):
103class MultipleRecordsError(SQLModelCRUDError):
104    """
105    Raised when multiple records are found where exactly one was expected.
106
107    This exception occurs when a query expected to return a single record
108    returns multiple results instead. This typically indicates a data integrity
109    issue or an incorrect query filter.
110
111    Attributes:
112        model: The SQLModel class that was being queried.
113        count: The number of records found.
114        filters: Optional dictionary of filters that were applied.
115
116    Example:
117        >>> from models import User
118        >>> raise MultipleRecordsError(
119        ...     model=User,
120        ...     count=3,
121        ...     filters={"email": "test@example.com"}
122        ... )
123        MultipleRecordsError: Expected 1 User, found 3
124            (filters: {'email': 'test@example.com'})
125    """
126
127    def __init__(
128        self,
129        model: type,
130        count: int,
131        filters: dict[str, Any] | None = None,
132    ):
133        """
134        Initialize MultipleRecordsError.
135
136        Args:
137            model: The SQLModel class being queried.
138            count: Number of records found.
139            filters: Optional dictionary of filters applied to the query.
140        """
141        self.model = model
142        self.count = count
143        self.filters = filters
144
145        # Create error message
146        model_name = (
147            model.__name__ if hasattr(model, "__name__") else str(model)
148        )
149        message = f"Expected 1 {model_name}, found {count}"
150
151        if filters:
152            message += f" (filters: {filters})"
153
154        super().__init__(message)

Raised when multiple records are found where exactly one was expected.

This exception occurs when a query expected to return a single record returns multiple results instead. This typically indicates a data integrity issue or an incorrect query filter.

Attributes: model: The SQLModel class that was being queried. count: The number of records found. filters: Optional dictionary of filters that were applied.

Example:

from models import User raise MultipleRecordsError( ... model=User, ... count=3, ... filters={"email": "test@example.com"} ... ) MultipleRecordsError: Expected 1 User, found 3 (filters: {'email': 'test@example.com'})

MultipleRecordsError( model: type, count: int, filters: dict[str, typing.Any] | None = None)
127    def __init__(
128        self,
129        model: type,
130        count: int,
131        filters: dict[str, Any] | None = None,
132    ):
133        """
134        Initialize MultipleRecordsError.
135
136        Args:
137            model: The SQLModel class being queried.
138            count: Number of records found.
139            filters: Optional dictionary of filters applied to the query.
140        """
141        self.model = model
142        self.count = count
143        self.filters = filters
144
145        # Create error message
146        model_name = (
147            model.__name__ if hasattr(model, "__name__") else str(model)
148        )
149        message = f"Expected 1 {model_name}, found {count}"
150
151        if filters:
152            message += f" (filters: {filters})"
153
154        super().__init__(message)

Initialize MultipleRecordsError.

Args: model: The SQLModel class being queried. count: Number of records found. filters: Optional dictionary of filters applied to the query.

model
count
filters
class ValidationError(sqlmodel_crud_utils.SQLModelCRUDError):
157class ValidationError(SQLModelCRUDError):
158    """
159    Raised when data validation fails before or after a database operation.
160
161    This exception is used for various validation failures, including:
162    - Invalid field values
163    - Missing required fields
164    - Type mismatches
165    - Business logic validation failures
166
167    Attributes:
168        field: Optional name of the field that failed validation.
169        value: Optional value that failed validation.
170        message: Detailed error message.
171        errors: Optional dictionary mapping field names to error messages.
172
173    Example:
174        >>> raise ValidationError(
175        ...     field="age",
176        ...     value=-5,
177        ...     message="Age must be a positive integer"
178        ... )
179        ValidationError: Validation failed for field 'age':
180            Age must be a positive integer (value: -5)
181
182        Multiple field errors:
183        >>> raise ValidationError(
184        ...     message="Multiple validation errors",
185        ...     errors={
186        ...         "email": "Invalid email format",
187        ...         "age": "Must be 18 or older"
188        ...     }
189        ... )
190    """
191
192    def __init__(
193        self,
194        message: str,
195        field: str | None = None,
196        value: Any | None = None,
197        errors: dict[str, str] | None = None,
198    ):
199        """
200        Initialize ValidationError.
201
202        Args:
203            message: Detailed error message.
204            field: Optional name of the field that failed.
205            value: Optional value that caused the validation failure.
206            errors: Optional dict mapping field names to error messages.
207        """
208        self.field = field
209        self.value = value
210        self.errors = errors
211
212        # Build comprehensive error message
213        if field:
214            full_message = f"Validation failed for field '{field}': {message}"
215            if value is not None:
216                full_message += f" (value: {value!r})"
217        else:
218            full_message = f"Validation failed: {message}"
219
220        if errors:
221            error_details = "\n".join(
222                f"  - {field}: {error}" for field, error in errors.items()
223            )
224            full_message += f"\n{error_details}"
225
226        super().__init__(full_message)

Raised when data validation fails before or after a database operation.

This exception is used for various validation failures, including:

  • Invalid field values
  • Missing required fields
  • Type mismatches
  • Business logic validation failures

Attributes: field: Optional name of the field that failed validation. value: Optional value that failed validation. message: Detailed error message. errors: Optional dictionary mapping field names to error messages.

Example:

raise ValidationError( ... field="age", ... value=-5, ... message="Age must be a positive integer" ... ) ValidationError: Validation failed for field 'age': Age must be a positive integer (value: -5)

Multiple field errors:
>>> raise ValidationError(
...     message="Multiple validation errors",
...     errors={
...         "email": "Invalid email format",
...         "age": "Must be 18 or older"
...     }
... )
ValidationError( message: str, field: str | None = None, value: typing.Any | None = None, errors: dict[str, str] | None = None)
192    def __init__(
193        self,
194        message: str,
195        field: str | None = None,
196        value: Any | None = None,
197        errors: dict[str, str] | None = None,
198    ):
199        """
200        Initialize ValidationError.
201
202        Args:
203            message: Detailed error message.
204            field: Optional name of the field that failed.
205            value: Optional value that caused the validation failure.
206            errors: Optional dict mapping field names to error messages.
207        """
208        self.field = field
209        self.value = value
210        self.errors = errors
211
212        # Build comprehensive error message
213        if field:
214            full_message = f"Validation failed for field '{field}': {message}"
215            if value is not None:
216                full_message += f" (value: {value!r})"
217        else:
218            full_message = f"Validation failed: {message}"
219
220        if errors:
221            error_details = "\n".join(
222                f"  - {field}: {error}" for field, error in errors.items()
223            )
224            full_message += f"\n{error_details}"
225
226        super().__init__(full_message)

Initialize ValidationError.

Args: message: Detailed error message. field: Optional name of the field that failed. value: Optional value that caused the validation failure. errors: Optional dict mapping field names to error messages.

field
value
errors
class BulkOperationError(sqlmodel_crud_utils.SQLModelCRUDError):
229class BulkOperationError(SQLModelCRUDError):
230    """
231    Raised when bulk database operations partially or completely fail.
232
233    This exception is used when processing multiple records in a batch
234    operation, and some or all of the records fail to process. It provides
235    detailed information about the failure rate and individual errors.
236
237    Attributes:
238        total: Total number of records in the bulk operation.
239        failed: Number of records that failed to process.
240        errors: List of error messages or exception details for failed records.
241        successful: Optional list of successfully processed record IDs.
242        failed_records: Optional list of records that failed processing.
243
244    Example:
245        >>> raise BulkOperationError(
246        ...     total=100,
247        ...     failed=5,
248        ...     errors=[
249        ...         "Record 23: Duplicate key violation",
250        ...         "Record 45: Invalid foreign key",
251        ...         "Record 67: Missing required field",
252        ...         "Record 89: Data too long for column",
253        ...         "Record 91: Constraint violation"
254        ...     ]
255        ... )
256        BulkOperationError: Bulk operation failed: 5/100 records failed
257    """
258
259    def __init__(
260        self,
261        total: int,
262        failed: int,
263        errors: list[str | Exception],
264        successful: list[Any] | None = None,
265        failed_records: list[Any] | None = None,
266    ):
267        """
268        Initialize BulkOperationError.
269
270        Args:
271            total: Total number of records in the operation.
272            failed: Number of records that failed.
273            errors: List of error messages or exceptions for failures.
274            successful: Optional list of successfully processed items.
275            failed_records: Optional list of records that failed.
276        """
277        self.total = total
278        self.failed = failed
279        self.errors = errors
280        self.successful = successful
281        self.failed_records = failed_records
282
283        # Calculate success rate for message
284        success_rate = ((total - failed) / total * 100) if total > 0 else 0
285
286        message = (
287            f"Bulk operation failed: {failed}/{total} records failed "
288            f"({success_rate:.1f}% success rate)"
289        )
290
291        super().__init__(message)
292
293    def get_error_summary(self) -> str:
294        """
295        Get a formatted summary of all errors.
296
297        Returns:
298            A multi-line string with formatted error details.
299
300        Example:
301            >>> try:
302            ...     bulk_operation()
303            ... except BulkOperationError as e:
304            ...     print(e.get_error_summary())
305        """
306        summary = [str(self)]
307        summary.append("\nError details:")
308
309        for i, error in enumerate(self.errors, 1):
310            if isinstance(error, Exception):
311                summary.append(f"  {i}. {type(error).__name__}: {error}")
312            else:
313                summary.append(f"  {i}. {error}")
314
315        return "\n".join(summary)

Raised when bulk database operations partially or completely fail.

This exception is used when processing multiple records in a batch operation, and some or all of the records fail to process. It provides detailed information about the failure rate and individual errors.

Attributes: total: Total number of records in the bulk operation. failed: Number of records that failed to process. errors: List of error messages or exception details for failed records. successful: Optional list of successfully processed record IDs. failed_records: Optional list of records that failed processing.

Example:

raise BulkOperationError( ... total=100, ... failed=5, ... errors=[ ... "Record 23: Duplicate key violation", ... "Record 45: Invalid foreign key", ... "Record 67: Missing required field", ... "Record 89: Data too long for column", ... "Record 91: Constraint violation" ... ] ... ) BulkOperationError: Bulk operation failed: 5/100 records failed

BulkOperationError( total: int, failed: int, errors: list[str | Exception], successful: list[typing.Any] | None = None, failed_records: list[typing.Any] | None = None)
259    def __init__(
260        self,
261        total: int,
262        failed: int,
263        errors: list[str | Exception],
264        successful: list[Any] | None = None,
265        failed_records: list[Any] | None = None,
266    ):
267        """
268        Initialize BulkOperationError.
269
270        Args:
271            total: Total number of records in the operation.
272            failed: Number of records that failed.
273            errors: List of error messages or exceptions for failures.
274            successful: Optional list of successfully processed items.
275            failed_records: Optional list of records that failed.
276        """
277        self.total = total
278        self.failed = failed
279        self.errors = errors
280        self.successful = successful
281        self.failed_records = failed_records
282
283        # Calculate success rate for message
284        success_rate = ((total - failed) / total * 100) if total > 0 else 0
285
286        message = (
287            f"Bulk operation failed: {failed}/{total} records failed "
288            f"({success_rate:.1f}% success rate)"
289        )
290
291        super().__init__(message)

Initialize BulkOperationError.

Args: total: Total number of records in the operation. failed: Number of records that failed. errors: List of error messages or exceptions for failures. successful: Optional list of successfully processed items. failed_records: Optional list of records that failed.

total
failed
errors
successful
failed_records
def get_error_summary(self) -> str:
293    def get_error_summary(self) -> str:
294        """
295        Get a formatted summary of all errors.
296
297        Returns:
298            A multi-line string with formatted error details.
299
300        Example:
301            >>> try:
302            ...     bulk_operation()
303            ... except BulkOperationError as e:
304            ...     print(e.get_error_summary())
305        """
306        summary = [str(self)]
307        summary.append("\nError details:")
308
309        for i, error in enumerate(self.errors, 1):
310            if isinstance(error, Exception):
311                summary.append(f"  {i}. {type(error).__name__}: {error}")
312            else:
313                summary.append(f"  {i}. {error}")
314
315        return "\n".join(summary)

Get a formatted summary of all errors.

Returns: A multi-line string with formatted error details.

Example:

try: ... bulk_operation() ... except BulkOperationError as e: ... print(e.get_error_summary())

class TransactionError(sqlmodel_crud_utils.SQLModelCRUDError):
318class TransactionError(SQLModelCRUDError):
319    """
320    Raised when database transaction operations fail.
321
322    This exception is used for transaction-related failures, including:
323    - Failed commits
324    - Rollback errors
325    - Nested transaction issues
326    - Deadlock scenarios
327    - Connection issues during transaction
328
329    Attributes:
330        operation: The transaction operation that failed
331            (e.g., "commit", "rollback").
332        original_error: The underlying exception that caused the
333            failure.
334
335    Example:
336        >>> try:
337        ...     session.commit()
338        ... except Exception as e:
339        ...     raise TransactionError(
340        ...         operation="commit",
341        ...         original_error=e
342        ...     )
343        TransactionError: Transaction operation 'commit' failed:
344            IntegrityError(...)
345    """
346
347    def __init__(
348        self,
349        message: str | None = None,
350        operation: str | None = None,
351        original_error: Exception | None = None,
352    ):
353        """
354        Initialize TransactionError.
355
356        Args:
357            message: Optional custom error message.
358            operation: The transaction operation that failed.
359            original_error: The underlying exception that caused the failure.
360        """
361        self.operation = operation
362        self.original_error = original_error
363
364        # Build error message
365        if message:
366            full_message = message
367        elif operation and original_error:
368            error_name = type(original_error).__name__
369            full_message = (
370                f"Transaction operation '{operation}' failed: "
371                f"{error_name}({original_error})"
372            )
373        elif operation:
374            full_message = f"Transaction operation '{operation}' failed"
375        elif original_error:
376            full_message = f"Transaction failed: {original_error}"
377        else:
378            full_message = "Transaction operation failed"
379
380        super().__init__(full_message)

Raised when database transaction operations fail.

This exception is used for transaction-related failures, including:

  • Failed commits
  • Rollback errors
  • Nested transaction issues
  • Deadlock scenarios
  • Connection issues during transaction

Attributes: operation: The transaction operation that failed (e.g., "commit", "rollback"). original_error: The underlying exception that caused the failure.

Example:

try: ... session.commit() ... except Exception as e: ... raise TransactionError( ... operation="commit", ... original_error=e ... ) TransactionError: Transaction operation 'commit' failed: IntegrityError(...)

TransactionError( message: str | None = None, operation: str | None = None, original_error: Exception | None = None)
347    def __init__(
348        self,
349        message: str | None = None,
350        operation: str | None = None,
351        original_error: Exception | None = None,
352    ):
353        """
354        Initialize TransactionError.
355
356        Args:
357            message: Optional custom error message.
358            operation: The transaction operation that failed.
359            original_error: The underlying exception that caused the failure.
360        """
361        self.operation = operation
362        self.original_error = original_error
363
364        # Build error message
365        if message:
366            full_message = message
367        elif operation and original_error:
368            error_name = type(original_error).__name__
369            full_message = (
370                f"Transaction operation '{operation}' failed: "
371                f"{error_name}({original_error})"
372            )
373        elif operation:
374            full_message = f"Transaction operation '{operation}' failed"
375        elif original_error:
376            full_message = f"Transaction failed: {original_error}"
377        else:
378            full_message = "Transaction operation failed"
379
380        super().__init__(full_message)

Initialize TransactionError.

Args: message: Optional custom error message. operation: The transaction operation that failed. original_error: The underlying exception that caused the failure.

operation
original_error
@contextmanager
def transaction( session: sqlmodel.orm.session.Session) -> Generator[sqlmodel.orm.session.Session, NoneType, NoneType]:
19@contextmanager
20def transaction(session: Session) -> Generator[Session, None, None]:
21    """Context manager for synchronous database transactions.
22
23    Automatically commits the transaction on successful completion or
24    rolls back on any exception. The original exception is wrapped in a
25    TransactionError to provide additional context while preserving the
26    exception chain.
27
28    Args:
29        session: An active SQLModel Session instance
30
31    Yields:
32        Session: The same session instance for use within the context
33
34    Raises:
35        TransactionError: Wraps any exception that occurs during
36            the transaction, with the original exception available
37            via __cause__
38
39    Example:
40        Basic usage with automatic commit:
41
42        >>> from sqlmodel import Session, create_engine
43        >>> from sqlmodel_crud_utils import write_row, update_row
44        >>> from sqlmodel_crud_utils.transactions import transaction
45        >>>
46        >>> engine = create_engine("sqlite:///database.db")
47        >>> with Session(engine) as session:
48        ...     with transaction(session) as tx:
49        ...         user = write_row(User(name="Alice"), tx)
50        ...         update_row(
51        ...             user.id, {"email": "alice@example.com"}, User, tx
52        ...         )
53        ...         # Automatically commits here if no exceptions
54
55        Error handling with automatic rollback:
56
57        >>> from sqlmodel import Session
58        >>> from sqlmodel_crud_utils.transactions import transaction
59        >>> from sqlmodel_crud_utils.exceptions import TransactionError
60        >>>
61        >>> with Session(engine) as session:
62        ...     try:
63        ...         with transaction(session) as tx:
64        ...             user = write_row(User(name="Bob"), tx)
65        ...             # Some operation that fails
66        ...             raise ValueError("Invalid email format")
67        ...     except TransactionError as e:
68        ...         print(f"Transaction failed: {e}")
69        ...         print(f"Original error: {e.__cause__}")
70        ...         # Database automatically rolled back
71
72        Multiple operations in a single transaction:
73
74        >>> with Session(engine) as session:
75        ...     with transaction(session) as tx:
76        ...         # All operations succeed together or all are rolled back
77        ...         user = write_row(User(name="Charlie"), tx)
78        ...         profile = write_row(Profile(user_id=user.id), tx)
79        ...         update_row(user.id, {"active": True}, User, tx)
80
81    Notes:
82        - The session is committed only if no exceptions occur
83        - Any exception triggers an automatic rollback before re-raising
84        - The session remains usable after the context exits
85        - Nested transactions are not supported; use savepoints instead
86        - The session must not be in an existing transaction when entering
87    """
88    try:
89        yield session
90        session.commit()
91    except Exception as e:
92        session.rollback()
93        raise TransactionError(f"Transaction failed: {e}") from e
94    else:
95        invalidate_written(session)

Context manager for synchronous database transactions.

Automatically commits the transaction on successful completion or rolls back on any exception. The original exception is wrapped in a TransactionError to provide additional context while preserving the exception chain.

Args: session: An active SQLModel Session instance

Yields: Session: The same session instance for use within the context

Raises: TransactionError: Wraps any exception that occurs during the transaction, with the original exception available via __cause__

Example: Basic usage with automatic commit:

>>> from sqlmodel import Session, create_engine
>>> from sqlmodel_crud_utils import write_row, update_row
>>> from sqlmodel_crud_utils.transactions import transaction
>>>
>>> engine = create_engine("sqlite:///database.db")
>>> with Session(engine) as session:
...     with transaction(session) as tx:
...         user = write_row(User(name="Alice"), tx)
...         update_row(
...             user.id, {"email": "alice@example.com"}, User, tx
...         )
...         # Automatically commits here if no exceptions

Error handling with automatic rollback:

>>> from sqlmodel import Session
>>> from sqlmodel_crud_utils.transactions import transaction
>>> from sqlmodel_crud_utils.exceptions import TransactionError
>>>
>>> with Session(engine) as session:
...     try:
...         with transaction(session) as tx:
...             user = write_row(User(name="Bob"), tx)
...             # Some operation that fails
...             raise ValueError("Invalid email format")
...     except TransactionError as e:
...         print(f"Transaction failed: {e}")
...         print(f"Original error: {e.__cause__}")
...         # Database automatically rolled back

Multiple operations in a single transaction:

>>> with Session(engine) as session:
...     with transaction(session) as tx:
...         # All operations succeed together or all are rolled back
...         user = write_row(User(name="Charlie"), tx)
...         profile = write_row(Profile(user_id=user.id), tx)
...         update_row(user.id, {"active": True}, User, tx)

Notes: - The session is committed only if no exceptions occur - Any exception triggers an automatic rollback before re-raising - The session remains usable after the context exits - Nested transactions are not supported; use savepoints instead - The session must not be in an existing transaction when entering

@asynccontextmanager
async def a_transaction( session: sqlmodel.ext.asyncio.session.AsyncSession) -> AsyncGenerator[sqlmodel.ext.asyncio.session.AsyncSession, NoneType]:
 98@asynccontextmanager
 99async def a_transaction(
100    session: AsyncSession,
101) -> AsyncGenerator[AsyncSession, None]:
102    """Context manager for asynchronous database transactions.
103
104    Asynchronous version of the transaction() context manager. Provides
105    the same automatic commit/rollback behavior for async database
106    operations.
107
108    Args:
109        session: An active AsyncSession instance from
110            sqlmodel.ext.asyncio.session
111
112    Yields:
113        AsyncSession: The same session instance for use within the
114            async context
115
116    Raises:
117        TransactionError: Wraps any exception that occurs during
118            the transaction, with the original exception available
119            via __cause__
120
121    Example:
122        Basic async usage with automatic commit:
123
124        >>> from sqlmodel.ext.asyncio.session import AsyncSession
125        >>> from sqlalchemy.ext.asyncio import create_async_engine
126        >>> from sqlmodel_crud_utils import a_write_row, a_update_row
127        >>> from sqlmodel_crud_utils.transactions import a_transaction
128        >>>
129        >>> engine = create_async_engine("sqlite+aiosqlite:///database.db")
130        >>> async with AsyncSession(engine) as session:
131        ...     async with a_transaction(session) as tx:
132        ...         user = await a_write_row(
133        ...             User(name="Alice"), tx
134        ...         )
135        ...         await a_update_row(
136        ...             user.id, {"email": "alice@example.com"},
137        ...             User, tx
138        ...         )
139        ...         # Automatically commits here if no exceptions
140
141        Error handling with automatic rollback:
142
143        >>> from sqlmodel.ext.asyncio.session import AsyncSession
144        >>> from sqlmodel_crud_utils.transactions import a_transaction
145        >>> from sqlmodel_crud_utils.exceptions import TransactionError
146        >>>
147        >>> async with AsyncSession(engine) as session:
148        ...     try:
149        ...         async with a_transaction(session) as tx:
150        ...             user = await a_write_row(User(name="Bob"), tx)
151        ...             # Some async operation that fails
152        ...             raise ValueError("Invalid email format")
153        ...     except TransactionError as e:
154        ...         print(f"Transaction failed: {e}")
155        ...         print(f"Original error: {e.__cause__}")
156        ...         # Database automatically rolled back
157
158        Multiple async operations in a single transaction:
159
160        >>> async with AsyncSession(engine) as session:
161        ...     async with a_transaction(session) as tx:
162        ...         # All operations succeed together or all are rolled back
163        ...         user = await a_write_row(User(name="Charlie"), tx)
164        ...         profile = await a_write_row(Profile(user_id=user.id), tx)
165        ...         await a_update_row(user.id, {"active": True}, User, tx)
166
167        Using with FastAPI dependency injection:
168
169        >>> from fastapi import Depends, FastAPI
170        >>> from sqlmodel.ext.asyncio.session import AsyncSession
171        >>> from sqlmodel_crud_utils.transactions import a_transaction
172        >>>
173        >>> app = FastAPI()
174        >>>
175        >>> async def get_session():
176        ...     async with AsyncSession(engine) as session:
177        ...         yield session
178        >>>
179        >>> @app.post("/users")
180        >>> async def create_user(
181        ...     user_data: dict,
182        ...     session: AsyncSession = Depends(get_session)
183        ... ):
184        ...     async with a_transaction(session) as tx:
185        ...         user = await a_write_row(User(**user_data), tx)
186        ...         await a_write_row(AuditLog(action="user_created"), tx)
187        ...         return user
188
189    Notes:
190        - The session is committed only if no exceptions occur
191        - Any exception triggers an automatic rollback before re-raising
192        - The session remains usable after the context exits
193        - Nested transactions are not supported; use savepoints instead
194        - The session must not be in an existing transaction when entering
195        - Works seamlessly with asyncio, FastAPI, and other async frameworks
196    """
197    try:
198        yield session
199        await session.commit()
200    except Exception as e:
201        await session.rollback()
202        raise TransactionError(f"Transaction failed: {e}") from e
203    else:
204        await a_invalidate_written(session)

Context manager for asynchronous database transactions.

Asynchronous version of the transaction() context manager. Provides the same automatic commit/rollback behavior for async database operations.

Args: session: An active AsyncSession instance from sqlmodel.ext.asyncio.session

Yields: AsyncSession: The same session instance for use within the async context

Raises: TransactionError: Wraps any exception that occurs during the transaction, with the original exception available via __cause__

Example: Basic async usage with automatic commit:

>>> from sqlmodel.ext.asyncio.session import AsyncSession
>>> from sqlalchemy.ext.asyncio import create_async_engine
>>> from sqlmodel_crud_utils import a_write_row, a_update_row
>>> from sqlmodel_crud_utils.transactions import a_transaction
>>>
>>> engine = create_async_engine("sqlite+aiosqlite:///database.db")
>>> async with AsyncSession(engine) as session:
...     async with a_transaction(session) as tx:
...         user = await a_write_row(
...             User(name="Alice"), tx
...         )
...         await a_update_row(
...             user.id, {"email": "alice@example.com"},
...             User, tx
...         )
...         # Automatically commits here if no exceptions

Error handling with automatic rollback:

>>> from sqlmodel.ext.asyncio.session import AsyncSession
>>> from sqlmodel_crud_utils.transactions import a_transaction
>>> from sqlmodel_crud_utils.exceptions import TransactionError
>>>
>>> async with AsyncSession(engine) as session:
...     try:
...         async with a_transaction(session) as tx:
...             user = await a_write_row(User(name="Bob"), tx)
...             # Some async operation that fails
...             raise ValueError("Invalid email format")
...     except TransactionError as e:
...         print(f"Transaction failed: {e}")
...         print(f"Original error: {e.__cause__}")
...         # Database automatically rolled back

Multiple async operations in a single transaction:

>>> async with AsyncSession(engine) as session:
...     async with a_transaction(session) as tx:
...         # All operations succeed together or all are rolled back
...         user = await a_write_row(User(name="Charlie"), tx)
...         profile = await a_write_row(Profile(user_id=user.id), tx)
...         await a_update_row(user.id, {"active": True}, User, tx)

Using with FastAPI dependency injection:

>>> from fastapi import Depends, FastAPI
>>> from sqlmodel.ext.asyncio.session import AsyncSession
>>> from sqlmodel_crud_utils.transactions import a_transaction
>>>
>>> app = FastAPI()
>>>
>>> async def get_session():
...     async with AsyncSession(engine) as session:
...         yield session
>>>
>>> @app.post("/users")
>>> async def create_user(
...     user_data: dict,
...     session: AsyncSession = Depends(get_session)
... ):
...     async with a_transaction(session) as tx:
...         user = await a_write_row(User(**user_data), tx)
...         await a_write_row(AuditLog(action="user_created"), tx)
...         return user

Notes: - The session is committed only if no exceptions occur - Any exception triggers an automatic rollback before re-raising - The session remains usable after the context exits - Nested transactions are not supported; use savepoints instead - The session must not be in an existing transaction when entering - Works seamlessly with asyncio, FastAPI, and other async frameworks

class AuditMixin:
 64class AuditMixin:
 65    """Mixin for automatic audit trail tracking.
 66
 67    Adds timestamp fields to track when records are created and updated,
 68    along with optional user tracking fields.
 69
 70    Attributes:
 71        created_at: Timestamp when the record was created (auto-set)
 72        updated_at: Timestamp when the record was last updated
 73            (auto-set on update)
 74        created_by: Optional username/ID of creator
 75        updated_by: Optional username/ID of last updater
 76
 77    Example:
 78        Basic usage:
 79
 80        >>> from sqlmodel import SQLModel, Field
 81        >>> from sqlmodel_crud_utils.mixins import AuditMixin
 82        >>>
 83        >>> class User(SQLModel, AuditMixin, table=True):
 84        ...     id: Optional[int] = Field(default=None, primary_key=True)
 85        ...     name: str
 86        ...     email: str
 87        >>>
 88        >>> # When you create a User, created_at is automatically set
 89        >>> user = User(name="Alice", email="alice@example.com")
 90        >>> # user.created_at will be set to current UTC time
 91
 92        With user tracking:
 93
 94        >>> user = User(
 95        ...     name="Bob",
 96        ...     email="bob@example.com",
 97        ...     created_by="admin"
 98        ... )
 99        >>> # Later when updating
100        >>> user.name = "Robert"
101        >>> user.updated_by = "admin"
102        >>> # updated_at will be automatically set on commit
103
104    Note:
105        The `updated_at` field uses SQLAlchemy's `onupdate` parameter to
106        automatically update the timestamp when the record is modified.
107        That is a column-level default, so it also fires for Core `UPDATE`
108        statements such as `bulk_update_rows`. `updated_by` has no default:
109        set it yourself, or pass it in `data` to `bulk_update_rows`.
110    """
111
112    created_at: datetime = Field(
113        default_factory=_utc_now,
114        nullable=False,
115        index=True,
116        description="Timestamp when the record was created",
117    )
118    updated_at: Optional[datetime] = Field(
119        default=None,
120        sa_column_kwargs={"onupdate": _utc_now},
121        description="Timestamp when the record was last updated",
122    )
123    created_by: Optional[str] = Field(
124        default=None,
125        max_length=100,
126        description="Username or ID of the user who created this record",
127    )
128    updated_by: Optional[str] = Field(
129        default=None,
130        max_length=100,
131        description="Username or ID of the user who last updated this record",
132    )

Mixin for automatic audit trail tracking.

Adds timestamp fields to track when records are created and updated, along with optional user tracking fields.

Attributes: created_at: Timestamp when the record was created (auto-set) updated_at: Timestamp when the record was last updated (auto-set on update) created_by: Optional username/ID of creator updated_by: Optional username/ID of last updater

Example: Basic usage:

>>> from sqlmodel import SQLModel, Field
>>> from sqlmodel_crud_utils.mixins import AuditMixin
>>>
>>> class User(SQLModel, AuditMixin, table=True):
...     id: Optional[int] = Field(default=None, primary_key=True)
...     name: str
...     email: str
>>>
>>> # When you create a User, created_at is automatically set
>>> user = User(name="Alice", email="alice@example.com")
>>> # user.created_at will be set to current UTC time

With user tracking:

>>> user = User(
...     name="Bob",
...     email="bob@example.com",
...     created_by="admin"
... )
>>> # Later when updating
>>> user.name = "Robert"
>>> user.updated_by = "admin"
>>> # updated_at will be automatically set on commit

Note: The updated_at field uses SQLAlchemy's onupdate parameter to automatically update the timestamp when the record is modified. That is a column-level default, so it also fires for Core UPDATE statements such as bulk_update_rows. updated_by has no default: set it yourself, or pass it in data to bulk_update_rows.

created_at: datetime.datetime = FieldInfo(annotation=NoneType, required=False, default_factory=_utc_now, description='Timestamp when the record was created')
updated_at: Optional[datetime.datetime] = FieldInfo(annotation=NoneType, required=False, default=None, description='Timestamp when the record was last updated')
created_by: Optional[str] = FieldInfo(annotation=NoneType, required=False, default=None, description='Username or ID of the user who created this record', metadata=[MaxLen(max_length=100)])
updated_by: Optional[str] = FieldInfo(annotation=NoneType, required=False, default=None, description='Username or ID of the user who last updated this record', metadata=[MaxLen(max_length=100)])
class SoftDeleteMixin:
135class SoftDeleteMixin:
136    """Mixin for soft delete functionality.
137
138    Adds fields and methods to support soft deletion, where records are marked
139    as deleted but not actually removed from the database. This allows for
140    recovery and maintains referential integrity.
141
142    Attributes:
143        deleted_at: Timestamp when the record was soft-deleted
144        deleted_by: Optional username/ID of user who deleted the record
145        is_deleted: Boolean flag indicating if record is deleted
146
147    Methods:
148        soft_delete: Mark the record as deleted
149        restore: Restore a soft-deleted record
150
151    Example:
152        Basic usage:
153
154        >>> from sqlmodel import SQLModel, Field
155        >>> from sqlmodel_crud_utils.mixins import SoftDeleteMixin
156        >>>
157        >>> class Product(SQLModel, SoftDeleteMixin, table=True):
158        ...     id: Optional[int] = Field(default=None, primary_key=True)
159        ...     name: str
160        ...     price: float
161        >>>
162        >>> product = Product(name="Widget", price=9.99)
163        >>> # Later, soft delete it
164        >>> product.soft_delete(user="admin")
165        >>> # product.is_deleted is now True
166        >>> # product.deleted_at is set to current time
167        >>> # product.deleted_by is "admin"
168
169        Restoring a deleted record:
170
171        >>> product.restore()
172        >>> # product.is_deleted is now False
173        >>> # product.deleted_at is None
174        >>> # product.deleted_by is None
175
176        Combining with AuditMixin:
177
178        >>> class Order(SQLModel, AuditMixin, SoftDeleteMixin, table=True):
179        ...     id: Optional[int] = Field(default=None, primary_key=True)
180        ...     total: float
181        >>>
182        >>> # Now Order has both audit trail and soft delete support
183
184    Note:
185        When querying, you'll typically want to filter out soft-deleted records:
186        `select(Product).where(Product.is_deleted == False)`
187
188        The `get_rows` function in this library can be extended to automatically
189        exclude soft-deleted records by default.
190
191        `delete_rows_within_id_list(..., soft=True, deleted_by=...)` marks
192        many rows deleted in bulk with the same field semantics as
193        `soft_delete()`. Without `soft=True` that helper is a hard `DELETE`.
194    """
195
196    deleted_at: Optional[datetime] = Field(
197        default=None,
198        index=True,
199        description="Timestamp when the record was soft-deleted",
200    )
201    deleted_by: Optional[str] = Field(
202        default=None,
203        max_length=100,
204        description="Username or ID of the user who deleted this record",
205    )
206    is_deleted: bool = Field(
207        default=False,
208        index=True,
209        description="Flag indicating if the record is soft-deleted",
210    )
211
212    def soft_delete(self, user: Optional[str] = None) -> None:
213        """Mark this record as deleted.
214
215        Sets the is_deleted flag to True, records the deletion
216        timestamp, and optionally tracks which user performed the
217        deletion.
218
219        Args:
220            user: Optional username or user ID of the person
221                performing the deletion
222
223        Example:
224            >>> product.soft_delete(user="admin")
225            >>> assert product.is_deleted is True
226            >>> assert product.deleted_at is not None
227            >>> assert product.deleted_by == "admin"
228
229        Note:
230            This method only modifies the object in memory. You must still
231            commit the session to persist the changes to the database.
232        """
233        self.is_deleted = True
234        self.deleted_at = _utc_now()
235        self.deleted_by = user
236
237    def restore(self) -> None:
238        """Restore a soft-deleted record.
239
240        Clears the deletion flag and resets all deletion-related fields
241        to their default values.
242
243        Example:
244            >>> product.restore()
245            >>> assert product.is_deleted is False
246            >>> assert product.deleted_at is None
247            >>> assert product.deleted_by is None
248
249        Note:
250            This method only modifies the object in memory. You must still
251            commit the session to persist the changes to the database.
252        """
253        self.is_deleted = False
254        self.deleted_at = None
255        self.deleted_by = None

Mixin for soft delete functionality.

Adds fields and methods to support soft deletion, where records are marked as deleted but not actually removed from the database. This allows for recovery and maintains referential integrity.

Attributes: deleted_at: Timestamp when the record was soft-deleted deleted_by: Optional username/ID of user who deleted the record is_deleted: Boolean flag indicating if record is deleted

Methods: soft_delete: Mark the record as deleted restore: Restore a soft-deleted record

Example: Basic usage:

>>> from sqlmodel import SQLModel, Field
>>> from sqlmodel_crud_utils.mixins import SoftDeleteMixin
>>>
>>> class Product(SQLModel, SoftDeleteMixin, table=True):
...     id: Optional[int] = Field(default=None, primary_key=True)
...     name: str
...     price: float
>>>
>>> product = Product(name="Widget", price=9.99)
>>> # Later, soft delete it
>>> product.soft_delete(user="admin")
>>> # product.is_deleted is now True
>>> # product.deleted_at is set to current time
>>> # product.deleted_by is "admin"

Restoring a deleted record:

>>> product.restore()
>>> # product.is_deleted is now False
>>> # product.deleted_at is None
>>> # product.deleted_by is None

Combining with AuditMixin:

>>> class Order(SQLModel, AuditMixin, SoftDeleteMixin, table=True):
...     id: Optional[int] = Field(default=None, primary_key=True)
...     total: float
>>>
>>> # Now Order has both audit trail and soft delete support

Note: When querying, you'll typically want to filter out soft-deleted records: select(Product).where(Product.is_deleted == False)

The `get_rows` function in this library can be extended to automatically
exclude soft-deleted records by default.

`delete_rows_within_id_list(..., soft=True, deleted_by=...)` marks
many rows deleted in bulk with the same field semantics as
`soft_delete()`. Without `soft=True` that helper is a hard `DELETE`.
deleted_at: Optional[datetime.datetime] = FieldInfo(annotation=NoneType, required=False, default=None, description='Timestamp when the record was soft-deleted')
deleted_by: Optional[str] = FieldInfo(annotation=NoneType, required=False, default=None, description='Username or ID of the user who deleted this record', metadata=[MaxLen(max_length=100)])
is_deleted: bool = FieldInfo(annotation=NoneType, required=False, default=False, description='Flag indicating if the record is soft-deleted')
def soft_delete(self, user: Optional[str] = None) -> None:
212    def soft_delete(self, user: Optional[str] = None) -> None:
213        """Mark this record as deleted.
214
215        Sets the is_deleted flag to True, records the deletion
216        timestamp, and optionally tracks which user performed the
217        deletion.
218
219        Args:
220            user: Optional username or user ID of the person
221                performing the deletion
222
223        Example:
224            >>> product.soft_delete(user="admin")
225            >>> assert product.is_deleted is True
226            >>> assert product.deleted_at is not None
227            >>> assert product.deleted_by == "admin"
228
229        Note:
230            This method only modifies the object in memory. You must still
231            commit the session to persist the changes to the database.
232        """
233        self.is_deleted = True
234        self.deleted_at = _utc_now()
235        self.deleted_by = user

Mark this record as deleted.

Sets the is_deleted flag to True, records the deletion timestamp, and optionally tracks which user performed the deletion.

Args: user: Optional username or user ID of the person performing the deletion

Example:

product.soft_delete(user="admin") assert product.is_deleted is True assert product.deleted_at is not None assert product.deleted_by == "admin"

Note: This method only modifies the object in memory. You must still commit the session to persist the changes to the database.

def restore(self) -> None:
237    def restore(self) -> None:
238        """Restore a soft-deleted record.
239
240        Clears the deletion flag and resets all deletion-related fields
241        to their default values.
242
243        Example:
244            >>> product.restore()
245            >>> assert product.is_deleted is False
246            >>> assert product.deleted_at is None
247            >>> assert product.deleted_by is None
248
249        Note:
250            This method only modifies the object in memory. You must still
251            commit the session to persist the changes to the database.
252        """
253        self.is_deleted = False
254        self.deleted_at = None
255        self.deleted_by = None

Restore a soft-deleted record.

Clears the deletion flag and resets all deletion-related fields to their default values.

Example:

product.restore() assert product.is_deleted is False assert product.deleted_at is None assert product.deleted_by is None

Note: This method only modifies the object in memory. You must still commit the session to persist the changes to the database.

class TimestampMixin:
21class TimestampMixin:
22    """Lightweight mixin adding only ``created_at`` and ``updated_at``.
23
24    Use this instead of ``AuditMixin`` when there is no user context to
25    record. Each column is produced by ``declared_attr``, so SQLAlchemy
26    builds a fresh ``Column`` for every concrete ``table=True`` model and
27    the mixin can safely be applied to any number of models.
28
29    Both columns use ``server_default=func.now()``, so the database fills
30    them even for inserts that bypass Python-side defaults, such as
31    ``bulk_insert_mappings`` or raw ``INSERT`` statements. ``updated_at``
32    additionally refreshes through ``onupdate=func.now()``. A column-level
33    ``onupdate`` fires for ORM updates and for Core ``UPDATE`` statements
34    such as ``bulk_update_rows``.
35
36    The ``declared_attr`` methods intentionally carry no return
37    annotations: SQLAlchemy's annotated-declarative scanner would treat
38    them as bare field annotations and raise ``MappedAnnotationError``.
39
40    Example:
41        >>> class Location(TimestampMixin, SQLModel, table=True):
42        ...     id: Optional[int] = Field(default=None, primary_key=True)
43        ...     name: str
44    """
45
46    @declared_attr
47    def created_at(cls):
48        return Column(
49            DateTime(timezone=True),
50            server_default=func.now(),
51            nullable=False,
52        )
53
54    @declared_attr
55    def updated_at(cls):
56        return Column(
57            DateTime(timezone=True),
58            server_default=func.now(),
59            onupdate=func.now(),
60            nullable=False,
61        )

Lightweight mixin adding only created_at and updated_at.

Use this instead of AuditMixin when there is no user context to record. Each column is produced by declared_attr, so SQLAlchemy builds a fresh Column for every concrete table=True model and the mixin can safely be applied to any number of models.

Both columns use server_default=func.now(), so the database fills them even for inserts that bypass Python-side defaults, such as bulk_insert_mappings or raw INSERT statements. updated_at additionally refreshes through onupdate=func.now(). A column-level onupdate fires for ORM updates and for Core UPDATE statements such as bulk_update_rows.

The declared_attr methods intentionally carry no return annotations: SQLAlchemy's annotated-declarative scanner would treat them as bare field annotations and raise MappedAnnotationError.

Example:

class Location(TimestampMixin, SQLModel, table=True): ... id: Optional[int] = Field(default=None, primary_key=True) ... name: str

def created_at(unknown):

The type of the None singleton.

def updated_at(unknown):

The type of the None singleton.

class CacheBackend(abc.ABC):
40class CacheBackend(ABC):
41    """Storage for cached read results, grouped by namespace.
42
43    Values are JSON-compatible dicts. ``blocking`` backends perform network
44    or disk I/O; the async helpers run their calls in a worker thread.
45    """
46
47    blocking: bool = False
48
49    @abstractmethod
50    def get(self, namespace: str, key: str) -> Any | None:
51        """Return the stored value, or ``None`` on a miss or expiry."""
52
53    @abstractmethod
54    def set(
55        self,
56        namespace: str,
57        key: str,
58        value: Any,
59        ttl: float | None = None,
60    ) -> None:
61        """Store ``value``; ``ttl`` in seconds overrides the default."""
62
63    @abstractmethod
64    def invalidate(self, namespace: str) -> None:
65        """Drop every entry in ``namespace``."""
66
67    @abstractmethod
68    def clear(self) -> None:
69        """Drop every entry in every namespace."""

Storage for cached read results, grouped by namespace.

Values are JSON-compatible dicts. blocking backends perform network or disk I/O; the async helpers run their calls in a worker thread.

blocking: bool = False
@abstractmethod
def get(self, namespace: str, key: str) -> typing.Any | None:
49    @abstractmethod
50    def get(self, namespace: str, key: str) -> Any | None:
51        """Return the stored value, or ``None`` on a miss or expiry."""

Return the stored value, or None on a miss or expiry.

@abstractmethod
def set( self, namespace: str, key: str, value: Any, ttl: float | None = None) -> None:
53    @abstractmethod
54    def set(
55        self,
56        namespace: str,
57        key: str,
58        value: Any,
59        ttl: float | None = None,
60    ) -> None:
61        """Store ``value``; ``ttl`` in seconds overrides the default."""

Store value; ttl in seconds overrides the default.

@abstractmethod
def invalidate(self, namespace: str) -> None:
63    @abstractmethod
64    def invalidate(self, namespace: str) -> None:
65        """Drop every entry in ``namespace``."""

Drop every entry in namespace.

@abstractmethod
def clear(self) -> None:
67    @abstractmethod
68    def clear(self) -> None:
69        """Drop every entry in every namespace."""

Drop every entry in every namespace.

class InMemoryCache(sqlmodel_crud_utils.CacheBackend):
 72class InMemoryCache(CacheBackend):
 73    """Thread-safe, per-process cache with TTL and a size cap.
 74
 75    :param default_ttl: Seconds an entry lives unless ``set`` overrides it.
 76    :param max_entries: Entry cap across namespaces; the oldest entry is
 77        evicted first when it is exceeded.
 78    """
 79
 80    def __init__(
 81        self, default_ttl: float = DEFAULT_TTL, max_entries: int = 1024
 82    ):
 83        self.default_ttl = default_ttl
 84        self.max_entries = max_entries
 85        self._data: dict[tuple[str, str], tuple[float, Any]] = {}
 86        self._lock = threading.Lock()
 87
 88    def get(self, namespace, key):
 89        with self._lock:
 90            item = self._data.get((namespace, key))
 91            if item is None:
 92                return None
 93            expires, value = item
 94            if expires <= time.monotonic():
 95                del self._data[(namespace, key)]
 96                return None
 97            return json.loads(value)
 98
 99    def set(self, namespace, key, value, ttl=None):
100        ttl = self.default_ttl if ttl is None else ttl
101        with self._lock:
102            self._data.pop((namespace, key), None)
103            self._data[(namespace, key)] = (
104                time.monotonic() + ttl,
105                json.dumps(value),
106            )
107            while len(self._data) > self.max_entries:
108                del self._data[next(iter(self._data))]
109
110    def invalidate(self, namespace):
111        with self._lock:
112            for k in [k for k in self._data if k[0] == namespace]:
113                del self._data[k]
114
115    def clear(self):
116        with self._lock:
117            self._data.clear()

Thread-safe, per-process cache with TTL and a size cap.

Parameters
  • default_ttl: Seconds an entry lives unless set overrides it.
  • max_entries: Entry cap across namespaces; the oldest entry is evicted first when it is exceeded.
InMemoryCache(default_ttl: float = 60.0, max_entries: int = 1024)
80    def __init__(
81        self, default_ttl: float = DEFAULT_TTL, max_entries: int = 1024
82    ):
83        self.default_ttl = default_ttl
84        self.max_entries = max_entries
85        self._data: dict[tuple[str, str], tuple[float, Any]] = {}
86        self._lock = threading.Lock()
default_ttl
max_entries
def get(self, namespace, key):
88    def get(self, namespace, key):
89        with self._lock:
90            item = self._data.get((namespace, key))
91            if item is None:
92                return None
93            expires, value = item
94            if expires <= time.monotonic():
95                del self._data[(namespace, key)]
96                return None
97            return json.loads(value)

Return the stored value, or None on a miss or expiry.

def set(self, namespace, key, value, ttl=None):
 99    def set(self, namespace, key, value, ttl=None):
100        ttl = self.default_ttl if ttl is None else ttl
101        with self._lock:
102            self._data.pop((namespace, key), None)
103            self._data[(namespace, key)] = (
104                time.monotonic() + ttl,
105                json.dumps(value),
106            )
107            while len(self._data) > self.max_entries:
108                del self._data[next(iter(self._data))]

Store value; ttl in seconds overrides the default.

def invalidate(self, namespace):
110    def invalidate(self, namespace):
111        with self._lock:
112            for k in [k for k in self._data if k[0] == namespace]:
113                del self._data[k]

Drop every entry in namespace.

def clear(self):
115    def clear(self):
116        with self._lock:
117            self._data.clear()

Drop every entry in every namespace.

class RedisCache(sqlmodel_crud_utils.CacheBackend):
133class RedisCache(CacheBackend):
134    """Redis-backed cache, shared across processes.
135
136    Needs the optional ``cache`` extra (``pip install
137    sqlmodel-crud-utilities[cache]``). Pass a ready ``client`` or a ``url``.
138    Each namespace keeps a Redis set of its keys so ``invalidate`` deletes
139    exactly those keys without scanning. An entry and its index membership
140    are written in one transaction, and the index set expires no earlier than
141    its latest entry (this needs Redis 7 for ``PEXPIRE`` with ``NX``/``GT``).
142    A ``ttl`` of zero or less stores nothing, matching ``InMemoryCache``.
143    The client must be synchronous; an async client raises ``TypeError``.
144
145    :param url: Redis URL used when no ``client`` is given.
146    :param client: An existing ``redis.Redis`` compatible client.
147    :param default_ttl: Seconds an entry lives unless ``set`` overrides it.
148    :param prefix: Prefix for every Redis key this backend writes.
149    """
150
151    blocking = True
152
153    def __init__(
154        self,
155        url: str | None = None,
156        client: Any = None,
157        default_ttl: float = DEFAULT_TTL,
158        prefix: str = "scu",
159    ):
160        if client is None:
161            try:
162                import redis
163            except ImportError as e:
164                raise ImportError(
165                    "RedisCache needs the 'cache' extra: "
166                    "pip install sqlmodel-crud-utilities[cache]"
167                ) from e
168            client = redis.Redis.from_url(url or "redis://localhost:6379/0")
169        if _is_async_client(client):
170            raise TypeError(
171                "RedisCache needs a synchronous client (redis.Redis), not an "
172                "async one; the async helpers already call it in a thread."
173            )
174        self._client = client
175        self.default_ttl = default_ttl
176        self._prefix = prefix
177
178    def _key(self, namespace, key):
179        return f"{self._prefix}:{namespace}:{key}"
180
181    def _index(self, namespace):
182        return f"{self._prefix}:idx:{namespace}"
183
184    def get(self, namespace, key):
185        raw = self._client.get(self._key(namespace, key))
186        if raw is None:
187            return None
188        if isinstance(raw, bytes):
189            raw = raw.decode()
190        return json.loads(raw)
191
192    def set(self, namespace, key, value, ttl=None):
193        ttl = self.default_ttl if ttl is None else ttl
194        full = self._key(namespace, key)
195        if ttl <= 0:
196            self._client.delete(full)
197            return
198        millis = max(1, math.ceil(ttl * 1000))
199        index = self._index(namespace)
200        pipe = self._client.pipeline(transaction=True)
201        pipe.set(full, json.dumps(value), px=millis)
202        pipe.sadd(index, full)
203        pipe.pexpire(index, millis, nx=True)
204        pipe.pexpire(index, millis, gt=True)
205        pipe.execute()
206
207    def invalidate(self, namespace):
208        index = self._index(namespace)
209        members = [
210            m.decode() if isinstance(m, bytes) else m
211            for m in self._client.smembers(index)
212        ]
213        self._client.delete(*members, index)
214
215    def clear(self):
216        keys = list(self._client.scan_iter(match=f"{self._prefix}:*"))
217        if keys:
218            self._client.delete(*keys)

Redis-backed cache, shared across processes.

Needs the optional cache extra (pip install sqlmodel-crud-utilities[cache]). Pass a ready client or a url. Each namespace keeps a Redis set of its keys so invalidate deletes exactly those keys without scanning. An entry and its index membership are written in one transaction, and the index set expires no earlier than its latest entry (this needs Redis 7 for PEXPIRE with NX/GT). A ttl of zero or less stores nothing, matching InMemoryCache. The client must be synchronous; an async client raises TypeError.

Parameters
  • url: Redis URL used when no client is given.
  • client: An existing redis.Redis compatible client.
  • default_ttl: Seconds an entry lives unless set overrides it.
  • prefix: Prefix for every Redis key this backend writes.
RedisCache( url: str | None = None, client: Any = None, default_ttl: float = 60.0, prefix: str = 'scu')
153    def __init__(
154        self,
155        url: str | None = None,
156        client: Any = None,
157        default_ttl: float = DEFAULT_TTL,
158        prefix: str = "scu",
159    ):
160        if client is None:
161            try:
162                import redis
163            except ImportError as e:
164                raise ImportError(
165                    "RedisCache needs the 'cache' extra: "
166                    "pip install sqlmodel-crud-utilities[cache]"
167                ) from e
168            client = redis.Redis.from_url(url or "redis://localhost:6379/0")
169        if _is_async_client(client):
170            raise TypeError(
171                "RedisCache needs a synchronous client (redis.Redis), not an "
172                "async one; the async helpers already call it in a thread."
173            )
174        self._client = client
175        self.default_ttl = default_ttl
176        self._prefix = prefix
blocking = True
default_ttl
def get(self, namespace, key):
184    def get(self, namespace, key):
185        raw = self._client.get(self._key(namespace, key))
186        if raw is None:
187            return None
188        if isinstance(raw, bytes):
189            raw = raw.decode()
190        return json.loads(raw)

Return the stored value, or None on a miss or expiry.

def set(self, namespace, key, value, ttl=None):
192    def set(self, namespace, key, value, ttl=None):
193        ttl = self.default_ttl if ttl is None else ttl
194        full = self._key(namespace, key)
195        if ttl <= 0:
196            self._client.delete(full)
197            return
198        millis = max(1, math.ceil(ttl * 1000))
199        index = self._index(namespace)
200        pipe = self._client.pipeline(transaction=True)
201        pipe.set(full, json.dumps(value), px=millis)
202        pipe.sadd(index, full)
203        pipe.pexpire(index, millis, nx=True)
204        pipe.pexpire(index, millis, gt=True)
205        pipe.execute()

Store value; ttl in seconds overrides the default.

def invalidate(self, namespace):
207    def invalidate(self, namespace):
208        index = self._index(namespace)
209        members = [
210            m.decode() if isinstance(m, bytes) else m
211            for m in self._client.smembers(index)
212        ]
213        self._client.delete(*members, index)

Drop every entry in namespace.

def clear(self):
215    def clear(self):
216        keys = list(self._client.scan_iter(match=f"{self._prefix}:*"))
217        if keys:
218            self._client.delete(*keys)

Drop every entry in every namespace.

def configure_cache(backend: CacheBackend | None) -> None:
224def configure_cache(backend: CacheBackend | None) -> None:
225    """Install ``backend`` as the process-wide cache, or ``None`` to disable.
226
227    Call once at startup. Without a backend, ``use_cache=True`` is a no-op.
228    """
229    global _backend
230    _backend = backend
231    if backend is not None:
232        _install_flush_listeners()

Install backend as the process-wide cache, or None to disable.

Call once at startup. Without a backend, use_cache=True is a no-op.

def invalidate_cache(model: type[sqlmodel.main.SQLModel] | None = None) -> None:
397def invalidate_cache(model: type[SQLModel] | None = None) -> None:
398    """Drop cached reads of ``model``, or everything when ``model`` is None.
399
400    Use after writes the library cannot see: raw SQL, direct
401    ``session.add``/``session.delete``, other processes, or database-side
402    cascades.
403    """
404    backend = _backend
405    if backend is None:
406        return
407    try:
408        if model is None:
409            backend.clear()
410        else:
411            backend.invalidate(namespace_for(model))
412    except Exception as e:
413        logger.error(f"Cache invalidation failed: {type(e), e}")

Drop cached reads of model, or everything when model is None.

Use after writes the library cannot see: raw SQL, direct session.add/session.delete, other processes, or database-side cascades.

HISTORY_METADATA = MetaData()
class TrackChangesMixin:
56class TrackChangesMixin:
57    """Marker mixin: row changes to this model are recorded.
58
59    It adds no columns. List column names in ``__track_exclude__`` to keep
60    them out of the history (for example credentials).
61
62    Example:
63        >>> class Widget(TrackChangesMixin, SQLModel, table=True):
64        ...     __track_exclude__ = ("token",)
65        ...     id: Optional[int] = Field(default=None, primary_key=True)
66        ...     name: str
67        ...     token: Optional[str] = None
68    """
69
70    __track_exclude__: tuple[str, ...] = ()

Marker mixin: row changes to this model are recorded.

It adds no columns. List column names in __track_exclude__ to keep them out of the history (for example credentials).

Example:

class Widget(TrackChangesMixin, SQLModel, table=True): ... __track_exclude__ = ("token",) ... id: Optional[int] = Field(default=None, primary_key=True) ... name: str ... token: Optional[str] = None

def register_change_tracking() -> None:
224def register_change_tracking() -> None:
225    """Enable change tracking process-wide. Safe to call more than once.
226
227    Installs ``before_flush`` and ``after_flush`` listeners on
228    :class:`sqlalchemy.orm.Session`.
229    ``AsyncSession`` delegates to a sync ``Session``, so this covers both.
230    Set ``session.info["changed_by"]`` (for ``AsyncSession`` use
231    ``session.sync_session.info``) to stamp records with an actor.
232    """
233    if not event.contains(Session, "after_flush", _record_flush):
234        event.listen(Session, "after_flush", _record_flush)
235    if not event.contains(Session, "before_flush", _capture_pre_images):
236        event.listen(Session, "before_flush", _capture_pre_images)

Enable change tracking process-wide. Safe to call more than once.

Installs before_flush and after_flush listeners on sqlalchemy.orm.Session. AsyncSession delegates to a sync Session, so this covers both. Set session.info["changed_by"] (for AsyncSession use session.sync_session.info) to stamp records with an actor.