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]
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]:
Trueand the number of rows matched by the update. The count is 0 when nothing matched or the list was empty.
Raises
- ValueError: If
pk_fieldis not a primary-key column,datais empty or names an unknown or primary-key column, orchunk_sizeis not positive. - Exception: Any database error, after the transaction has been rolled back and the error logged.
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
SETclause ofON CONFLICT DO UPDATE, ADR-0016). Defaults toNone: every column in the first mapping is overwritten. New rows are always inserted with every provided column. Names are table column keys, as inpayload; duplicates are ignored.
Returns
(True, rows)whererowsare the upserted model instances.
Raises
- ValueError: Before any statement is sent, if
update_columnsis empty or a bare string, names an unknown column, a primary-key column or apk_fieldscolumn, or names a column the first mapping ofpayloaddoes not provide.
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
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 theSoftDeleteMixincolumnsis_deleted,deleted_atanddeleted_by(default:False). - deleted_by: User recorded in
deleted_byon soft-deleted rows. Only valid withsoft=True(default:None).
Returns
Tuple[bool, int]:
Trueand 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_fieldis not a primary-key column ofmodel,chunk_sizeis not positive,soft=Trueis used on a model without theSoftDeleteMixincolumns (the message names the missing ones), ordeleted_byis given withoutsoft=True. - Exception: Any database error, after the transaction has been rolled back and the error logged.
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]]
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]
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
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
selectinloadforselect_in_keys. - select_in_keys: Relationship name(s) to eager load.
- lazy: Apply
lazyloadforlazy_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 withselectinorlazy. - cache_ttl: Seconds the entry lives; backend default when None.
Returns
Tuple[bool, SQLModel | None]: Success flag and the row, if found.
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
selectinloadforselect_in_keys. - select_in_keys: Relationship names to eager load via selectin.
- lazy: Enable
lazyloadforlazy_load_keys. - lazy_load_keys: Relationship names to lazy load.
- page_size: Rows per page; ignored when
stmntis supplied. - page: 1-based page number; ignored when
stmntis 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 withstmnt,selectinorlazy. - cache_ttl: Seconds the entry lives; backend default when None.
- kwargs: Filters and sort options for the built query.
Returns
(success, rows)wheresuccessis True if rows exist.
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.
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
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
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]
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]:
Trueand the number of rows matched by the update. The count is 0 when nothing matched or the list was empty.
Raises
- ValueError: If
pk_fieldis not a primary-key column,datais empty or names an unknown or primary-key column, orchunk_sizeis not positive. - Exception: Any database error, after the transaction has been rolled back and the error logged.
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
SETclause ofON CONFLICT DO UPDATE, ADR-0016). Defaults toNone: every column in the first mapping is overwritten. New rows are always inserted with every provided column. Names are table column keys, as inpayload; duplicates are ignored.
Returns
(True, rows)whererowsare the upserted model instances.
Raises
- ValueError: Before any statement is sent, if
update_columnsis empty or a bare string, names an unknown column, a primary-key column or apk_fieldscolumn, or names a column the first mapping ofpayloaddoes not provide.
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
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 theSoftDeleteMixincolumnsis_deleted,deleted_atanddeleted_by(default:False). - deleted_by: User recorded in
deleted_byon soft-deleted rows. Only valid withsoft=True(default:None).
Returns
Tuple[bool, int]:
Trueand 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_fieldis not a primary-key column ofmodel,chunk_sizeis not positive,soft=Trueis used on a model without theSoftDeleteMixincolumns (the message names the missing ones), ordeleted_byis given withoutsoft=True. - Exception: Any database error, after the transaction has been rolled back and the error logged.
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]]
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]
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
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 withselectinorlazy. - cache_ttl: Seconds the entry lives; backend default when None.
Returns
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
selectinloadforselect_in_keys. - select_in_keys: Relationship names to eager load via selectin.
- lazy: Enable
lazyloadforlazy_load_keys. - lazy_load_keys: Relationship names to lazy load.
- page_size: Rows per page; ignored when
stmntis supplied. - page: 1-based page number; ignored when
stmntis 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 withstmnt,selectinorlazy. - cache_ttl: Seconds the entry lives; backend default when None.
- kwargs: Filters and sort options for the built query.
Returns
(success, rows)wheresuccessis True if rows exist.
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
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
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
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]
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).
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);rowsis an empty list when nothing matched.
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);rowisNonewhen nothing matched.
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)withsuccessTrue when count > 0.
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).
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);rowsis an empty list when nothing matched.
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);rowisNonewhen nothing matched.
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)withsuccessTrue when count > 0.
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}")
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
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").
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'})
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.
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" ... } ... )
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.
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
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.
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())
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(...)
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.
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
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
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.
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`.
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.
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.
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
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.
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.
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.
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
setoverrides it. - max_entries: Entry cap across namespaces; the oldest entry is evicted first when it is exceeded.
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.
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.
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
clientis given. - client: An existing
redis.Rediscompatible client. - default_ttl: Seconds an entry lives unless
setoverrides it. - prefix: Prefix for every Redis key this backend writes.
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
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.
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.
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.
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.
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
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.