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.
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/PKG-INFO +27 -7
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/README.md +26 -6
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/pyproject.toml +1 -1
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/__init__.py +1 -1
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/mutations.py +179 -103
- sqlmodel_object_helpers-0.0.11/tests/test_conflict_mutations_limits.py +224 -0
- sqlmodel_object_helpers-0.0.11/tests/test_conflict_mutations_postgres.py +874 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_insert_ignore_conflicts.py +2 -2
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_upsert_objects.py +2 -2
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/uv.lock +39 -19
- sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_limits.py +0 -150
- sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_postgres.py +0 -473
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.git-blame-ignore-revs +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.gitattributes +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.github/workflows/publish.yml +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.gitignore +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/.vscode/settings.json +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/LICENSE +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/pyrightconfig.json +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/ruff.toml +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/constants.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/operators.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/query.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/session.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/standalone.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/columns.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/projections.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/conftest.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_bulk_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_column_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_computed_columns.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_count_exists.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_data_error_handling.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_datetime_range.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_filters.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_for_update.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_generated_columns_pg.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_implicit_datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_operators.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_query.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_settings.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_standalone.py +0 -0
- {sqlmodel_object_helpers-0.0.10 → sqlmodel_object_helpers-0.0.11}/tests/test_time_filter.py +0 -0
- {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.
|
|
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`:
|
|
47
|
-
- **Bulk Upsert** - `upsert_objects`:
|
|
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`).
|
|
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
|
|
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
|
|
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.
|
|
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`:
|
|
19
|
-
- **Bulk Upsert** - `upsert_objects`:
|
|
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`).
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
@@ -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
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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
|
|
507
|
-
"""
|
|
508
|
-
|
|
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
|
|
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
|
-
|
|
581
|
+
key_columns: Sequence[Any],
|
|
582
|
+
page: list[Mapping[str, Any]],
|
|
521
583
|
*,
|
|
522
|
-
|
|
523
|
-
) ->
|
|
524
|
-
"""Execute
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
534
|
-
logger.debug("upsert
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
547
|
-
make_stmt: Callable[[list[dict[str, Any]]], Any],
|
|
608
|
+
stmt: Any,
|
|
548
609
|
*,
|
|
549
|
-
|
|
610
|
+
upsert_keys: Sequence[Any] = (),
|
|
611
|
+
row_by_row: bool = False,
|
|
550
612
|
) -> int:
|
|
551
|
-
"""Execute
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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,
|
|
644
|
+
"%s(%s) → %d of %d rows, %.3fs", func_name, model_name, written, len(rows), time.perf_counter() - start
|
|
582
645
|
)
|
|
583
|
-
return
|
|
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.
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
return await _execute_bulk_insert(func_name, session, model,
|
|
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.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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,
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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](
|