sqlmodel-object-helpers 0.0.10__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 (59) hide show
  1. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/PKG-INFO +39 -8
  2. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/README.md +38 -7
  3. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/pyproject.toml +5 -5
  4. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/__init__.py +1 -1
  5. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/mutations.py +179 -103
  6. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/query.py +12 -0
  7. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/standalone.py +6 -0
  8. sqlmodel_object_helpers-0.0.12/tests/test_conflict_mutations_limits.py +224 -0
  9. sqlmodel_object_helpers-0.0.12/tests/test_conflict_mutations_postgres.py +874 -0
  10. sqlmodel_object_helpers-0.0.12/tests/test_for_update.py +72 -0
  11. sqlmodel_object_helpers-0.0.12/tests/test_for_update_postgres.py +92 -0
  12. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_insert_ignore_conflicts.py +2 -2
  13. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_upsert_objects.py +2 -2
  14. sqlmodel_object_helpers-0.0.12/uv.lock +460 -0
  15. sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_limits.py +0 -150
  16. sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_postgres.py +0 -473
  17. sqlmodel_object_helpers-0.0.10/tests/test_for_update.py +0 -39
  18. sqlmodel_object_helpers-0.0.10/uv.lock +0 -382
  19. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/.git-blame-ignore-revs +0 -0
  20. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/.gitattributes +0 -0
  21. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/.github/workflows/publish.yml +0 -0
  22. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/.gitignore +0 -0
  23. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/.vscode/settings.json +0 -0
  24. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/LICENSE +0 -0
  25. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/pyrightconfig.json +0 -0
  26. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/ruff.toml +0 -0
  27. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/constants.py +0 -0
  28. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
  29. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/exceptions.py +0 -0
  30. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/filters.py +0 -0
  31. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/loaders.py +0 -0
  32. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/operators.py +0 -0
  33. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/session.py +0 -0
  34. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
  35. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/columns.py +0 -0
  36. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
  37. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/filters.py +0 -0
  38. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
  39. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/src/sqlmodel_object_helpers/types/projections.py +0 -0
  40. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/conftest.py +0 -0
  41. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_bulk_mutations.py +0 -0
  42. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_column_meta.py +0 -0
  43. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_computed_columns.py +0 -0
  44. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_count_exists.py +0 -0
  45. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_data_error_handling.py +0 -0
  46. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_datetime_range.py +0 -0
  47. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_dynamic_meta.py +0 -0
  48. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_exceptions.py +0 -0
  49. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_filters.py +0 -0
  50. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_generated_columns_pg.py +0 -0
  51. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_implicit_datetime.py +0 -0
  52. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_loaders.py +0 -0
  53. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_mutations.py +0 -0
  54. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_operators.py +0 -0
  55. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_query.py +0 -0
  56. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_settings.py +0 -0
  57. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_standalone.py +0 -0
  58. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_time_filter.py +0 -0
  59. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.12}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sqlmodel-object-helpers
