sqlmodel-crud-utilities 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {sqlmodel_crud_utilities-0.2.0/sqlmodel_crud_utilities.egg-info → sqlmodel_crud_utilities-0.3.0}/PKG-INFO +3 -3
  2. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/pyproject.toml +4 -4
  3. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0/sqlmodel_crud_utilities.egg-info}/PKG-INFO +3 -3
  4. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/SOURCES.txt +6 -0
  5. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/__init__.py +18 -3
  6. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/a_sync.py +162 -30
  7. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/mixins.py +43 -0
  8. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/sync.py +177 -40
  9. sqlmodel_crud_utilities-0.3.0/sqlmodel_crud_utils/utils.py +111 -0
  10. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/tests/test_async_utils.py +4 -4
  11. sqlmodel_crud_utilities-0.3.0/tests/test_bulk_id_list_ops.py +290 -0
  12. sqlmodel_crud_utilities-0.3.0/tests/test_bulk_upsert_sqlite.py +159 -0
  13. sqlmodel_crud_utilities-0.3.0/tests/test_get_row_param_order.py +38 -0
  14. sqlmodel_crud_utilities-0.3.0/tests/test_get_rows_custom_stmnt.py +129 -0
  15. sqlmodel_crud_utilities-0.3.0/tests/test_sync_async_parity.py +214 -0
  16. sqlmodel_crud_utilities-0.3.0/tests/test_timestamp_mixin.py +109 -0
  17. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/tests/test_v0_2_0_features.py +1 -1
  18. sqlmodel_crud_utilities-0.2.0/sqlmodel_crud_utils/utils.py +0 -52
  19. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/LICENSE +0 -0
  20. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/README.md +0 -0
  21. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/setup.cfg +0 -0
  22. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/dependency_links.txt +0 -0
  23. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/requires.txt +0 -0
  24. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/top_level.txt +0 -0
  25. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/exceptions.py +0 -0
  26. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/transactions.py +0 -0
  27. {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/tests/test_sync_utils.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel_crud_utilities
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A set of CRUD utilities to expedite operations with SQLModel
5
5
  Author-email: Francis Secada <francis.secada@gmail.com>
6
6
  License-Expression: MIT AND (Apache-2.0 OR BSD-2-Clause)
7
7
  Project-URL: Homepage, https://fsecada01.github.io/SQLModel-CRUD-Utilities/sqlmodel_crud_utils.html
8
- Project-URL: Repository, https://github.com/fsecada01/sqlmodel_crud_utils
9
- Project-URL: Bug Tracker, https://github.com/fsecada01/sqlmodel_crud_utils/issues
8
+ Project-URL: Repository, https://github.com/fsecada01/SQLModel-CRUD-Utilities
9
+ Project-URL: Bug Tracker, https://github.com/fsecada01/SQLModel-CRUD-Utilities/issues
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.9
@@ -58,7 +58,7 @@ line_length = 80
58
58
 
59
59
  [project]
60
60
  name = "sqlmodel_crud_utilities"
61
- version = "0.2.0"
61
+ version = "0.3.0"
62
62
  authors = [
63
63
  { name="Francis Secada", email = "francis.secada@gmail.com" }
64
64
  ]
@@ -80,8 +80,8 @@ dependencies = [
80
80
  loguru = ["loguru>=0.7.3"]
81
81
  [project.urls]
82
82
  "Homepage" = "https://fsecada01.github.io/SQLModel-CRUD-Utilities/sqlmodel_crud_utils.html"
83
- "Repository" = "https://github.com/fsecada01/sqlmodel_crud_utils"
84
- "Bug Tracker" = "https://github.com/fsecada01/sqlmodel_crud_utils/issues"
83
+ "Repository" = "https://github.com/fsecada01/SQLModel-CRUD-Utilities"
84
+ "Bug Tracker" = "https://github.com/fsecada01/SQLModel-CRUD-Utilities/issues"
85
85
 
86
86
 
87
87
  [dependency-groups]
@@ -91,6 +91,7 @@ dev = [
91
91
  "build>=1.2.2.post1",
92
92
  "factory-boy>=3.3.3",
93
93
  "isort>=6.0.1",
94
+ "loguru>=0.7.3",
94
95
  "pre-commit>=4.2.0",
95
96
  "pytest>=8.3.5",
96
97
  "pytest-asyncio>=0.26.0",
@@ -100,7 +101,6 @@ dev = [
100
101
  "ruff>=0.11.10",
101
102
  ]
102
103
 
103
-
104
104
  doc = [
105
105
  "pdoc>=15.0.1",
106
106
  ]
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel_crud_utilities
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A set of CRUD utilities to expedite operations with SQLModel
5
5
  Author-email: Francis Secada <francis.secada@gmail.com>
6
6
  License-Expression: MIT AND (Apache-2.0 OR BSD-2-Clause)
7
7
  Project-URL: Homepage, https://fsecada01.github.io/SQLModel-CRUD-Utilities/sqlmodel_crud_utils.html
8
- Project-URL: Repository, https://github.com/fsecada01/sqlmodel_crud_utils
9
- Project-URL: Bug Tracker, https://github.com/fsecada01/sqlmodel_crud_utils/issues
8
+ Project-URL: Repository, https://github.com/fsecada01/SQLModel-CRUD-Utilities
9
+ Project-URL: Bug Tracker, https://github.com/fsecada01/SQLModel-CRUD-Utilities/issues
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.9
@@ -14,5 +14,11 @@ sqlmodel_crud_utils/sync.py
14
14
  sqlmodel_crud_utils/transactions.py
15
15
  sqlmodel_crud_utils/utils.py
16
16
  tests/test_async_utils.py
17
+ tests/test_bulk_id_list_ops.py
18
+ tests/test_bulk_upsert_sqlite.py
19
+ tests/test_get_row_param_order.py
20
+ tests/test_get_rows_custom_stmnt.py
21
+ tests/test_sync_async_parity.py
17
22
  tests/test_sync_utils.py
23
+ tests/test_timestamp_mixin.py
18
24
  tests/test_v0_2_0_features.py
@@ -4,7 +4,7 @@ SQLModel CRUD Utilities
4
4
  A set of CRUD utilities to expedite operations with SQLModel, providing both
5
5
  synchronous and asynchronous support for common database operations.
6
6
 
7
- New in v0.2.0:
7
+ New in v0.2.0 (see CHANGELOG.md for later releases):
8
8
  - Custom exception hierarchy for better error handling
9
9
  - Transaction context managers for safe operations
10
10
  - Audit trail mixins (created_at, updated_at tracking)
@@ -12,13 +12,17 @@ New in v0.2.0:
12
12
  - Public API exports for easier imports
13
13
  """
14
14
 
15
- __version__ = "0.2.0"
15
+ __version__ = "0.3.0"
16
16
 
17
17
  # Import asynchronous functions with a_ prefix
18
+ from sqlmodel_crud_utils.a_sync import bulk_update_rows as a_bulk_update_rows
18
19
  from sqlmodel_crud_utils.a_sync import (
19
20
  bulk_upsert_mappings as a_bulk_upsert_mappings,
20
21
  )
21
22
  from sqlmodel_crud_utils.a_sync import delete_row as a_delete_row
23
+ from sqlmodel_crud_utils.a_sync import (
24
+ delete_rows_within_id_list as a_delete_rows_within_id_list,
25
+ )
22
26
  from sqlmodel_crud_utils.a_sync import get_one_or_create as a_get_one_or_create
23
27
  from sqlmodel_crud_utils.a_sync import (
24
28
  get_result_from_query as a_get_result_from_query,
@@ -43,12 +47,18 @@ from sqlmodel_crud_utils.exceptions import (
43
47
  )
44
48
 
45
49
  # Import mixins
46
- from sqlmodel_crud_utils.mixins import AuditMixin, SoftDeleteMixin
50
+ from sqlmodel_crud_utils.mixins import (
51
+ AuditMixin,
52
+ SoftDeleteMixin,
53
+ TimestampMixin,
54
+ )
47
55
 
48
56
  # Import synchronous functions
49
57
  from sqlmodel_crud_utils.sync import (
58
+ bulk_update_rows,
50
59
  bulk_upsert_mappings,
51
60
  delete_row,
61
+ delete_rows_within_id_list,
52
62
  get_one_or_create,
53
63
  get_result_from_query,
54
64
  get_row,
@@ -66,8 +76,10 @@ __all__ = [
66
76
  # Version
67
77
  "__version__",
68
78
  # Synchronous functions
79
+ "bulk_update_rows",
69
80
  "bulk_upsert_mappings",
70
81
  "delete_row",
82
+ "delete_rows_within_id_list",
71
83
  "get_one_or_create",
72
84
  "get_result_from_query",
73
85
  "get_row",
@@ -77,8 +89,10 @@ __all__ = [
77
89
  "update_row",
78
90
  "write_row",
79
91
  # Asynchronous functions
92
+ "a_bulk_update_rows",
80
93
  "a_bulk_upsert_mappings",
81
94
  "a_delete_row",
95
+ "a_delete_rows_within_id_list",
82
96
  "a_get_one_or_create",
83
97
  "a_get_result_from_query",
84
98
  "a_get_row",
@@ -100,4 +114,5 @@ __all__ = [
100
114
  # Mixins
101
115
  "AuditMixin",
102
116
  "SoftDeleteMixin",
117
+ "TimestampMixin",
103
118
  ]
@@ -4,15 +4,18 @@ from dateutil.parser import parse as date_parse
4
4
  from dotenv import load_dotenv
5
5
  from sqlalchemy.exc import MultipleResultsFound
6
6
  from sqlalchemy.orm import lazyload, selectinload
7
- from sqlmodel import SQLModel, select
7
+ from sqlmodel import SQLModel, delete, select, update
8
8
  from sqlmodel.ext.asyncio.session import AsyncSession
9
9
  from sqlmodel.sql.expression import SelectOfScalar
10
10
 
11
11
  from sqlmodel_crud_utils.utils import (
12
+ chunked,
12
13
  get_sql_dialect_import,
13
14
  get_val,
14
15
  is_date,
15
16
  logger,
17
+ validate_primary_key_field,
18
+ validate_update_columns,
16
19
  )
17
20
 
18
21
  load_dotenv() # take environment variables from .env.
@@ -215,20 +218,34 @@ async def get_rows(
215
218
  **kwargs,
216
219
  ):
217
220
  """
218
-
219
- :param session_inst:
220
- :param model:
221
- :param selectin:
222
- :param select_in_keys:
223
- :param lazy:
224
- :param lazy_load_keys:
225
- :param page_size:
226
- :param page:
227
- :param text_field:
228
- :param stmnt:
229
- :param kwargs:
230
- :return:
221
+ Retrieve rows for ``model``, building the query from ``kwargs`` or
222
+ running a caller-supplied statement.
223
+
224
+ Pagination applies only to statements built internally (``stmnt`` is
225
+ ``None``): ``page`` and ``page_size`` translate to OFFSET and LIMIT. A
226
+ caller-supplied ``stmnt`` is executed exactly as given; the caller owns
227
+ its pagination, so ``page``, ``page_size`` and any filter, sort or
228
+ loading arguments are ignored. Add ``.limit()``/``.offset()`` to the
229
+ statement yourself when you want a bounded result.
230
+
231
+ .. versionchanged:: Unreleased
232
+ A custom ``stmnt`` is no longer silently capped at ``page_size``
233
+ rows (issue #11).
234
+
235
+ :param session_inst: Active session used to execute the query.
236
+ :param model: SQLModel table class to query.
237
+ :param selectin: Enable ``selectinload`` for ``select_in_keys``.
238
+ :param select_in_keys: Relationship names to eager load via selectin.
239
+ :param lazy: Enable ``lazyload`` for ``lazy_load_keys``.
240
+ :param lazy_load_keys: Relationship names to lazy load.
241
+ :param page_size: Rows per page; ignored when ``stmnt`` is supplied.
242
+ :param page: 1-based page number; ignored when ``stmnt`` is supplied.
243
+ :param text_field: Field matched with a text search from ``kwargs``.
244
+ :param stmnt: Custom statement, executed as-is and never paginated.
245
+ :param kwargs: Filters and sort options for the built query.
246
+ :return: ``(success, rows)`` where ``success`` is True if rows exist.
231
247
  """
248
+ paginate = stmnt is None
232
249
  # kwargs = {k: v for k, v in kwargs.items() if v}
233
250
  # Inside get_rows (sync and async versions)
234
251
 
@@ -402,10 +419,8 @@ async def get_rows(
402
419
  f"'{key}' on model {model.__name__}"
403
420
  )
404
421
 
405
- # Apply pagination
406
- stmnt = stmnt.offset((page - 1) * page_size).limit(
407
- page_size
408
- ) # Corrected offset calculation
422
+ if paginate:
423
+ stmnt = stmnt.offset((page - 1) * page_size).limit(page_size)
409
424
  _result = await session_inst.exec(stmnt)
410
425
  results = _result.all()
411
426
  success = True if len(results) > 0 else False
@@ -438,6 +453,109 @@ async def get_rows_within_id_list(
438
453
  return success, results
439
454
 
440
455
 
456
+ async def delete_rows_within_id_list(
457
+ id_str_list: list[str | int],
458
+ session_inst: AsyncSession,
459
+ model: type[SQLModel],
460
+ pk_field: str = "id",
461
+ chunk_size: int = 500,
462
+ ) -> tuple[bool, int]:
463
+ """
464
+ Deletes every row whose primary key is within the provided list and
465
+ commits once. IDs with no matching row are ignored.
466
+
467
+ This is a hard SQL ``DELETE``: it bypasses ``SoftDeleteMixin`` (rows are
468
+ removed, not marked with ``deleted_at``) and any ORM-level hooks. The ID
469
+ list is split into ``chunk_size`` batches to stay under backend
470
+ bind-parameter limits. One ``DELETE ... WHERE pk IN (...)`` statement is
471
+ sent per batch (never per row), so 1,200 IDs at the default size is 3
472
+ statements. All batches share one transaction, so the call is
473
+ all-or-nothing.
474
+
475
+ :param id_str_list: List of primary key values to delete.
476
+ :param session_inst: SQLModel AsyncSession instance.
477
+ :param model: SQLModel class representing the table.
478
+ :param pk_field: Primary-key column to match against (default: "id").
479
+ :param chunk_size: Maximum IDs per DELETE statement (default: 500).
480
+ :return: Tuple[bool, int]: ``True`` and the number of rows deleted. The
481
+ count is 0 when nothing matched or the list was empty.
482
+ :raises ValueError: If ``pk_field`` is not a primary-key column of
483
+ ``model`` or ``chunk_size`` is not positive.
484
+ :raises Exception: Any database error, after the transaction has been
485
+ rolled back and the error logged.
486
+ """
487
+ validate_primary_key_field(model, pk_field)
488
+ deleted = 0
489
+ try:
490
+ for chunk in chunked(id_str_list, chunk_size):
491
+ stmnt = delete(model).where(getattr(model, pk_field).in_(chunk))
492
+ result = await session_inst.exec(stmnt)
493
+ deleted += max(result.rowcount, 0)
494
+ await session_inst.commit()
495
+ except Exception as e:
496
+ await session_inst.rollback()
497
+ logger.error(f"Failed to bulk delete rows: {type(e)}, {e}")
498
+ raise
499
+
500
+ return True, deleted
501
+
502
+
503
+ async def bulk_update_rows(
504
+ id_str_list: list[str | int],
505
+ data: dict,
506
+ session_inst: AsyncSession,
507
+ model: type[SQLModel],
508
+ pk_field: str = "id",
509
+ chunk_size: int = 500,
510
+ ) -> tuple[bool, int]:
511
+ """
512
+ Applies the same column values to every row whose primary key is within
513
+ the provided list and commits once. IDs with no matching row are ignored.
514
+
515
+ This is a Core ``UPDATE``: ORM-level hooks and Python-side defaults do
516
+ not run, so ``AuditMixin.updated_at``/``updated_by`` are not refreshed
517
+ unless the caller includes them in ``data`` (column-level ``onupdate``
518
+ defaults, such as ``TimestampMixin.updated_at``, do fire). The ID list is
519
+ split into ``chunk_size`` batches. One ``UPDATE ... WHERE pk IN (...)``
520
+ statement is sent per batch (never per row), and all batches share one
521
+ transaction.
522
+
523
+ :param id_str_list: List of primary key values to update.
524
+ :param data: Mapping of column name to the new value for every row. Must
525
+ be non-empty and may not name a primary-key column.
526
+ :param session_inst: SQLModel AsyncSession instance.
527
+ :param model: SQLModel class representing the table.
528
+ :param pk_field: Primary-key column to match against (default: "id").
529
+ :param chunk_size: Maximum IDs per UPDATE statement (default: 500).
530
+ :return: Tuple[bool, int]: ``True`` and the number of rows matched by the
531
+ update. The count is 0 when nothing matched or the list was empty.
532
+ :raises ValueError: If ``pk_field`` is not a primary-key column,
533
+ ``data`` is empty or names an unknown or primary-key column, or
534
+ ``chunk_size`` is not positive.
535
+ :raises Exception: Any database error, after the transaction has been
536
+ rolled back and the error logged.
537
+ """
538
+ validate_primary_key_field(model, pk_field)
539
+ validate_update_columns(model, data)
540
+ updated = 0
541
+ try:
542
+ for chunk in chunked(id_str_list, chunk_size):
543
+ stmnt = (
544
+ update(model)
545
+ .where(getattr(model, pk_field).in_(chunk))
546
+ .values(**data)
547
+ )
548
+ result = await session_inst.exec(stmnt)
549
+ updated += max(result.rowcount, 0)
550
+ await session_inst.commit()
551
+ except Exception as e:
552
+ await session_inst.rollback()
553
+ logger.error(f"Failed to bulk update rows: {type(e)}, {e}")
554
+ raise
555
+
556
+ return True, updated
557
+
558
+
441
559
  async def delete_row(
442
560
  id_str: str | int,
443
561
  session_inst: AsyncSession,
@@ -482,12 +600,25 @@ async def bulk_upsert_mappings(
482
600
  pk_fields: list[str] | None = None,
483
601
  ):
484
602
  """
485
-
486
- :param payload:
487
- :param session_inst:
488
- :param model:
489
- :param pk_fields:
490
- :return:
603
+ Insert or update a batch of rows in a single ``INSERT ... ON CONFLICT DO
604
+ UPDATE ... RETURNING`` statement.
605
+
606
+ The ``RETURNING`` rows are fully read (``.all()``) before ``commit()``.
607
+ SQLite refuses to commit while a result cursor is still unread
608
+ ("cannot commit transaction - SQL statements in progress"); Postgres
609
+ tolerates it. The upsert is also executed exactly once rather than once
610
+ bare and once more to fetch the rows.
611
+
612
+ Returned instances are expired by ``commit()`` when the session uses
613
+ ``expire_on_commit=True`` (the default), so read their attributes while
614
+ the session is open or create the session with ``expire_on_commit=False``.
615
+
616
+ :param payload: Row mappings to upsert; the first mapping's keys decide
617
+ which columns are overwritten on conflict.
618
+ :param session_inst: Active session.
619
+ :param model: Table model being upserted.
620
+ :param pk_fields: Conflict-target columns. Defaults to ``["id"]``.
621
+ :return: ``(True, rows)`` where ``rows`` are the upserted model instances.
491
622
  """
492
623
  if not pk_fields:
493
624
  pk_fields = ["id"]
@@ -496,15 +627,16 @@ async def bulk_upsert_mappings(
496
627
  index_elements=[getattr(model, x) for x in pk_fields],
497
628
  set_={k: getattr(stmnt.excluded, k) for k in payload[0].keys()},
498
629
  )
499
- await session_inst.exec(stmnt)
500
-
501
- results = await session_inst.scalars(
502
- stmnt.returning(model), execution_options={"populate_existing": True}
503
- )
630
+ results = (
631
+ await session_inst.scalars(
632
+ stmnt.returning(model),
633
+ execution_options={"populate_existing": True},
634
+ )
635
+ ).all()
504
636
 
505
637
  await session_inst.commit()
506
638
 
507
- return True, results.all()
639
+ return True, results
508
640
 
509
641
 
510
642
  async def update_row(
@@ -7,6 +7,8 @@ functionality like audit trails and soft delete support.
7
7
  from datetime import datetime, timezone
8
8
  from typing import Optional
9
9
 
10
+ from sqlalchemy import Column, DateTime, func
11
+ from sqlalchemy.orm import declared_attr
10
12
  from sqlmodel import Field
11
13
 
12
14
 
@@ -15,6 +17,47 @@ def _utc_now() -> datetime:
15
17
  return datetime.now(timezone.utc)
16
18
 
17
19
 
20
+ class TimestampMixin:
21
+ """Lightweight mixin adding only ``created_at`` and ``updated_at``.
22
+
23
+ Use this instead of ``AuditMixin`` when there is no user context to
24
+ record. Each column is produced by ``declared_attr``, so SQLAlchemy
25
+ builds a fresh ``Column`` for every concrete ``table=True`` model and
26
+ the mixin can safely be applied to any number of models.
27
+
28
+ Both columns use ``server_default=func.now()``, so the database fills
29
+ them even for inserts that bypass Python-side defaults, such as
30
+ ``bulk_insert_mappings`` or raw ``INSERT`` statements. ``updated_at``
31
+ additionally refreshes through ``onupdate=func.now()`` on ORM updates.
32
+
33
+ The ``declared_attr`` methods intentionally carry no return
34
+ annotations: SQLAlchemy's annotated-declarative scanner would treat
35
+ them as bare field annotations and raise ``MappedAnnotationError``.
36
+
37
+ Example:
38
+ >>> class Location(TimestampMixin, SQLModel, table=True):
39
+ ... id: Optional[int] = Field(default=None, primary_key=True)
40
+ ... name: str
41
+ """
42
+
43
+ @declared_attr
44
+ def created_at(cls):
45
+ return Column(
46
+ DateTime(timezone=True),
47
+ server_default=func.now(),
48
+ nullable=False,
49
+ )
50
+
51
+ @declared_attr
52
+ def updated_at(cls):
53
+ return Column(
54
+ DateTime(timezone=True),
55
+ server_default=func.now(),
56
+ onupdate=func.now(),
57
+ nullable=False,
58
+ )
59
+
60
+
18
61
  class AuditMixin:
19
62
  """Mixin for automatic audit trail tracking.
20
63