sqlmodel-object-helpers 0.0.10__tar.gz → 0.0.11__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 (56) hide show
  1. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/PKG-INFO +27 -7
  2. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/README.md +26 -6
  3. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/pyproject.toml +1 -1
  4. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/__init__.py +1 -1
  5. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/mutations.py +179 -103
  6. sqlmodel_object_helpers-0.0.11/tests/test_conflict_mutations_limits.py +224 -0
  7. sqlmodel_object_helpers-0.0.11/tests/test_conflict_mutations_postgres.py +874 -0
  8. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_insert_ignore_conflicts.py +2 -2
  9. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_upsert_objects.py +2 -2
  10. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/uv.lock +39 -19
  11. sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_limits.py +0 -150
  12. sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_postgres.py +0 -473
  13. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.git-blame-ignore-revs +0 -0
  14. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.gitattributes +0 -0
  15. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.github/workflows/publish.yml +0 -0
  16. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.gitignore +0 -0
  17. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.vscode/settings.json +0 -0
  18. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/LICENSE +0 -0
  19. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/pyrightconfig.json +0 -0
  20. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/ruff.toml +0 -0
  21. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/constants.py +0 -0
  22. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
  23. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/exceptions.py +0 -0
  24. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/filters.py +0 -0
  25. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/loaders.py +0 -0
  26. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/operators.py +0 -0
  27. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/query.py +0 -0
  28. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/session.py +0 -0
  29. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/standalone.py +0 -0
  30. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
  31. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/columns.py +0 -0
  32. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
  33. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/filters.py +0 -0
  34. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
  35. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/projections.py +0 -0
  36. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/conftest.py +0 -0
  37. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_bulk_mutations.py +0 -0
  38. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_column_meta.py +0 -0
  39. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_computed_columns.py +0 -0
  40. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_count_exists.py +0 -0
  41. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_data_error_handling.py +0 -0
  42. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_datetime_range.py +0 -0
  43. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_dynamic_meta.py +0 -0
  44. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_exceptions.py +0 -0
  45. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_filters.py +0 -0
  46. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_for_update.py +0 -0
  47. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_generated_columns_pg.py +0 -0
  48. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_implicit_datetime.py +0 -0
  49. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_loaders.py +0 -0
  50. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_mutations.py +0 -0
  51. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_operators.py +0 -0
  52. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_query.py +0 -0
  53. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_settings.py +0 -0
  54. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_standalone.py +0 -0
  55. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_time_filter.py +0 -0
  56. {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/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.11
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
@@ -43,8 +43,8 @@ Generic async query helpers for [SQLModel](https://sqlmodel.tiangolo.com/): filt
43
43
  - **Pagination** - Page/per\_page with total count and configurable max\_per\_page limit
44
44
  - **Projections** - Select specific columns across joins with dot-notation and SQL aliases
45
45
  - **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
46
+ - **Idempotent Bulk Insert** - `insert_objects_ignore_conflicts`: `INSERT ... ON CONFLICT DO NOTHING` as executemany of one single-row statement (PostgreSQL, SQLite), safe to replay
47
+ - **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
48
  - **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
49
49
  - **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
50
50
  - **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
@@ -465,9 +465,9 @@ inserted = await soh.insert_objects_ignore_conflicts(
465
465
  # inserted -> rows actually written; duplicates by source_id are skipped and not counted
466
466
  ```
467
467
 
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).
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`). 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
469
 
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.
470
+ 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
471
 
472
472
  ### upsert_objects
473
473
 
@@ -487,10 +487,19 @@ written = await soh.upsert_objects(
487
487
  # written -> distinct table rows inserted or updated, counted by primary key from RETURNING
488
488
  ```
489
489
 
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.
490
+ 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:
491
+
492
+ - 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;
493
+ - 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.
494
+
495
+ 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.
496
+
497
+ 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
498
 
492
499
  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
500
 
501
+ 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.
502
+
494
503
  ### check_for_related_records
495
504
 
496
505
  Pre-deletion check that inspects all ONETOMANY relationships:
@@ -952,7 +961,18 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
952
961
 
953
962
  ## Changelog
954
963
 
955
- ### 0.0.10 — unreleased
964
+ ### 0.0.11 — unreleased
965
+
966
+ - **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
+ - `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.
968
+ - 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.
969
+ - An engine with `use_insertmanyvalues=False` runs every row as its own statement: an executemany cannot return rows there.
970
+ - 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.
971
+ - Pending session changes are flushed before the insert on every dialect: a Core INSERT does not autoflush.
972
+ - A single row no longer fails with `MutationError` for exceeding a parameter budget: SQLAlchemy keeps every statement within the dialect limit.
973
+ - 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.
974
+
975
+ ### 0.0.10 — 2026-10-26
956
976
 
957
977
  - `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
958
978
  - 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.
@@ -15,8 +15,8 @@ Generic async query helpers for [SQLModel](https://sqlmodel.tiangolo.com/): filt
15
15
  - **Pagination** - Page/per\_page with total count and configurable max\_per\_page limit
16
16
  - **Projections** - Select specific columns across joins with dot-notation and SQL aliases
17
17
  - **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
18
+ - **Idempotent Bulk Insert** - `insert_objects_ignore_conflicts`: `INSERT ... ON CONFLICT DO NOTHING` as executemany of one single-row statement (PostgreSQL, SQLite), safe to replay
19
+ - **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
20
  - **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
21
21
  - **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
22
22
  - **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
@@ -437,9 +437,9 @@ inserted = await soh.insert_objects_ignore_conflicts(
437
437
  # inserted -> rows actually written; duplicates by source_id are skipped and not counted
438
438
  ```
439
439
 
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).
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`). 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
441
 
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.
442
+ 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
443
 
444
444
  ### upsert_objects
445
445
 
@@ -459,10 +459,19 @@ written = await soh.upsert_objects(
459
459
  # written -> distinct table rows inserted or updated, counted by primary key from RETURNING
460
460
  ```
461
461
 
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.
462
+ 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:
463
+
464
+ - 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;
465
+ - 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.
466
+
467
+ 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.
468
+
469
+ 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
470
 
464
471
  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
472
 
473
+ 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.
474
+
466
475
  ### check_for_related_records
467
476
 
468
477
  Pre-deletion check that inspects all ONETOMANY relationships:
@@ -924,7 +933,18 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
924
933
 
925
934
  ## Changelog
926
935
 
927
- ### 0.0.10 — unreleased
936
+ ### 0.0.11 — unreleased
937
+
938
+ - **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
+ - `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.
940
+ - 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.
941
+ - An engine with `use_insertmanyvalues=False` runs every row as its own statement: an executemany cannot return rows there.
942
+ - 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.
943
+ - Pending session changes are flushed before the insert on every dialect: a Core INSERT does not autoflush.
944
+ - A single row no longer fails with `MutationError` for exceeding a parameter budget: SQLAlchemy keeps every statement within the dialect limit.
945
+ - 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.
946
+
947
+ ### 0.0.10 — 2026-10-26
928
948
 
929
949
  - `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
930
950
  - 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.
@@ -53,5 +53,5 @@ dev = [
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.11"
4
4
 
5
5
  from .constants import QueryHelperSettings, settings
6
6
  from .dynamic_meta import (
@@ -18,10 +18,10 @@ composed into a single atomic transaction at the call-site::
18
18
 
19
19
  import logging
20
20
  import time
21
- from collections.abc import Callable, Iterator, Mapping, Sequence
21
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
22
22
  from typing import Any, cast
23
23
 
24
- from sqlalchemy import CursorResult, inspect
24
+ from sqlalchemy import BindParameter, ClauseElement, CursorResult, inspect, null
25
25
  from sqlalchemy import delete as sa_delete
26
26
  from sqlalchemy import update as sa_update
27
27
  from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -29,6 +29,7 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
29
29
  from sqlalchemy.exc import DBAPIError, IntegrityError
30
30
  from sqlalchemy.ext.asyncio import AsyncSession
31
31
  from sqlalchemy.orm import ONETOMANY
32
+ from sqlalchemy.sql import visitors
32
33
  from sqlmodel import SQLModel, and_, select
33
34
 
34
35
  from .exceptions import DatabaseError, MutationError, ObjectNotFoundError
@@ -41,9 +42,6 @@ logger = logging.getLogger("sqlmodel_object_helpers")
41
42
  # Dialects that support INSERT ... ON CONFLICT.
42
43
  _ON_CONFLICT_INSERT: dict[str, Callable[..., Any]] = {"postgresql": pg_insert, "sqlite": sqlite_insert}
43
44
 
44
- # Headroom below the bind-parameter limits of asyncpg (32767) and SQLite (32766).
45
- _MAX_BIND_PARAMS = 30_000
46
-
47
45
  # PostgreSQL SQLSTATE: ON CONFLICT DO UPDATE affected the same row twice in one statement.
48
46
  _CARDINALITY_VIOLATION = "21000"
49
47
 
@@ -466,46 +464,99 @@ def _prepare_bulk_insert(
466
464
  return insert_fn, columns, fields
467
465
 
468
466
 
469
- def _bulk_insert_statements(
470
- session: AsyncSession,
471
- model: type[SQLModel],
472
- rows: Sequence[Mapping[str, Any]],
473
- fields: set[str],
474
- make_stmt: Callable[[list[dict[str, Any]]], Any],
475
- ) -> Iterator[tuple[list[dict[str, Any]], Any]]:
476
- """Chunk rows by the compiled bind-parameter count, including defaults and update expressions."""
477
- dialect = session.get_bind(model).dialect
478
- chunk_size = max(1, _MAX_BIND_PARAMS // len(fields))
479
- offset = 0
480
- while offset < len(rows):
481
- chunk = [dict(row) for row in rows[offset : offset + chunk_size]]
482
- while True:
483
- stmt = make_stmt(chunk)
484
- compiled = stmt.compile(dialect=dialect)
485
- expanded = compiled.construct_expanded_state()
486
- # Positional dialects may repeat one named parameter several times.
487
- count = len(expanded.positiontup) if expanded.positiontup is not None else len(expanded.parameters)
488
- if count <= _MAX_BIND_PARAMS:
489
- break
490
- if len(chunk) == 1:
491
- msg = f"A single row requires {count} bind parameters; limit is {_MAX_BIND_PARAMS}"
492
- raise MutationError(msg)
493
- chunk_size = max(1, min(len(chunk) - 1, len(chunk) * _MAX_BIND_PARAMS // count))
494
- chunk = chunk[:chunk_size]
495
- yield chunk, stmt
496
- offset += len(chunk)
467
+ def _bind_rows(columns: Any, fields: set[str], rows: Sequence[Mapping[str, Any]]) -> Sequence[Mapping[str, Any]]:
468
+ """Rename row fields to column keys: ``sa_column`` may give a column another name."""
469
+ keys = {name: columns[name].key for name in fields}
470
+ if all(name == key for name, key in keys.items()):
471
+ return rows
472
+ return [{keys[name]: value for name, value in row.items()} for row in rows]
473
+
474
+
475
+ def _is_sql_type(value_type: type) -> bool:
476
+ """SQL expression or ORM attribute: such a value cannot be an executemany parameter."""
477
+ return issubclass(value_type, ClauseElement) or hasattr(value_type, "__clause_element__")
478
+
479
+
480
+ def _batches(rows: Sequence[Mapping[str, Any]], size: int) -> Iterator[tuple[list[Mapping[str, Any]], bool]]:
481
+ """Split rows into executemany batches in input order; a row with SQL expressions goes alone (``True``)."""
482
+ if not any(_is_sql_type(value_type) for value_type in {type(value) for row in rows for value in row.values()}):
483
+ for offset in range(0, len(rows), size):
484
+ yield list(rows[offset : offset + size]), False
485
+ return
486
+ batch: list[Mapping[str, Any]] = []
487
+ for row in rows:
488
+ if any(_is_sql_type(type(value)) for value in row.values()):
489
+ if batch:
490
+ yield batch, False
491
+ batch = []
492
+ yield [row], True
493
+ continue
494
+ batch.append(row)
495
+ if len(batch) == size:
496
+ yield batch, False
497
+ batch = []
498
+ if batch:
499
+ yield batch, False
500
+
501
+
502
+ def _has_post_compile_params(values: Iterable[Any]) -> bool:
503
+ """Expanding ``IN`` or ``literal_execute`` parameters, which executemany rejects."""
504
+ return any(
505
+ isinstance(node, BindParameter) and (node.expanding or node.literal_execute)
506
+ for value in values
507
+ if isinstance(value, ClauseElement)
508
+ for node in visitors.iterate(value)
509
+ )
497
510
 
498
511
 
499
512
  def _hashable(value: Any) -> Any:
500
- """Make a RETURNING value hashable: ARRAY values come back as lists."""
513
+ """Make JSON/ARRAY keys hashable while keeping booleans distinct from numbers."""
514
+ if isinstance(value, bool):
515
+ return bool, value
516
+ if isinstance(value, dict):
517
+ return frozenset((key, _hashable(item)) for key, item in value.items())
501
518
  if isinstance(value, list | tuple):
502
519
  return tuple(_hashable(item) for item in value)
503
520
  return value
504
521
 
505
522
 
506
- def _row_keys(result: Any) -> set[Any]:
507
- """Primary keys of the rows returned by RETURNING."""
508
- return {_hashable(tuple(row)) for row in result}
523
+ def _distinct_count(returned: list[Any]) -> int:
524
+ """Count distinct RETURNING primary keys, normalizing scalar JSON booleans and numbers too."""
525
+ hashable = [_hashable(tuple(row)) for row in returned]
526
+ try:
527
+ return len(set(hashable))
528
+ except TypeError: # Compare unhashable custom types by equality.
529
+ distinct: list[Any] = []
530
+ for row in hashable:
531
+ if row not in distinct:
532
+ distinct.append(row)
533
+ return len(distinct)
534
+
535
+
536
+ def _distinct_key_parts(key_columns: Sequence[Any], batch: list[Mapping[str, Any]]) -> list[list[Mapping[str, Any]]]:
537
+ """Cut a batch before each key equal in Python to an earlier one, so no statement repeats it."""
538
+ parts: list[list[Mapping[str, Any]]] = [[]]
539
+ seen: set[Any] = set()
540
+ names = [column.key for column in key_columns]
541
+ try:
542
+ # Parts keep the input order: rows of other keys may depend on earlier rows through another unique constraint.
543
+ for row in batch:
544
+ key = tuple([row[name] for name in names])
545
+ # NULL keys never conflict under the default NULLS DISTINCT; NULLS NOT DISTINCT goes to the retry.
546
+ if all(value is not None for value in key):
547
+ try:
548
+ repeat = key in seen
549
+ except TypeError: # ARRAY values come as lists, JSON as dicts
550
+ key = _hashable(key)
551
+ repeat = key in seen
552
+ if repeat:
553
+ parts.append([])
554
+ seen.clear()
555
+ seen.add(key)
556
+ parts[-1].append(row)
557
+ except TypeError: # an unhashable key type: the database decides, see _upsert_page
558
+ return [batch]
559
+ return parts
509
560
 
510
561
 
511
562
  def _sqlstate(error: DBAPIError) -> str | None:
@@ -513,29 +564,40 @@ def _sqlstate(error: DBAPIError) -> str | None:
513
564
  return getattr(error.orig, "sqlstate", None) or getattr(error.orig, "pgcode", None)
514
565
 
515
566
 
516
- async def _upsert_chunk(
567
+ async def _execute_rows(session: AsyncSession, stmt: Any, rows: Sequence[Mapping[str, Any]], size: int) -> list[Any]:
568
+ """Execute rows in executemany batches of ``size``; return the RETURNING rows."""
569
+ returned: list[Any] = []
570
+ for batch, sql_row in _batches(rows, size):
571
+ if sql_row:
572
+ returned += (await session.execute(stmt.values(batch[0]))).all()
573
+ else:
574
+ returned += (await session.execute(stmt, batch)).all()
575
+ return returned
576
+
577
+
578
+ async def _upsert_page(
517
579
  session: AsyncSession,
518
- chunk: list[dict[str, Any]],
519
580
  stmt: Any,
520
- make_stmt: Callable[[list[dict[str, Any]]], Any],
581
+ key_columns: Sequence[Any],
582
+ page: list[Mapping[str, Any]],
521
583
  *,
522
- savepoint: bool,
523
- ) -> set[Any]:
524
- """Execute one upsert chunk and return the primary keys of the affected rows."""
525
- if not savepoint or len(chunk) == 1:
526
- return _row_keys(await session.execute(stmt))
584
+ row_by_row: bool,
585
+ ) -> list[Any]:
586
+ """Execute an upsert page and its retry in SAVEPOINTs; return the RETURNING rows."""
587
+ returned: list[Any] = []
527
588
  try:
528
589
  async with session.begin_nested():
529
- return _row_keys(await session.execute(stmt))
590
+ # A single-row statement cannot repeat a key, so row-by-row pages need no cuts.
591
+ for part in [page] if row_by_row else _distinct_key_parts(key_columns, page):
592
+ returned += await _execute_rows(session, stmt, part, 1 if row_by_row else len(part))
593
+ return returned
530
594
  except DBAPIError as e:
531
595
  if _sqlstate(e) != _CARDINALITY_VIOLATION:
532
596
  raise
533
- # PostgreSQL rejects keys that are equal for the database (per column type) within one statement.
534
- logger.debug("upsert chunk of %d rows has equal keys, retrying row by row", len(chunk))
535
- keys: set[Any] = set()
536
- for row in chunk:
537
- keys |= _row_keys(await session.execute(make_stmt([row])))
538
- return keys
597
+ # The database equated distinct Python keys through a column type, index collation or trigger.
598
+ logger.debug("upsert batch of %d rows has equal keys, retrying row by row", len(page))
599
+ async with session.begin_nested():
600
+ return await _execute_rows(session, stmt, page, 1)
539
601
 
540
602
 
541
603
  async def _execute_bulk_insert(
@@ -543,29 +605,30 @@ async def _execute_bulk_insert(
543
605
  session: AsyncSession,
544
606
  model: type[SQLModel],
545
607
  rows: Sequence[Mapping[str, Any]],
546
- fields: set[str],
547
- make_stmt: Callable[[list[dict[str, Any]]], Any],
608
+ stmt: Any,
548
609
  *,
549
- upsert: bool = False,
610
+ upsert_keys: Sequence[Any] = (),
611
+ row_by_row: bool = False,
550
612
  ) -> int:
551
- """Execute chunks without committing: total rowcount, or distinct RETURNING PKs for upsert."""
613
+ """Execute ``stmt`` in executemany batches without committing; return the number of written table rows."""
552
614
  start = time.perf_counter()
553
615
  model_name = model.__name__
554
- affected = 0
555
- written: set[Any] = set()
556
- # Only PostgreSQL rejects a repeated key within one statement; SQLite applies rows in order.
557
- savepoint = session.get_bind(model).dialect.name == "postgresql"
616
+ dialect = session.get_bind(model).dialect
617
+ # Without insertmanyvalues (use_insertmanyvalues=False) an executemany cannot return rows.
618
+ row_by_row = row_by_row or not dialect.insert_executemany_returning
619
+ page_size = dialect.insertmanyvalues_page_size
620
+ returned: list[Any] = []
558
621
 
559
622
  try:
560
- for chunk, stmt in _bulk_insert_statements(session, model, rows, fields, make_stmt):
561
- if upsert:
562
- written |= await _upsert_chunk(session, chunk, stmt, make_stmt, savepoint=savepoint)
563
- else:
564
- result = cast("CursorResult[Any]", await session.execute(stmt))
565
- affected += result.rowcount
623
+ # A Core statement does not autoflush: pending ORM changes (e.g. parent rows) go first.
566
624
  await session.flush()
567
- except MutationError:
568
- raise
625
+ # Only PostgreSQL rejects a repeated key within one statement; SQLite applies rows in order.
626
+ if upsert_keys and dialect.name == "postgresql" and len(rows) > 1:
627
+ for offset in range(0, len(rows), page_size):
628
+ page = list(rows[offset : offset + page_size])
629
+ returned += await _upsert_page(session, stmt, upsert_keys, page, row_by_row=row_by_row)
630
+ else:
631
+ returned += await _execute_rows(session, stmt, rows, 1 if row_by_row else page_size)
569
632
  except IntegrityError as e:
570
633
  logger.warning("%s(%s) — integrity error: %s", func_name, model_name, e.orig)
571
634
  msg = f"Integrity error inserting {model_name}: {e.orig}"
@@ -575,12 +638,12 @@ async def _execute_bulk_insert(
575
638
  msg = f"Database error inserting {model_name}: {e}"
576
639
  raise DatabaseError(msg) from e
577
640
 
578
- if upsert:
579
- affected = len(written)
641
+ # Each statement returns the rows it wrote: a repeated upsert key returns one row several times.
642
+ written = _distinct_count(returned) if upsert_keys else len(returned)
580
643
  logger.debug(
581
- "%s(%s) → %d of %d rows, %.3fs", func_name, model_name, affected, len(rows), time.perf_counter() - start
644
+ "%s(%s) → %d of %d rows, %.3fs", func_name, model_name, written, len(rows), time.perf_counter() - start
582
645
  )
583
- return affected
646
+ return written
584
647
 
585
648
 
586
649
  async def insert_objects_ignore_conflicts[T: SQLModel](
@@ -595,9 +658,12 @@ async def insert_objects_ignore_conflicts[T: SQLModel](
595
658
 
596
659
  Executes ``INSERT ... ON CONFLICT (...) DO NOTHING``. Replayed and concurrent batches
597
660
  are protected by the database unique constraint. No ORM objects are created or
598
- refreshed. Large inputs are split by the actual compiled bind parameters, including
599
- Python defaults of omitted columns and SQL expressions. Does not commit; the caller
600
- rolls back the whole operation, including after a failure in a later chunk.
661
+ refreshed. Rows go as executemany of one single-row statement: SQLAlchemy renders up to
662
+ ``insertmanyvalues_page_size`` rows per INSERT within the dialect parameter limit.
663
+ A row with SQL expression values is inserted by its own statement, in input order, and
664
+ so is every row of an engine with ``use_insertmanyvalues=False``.
665
+ Pending session changes are flushed first. Does not commit; the caller rolls back
666
+ the whole operation, including after a failure in a later batch.
601
667
 
602
668
  Values are passed without Pydantic validation and must already have the right types.
603
669
  Omitted columns get their column defaults: ``Field(default=...)``/``default_factory``
@@ -615,7 +681,7 @@ async def insert_objects_ignore_conflicts[T: SQLModel](
615
681
 
616
682
  Raises:
617
683
  MutationError: Invalid fields, conflict key or row keys, unsupported dialect,
618
- a single row over the bind-parameter limit, or another constraint violation.
684
+ or another constraint violation.
619
685
  DatabaseError: On unexpected database errors.
620
686
 
621
687
  """
@@ -624,12 +690,12 @@ async def insert_objects_ignore_conflicts[T: SQLModel](
624
690
 
625
691
  func_name = "insert_objects_ignore_conflicts"
626
692
  insert_fn, columns, fields = _prepare_bulk_insert(func_name, session, model, rows, conflict_columns)
627
- index_elements = [columns[name] for name in conflict_columns]
628
-
629
- def make_stmt(chunk: list[dict[str, Any]]) -> Any:
630
- return insert_fn(model).values(chunk).on_conflict_do_nothing(index_elements=index_elements)
631
-
632
- return await _execute_bulk_insert(func_name, session, model, rows, fields, make_stmt)
693
+ stmt = (
694
+ insert_fn(model.__table__) # type: ignore[attr-defined] # SQLAlchemy metaclass
695
+ .on_conflict_do_nothing(index_elements=[columns[name] for name in conflict_columns])
696
+ .returning(*inspect(model).primary_key)
697
+ )
698
+ return await _execute_bulk_insert(func_name, session, model, _bind_rows(columns, fields, rows), stmt)
633
699
 
634
700
 
635
701
  async def upsert_objects[T: SQLModel](
@@ -645,15 +711,21 @@ async def upsert_objects[T: SQLModel](
645
711
  Insert new rows and update existing ones by the key ``conflict_columns``.
646
712
 
647
713
  Executes ``INSERT ... ON CONFLICT (...) DO UPDATE SET ...`` without creating or
648
- refreshing ORM objects. Chunks are split by the actual bind-parameter count, including
649
- defaults and ``set_extra``. Does not commit; the caller owns the rollback of all chunks.
650
-
651
- If a key repeats in the input, the last row wins. The database decides what is a repeat
652
- by the column type (``NUMERIC`` rounding, ``citext``, ``NULLS NOT DISTINCT``), not Python
653
- equality. On PostgreSQL a multi-row chunk runs in a SAVEPOINT (``begin_nested`` flushes
654
- the session first); the database rejects a chunk with a repeated key, and it is re-run
655
- row by row. All key columns must be present in rows. Replaying the operation creates no
656
- duplicates, but ``set_extra`` expressions run again: ``hits + 1`` is not idempotent.
714
+ refreshing ORM objects. Rows are batched as in ``insert_objects_ignore_conflicts``;
715
+ an expanding ``IN`` or ``literal_execute`` parameter in ``set_extra`` cannot be used
716
+ with executemany, so each row then runs as its own statement. Does not commit; the
717
+ caller owns the rollback of all batches and must roll back after an error.
718
+
719
+ If a key repeats in the input, the last row wins. PostgreSQL rejects a statement that hits
720
+ one key twice, so a key value equal in Python to an earlier one starts the next statement;
721
+ statements keep the input order, with the result of row-by-row execution. On PostgreSQL
722
+ each page of ``insertmanyvalues_page_size`` rows of a multi-row call runs in a SAVEPOINT,
723
+ also when its rows go one by one: the database decides what is a repeat (column type,
724
+ ``NUMERIC`` rounding, index collation, a ``BEFORE INSERT`` trigger, ``NULLS NOT
725
+ DISTINCT``), and a page it rejects is rolled back to the SAVEPOINT and re-run row by row.
726
+ All key columns must be present in rows.
727
+ Replaying the operation creates no duplicates, but ``set_extra`` expressions run again:
728
+ ``hits + 1`` is not idempotent.
657
729
 
658
730
  Values are passed without Pydantic validation. Defaults of omitted columns apply on
659
731
  insert; on conflict those columns keep their values unless set in ``set_extra``.
@@ -675,8 +747,7 @@ async def upsert_objects[T: SQLModel](
675
747
  a key repeated in the input counts once.
676
748
 
677
749
  Raises:
678
- MutationError: Invalid input, a single row over the bind-parameter limit,
679
- unsupported dialect, or a database constraint violation.
750
+ MutationError: Invalid input, unsupported dialect, or a database constraint violation.
680
751
  DatabaseError: On unexpected database errors.
681
752
 
682
753
  """
@@ -711,17 +782,22 @@ async def upsert_objects[T: SQLModel](
711
782
  msg = f"{func_name}({model_name}): nothing to update, use insert_objects_ignore_conflicts"
712
783
  raise MutationError(msg)
713
784
 
785
+ # A literal None would become a SET bind parameter, and SQLAlchemy inserts such statements row by row.
786
+ extra_set = {columns[name]: null() if value is None else value for name, value in extra.items()}
787
+ stmt = insert_fn(model.__table__) # type: ignore[attr-defined] # SQLAlchemy metaclass
788
+ # Keys are columns, not field names: sa_column may use a different database name.
789
+ set_ = {columns[name]: stmt.excluded[columns[name].key] for name in to_update} | extra_set
714
790
  index_elements = [columns[name] for name in conflict_columns]
715
- extra_set = {columns[name]: value for name, value in extra.items()}
716
- primary_key = inspect(model).primary_key
717
-
718
- def make_stmt(chunk: list[dict[str, Any]]) -> Any:
719
- stmt = insert_fn(model).values(chunk)
720
- # Keys are columns, not field names: sa_column may use a different database name.
721
- set_ = {columns[name]: stmt.excluded[columns[name].key] for name in to_update} | extra_set
722
- return stmt.on_conflict_do_update(index_elements=index_elements, set_=set_).returning(*primary_key)
723
-
724
- return await _execute_bulk_insert(func_name, session, model, rows, fields, make_stmt, upsert=True)
791
+ stmt = stmt.on_conflict_do_update(index_elements=index_elements, set_=set_).returning(*inspect(model).primary_key)
792
+ return await _execute_bulk_insert(
793
+ func_name,
794
+ session,
795
+ model,
796
+ _bind_rows(columns, fields, rows),
797
+ stmt,
798
+ upsert_keys=index_elements,
799
+ row_by_row=_has_post_compile_params(extra_set.values()),
800
+ )
725
801
 
726
802
 
727
803
  async def check_for_related_records[T: SQLModel](