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.
Files changed (57) hide show
  1. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/PKG-INFO +14 -3
  2. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/README.md +13 -2
  3. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/pyproject.toml +4 -4
  4. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/__init__.py +1 -1
  5. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/query.py +12 -0
  6. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/standalone.py +6 -0
  7. sqlmodel_object_helpers-0.0.12/tests/test_for_update.py +72 -0
  8. sqlmodel_object_helpers-0.0.12/tests/test_for_update_postgres.py +92 -0
  9. sqlmodel_object_helpers-0.0.12/uv.lock +460 -0
  10. sqlmodel_object_helpers-0.0.11/tests/test_for_update.py +0 -39
  11. sqlmodel_object_helpers-0.0.11/uv.lock +0 -402
  12. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.git-blame-ignore-revs +0 -0
  13. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.gitattributes +0 -0
  14. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.github/workflows/publish.yml +0 -0
  15. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.gitignore +0 -0
  16. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/.vscode/settings.json +0 -0
  17. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/LICENSE +0 -0
  18. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/pyrightconfig.json +0 -0
  19. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/ruff.toml +0 -0
  20. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/constants.py +0 -0
  21. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
  22. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/exceptions.py +0 -0
  23. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/filters.py +0 -0
  24. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/loaders.py +0 -0
  25. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/mutations.py +0 -0
  26. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/operators.py +0 -0
  27. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/session.py +0 -0
  28. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
  29. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/columns.py +0 -0
  30. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
  31. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/filters.py +0 -0
  32. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
  33. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/projections.py +0 -0
  34. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/conftest.py +0 -0
  35. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_bulk_mutations.py +0 -0
  36. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_column_meta.py +0 -0
  37. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_computed_columns.py +0 -0
  38. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_conflict_mutations_limits.py +0 -0
  39. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_conflict_mutations_postgres.py +0 -0
  40. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_count_exists.py +0 -0
  41. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_data_error_handling.py +0 -0
  42. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_datetime_range.py +0 -0
  43. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_dynamic_meta.py +0 -0
  44. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_exceptions.py +0 -0
  45. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_filters.py +0 -0
  46. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_generated_columns_pg.py +0 -0
  47. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_implicit_datetime.py +0 -0
  48. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_insert_ignore_conflicts.py +0 -0
  49. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_loaders.py +0 -0
  50. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_mutations.py +0 -0
  51. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_operators.py +0 -0
  52. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_query.py +0 -0
  53. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_settings.py +0 -0
  54. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_standalone.py +0 -0
  55. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_time_filter.py +0 -0
  56. {sqlmodel_object_helpers-0.0.11 → sqlmodel_object_helpers-0.0.12}/tests/test_types.py +0 -0
  57. {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.11
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
  [![PyPI version](https://badge.fury.io/py/sqlmodel-object-helpers.svg)](https://pypi.org/project/sqlmodel-object-helpers/)
32
34
  [![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/)
33
35
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](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.11 — unreleased
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
  [![PyPI version](https://badge.fury.io/py/sqlmodel-object-helpers.svg)](https://pypi.org/project/sqlmodel-object-helpers/)
4
6
  [![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/)
5
7
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](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.11 — unreleased
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.2",
48
+ "ruff==0.17.4",
49
49
  "pyright==1.1.414",
50
- "tzdata==2026.5",
51
- "pytest==9.1.1",
52
- "pytest-asyncio==1.4.0",
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 = [
@@ -1,6 +1,6 @@
1
1
  """sqlmodel-object-helpers — reusable query helpers for SQLModel projects."""
2
2
 
3
- __version__ = "0.0.11"
3
+ __version__ = "0.0.12"
4
4
 
5
5
  from .constants import QueryHelperSettings, settings
6
6
  from .dynamic_meta import (
@@ -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()