3
- Version: 0.0.10
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/)
@@ -43,8 +45,8 @@ Generic async query helpers for [SQLModel](https://sqlmodel.tiangolo.com/): filt
43
45
  - **Pagination** - Page/per\_page with total count and configurable max\_per\_page limit
44
46
  - **Projections** - Select specific columns across joins with dot-notation and SQL aliases
45
47
  - **CRUD Mutations** - `add_object`, `update_object`, `delete_object` plus bulk `update_objects`, `delete_objects` with flush-only semantics for composable transactions
46
- - **Idempotent Bulk Insert** - `insert_objects_ignore_conflicts`: one `INSERT ... ON CONFLICT DO NOTHING` per chunk (PostgreSQL, SQLite), safe to replay
47
- - **Bulk Upsert** - `upsert_objects`: one `INSERT ... ON CONFLICT DO UPDATE` per chunk (PostgreSQL, SQLite); update expressions run on every call
48
+ - **Idempotent Bulk Insert** - `insert_objects_ignore_conflicts`: `INSERT ... ON CONFLICT DO NOTHING` as executemany of one single-row statement (PostgreSQL, SQLite), safe to replay
49
+ - **Bulk Upsert** - `upsert_objects`: `INSERT ... ON CONFLICT DO UPDATE` as executemany of one single-row statement (PostgreSQL, SQLite); update expressions run on every call
48
50
  - **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
49
51
  - **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
50
52
  - **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
@@ -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.
@@ -465,9 +472,9 @@ inserted = await soh.insert_objects_ignore_conflicts(
465
472
  # inserted -> rows actually written; duplicates by source_id are skipped and not counted
466
473
  ```
467
474
 
468
- Replaying the same batch (or two concurrent identical batches) is safe, unlike `add_objects`, where one duplicate fails the whole batch. All rows must have the same keys. Rows go to the database as given (no Pydantic validation or coercion -- pass already-typed values); omitted columns get their column defaults: `Field(default=...)` / `default_factory` of a plain field and `server_default` (SQLModel does not copy `Field(default)` into an explicit `sa_column`). Large inputs are split using the actual compiled bind-parameter count, including omitted Python defaults and SQL expressions. One row exceeding the parameter budget raises `MutationError`. PostgreSQL and SQLite only (`MutationError` on other dialects).
475
+ Replaying the same batch (or two concurrent identical batches) is safe, unlike `add_objects`, where one duplicate fails the whole batch. All rows must have the same keys. Rows go to the database as given (no Pydantic validation or coercion -- pass already-typed values); omitted columns get their column defaults: `Field(default=...)` / `default_factory` of a plain field and `server_default` (SQLModel does not copy `Field(default)` into an explicit `sa_column`). Rows go as executemany of one single-row statement: SQLAlchemy [insertmanyvalues](https://docs.sqlalchemy.org/en/20/core/connections.html#engine-insertmanyvalues) renders up to `insertmanyvalues_page_size` rows (1000 by default) per INSERT and keeps each statement within the dialect parameter limit, omitted Python defaults included. A row with an SQL expression value (`func.now()`, `literal(...)`) cannot be an executemany parameter set, so it is inserted by its own statement, in input order. An engine created with `use_insertmanyvalues=False` cannot return rows from an executemany, so there every row runs as its own statement. A Core INSERT does not autoflush, so pending session changes are flushed first. PostgreSQL and SQLite only (`MutationError` on other dialects).
469
476
 
470
- The caller owns the transaction. If a later chunk fails, roll back the transaction to discard earlier chunks too. The helper does not commit or perform its own rollback. Standalone wrappers manage commit/rollback automatically.
477
+ The caller owns the transaction. If a later batch fails, roll back the transaction to discard earlier batches too. The helper does not commit or perform its own rollback. Standalone wrappers manage commit/rollback automatically.
471
478
 
472
479
  ### upsert_objects
473
480
 
@@ -487,10 +494,19 @@ written = await soh.upsert_objects(
487
494
  # written -> distinct table rows inserted or updated, counted by primary key from RETURNING
488
495
  ```
489
496
 
490
- If a key repeats inside `rows`, the last row wins and the row is counted once. Repeats are decided by the database for the column type (`NUMERIC` rounding, `citext`, `NULLS NOT DISTINCT`), not by Python equality: distinct values such as two instants of a DST fold stay distinct, and key values do not have to be hashable (`ARRAY`). On PostgreSQL a chunk of several rows runs inside a SAVEPOINT (`session.begin_nested()` flushes pending session changes first); a chunk the database rejects for a repeated key is retried row by row, which is slower. SQLite applies repeated keys in order within one statement. Every `conflict_columns` field must be present in each row. `update_columns` must be a subset of the row fields outside `conflict_columns`; `set_extra` takes model fields (value or SQL expression) that are in neither. When nothing is left to update, use `insert_objects_ignore_conflicts` instead (`MutationError`). `Column.onupdate` does not fire on conflict -- pass it in `set_extra`. Objects already loaded in the session are not refreshed. A conditional update (`WHERE`) or `RETURNING` is not covered -- build the statement with SQLAlchemy for that. Other rules as in `insert_objects_ignore_conflicts`: same keys in all rows, no Pydantic validation, actual parameter-based chunking, caller-managed transaction, PostgreSQL and SQLite only.
497
+ If a key repeats inside `rows`, the last row wins and the row is counted once, with the result of row-by-row execution. PostgreSQL rejects a statement that hits one key twice, so on PostgreSQL:
498
+
499
+ - a key value equal in Python to an earlier one starts the next statement, in input order (rows of other keys may depend on earlier rows through another unique constraint): one INSERT per page plus one per repeat. `ARRAY` and `JSONB` values are compared by content;
500
+ - each page of up to `insertmanyvalues_page_size` rows of a multi-row call runs in a SAVEPOINT, also when its rows go one by one (a row with an SQL expression, an expanding `IN` in `set_extra`, `use_insertmanyvalues=False`), since only the database decides what is a repeat: the column type (`NUMERIC` rounding, `citext`, the `VARCHAR(n)` cut), the collation of the column or of the unique index, a `BEFORE INSERT` trigger that changes the key, `NULLS NOT DISTINCT`. A page the database rejects for a repeated key is rolled back to the SAVEPOINT and re-run row by row in a new SAVEPOINT, so an error during the retry also rolls back that entire page; distinct values such as two instants of a DST fold stay distinct. Each SAVEPOINT costs two round trips: one per page, plus one when a page is retried; a single-row call goes without it. A released SAVEPOINT keeps its subtransaction until commit: more than 64 pages in one transaction (over 64000 rows at the default page size) overflow the backend subtransaction cache, and until commit other sessions check this transaction's rows through `pg_subtrans`, which is slower under load. Split such a load into several transactions.
501
+
502
+ The distinct primary-key count preserves JSON types: `true` and `1` (also `false` and `0`) are different keys, including inside objects and arrays. Numerically equal JSON numbers such as `1` and `1.0` remain one key, and object field order does not affect equality. Repeated keys across pages are counted once.
503
+
504
+ SQLite applies repeated keys in order within one statement. Every `conflict_columns` field must be present in each row. `update_columns` must be a subset of the row fields outside `conflict_columns`; `set_extra` takes model fields (value or SQL expression) that are in neither; a `None` value becomes SQL `NULL`. An expanding `IN` or `literal_execute` parameter in `set_extra` cannot be used with executemany, so each row then runs as its own statement. When nothing is left to update, use `insert_objects_ignore_conflicts` instead (`MutationError`). `Column.onupdate` does not fire on conflict -- pass it in `set_extra`. Objects already loaded in the session are not refreshed. A conditional update (`WHERE`) or `RETURNING` is not covered -- build the statement with SQLAlchemy for that. Other rules as in `insert_objects_ignore_conflicts`: same keys in all rows, no Pydantic validation, executemany batching, caller-managed transaction, PostgreSQL and SQLite only.
491
505
 
492
506
  Repeating an upsert does not create duplicate keys, but it repeats the update: expressions such as `hits + 1` and `func.now()` run again. NULL conflict-key values follow the index: an ordinary UNIQUE keeps them distinct, `NULLS NOT DISTINCT` treats them as one key.
493
507
 
508
+ Other database errors are not retried. On PostgreSQL an error in a multi-row upsert is rolled back to the SAVEPOINT of its page; an error in `insert_objects_ignore_conflicts`, in a single-row upsert or on SQLite aborts the transaction, as with a hand-written INSERT. Earlier pages stay written either way: roll back after `MutationError` / `DatabaseError`, or wrap the call in `session.begin_nested()` to keep the earlier work.
509
+
494
510
  ### check_for_related_records
495
511
 
496
512
  Pre-deletion check that inspects all ONETOMANY relationships:
@@ -952,7 +968,22 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
952
968
 
953
969
  ## Changelog
954
970
 
955
- ### 0.0.10 — 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
976
+
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.
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.
979
+ - A `JSONB` primary key works in both operations: `insert_objects_ignore_conflicts` counts the returned rows, and the upsert count compares `JSONB` / `ARRAY` primary keys by content (the 0.0.10 upsert failed with `DatabaseError`). JSON booleans are distinct from numbers, both as scalar keys and inside objects or arrays; numerically equal JSON numbers remain one key.
980
+ - An engine with `use_insertmanyvalues=False` runs every row as its own statement: an executemany cannot return rows there.
981
+ - A row with SQL expression values is inserted by its own statement in input order; a `None` in `set_extra` is sent as `NULL`, which keeps the upsert batched; an expanding `IN` in `set_extra` makes the upsert run row by row.
982
+ - Pending session changes are flushed before the insert on every dialect: a Core INSERT does not autoflush.
983
+ - A single row no longer fails with `MutationError` for exceeding a parameter budget: SQLAlchemy keeps every statement within the dialect limit.
984
+ - Tested on PostgreSQL 13.23, 14.24, 15.19, 16.15, 17.7, 18.1 and 18.4 (on 13 and 14 without the `NULLS NOT DISTINCT` test, a PostgreSQL 15 feature) and with SQLAlchemy 2.0.46, 2.0.52 and 2.1.6.
985
+
986
+ ### 0.0.10 — 2026-10-26
956
987
 
957
988
  - `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
958
989
  - Chunk sizes account for actual SQL parameters, including omitted Python defaults, repeated positional parameters, expanding `IN` expressions, and update expressions. Tested with SQLAlchemy 2.0.46, 2.0.52 and 2.1.6.
@@ -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/)
@@ -15,8 +17,8 @@ Generic async query helpers for [SQLModel](https://sqlmodel.tiangolo.com/): filt
15
17
  - **Pagination** - Page/per\_page with total count and configurable max\_per\_page limit
16
18
  - **Projections** - Select specific columns across joins with dot-notation and SQL aliases
17
19
  - **CRUD Mutations** - `add_object`, `update_object`, `delete_object` plus bulk `update_objects`, `delete_objects` with flush-only semantics for composable transactions
18
- - **Idempotent Bulk Insert** - `insert_objects_ignore_conflicts`: one `INSERT ... ON CONFLICT DO NOTHING` per chunk (PostgreSQL, SQLite), safe to replay
19
- - **Bulk Upsert** - `upsert_objects`: one `INSERT ... ON CONFLICT DO UPDATE` per chunk (PostgreSQL, SQLite); update expressions run on every call
20
+ - **Idempotent Bulk Insert** - `insert_objects_ignore_conflicts`: `INSERT ... ON CONFLICT DO NOTHING` as executemany of one single-row statement (PostgreSQL, SQLite), safe to replay
21
+ - **Bulk Upsert** - `upsert_objects`: `INSERT ... ON CONFLICT DO UPDATE` as executemany of one single-row statement (PostgreSQL, SQLite); update expressions run on every call
20
22
  - **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
21
23
  - **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
22
24
  - **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
@@ -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.
@@ -437,9 +444,9 @@ inserted = await soh.insert_objects_ignore_conflicts(
437
444
  # inserted -> rows actually written; duplicates by source_id are skipped and not counted
438
445
  ```
439
446
 
440
- Replaying the same batch (or two concurrent identical batches) is safe, unlike `add_objects`, where one duplicate fails the whole batch. All rows must have the same keys. Rows go to the database as given (no Pydantic validation or coercion -- pass already-typed values); omitted columns get their column defaults: `Field(default=...)` / `default_factory` of a plain field and `server_default` (SQLModel does not copy `Field(default)` into an explicit `sa_column`). Large inputs are split using the actual compiled bind-parameter count, including omitted Python defaults and SQL expressions. One row exceeding the parameter budget raises `MutationError`. PostgreSQL and SQLite only (`MutationError` on other dialects).
447
+ Replaying the same batch (or two concurrent identical batches) is safe, unlike `add_objects`, where one duplicate fails the whole batch. All rows must have the same keys. Rows go to the database as given (no Pydantic validation or coercion -- pass already-typed values); omitted columns get their column defaults: `Field(default=...)` / `default_factory` of a plain field and `server_default` (SQLModel does not copy `Field(default)` into an explicit `sa_column`). Rows go as executemany of one single-row statement: SQLAlchemy [insertmanyvalues](https://docs.sqlalchemy.org/en/20/core/connections.html#engine-insertmanyvalues) renders up to `insertmanyvalues_page_size` rows (1000 by default) per INSERT and keeps each statement within the dialect parameter limit, omitted Python defaults included. A row with an SQL expression value (`func.now()`, `literal(...)`) cannot be an executemany parameter set, so it is inserted by its own statement, in input order. An engine created with `use_insertmanyvalues=False` cannot return rows from an executemany, so there every row runs as its own statement. A Core INSERT does not autoflush, so pending session changes are flushed first. PostgreSQL and SQLite only (`MutationError` on other dialects).
441
448
 
442
- The caller owns the transaction. If a later chunk fails, roll back the transaction to discard earlier chunks too. The helper does not commit or perform its own rollback. Standalone wrappers manage commit/rollback automatically.
449
+ The caller owns the transaction. If a later batch fails, roll back the transaction to discard earlier batches too. The helper does not commit or perform its own rollback. Standalone wrappers manage commit/rollback automatically.
443
450
 
444
451
  ### upsert_objects
445
452
 
@@ -459,10 +466,19 @@ written = await soh.upsert_objects(
459
466
  # written -> distinct table rows inserted or updated, counted by primary key from RETURNING
460
467
  ```
461
468
 
462
- If a key repeats inside `rows`, the last row wins and the row is counted once. Repeats are decided by the database for the column type (`NUMERIC` rounding, `citext`, `NULLS NOT DISTINCT`), not by Python equality: distinct values such as two instants of a DST fold stay distinct, and key values do not have to be hashable (`ARRAY`). On PostgreSQL a chunk of several rows runs inside a SAVEPOINT (`session.begin_nested()` flushes pending session changes first); a chunk the database rejects for a repeated key is retried row by row, which is slower. SQLite applies repeated keys in order within one statement. Every `conflict_columns` field must be present in each row. `update_columns` must be a subset of the row fields outside `conflict_columns`; `set_extra` takes model fields (value or SQL expression) that are in neither. When nothing is left to update, use `insert_objects_ignore_conflicts` instead (`MutationError`). `Column.onupdate` does not fire on conflict -- pass it in `set_extra`. Objects already loaded in the session are not refreshed. A conditional update (`WHERE`) or `RETURNING` is not covered -- build the statement with SQLAlchemy for that. Other rules as in `insert_objects_ignore_conflicts`: same keys in all rows, no Pydantic validation, actual parameter-based chunking, caller-managed transaction, PostgreSQL and SQLite only.
469
+ If a key repeats inside `rows`, the last row wins and the row is counted once, with the result of row-by-row execution. PostgreSQL rejects a statement that hits one key twice, so on PostgreSQL:
470
+
471
+ - a key value equal in Python to an earlier one starts the next statement, in input order (rows of other keys may depend on earlier rows through another unique constraint): one INSERT per page plus one per repeat. `ARRAY` and `JSONB` values are compared by content;
472
+ - each page of up to `insertmanyvalues_page_size` rows of a multi-row call runs in a SAVEPOINT, also when its rows go one by one (a row with an SQL expression, an expanding `IN` in `set_extra`, `use_insertmanyvalues=False`), since only the database decides what is a repeat: the column type (`NUMERIC` rounding, `citext`, the `VARCHAR(n)` cut), the collation of the column or of the unique index, a `BEFORE INSERT` trigger that changes the key, `NULLS NOT DISTINCT`. A page the database rejects for a repeated key is rolled back to the SAVEPOINT and re-run row by row in a new SAVEPOINT, so an error during the retry also rolls back that entire page; distinct values such as two instants of a DST fold stay distinct. Each SAVEPOINT costs two round trips: one per page, plus one when a page is retried; a single-row call goes without it. A released SAVEPOINT keeps its subtransaction until commit: more than 64 pages in one transaction (over 64000 rows at the default page size) overflow the backend subtransaction cache, and until commit other sessions check this transaction's rows through `pg_subtrans`, which is slower under load. Split such a load into several transactions.
473
+
474
+ The distinct primary-key count preserves JSON types: `true` and `1` (also `false` and `0`) are different keys, including inside objects and arrays. Numerically equal JSON numbers such as `1` and `1.0` remain one key, and object field order does not affect equality. Repeated keys across pages are counted once.
475
+
476
+ SQLite applies repeated keys in order within one statement. Every `conflict_columns` field must be present in each row. `update_columns` must be a subset of the row fields outside `conflict_columns`; `set_extra` takes model fields (value or SQL expression) that are in neither; a `None` value becomes SQL `NULL`. An expanding `IN` or `literal_execute` parameter in `set_extra` cannot be used with executemany, so each row then runs as its own statement. When nothing is left to update, use `insert_objects_ignore_conflicts` instead (`MutationError`). `Column.onupdate` does not fire on conflict -- pass it in `set_extra`. Objects already loaded in the session are not refreshed. A conditional update (`WHERE`) or `RETURNING` is not covered -- build the statement with SQLAlchemy for that. Other rules as in `insert_objects_ignore_conflicts`: same keys in all rows, no Pydantic validation, executemany batching, caller-managed transaction, PostgreSQL and SQLite only.
463
477
 
464
478
  Repeating an upsert does not create duplicate keys, but it repeats the update: expressions such as `hits + 1` and `func.now()` run again. NULL conflict-key values follow the index: an ordinary UNIQUE keeps them distinct, `NULLS NOT DISTINCT` treats them as one key.
465
479
 
480
+ Other database errors are not retried. On PostgreSQL an error in a multi-row upsert is rolled back to the SAVEPOINT of its page; an error in `insert_objects_ignore_conflicts`, in a single-row upsert or on SQLite aborts the transaction, as with a hand-written INSERT. Earlier pages stay written either way: roll back after `MutationError` / `DatabaseError`, or wrap the call in `session.begin_nested()` to keep the earlier work.
481
+
466
482
  ### check_for_related_records
467
483
 
468
484
  Pre-deletion check that inspects all ONETOMANY relationships:
@@ -924,7 +940,22 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
924
940
 
925
941
  ## Changelog
926
942
 
927
- ### 0.0.10 — 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
948
+
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.
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.
951
+ - A `JSONB` primary key works in both operations: `insert_objects_ignore_conflicts` counts the returned rows, and the upsert count compares `JSONB` / `ARRAY` primary keys by content (the 0.0.10 upsert failed with `DatabaseError`). JSON booleans are distinct from numbers, both as scalar keys and inside objects or arrays; numerically equal JSON numbers remain one key.
952
+ - An engine with `use_insertmanyvalues=False` runs every row as its own statement: an executemany cannot return rows there.
953
+ - A row with SQL expression values is inserted by its own statement in input order; a `None` in `set_extra` is sent as `NULL`, which keeps the upsert batched; an expanding `IN` in `set_extra` makes the upsert run row by row.
954
+ - Pending session changes are flushed before the insert on every dialect: a Core INSERT does not autoflush.
955
+ - A single row no longer fails with `MutationError` for exceeding a parameter budget: SQLAlchemy keeps every statement within the dialect limit.
956
+ - Tested on PostgreSQL 13.23, 14.24, 15.19, 16.15, 17.7, 18.1 and 18.4 (on 13 and 14 without the `NULLS NOT DISTINCT` test, a PostgreSQL 15 feature) and with SQLAlchemy 2.0.46, 2.0.52 and 2.1.6.
957
+
958
+ ### 0.0.10 — 2026-10-26
928
959
 
929
960
  - `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
930
961
  - Chunk sizes account for actual SQL parameters, including omitted Python defaults, repeated positional parameters, expanding `IN` expressions, and update expressions. Tested with SQLAlchemy 2.0.46, 2.0.52 and 2.1.6.
@@ -45,13 +45,13 @@ 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 = [
56
- "asyncpg==0.31.0",
56
+ "asyncpg==0.32.0",
57
57
  ]
@@ -1,6 +1,6 @@
1
1
  """sqlmodel-object-helpers — reusable query helpers for SQLModel projects."""
2
2
 
3
- __version__ = "0.0.10"
3
+ __version__ = "0.0.12"
4
4
 
5
5
  from .constants import QueryHelperSettings, settings
6
6
  from .dynamic_meta import (