sqlmodel-object-helpers 0.0.11__tar.gz → 0.0.12__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_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/PKG-INFO +14 -3
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/README.md +13 -2
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/pyproject.toml +4 -4
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/__init__.py +1 -1
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/query.py +12 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/standalone.py +6 -0
- sqlmodel_object_helpers-0.0.12/tests/test_for_update.py +72 -0
- sqlmodel_object_helpers-0.0.12/tests/test_for_update_postgres.py +92 -0
- sqlmodel_object_helpers-0.0.12/uv.lock +460 -0
- sqlmodel_object_helpers-0.0.11/tests/test_for_update.py +0 -39
- sqlmodel_object_helpers-0.0.11/uv.lock +0 -402
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.git-blame-ignore-revs +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.gitattributes +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.github/workflows/publish.yml +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.gitignore +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.vscode/settings.json +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/LICENSE +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/pyrightconfig.json +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/ruff.toml +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/constants.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/operators.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/session.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/columns.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/projections.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/conftest.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_bulk_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_column_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_computed_columns.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_conflict_mutations_limits.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_conflict_mutations_postgres.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_count_exists.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_data_error_handling.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_datetime_range.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_filters.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_generated_columns_pg.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_implicit_datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_insert_ignore_conflicts.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_operators.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_query.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_settings.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_standalone.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_time_filter.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_types.py +0 -0
- {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_upsert_objects.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlmodel-object-helpers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.12
|
|
4
4
|
Summary: Generic async query helpers for SQLModel: filtering, eager loading, pagination
|
|
5
5
|
Project-URL: Homepage, https://github.com/itstandart/sqlmodel-object-helpers
|
|
6
6
|
Project-URL: Repository, https://github.com/itstandart/sqlmodel-object-helpers
|
|
@@ -28,6 +28,8 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
|
|
29
29
|
# sqlmodel-object-helpers
|
|
30
30
|
|
|
31
|
+
**Version:** v0.0.12 · 2026-10-29 · Рома
|
|
32
|
+
|
|
31
33
|
[](https://pypi.org/project/sqlmodel-object-helpers/)
|
|
32
34
|
[](https://www.python.org/downloads/)
|
|
33
35
|
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
|
@@ -264,10 +266,15 @@ user = await soh.get_object(
|
|
|
264
266
|
# Graceful mode (returns None instead of raising)
|
|
265
267
|
user = await soh.get_object(session, User, pk={"id": 999}, suspend_error=True)
|
|
266
268
|
|
|
267
|
-
# Row-level locking (SELECT ... FOR UPDATE)
|
|
269
|
+
# Row-level locking (SELECT ... FOR UPDATE), preserving pending changes
|
|
268
270
|
user = await soh.get_object(session, User, pk={"id": 5}, for_update=True)
|
|
271
|
+
|
|
272
|
+
# Refresh an already loaded object when its pending changes have been flushed
|
|
273
|
+
user = await soh.get_object(session, User, pk={"id": 5}, for_update=True, populate_existing=True)
|
|
269
274
|
```
|
|
270
275
|
|
|
276
|
+
Use a caller-managed session for a read-modify-write transaction. The standalone wrapper commits and releases the lock before returning. `for_update=True` preserves pending changes in the session by default, so a previously loaded object may still show stale values. `populate_existing=True` refreshes that object, but discards its unflushed changes; flush first when those changes must be kept.
|
|
277
|
+
|
|
271
278
|
### get_objects
|
|
272
279
|
|
|
273
280
|
Fetch multiple objects with filtering, pagination, sorting, and eager loading.
|
|
@@ -961,7 +968,11 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
|
|
|
961
968
|
|
|
962
969
|
## Changelog
|
|
963
970
|
|
|
964
|
-
### 0.0.
|
|
971
|
+
### 0.0.12 — unreleased
|
|
972
|
+
|
|
973
|
+
- `get_object` supports explicit `populate_existing=True` for refreshing an already loaded ORM object, including locked reads. The default keeps unflushed changes in sessions with `autoflush=False`. SQL mode is unchanged.
|
|
974
|
+
|
|
975
|
+
### 0.0.11 — 2026-10-28
|
|
965
976
|
|
|
966
977
|
- **Performance** — `insert_objects_ignore_conflicts` and `upsert_objects` execute one single-row statement as executemany instead of compiling a multi-row `VALUES` statement per chunk; SQLAlchemy insertmanyvalues renders the rows into multi-row pages. No statement cache is involved: SQLAlchemy does not cache PostgreSQL `INSERT ... ON CONFLICT`. On PostgreSQL 18 with asyncpg, inserting 1000 rows of an 18-column table takes about 27 ms instead of 610 ms (20000 rows: 0.56 s instead of 13 s), on par with a hand-written bulk insert; upserting 1000 rows of a 6-column table takes about 12 ms instead of 165 ms (10000 rows: 0.11 s instead of 1.65 s), SAVEPOINTs included. Server-side execution time is unchanged.
|
|
967
978
|
- `upsert_objects` on PostgreSQL: a key repeated in Python terms starts the next statement, in input order, instead of re-running the batch row by row. Each page of a multi-row call keeps the 0.0.10 SAVEPOINT, also when its rows go one by one: a repeat only the database sees (column type, collation of the column or of the unique index, a `BEFORE INSERT` trigger) is retried row by row in a new SAVEPOINT. Another error, including during that retry, rolls back the page without aborting the caller's transaction. A single-row call goes without a SAVEPOINT.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# sqlmodel-object-helpers
|
|
2
2
|
|
|
3
|
+
**Version:** v0.0.12 · 2026-10-29 · Рома
|
|
4
|
+
|
|
3
5
|
[](https://pypi.org/project/sqlmodel-object-helpers/)
|
|
4
6
|
[](https://www.python.org/downloads/)
|
|
5
7
|
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
|
@@ -236,10 +238,15 @@ user = await soh.get_object(
|
|
|
236
238
|
# Graceful mode (returns None instead of raising)
|
|
237
239
|
user = await soh.get_object(session, User, pk={"id": 999}, suspend_error=True)
|
|
238
240
|
|
|
239
|
-
# Row-level locking (SELECT ... FOR UPDATE)
|
|
241
|
+
# Row-level locking (SELECT ... FOR UPDATE), preserving pending changes
|
|
240
242
|
user = await soh.get_object(session, User, pk={"id": 5}, for_update=True)
|
|
243
|
+
|
|
244
|
+
# Refresh an already loaded object when its pending changes have been flushed
|
|
245
|
+
user = await soh.get_object(session, User, pk={"id": 5}, for_update=True, populate_existing=True)
|
|
241
246
|
```
|
|
242
247
|
|
|
248
|
+
Use a caller-managed session for a read-modify-write transaction. The standalone wrapper commits and releases the lock before returning. `for_update=True` preserves pending changes in the session by default, so a previously loaded object may still show stale values. `populate_existing=True` refreshes that object, but discards its unflushed changes; flush first when those changes must be kept.
|
|
249
|
+
|
|
243
250
|
### get_objects
|
|
244
251
|
|
|
245
252
|
Fetch multiple objects with filtering, pagination, sorting, and eager loading.
|
|
@@ -933,7 +940,11 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
|
|
|
933
940
|
|
|
934
941
|
## Changelog
|
|
935
942
|
|
|
936
|
-
### 0.0.
|
|
943
|
+
### 0.0.12 — unreleased
|
|
944
|
+
|
|
945
|
+
- `get_object` supports explicit `populate_existing=True` for refreshing an already loaded ORM object, including locked reads. The default keeps unflushed changes in sessions with `autoflush=False`. SQL mode is unchanged.
|
|
946
|
+
|
|
947
|
+
### 0.0.11 — 2026-10-28
|
|
937
948
|
|
|
938
949
|
- **Performance** — `insert_objects_ignore_conflicts` and `upsert_objects` execute one single-row statement as executemany instead of compiling a multi-row `VALUES` statement per chunk; SQLAlchemy insertmanyvalues renders the rows into multi-row pages. No statement cache is involved: SQLAlchemy does not cache PostgreSQL `INSERT ... ON CONFLICT`. On PostgreSQL 18 with asyncpg, inserting 1000 rows of an 18-column table takes about 27 ms instead of 610 ms (20000 rows: 0.56 s instead of 13 s), on par with a hand-written bulk insert; upserting 1000 rows of a 6-column table takes about 12 ms instead of 165 ms (10000 rows: 0.11 s instead of 1.65 s), SAVEPOINTs included. Server-side execution time is unchanged.
|
|
939
950
|
- `upsert_objects` on PostgreSQL: a key repeated in Python terms starts the next statement, in input order, instead of re-running the batch row by row. Each page of a multi-row call keeps the 0.0.10 SAVEPOINT, also when its rows go one by one: a repeat only the database sees (column type, collation of the column or of the unique index, a `BEFORE INSERT` trigger) is retried row by row in a new SAVEPOINT. Another error, including during that retry, rolls back the page without aborting the caller's transaction. A single-row call goes without a SAVEPOINT.
|
|
@@ -45,11 +45,11 @@ packages = ["src/sqlmodel_object_helpers"]
|
|
|
45
45
|
|
|
46
46
|
[dependency-groups]
|
|
47
47
|
dev = [
|
|
48
|
-
"ruff==0.17.
|
|
48
|
+
"ruff==0.17.4",
|
|
49
49
|
"pyright==1.1.414",
|
|
50
|
-
"tzdata==2026.
|
|
51
|
-
"pytest==9.
|
|
52
|
-
"pytest-asyncio==1.4.
|
|
50
|
+
"tzdata==2026.6",
|
|
51
|
+
"pytest==9.2.0",
|
|
52
|
+
"pytest-asyncio==1.4.1.post1",
|
|
53
53
|
"aiosqlite==0.22.1",
|
|
54
54
|
]
|
|
55
55
|
postgres = [
|
|
@@ -256,6 +256,10 @@ async def _execute_with_pagination_dicts(
|
|
|
256
256
|
# ---------------------------------------------------------------------------
|
|
257
257
|
|
|
258
258
|
|
|
259
|
+
def _with_populate_existing(stmt: Any, enabled: bool) -> Any:
|
|
260
|
+
return stmt.execution_options(populate_existing=True) if enabled else stmt
|
|
261
|
+
|
|
262
|
+
|
|
259
263
|
@overload
|
|
260
264
|
async def get_object[T: SQLModel](
|
|
261
265
|
session: AsyncSession,
|
|
@@ -270,6 +274,7 @@ async def get_object[T: SQLModel](
|
|
|
270
274
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
271
275
|
limit_one: bool = True,
|
|
272
276
|
for_update: bool = False,
|
|
277
|
+
populate_existing: bool = False,
|
|
273
278
|
) -> T: ...
|
|
274
279
|
|
|
275
280
|
|
|
@@ -287,6 +292,7 @@ async def get_object[T: SQLModel](
|
|
|
287
292
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
288
293
|
limit_one: bool = True,
|
|
289
294
|
for_update: bool = False,
|
|
295
|
+
populate_existing: bool = False,
|
|
290
296
|
) -> T | None: ...
|
|
291
297
|
|
|
292
298
|
|
|
@@ -304,6 +310,7 @@ async def get_object[T: SQLModel](
|
|
|
304
310
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
305
311
|
limit_one: bool = True,
|
|
306
312
|
for_update: bool = False,
|
|
313
|
+
populate_existing: bool = False,
|
|
307
314
|
) -> list[dict[str, Any]]: ...
|
|
308
315
|
|
|
309
316
|
|
|
@@ -321,6 +328,7 @@ async def get_object[T: SQLModel](
|
|
|
321
328
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
322
329
|
limit_one: bool = True,
|
|
323
330
|
for_update: bool = False,
|
|
331
|
+
populate_existing: bool = False,
|
|
324
332
|
) -> list[dict[str, Any]] | T | None: ...
|
|
325
333
|
|
|
326
334
|
|
|
@@ -337,6 +345,7 @@ async def get_object[T: SQLModel](
|
|
|
337
345
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
338
346
|
limit_one: bool = True,
|
|
339
347
|
for_update: bool = False,
|
|
348
|
+
populate_existing: bool = False,
|
|
340
349
|
) -> list[dict[str, Any]] | T | None:
|
|
341
350
|
"""
|
|
342
351
|
Fetch a single object by primary key or filters.
|
|
@@ -344,6 +353,8 @@ async def get_object[T: SQLModel](
|
|
|
344
353
|
Supports two modes:
|
|
345
354
|
- ORM mode: returns a model instance with eager-loaded relationships
|
|
346
355
|
- SQL mode (columns + extra_joins): returns flat dicts
|
|
356
|
+
|
|
357
|
+
Flush pending changes before setting populate_existing=True.
|
|
347
358
|
"""
|
|
348
359
|
start = time.perf_counter()
|
|
349
360
|
|
|
@@ -381,6 +392,7 @@ async def get_object[T: SQLModel](
|
|
|
381
392
|
|
|
382
393
|
if for_update:
|
|
383
394
|
stmt = stmt.with_for_update()
|
|
395
|
+
stmt = _with_populate_existing(stmt, populate_existing)
|
|
384
396
|
|
|
385
397
|
if limit_one:
|
|
386
398
|
stmt = stmt.limit(1)
|
|
@@ -64,6 +64,7 @@ async def get_object[T: SQLModel](
|
|
|
64
64
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
65
65
|
limit_one: bool = True,
|
|
66
66
|
for_update: bool = False,
|
|
67
|
+
populate_existing: bool = False,
|
|
67
68
|
) -> T: ...
|
|
68
69
|
|
|
69
70
|
|
|
@@ -80,6 +81,7 @@ async def get_object[T: SQLModel](
|
|
|
80
81
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
81
82
|
limit_one: bool = True,
|
|
82
83
|
for_update: bool = False,
|
|
84
|
+
populate_existing: bool = False,
|
|
83
85
|
) -> T | None: ...
|
|
84
86
|
|
|
85
87
|
|
|
@@ -96,6 +98,7 @@ async def get_object[T: SQLModel](
|
|
|
96
98
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
97
99
|
limit_one: bool = True,
|
|
98
100
|
for_update: bool = False,
|
|
101
|
+
populate_existing: bool = False,
|
|
99
102
|
) -> list[dict[str, Any]]: ...
|
|
100
103
|
|
|
101
104
|
|
|
@@ -112,6 +115,7 @@ async def get_object[T: SQLModel](
|
|
|
112
115
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
113
116
|
limit_one: bool = True,
|
|
114
117
|
for_update: bool = False,
|
|
118
|
+
populate_existing: bool = False,
|
|
115
119
|
) -> list[dict[str, Any]] | T | None: ...
|
|
116
120
|
|
|
117
121
|
|
|
@@ -127,6 +131,7 @@ async def get_object[T: SQLModel](
|
|
|
127
131
|
extra_joins: list[tuple[Any, ...]] | None = None,
|
|
128
132
|
limit_one: bool = True,
|
|
129
133
|
for_update: bool = False,
|
|
134
|
+
populate_existing: bool = False,
|
|
130
135
|
) -> list[dict[str, Any]] | T | None:
|
|
131
136
|
"""Fetch a single object. Auto-creates session and commits."""
|
|
132
137
|
logger.debug("standalone.get_object(%s, pk=%s)", model.__name__, pk)
|
|
@@ -143,6 +148,7 @@ async def get_object[T: SQLModel](
|
|
|
143
148
|
extra_joins=extra_joins,
|
|
144
149
|
limit_one=limit_one,
|
|
145
150
|
for_update=for_update,
|
|
151
|
+
populate_existing=populate_existing,
|
|
146
152
|
)
|
|
147
153
|
|
|
148
154
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration tests for the for_update parameter in get_object().
|
|
3
|
+
|
|
4
|
+
SQLite does not support SELECT ... FOR UPDATE, so these tests cover:
|
|
5
|
+
1) for_update=False (default) works as before
|
|
6
|
+
2) for_update=True executes without error on SQLite (silently ignored)
|
|
7
|
+
3) for_update=True keeps unflushed changes by default
|
|
8
|
+
4) populate_existing=True refreshes an object already present in the identity map
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from conftest import Applicant
|
|
13
|
+
from sqlalchemy import update
|
|
14
|
+
|
|
15
|
+
import sqlmodel_object_helpers as soh
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.asyncio
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def test_get_object_default_no_for_update(session, seed_data):
|
|
21
|
+
"""Default for_update=False returns object normally."""
|
|
22
|
+
applicant = seed_data["applicants"][0]
|
|
23
|
+
result = await soh.get_object(session, Applicant, pk={"id": applicant.id})
|
|
24
|
+
|
|
25
|
+
assert result is not None
|
|
26
|
+
assert result.last_name == "Иванов"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def test_get_object_with_for_update(session, seed_data):
|
|
30
|
+
"""for_update=True adds WITH FOR UPDATE clause; SQLite ignores it gracefully."""
|
|
31
|
+
applicant = seed_data["applicants"][0]
|
|
32
|
+
result = await soh.get_object(
|
|
33
|
+
session,
|
|
34
|
+
Applicant,
|
|
35
|
+
pk={"id": applicant.id},
|
|
36
|
+
for_update=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert result is not None
|
|
40
|
+
assert result.id == applicant.id
|
|
41
|
+
assert result.last_name == "Иванов"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def test_for_update_refreshes_object_in_identity_map(session, seed_data):
|
|
45
|
+
applicant = seed_data["applicants"][0]
|
|
46
|
+
await session.execute(
|
|
47
|
+
update(Applicant)
|
|
48
|
+
.where(Applicant.id == applicant.id)
|
|
49
|
+
.values(last_name="Updated")
|
|
50
|
+
.execution_options(synchronize_session=False)
|
|
51
|
+
)
|
|
52
|
+
assert applicant.last_name == "Иванов"
|
|
53
|
+
|
|
54
|
+
ordinary = await soh.get_object(session, Applicant, pk={"id": applicant.id})
|
|
55
|
+
assert ordinary is applicant
|
|
56
|
+
assert ordinary.last_name == "Иванов"
|
|
57
|
+
|
|
58
|
+
locked = await soh.get_object(session, Applicant, pk={"id": applicant.id}, for_update=True, populate_existing=True)
|
|
59
|
+
assert locked is applicant
|
|
60
|
+
assert locked.last_name == "Updated"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_for_update_preserves_unflushed_changes_by_default(session, seed_data):
|
|
64
|
+
session.autoflush = False
|
|
65
|
+
applicant = seed_data["applicants"][0]
|
|
66
|
+
applicant.last_name = "Pending"
|
|
67
|
+
|
|
68
|
+
locked = await soh.get_object(session, Applicant, pk={"id": applicant.id}, for_update=True)
|
|
69
|
+
|
|
70
|
+
assert locked is applicant
|
|
71
|
+
assert locked.last_name == "Pending"
|
|
72
|
+
assert session.is_modified(applicant)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""PostgreSQL row lock and identity-map refresh tests."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from uuid import uuid7
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import pytest_asyncio
|
|
9
|
+
from sqlalchemy import MetaData, select, update
|
|
10
|
+
from sqlalchemy.exc import DBAPIError
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
12
|
+
from sqlalchemy.schema import CreateSchema, DropSchema
|
|
13
|
+
from sqlmodel import Field, SQLModel, col
|
|
14
|
+
|
|
15
|
+
import sqlmodel_object_helpers as soh
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.asyncio
|
|
18
|
+
|
|
19
|
+
_SCHEMA = "soh_lock_test"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _PgBase(SQLModel):
|
|
23
|
+
metadata: ClassVar[MetaData] = MetaData(schema=_SCHEMA) # pyright: ignore[reportIncompatibleVariableOverride]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LockedRow(_PgBase, table=True):
|
|
27
|
+
__tablename__ = "locked_rows" # pyright: ignore[reportAssignmentType] SQLModel declares it via declared_attr
|
|
28
|
+
|
|
29
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
30
|
+
value: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest_asyncio.fixture
|
|
34
|
+
async def engine():
|
|
35
|
+
url = os.environ.get("SOH_TEST_POSTGRES_URL")
|
|
36
|
+
if not url:
|
|
37
|
+
pytest.skip("Set SOH_TEST_POSTGRES_URL to run PostgreSQL tests")
|
|
38
|
+
schema = f"soh_lock_{uuid7().hex}"
|
|
39
|
+
eng = create_async_engine(url, execution_options={"schema_translate_map": {_SCHEMA: schema}})
|
|
40
|
+
try:
|
|
41
|
+
async with eng.begin() as connection:
|
|
42
|
+
await connection.execute(CreateSchema(schema))
|
|
43
|
+
await connection.run_sync(_PgBase.metadata.create_all)
|
|
44
|
+
yield eng
|
|
45
|
+
finally:
|
|
46
|
+
async with eng.begin() as connection:
|
|
47
|
+
await connection.execute(DropSchema(schema, cascade=True, if_exists=True))
|
|
48
|
+
await eng.dispose()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_locked_read_refreshes_cached_row_and_holds_lock(engine):
|
|
52
|
+
async with (
|
|
53
|
+
AsyncSession(engine, expire_on_commit=False) as holder,
|
|
54
|
+
AsyncSession(engine, expire_on_commit=False) as writer,
|
|
55
|
+
AsyncSession(engine, expire_on_commit=False) as contender,
|
|
56
|
+
):
|
|
57
|
+
row = LockedRow(value="before")
|
|
58
|
+
holder.add(row)
|
|
59
|
+
await holder.commit()
|
|
60
|
+
assert row.id is not None
|
|
61
|
+
|
|
62
|
+
cached = await soh.get_object(holder, LockedRow, pk={"id": row.id})
|
|
63
|
+
await holder.commit()
|
|
64
|
+
await writer.execute(update(LockedRow).where(col(LockedRow.id) == row.id).values(value="after"))
|
|
65
|
+
await writer.commit()
|
|
66
|
+
assert cached.value == "before"
|
|
67
|
+
|
|
68
|
+
locked = await soh.get_object(holder, LockedRow, pk={"id": row.id}, for_update=True, populate_existing=True)
|
|
69
|
+
assert locked is cached
|
|
70
|
+
assert locked.value == "after"
|
|
71
|
+
|
|
72
|
+
with pytest.raises(DBAPIError):
|
|
73
|
+
await contender.execute(select(LockedRow).where(col(LockedRow.id) == row.id).with_for_update(nowait=True))
|
|
74
|
+
await contender.rollback()
|
|
75
|
+
await holder.commit()
|
|
76
|
+
|
|
77
|
+
await contender.execute(select(LockedRow).where(col(LockedRow.id) == row.id).with_for_update(nowait=True))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_locked_read_keeps_pending_changes_with_autoflush_disabled(engine):
|
|
81
|
+
async with AsyncSession(engine, autoflush=False, expire_on_commit=False) as session:
|
|
82
|
+
row = LockedRow(value="saved")
|
|
83
|
+
session.add(row)
|
|
84
|
+
await session.commit()
|
|
85
|
+
|
|
86
|
+
row.value = "pending"
|
|
87
|
+
locked = await soh.get_object(session, LockedRow, pk={"id": row.id}, for_update=True)
|
|
88
|
+
|
|
89
|
+
assert locked is row
|
|
90
|
+
assert locked.value == "pending"
|
|
91
|
+
assert session.is_modified(row)
|
|
92
|
+
await session.rollback()
|