sqlmodel-object-helpers 0.0.9__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.9 → sqlmodel_object_helpers-0.0.11}/PKG-INFO +90 -1
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/README.md +89 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/pyproject.toml +4 -1
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/__init__.py +5 -1
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/mutations.py +386 -2
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/standalone.py +34 -0
- 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.11/tests/test_insert_ignore_conflicts.py +179 -0
- sqlmodel_object_helpers-0.0.11/tests/test_upsert_objects.py +323 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/uv.lock +69 -21
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/.git-blame-ignore-revs +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/.gitattributes +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/.github/workflows/publish.yml +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/.gitignore +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/.vscode/settings.json +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/LICENSE +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/pyrightconfig.json +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/ruff.toml +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/constants.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/operators.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/query.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/session.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/columns.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/src/sqlmodel_object_helpers/types/projections.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/conftest.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_bulk_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_column_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_computed_columns.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_count_exists.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_data_error_handling.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_datetime_range.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_filters.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_for_update.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_generated_columns_pg.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_implicit_datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_operators.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_query.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_settings.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_standalone.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → sqlmodel_object_helpers-0.0.11}/tests/test_time_filter.py +0 -0
- {sqlmodel_object_helpers-0.0.9 → 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,6 +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`: `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
|
|
46
48
|
- **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
|
|
47
49
|
- **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
|
|
48
50
|
- **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
|
|
@@ -392,6 +394,14 @@ user = await soh.add_object(session, User(name="Alice"))
|
|
|
392
394
|
users = await soh.add_objects(session, [User(name="Alice"), User(name="Bob")])
|
|
393
395
|
```
|
|
394
396
|
|
|
397
|
+
Choose the insert helper by its contract:
|
|
398
|
+
|
|
399
|
+
| Need | Helper | Result |
|
|
400
|
+
|---|---|---|
|
|
401
|
+
| Persist ORM objects and populate generated fields; duplicates are errors | `add_objects` | ORM objects |
|
|
402
|
+
| Insert rows and skip duplicates by a unique key | [`insert_objects_ignore_conflicts`](#insert_objects_ignore_conflicts) | Inserted count |
|
|
403
|
+
| Insert rows and update existing ones by a unique key | [`upsert_objects`](#upsert_objects) | Inserted + updated count |
|
|
404
|
+
|
|
395
405
|
### update_object
|
|
396
406
|
|
|
397
407
|
```python
|
|
@@ -440,6 +450,56 @@ count = await soh.delete_objects(
|
|
|
440
450
|
|
|
441
451
|
Filters are **required** for safety -- empty filters are rejected. Dot-notation (relationship) filters are not supported in bulk operations.
|
|
442
452
|
|
|
453
|
+
### insert_objects_ignore_conflicts
|
|
454
|
+
|
|
455
|
+
Idempotent bulk insert -- `INSERT ... ON CONFLICT (...) DO NOTHING`, no objects loaded or refreshed:
|
|
456
|
+
|
|
457
|
+
```python
|
|
458
|
+
import sqlmodel_object_helpers as soh
|
|
459
|
+
|
|
460
|
+
inserted = await soh.insert_objects_ignore_conflicts(
|
|
461
|
+
session, PaymentLog,
|
|
462
|
+
rows=[{"source_id": "run1:1", "base": "a-cso"}, {"source_id": "run1:2", "base": "a-cso"}],
|
|
463
|
+
conflict_columns=["source_id"],
|
|
464
|
+
)
|
|
465
|
+
# inserted -> rows actually written; duplicates by source_id are skipped and not counted
|
|
466
|
+
```
|
|
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`). 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
|
+
|
|
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
|
+
|
|
472
|
+
### upsert_objects
|
|
473
|
+
|
|
474
|
+
Bulk upsert -- `INSERT ... ON CONFLICT (...) DO UPDATE SET ...`: new rows are inserted, rows hitting the unique key are updated:
|
|
475
|
+
|
|
476
|
+
```python
|
|
477
|
+
import sqlmodel_object_helpers as soh
|
|
478
|
+
from sqlalchemy import func
|
|
479
|
+
|
|
480
|
+
written = await soh.upsert_objects(
|
|
481
|
+
session, Ast,
|
|
482
|
+
rows=[{"code": "a", "width": 10, "ordinal": 1}, {"code": "b", "width": 20, "ordinal": 2}],
|
|
483
|
+
conflict_columns=["code"],
|
|
484
|
+
update_columns=["width", "ordinal"], # default: every row field except conflict_columns
|
|
485
|
+
set_extra={"updated_at": func.now()}, # applied only on update
|
|
486
|
+
)
|
|
487
|
+
# written -> distinct table rows inserted or updated, counted by primary key from RETURNING
|
|
488
|
+
```
|
|
489
|
+
|
|
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.
|
|
498
|
+
|
|
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.
|
|
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
|
+
|
|
443
503
|
### check_for_related_records
|
|
444
504
|
|
|
445
505
|
Pre-deletion check that inspects all ONETOMANY relationships:
|
|
@@ -772,6 +832,8 @@ Endpoints can be migrated one at a time. Three strategies per endpoint:
|
|
|
772
832
|
- `soh.update_objects(session, model, data, filters)` -- Bulk update via single `UPDATE ... WHERE`
|
|
773
833
|
- `soh.delete_object(session, model, *, instance, pk)` -- Delete by reference or PK
|
|
774
834
|
- `soh.delete_objects(session, model, filters)` -- Bulk delete via single `DELETE ... WHERE`
|
|
835
|
+
- `soh.insert_objects_ignore_conflicts(session, model, rows, *, conflict_columns)` -- Bulk insert via `INSERT ... ON CONFLICT DO NOTHING`, returns inserted count
|
|
836
|
+
- `soh.upsert_objects(session, model, rows, *, conflict_columns, update_columns=None, set_extra=None)` -- Bulk insert via `INSERT ... ON CONFLICT DO UPDATE`, returns inserted + updated count
|
|
775
837
|
- `soh.check_for_related_records(session, model, pk)` -- Pre-deletion dependency check
|
|
776
838
|
|
|
777
839
|
### Dynamic Meta
|
|
@@ -850,6 +912,14 @@ uv sync
|
|
|
850
912
|
uv run pytest tests/
|
|
851
913
|
```
|
|
852
914
|
|
|
915
|
+
PostgreSQL integration tests use the optional `postgres` dependency group (asyncpg). Each test creates a randomly named schema and removes that schema afterwards; it does not recreate application tables. Without `SOH_TEST_POSTGRES_URL`, these tests are skipped.
|
|
916
|
+
|
|
917
|
+
```bash
|
|
918
|
+
uv sync --group postgres
|
|
919
|
+
SOH_TEST_POSTGRES_URL=postgresql+asyncpg://user:password@localhost/test_db \
|
|
920
|
+
uv run --group postgres pytest -q tests/test_conflict_mutations_postgres.py
|
|
921
|
+
```
|
|
922
|
+
|
|
853
923
|
### Syncing with Remote
|
|
854
924
|
|
|
855
925
|
```bash
|
|
@@ -891,6 +961,25 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
|
|
|
891
961
|
|
|
892
962
|
## Changelog
|
|
893
963
|
|
|
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
|
|
976
|
+
|
|
977
|
+
- `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
|
|
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.
|
|
979
|
+
- Upsert reports a missing conflict-key field as `MutationError`. Repeated update expressions are explicitly documented as non-idempotent.
|
|
980
|
+
- Upsert leaves repeated keys to the database instead of Python equality: no silent loss of distinct keys (DST fold), no `DatabaseError` for keys equal only after column type coercion (`NUMERIC(10,2)`), no `TypeError` for unhashable keys (`ARRAY`). The count is distinct rows by primary key.
|
|
981
|
+
- PostgreSQL tests run through the optional `postgres` dependency group; tested on PostgreSQL 18.4 and 18.1, the versions of the SRO Agent and ExamCRM dev databases.
|
|
982
|
+
|
|
894
983
|
### 0.0.9 — 2026-10-09
|
|
895
984
|
|
|
896
985
|
- Dependency lower bound is now `sqlmodel>=0.0.27`. SQLModel 0.0.22–0.0.26 cannot create models on Python 3.14 (`Field 'id' requires a type annotation`), so the previous `>=0.0.22` bound was never satisfiable. Test suite passes on every SQLModel from 0.0.27 to 0.0.48.
|
|
@@ -15,6 +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`: `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
|
|
18
20
|
- **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
|
|
19
21
|
- **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
|
|
20
22
|
- **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
|
|
@@ -364,6 +366,14 @@ user = await soh.add_object(session, User(name="Alice"))
|
|
|
364
366
|
users = await soh.add_objects(session, [User(name="Alice"), User(name="Bob")])
|
|
365
367
|
```
|
|
366
368
|
|
|
369
|
+
Choose the insert helper by its contract:
|
|
370
|
+
|
|
371
|
+
| Need | Helper | Result |
|
|
372
|
+
|---|---|---|
|
|
373
|
+
| Persist ORM objects and populate generated fields; duplicates are errors | `add_objects` | ORM objects |
|
|
374
|
+
| Insert rows and skip duplicates by a unique key | [`insert_objects_ignore_conflicts`](#insert_objects_ignore_conflicts) | Inserted count |
|
|
375
|
+
| Insert rows and update existing ones by a unique key | [`upsert_objects`](#upsert_objects) | Inserted + updated count |
|
|
376
|
+
|
|
367
377
|
### update_object
|
|
368
378
|
|
|
369
379
|
```python
|
|
@@ -412,6 +422,56 @@ count = await soh.delete_objects(
|
|
|
412
422
|
|
|
413
423
|
Filters are **required** for safety -- empty filters are rejected. Dot-notation (relationship) filters are not supported in bulk operations.
|
|
414
424
|
|
|
425
|
+
### insert_objects_ignore_conflicts
|
|
426
|
+
|
|
427
|
+
Idempotent bulk insert -- `INSERT ... ON CONFLICT (...) DO NOTHING`, no objects loaded or refreshed:
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
import sqlmodel_object_helpers as soh
|
|
431
|
+
|
|
432
|
+
inserted = await soh.insert_objects_ignore_conflicts(
|
|
433
|
+
session, PaymentLog,
|
|
434
|
+
rows=[{"source_id": "run1:1", "base": "a-cso"}, {"source_id": "run1:2", "base": "a-cso"}],
|
|
435
|
+
conflict_columns=["source_id"],
|
|
436
|
+
)
|
|
437
|
+
# inserted -> rows actually written; duplicates by source_id are skipped and not counted
|
|
438
|
+
```
|
|
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`). 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
|
+
|
|
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
|
+
|
|
444
|
+
### upsert_objects
|
|
445
|
+
|
|
446
|
+
Bulk upsert -- `INSERT ... ON CONFLICT (...) DO UPDATE SET ...`: new rows are inserted, rows hitting the unique key are updated:
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
import sqlmodel_object_helpers as soh
|
|
450
|
+
from sqlalchemy import func
|
|
451
|
+
|
|
452
|
+
written = await soh.upsert_objects(
|
|
453
|
+
session, Ast,
|
|
454
|
+
rows=[{"code": "a", "width": 10, "ordinal": 1}, {"code": "b", "width": 20, "ordinal": 2}],
|
|
455
|
+
conflict_columns=["code"],
|
|
456
|
+
update_columns=["width", "ordinal"], # default: every row field except conflict_columns
|
|
457
|
+
set_extra={"updated_at": func.now()}, # applied only on update
|
|
458
|
+
)
|
|
459
|
+
# written -> distinct table rows inserted or updated, counted by primary key from RETURNING
|
|
460
|
+
```
|
|
461
|
+
|
|
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.
|
|
470
|
+
|
|
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.
|
|
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
|
+
|
|
415
475
|
### check_for_related_records
|
|
416
476
|
|
|
417
477
|
Pre-deletion check that inspects all ONETOMANY relationships:
|
|
@@ -744,6 +804,8 @@ Endpoints can be migrated one at a time. Three strategies per endpoint:
|
|
|
744
804
|
- `soh.update_objects(session, model, data, filters)` -- Bulk update via single `UPDATE ... WHERE`
|
|
745
805
|
- `soh.delete_object(session, model, *, instance, pk)` -- Delete by reference or PK
|
|
746
806
|
- `soh.delete_objects(session, model, filters)` -- Bulk delete via single `DELETE ... WHERE`
|
|
807
|
+
- `soh.insert_objects_ignore_conflicts(session, model, rows, *, conflict_columns)` -- Bulk insert via `INSERT ... ON CONFLICT DO NOTHING`, returns inserted count
|
|
808
|
+
- `soh.upsert_objects(session, model, rows, *, conflict_columns, update_columns=None, set_extra=None)` -- Bulk insert via `INSERT ... ON CONFLICT DO UPDATE`, returns inserted + updated count
|
|
747
809
|
- `soh.check_for_related_records(session, model, pk)` -- Pre-deletion dependency check
|
|
748
810
|
|
|
749
811
|
### Dynamic Meta
|
|
@@ -822,6 +884,14 @@ uv sync
|
|
|
822
884
|
uv run pytest tests/
|
|
823
885
|
```
|
|
824
886
|
|
|
887
|
+
PostgreSQL integration tests use the optional `postgres` dependency group (asyncpg). Each test creates a randomly named schema and removes that schema afterwards; it does not recreate application tables. Without `SOH_TEST_POSTGRES_URL`, these tests are skipped.
|
|
888
|
+
|
|
889
|
+
```bash
|
|
890
|
+
uv sync --group postgres
|
|
891
|
+
SOH_TEST_POSTGRES_URL=postgresql+asyncpg://user:password@localhost/test_db \
|
|
892
|
+
uv run --group postgres pytest -q tests/test_conflict_mutations_postgres.py
|
|
893
|
+
```
|
|
894
|
+
|
|
825
895
|
### Syncing with Remote
|
|
826
896
|
|
|
827
897
|
```bash
|
|
@@ -863,6 +933,25 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
|
|
|
863
933
|
|
|
864
934
|
## Changelog
|
|
865
935
|
|
|
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
|
|
948
|
+
|
|
949
|
+
- `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
|
|
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.
|
|
951
|
+
- Upsert reports a missing conflict-key field as `MutationError`. Repeated update expressions are explicitly documented as non-idempotent.
|
|
952
|
+
- Upsert leaves repeated keys to the database instead of Python equality: no silent loss of distinct keys (DST fold), no `DatabaseError` for keys equal only after column type coercion (`NUMERIC(10,2)`), no `TypeError` for unhashable keys (`ARRAY`). The count is distinct rows by primary key.
|
|
953
|
+
- PostgreSQL tests run through the optional `postgres` dependency group; tested on PostgreSQL 18.4 and 18.1, the versions of the SRO Agent and ExamCRM dev databases.
|
|
954
|
+
|
|
866
955
|
### 0.0.9 — 2026-10-09
|
|
867
956
|
|
|
868
957
|
- Dependency lower bound is now `sqlmodel>=0.0.27`. SQLModel 0.0.22–0.0.26 cannot create models on Python 3.14 (`Field 'id' requires a type annotation`), so the previous `>=0.0.22` bound was never satisfiable. Test suite passes on every SQLModel from 0.0.27 to 0.0.48.
|
|
@@ -45,10 +45,13 @@ packages = ["src/sqlmodel_object_helpers"]
|
|
|
45
45
|
|
|
46
46
|
[dependency-groups]
|
|
47
47
|
dev = [
|
|
48
|
-
"ruff==0.
|
|
48
|
+
"ruff==0.17.2",
|
|
49
49
|
"pyright==1.1.414",
|
|
50
50
|
"tzdata==2026.5",
|
|
51
51
|
"pytest==9.1.1",
|
|
52
52
|
"pytest-asyncio==1.4.0",
|
|
53
53
|
"aiosqlite==0.22.1",
|
|
54
54
|
]
|
|
55
|
+
postgres = [
|
|
56
|
+
"asyncpg==0.32.0",
|
|
57
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""sqlmodel-object-helpers — reusable query helpers for SQLModel projects."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.0.
|
|
3
|
+
__version__ = "0.0.11"
|
|
4
4
|
|
|
5
5
|
from .constants import QueryHelperSettings, settings
|
|
6
6
|
from .dynamic_meta import (
|
|
@@ -26,8 +26,10 @@ from .mutations import (
|
|
|
26
26
|
check_for_related_records,
|
|
27
27
|
delete_object,
|
|
28
28
|
delete_objects,
|
|
29
|
+
insert_objects_ignore_conflicts,
|
|
29
30
|
update_object,
|
|
30
31
|
update_objects,
|
|
32
|
+
upsert_objects,
|
|
31
33
|
)
|
|
32
34
|
from .operators import SPECIAL_OPERATORS, SUPPORTED_OPERATORS, LogicalOperator, Operator
|
|
33
35
|
from .query import count_objects, exists_object, get_object, get_objects, get_projection
|
|
@@ -119,9 +121,11 @@ __all__ = [
|
|
|
119
121
|
"get_object",
|
|
120
122
|
"get_objects",
|
|
121
123
|
"get_projection",
|
|
124
|
+
"insert_objects_ignore_conflicts",
|
|
122
125
|
"invalidate_meta_cache",
|
|
123
126
|
"load_pg_comments",
|
|
124
127
|
"settings",
|
|
125
128
|
"update_object",
|
|
126
129
|
"update_objects",
|
|
130
|
+
"upsert_objects",
|
|
127
131
|
]
|