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.
- {sqlmodel_crud_utilities-0.2.0/sqlmodel_crud_utilities.egg-info → sqlmodel_crud_utilities-0.3.0}/PKG-INFO +3 -3
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/pyproject.toml +4 -4
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0/sqlmodel_crud_utilities.egg-info}/PKG-INFO +3 -3
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/SOURCES.txt +6 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/__init__.py +18 -3
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/a_sync.py +162 -30
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/mixins.py +43 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/sync.py +177 -40
- sqlmodel_crud_utilities-0.3.0/sqlmodel_crud_utils/utils.py +111 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/tests/test_async_utils.py +4 -4
- sqlmodel_crud_utilities-0.3.0/tests/test_bulk_id_list_ops.py +290 -0
- sqlmodel_crud_utilities-0.3.0/tests/test_bulk_upsert_sqlite.py +159 -0
- sqlmodel_crud_utilities-0.3.0/tests/test_get_row_param_order.py +38 -0
- sqlmodel_crud_utilities-0.3.0/tests/test_get_rows_custom_stmnt.py +129 -0
- sqlmodel_crud_utilities-0.3.0/tests/test_sync_async_parity.py +214 -0
- sqlmodel_crud_utilities-0.3.0/tests/test_timestamp_mixin.py +109 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/tests/test_v0_2_0_features.py +1 -1
- sqlmodel_crud_utilities-0.2.0/sqlmodel_crud_utils/utils.py +0 -52
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/LICENSE +0 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/README.md +0 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/setup.cfg +0 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/dependency_links.txt +0 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/requires.txt +0 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utilities.egg-info/top_level.txt +0 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/exceptions.py +0 -0
- {sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/transactions.py +0 -0
- {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.
|
|
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/
|
|
9
|
-
Project-URL: Bug Tracker, https://github.com/fsecada01/
|
|
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.
|
|
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/
|
|
84
|
-
"Bug Tracker" = "https://github.com/fsecada01/
|
|
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.
|
|
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/
|
|
9
|
-
Project-URL: Bug Tracker, https://github.com/fsecada01/
|
|
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
|
{sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/__init__.py
RENAMED
|
@@ -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.
|
|
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
|
|
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
|
]
|
{sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/a_sync.py
RENAMED
|
@@ -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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
639
|
+
return True, results
|
|
508
640
|
|
|
509
641
|
|
|
510
642
|
async def update_row(
|
{sqlmodel_crud_utilities-0.2.0 → sqlmodel_crud_utilities-0.3.0}/sqlmodel_crud_utils/mixins.py
RENAMED
|
@@ -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
|
|