sqlmodel-object-helpers 0.0.8__tar.gz → 0.0.10__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.8 → sqlmodel_object_helpers-0.0.10}/PKG-INFO +76 -5
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/README.md +74 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/pyproject.toml +5 -12
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/__init__.py +5 -1
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/mutations.py +309 -1
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/standalone.py +34 -0
- sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_limits.py +150 -0
- sqlmodel_object_helpers-0.0.10/tests/test_conflict_mutations_postgres.py +473 -0
- sqlmodel_object_helpers-0.0.10/tests/test_insert_ignore_conflicts.py +179 -0
- sqlmodel_object_helpers-0.0.10/tests/test_upsert_objects.py +323 -0
- sqlmodel_object_helpers-0.0.10/uv.lock +382 -0
- sqlmodel_object_helpers-0.0.8/uv.lock +0 -557
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/.git-blame-ignore-revs +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/.gitattributes +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/.github/workflows/publish.yml +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/.gitignore +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/.vscode/settings.json +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/LICENSE +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/pyrightconfig.json +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/ruff.toml +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/constants.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/operators.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/query.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/session.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/types/columns.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/types/filters.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/src/sqlmodel_object_helpers/types/projections.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/conftest.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_bulk_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_column_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_computed_columns.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_count_exists.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_data_error_handling.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_datetime_range.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_dynamic_meta.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_exceptions.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_filters.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_for_update.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_generated_columns_pg.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_implicit_datetime.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_loaders.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_mutations.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_operators.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_query.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_settings.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_standalone.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/tests/test_time_filter.py +0 -0
- {sqlmodel_object_helpers-0.0.8 → sqlmodel_object_helpers-0.0.10}/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.10
|
|
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
|
|
@@ -23,10 +23,7 @@ Classifier: Typing :: Typed
|
|
|
23
23
|
Requires-Python: >=3.14
|
|
24
24
|
Requires-Dist: pydantic>=2.12
|
|
25
25
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.46
|
|
26
|
-
Requires-Dist: sqlmodel>=0.0.
|
|
27
|
-
Provides-Extra: dev
|
|
28
|
-
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
29
|
-
Requires-Dist: ruff>=0.8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: sqlmodel>=0.0.27
|
|
30
27
|
Description-Content-Type: text/markdown
|
|
31
28
|
|
|
32
29
|
# sqlmodel-object-helpers
|
|
@@ -46,6 +43,8 @@ Generic async query helpers for [SQLModel](https://sqlmodel.tiangolo.com/): filt
|
|
|
46
43
|
- **Pagination** - Page/per\_page with total count and configurable max\_per\_page limit
|
|
47
44
|
- **Projections** - Select specific columns across joins with dot-notation and SQL aliases
|
|
48
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
|
|
49
48
|
- **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
|
|
50
49
|
- **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
|
|
51
50
|
- **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
|
|
@@ -395,6 +394,14 @@ user = await soh.add_object(session, User(name="Alice"))
|
|
|
395
394
|
users = await soh.add_objects(session, [User(name="Alice"), User(name="Bob")])
|
|
396
395
|
```
|
|
397
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
|
+
|
|
398
405
|
### update_object
|
|
399
406
|
|
|
400
407
|
```python
|
|
@@ -443,6 +450,47 @@ count = await soh.delete_objects(
|
|
|
443
450
|
|
|
444
451
|
Filters are **required** for safety -- empty filters are rejected. Dot-notation (relationship) filters are not supported in bulk operations.
|
|
445
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`). 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).
|
|
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.
|
|
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. 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.
|
|
491
|
+
|
|
492
|
+
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
|
+
|
|
446
494
|
### check_for_related_records
|
|
447
495
|
|
|
448
496
|
Pre-deletion check that inspects all ONETOMANY relationships:
|
|
@@ -775,6 +823,8 @@ Endpoints can be migrated one at a time. Three strategies per endpoint:
|
|
|
775
823
|
- `soh.update_objects(session, model, data, filters)` -- Bulk update via single `UPDATE ... WHERE`
|
|
776
824
|
- `soh.delete_object(session, model, *, instance, pk)` -- Delete by reference or PK
|
|
777
825
|
- `soh.delete_objects(session, model, filters)` -- Bulk delete via single `DELETE ... WHERE`
|
|
826
|
+
- `soh.insert_objects_ignore_conflicts(session, model, rows, *, conflict_columns)` -- Bulk insert via `INSERT ... ON CONFLICT DO NOTHING`, returns inserted count
|
|
827
|
+
- `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
|
|
778
828
|
- `soh.check_for_related_records(session, model, pk)` -- Pre-deletion dependency check
|
|
779
829
|
|
|
780
830
|
### Dynamic Meta
|
|
@@ -853,6 +903,14 @@ uv sync
|
|
|
853
903
|
uv run pytest tests/
|
|
854
904
|
```
|
|
855
905
|
|
|
906
|
+
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.
|
|
907
|
+
|
|
908
|
+
```bash
|
|
909
|
+
uv sync --group postgres
|
|
910
|
+
SOH_TEST_POSTGRES_URL=postgresql+asyncpg://user:password@localhost/test_db \
|
|
911
|
+
uv run --group postgres pytest -q tests/test_conflict_mutations_postgres.py
|
|
912
|
+
```
|
|
913
|
+
|
|
856
914
|
### Syncing with Remote
|
|
857
915
|
|
|
858
916
|
```bash
|
|
@@ -894,6 +952,19 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
|
|
|
894
952
|
|
|
895
953
|
## Changelog
|
|
896
954
|
|
|
955
|
+
### 0.0.10 — unreleased
|
|
956
|
+
|
|
957
|
+
- `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
|
|
958
|
+
- 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.
|
|
959
|
+
- Upsert reports a missing conflict-key field as `MutationError`. Repeated update expressions are explicitly documented as non-idempotent.
|
|
960
|
+
- 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.
|
|
961
|
+
- 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.
|
|
962
|
+
|
|
963
|
+
### 0.0.9 — 2026-10-09
|
|
964
|
+
|
|
965
|
+
- 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.
|
|
966
|
+
- Removed the unused `dev` extra (`mypy`, `ruff>=0.8.0`) and `[tool.mypy]`: types are checked with `pyright`, dev tools live in `[dependency-groups]`. `pip install sqlmodel-object-helpers[dev]` no longer exists.
|
|
967
|
+
|
|
897
968
|
### 0.0.8 — 2026-10-09
|
|
898
969
|
|
|
899
970
|
- **SQLModel ≥0.0.45 support** — `build_dynamic_meta` maps an unmapped `TypeDecorator` by its `impl`, so bare `datetime` fields (`UTCDateTime` in SQLModel ≥0.0.45) stay `datetime` instead of falling back to `string`. `Interval` stays `string`. Tested on SQLModel 0.0.42 and 0.0.48. The `sqlmodel>=0.0.22` lower bound is unchanged.
|
|
@@ -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`: 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
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,47 @@ 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`). 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).
|
|
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.
|
|
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. 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.
|
|
463
|
+
|
|
464
|
+
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
|
+
|
|
415
466
|
### check_for_related_records
|
|
416
467
|
|
|
417
468
|
Pre-deletion check that inspects all ONETOMANY relationships:
|
|
@@ -744,6 +795,8 @@ Endpoints can be migrated one at a time. Three strategies per endpoint:
|
|
|
744
795
|
- `soh.update_objects(session, model, data, filters)` -- Bulk update via single `UPDATE ... WHERE`
|
|
745
796
|
- `soh.delete_object(session, model, *, instance, pk)` -- Delete by reference or PK
|
|
746
797
|
- `soh.delete_objects(session, model, filters)` -- Bulk delete via single `DELETE ... WHERE`
|
|
798
|
+
- `soh.insert_objects_ignore_conflicts(session, model, rows, *, conflict_columns)` -- Bulk insert via `INSERT ... ON CONFLICT DO NOTHING`, returns inserted count
|
|
799
|
+
- `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
800
|
- `soh.check_for_related_records(session, model, pk)` -- Pre-deletion dependency check
|
|
748
801
|
|
|
749
802
|
### Dynamic Meta
|
|
@@ -822,6 +875,14 @@ uv sync
|
|
|
822
875
|
uv run pytest tests/
|
|
823
876
|
```
|
|
824
877
|
|
|
878
|
+
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.
|
|
879
|
+
|
|
880
|
+
```bash
|
|
881
|
+
uv sync --group postgres
|
|
882
|
+
SOH_TEST_POSTGRES_URL=postgresql+asyncpg://user:password@localhost/test_db \
|
|
883
|
+
uv run --group postgres pytest -q tests/test_conflict_mutations_postgres.py
|
|
884
|
+
```
|
|
885
|
+
|
|
825
886
|
### Syncing with Remote
|
|
826
887
|
|
|
827
888
|
```bash
|
|
@@ -863,6 +924,19 @@ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PAT
|
|
|
863
924
|
|
|
864
925
|
## Changelog
|
|
865
926
|
|
|
927
|
+
### 0.0.10 — unreleased
|
|
928
|
+
|
|
929
|
+
- `insert_objects_ignore_conflicts` and `upsert_objects`: bulk insert with explicit unique-key conflict handling, inserted/updated counts, and standalone wrappers.
|
|
930
|
+
- 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.
|
|
931
|
+
- Upsert reports a missing conflict-key field as `MutationError`. Repeated update expressions are explicitly documented as non-idempotent.
|
|
932
|
+
- 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.
|
|
933
|
+
- 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.
|
|
934
|
+
|
|
935
|
+
### 0.0.9 — 2026-10-09
|
|
936
|
+
|
|
937
|
+
- 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.
|
|
938
|
+
- Removed the unused `dev` extra (`mypy`, `ruff>=0.8.0`) and `[tool.mypy]`: types are checked with `pyright`, dev tools live in `[dependency-groups]`. `pip install sqlmodel-object-helpers[dev]` no longer exists.
|
|
939
|
+
|
|
866
940
|
### 0.0.8 — 2026-10-09
|
|
867
941
|
|
|
868
942
|
- **SQLModel ≥0.0.45 support** — `build_dynamic_meta` maps an unmapped `TypeDecorator` by its `impl`, so bare `datetime` fields (`UTCDateTime` in SQLModel ≥0.0.45) stay `datetime` instead of falling back to `string`. `Interval` stays `string`. Tested on SQLModel 0.0.42 and 0.0.48. The `sqlmodel>=0.0.22` lower bound is unchanged.
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
"sqlmodel>=0.0.
|
|
29
|
+
"sqlmodel>=0.0.27",
|
|
30
30
|
"sqlalchemy[asyncio]>=2.0.46",
|
|
31
31
|
"pydantic>=2.12",
|
|
32
32
|
]
|
|
@@ -37,16 +37,6 @@ Repository = "https://github.com/itstandart/sqlmodel-object-helpers"
|
|
|
37
37
|
Documentation = "https://github.com/itstandart/sqlmodel-object-helpers#readme"
|
|
38
38
|
Issues = "https://github.com/itstandart/sqlmodel-object-helpers/issues"
|
|
39
39
|
|
|
40
|
-
[project.optional-dependencies]
|
|
41
|
-
dev = [
|
|
42
|
-
"ruff>=0.8.0",
|
|
43
|
-
"mypy>=1.13.0",
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
[tool.mypy]
|
|
47
|
-
python_version = "3.14"
|
|
48
|
-
strict = true
|
|
49
|
-
|
|
50
40
|
[tool.hatch.version]
|
|
51
41
|
path = "src/sqlmodel_object_helpers/__init__.py"
|
|
52
42
|
|
|
@@ -55,10 +45,13 @@ packages = ["src/sqlmodel_object_helpers"]
|
|
|
55
45
|
|
|
56
46
|
[dependency-groups]
|
|
57
47
|
dev = [
|
|
58
|
-
"ruff==0.
|
|
48
|
+
"ruff==0.17.2",
|
|
59
49
|
"pyright==1.1.414",
|
|
60
50
|
"tzdata==2026.5",
|
|
61
51
|
"pytest==9.1.1",
|
|
62
52
|
"pytest-asyncio==1.4.0",
|
|
63
53
|
"aiosqlite==0.22.1",
|
|
64
54
|
]
|
|
55
|
+
postgres = [
|
|
56
|
+
"asyncpg==0.31.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.10"
|
|
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
|
]
|
|
@@ -18,12 +18,15 @@ 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
22
|
from typing import Any, cast
|
|
22
23
|
|
|
23
24
|
from sqlalchemy import CursorResult, inspect
|
|
24
25
|
from sqlalchemy import delete as sa_delete
|
|
25
26
|
from sqlalchemy import update as sa_update
|
|
26
|
-
from sqlalchemy.
|
|
27
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
28
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
29
|
+
from sqlalchemy.exc import DBAPIError, IntegrityError
|
|
27
30
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
28
31
|
from sqlalchemy.orm import ONETOMANY
|
|
29
32
|
from sqlmodel import SQLModel, and_, select
|
|
@@ -35,6 +38,15 @@ from .query import get_object
|
|
|
35
38
|
|
|
36
39
|
logger = logging.getLogger("sqlmodel_object_helpers")
|
|
37
40
|
|
|
41
|
+
# Dialects that support INSERT ... ON CONFLICT.
|
|
42
|
+
_ON_CONFLICT_INSERT: dict[str, Callable[..., Any]] = {"postgresql": pg_insert, "sqlite": sqlite_insert}
|
|
43
|
+
|
|
44
|
+
# Headroom below the bind-parameter limits of asyncpg (32767) and SQLite (32766).
|
|
45
|
+
_MAX_BIND_PARAMS = 30_000
|
|
46
|
+
|
|
47
|
+
# PostgreSQL SQLSTATE: ON CONFLICT DO UPDATE affected the same row twice in one statement.
|
|
48
|
+
_CARDINALITY_VIOLATION = "21000"
|
|
49
|
+
|
|
38
50
|
|
|
39
51
|
async def add_object[T: SQLModel](
|
|
40
52
|
session: AsyncSession,
|
|
@@ -416,6 +428,302 @@ async def delete_objects[T: SQLModel](
|
|
|
416
428
|
return row_count
|
|
417
429
|
|
|
418
430
|
|
|
431
|
+
def _prepare_bulk_insert(
|
|
432
|
+
func_name: str,
|
|
433
|
+
session: AsyncSession,
|
|
434
|
+
model: type[SQLModel],
|
|
435
|
+
rows: Sequence[Mapping[str, Any]],
|
|
436
|
+
conflict_columns: Sequence[str],
|
|
437
|
+
) -> tuple[Callable[..., Any], Any, set[str]]:
|
|
438
|
+
"""Validate rows and the conflict key; return the INSERT constructor, model columns and row fields."""
|
|
439
|
+
model_name = model.__name__
|
|
440
|
+
|
|
441
|
+
if not conflict_columns:
|
|
442
|
+
msg = f"{func_name}({model_name}): conflict_columns are required"
|
|
443
|
+
raise MutationError(msg)
|
|
444
|
+
|
|
445
|
+
columns = inspect(model).columns
|
|
446
|
+
fields = set(rows[0])
|
|
447
|
+
if not fields:
|
|
448
|
+
msg = f"{func_name}({model_name}): rows cannot be empty mappings"
|
|
449
|
+
raise MutationError(msg)
|
|
450
|
+
|
|
451
|
+
for key in (*fields, *conflict_columns):
|
|
452
|
+
if key not in columns:
|
|
453
|
+
msg = f"Model {model_name} has no field {key}"
|
|
454
|
+
raise MutationError(msg)
|
|
455
|
+
|
|
456
|
+
if any(set(row) != fields for row in rows):
|
|
457
|
+
msg = f"{func_name}({model_name}): all rows must have the same keys"
|
|
458
|
+
raise MutationError(msg)
|
|
459
|
+
|
|
460
|
+
dialect = session.get_bind(model).dialect.name
|
|
461
|
+
insert_fn = _ON_CONFLICT_INSERT.get(dialect)
|
|
462
|
+
if insert_fn is None:
|
|
463
|
+
msg = f"{func_name}({model_name}): ON CONFLICT is not supported on {dialect}"
|
|
464
|
+
raise MutationError(msg)
|
|
465
|
+
|
|
466
|
+
return insert_fn, columns, fields
|
|
467
|
+
|
|
468
|
+
|
|
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)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _hashable(value: Any) -> Any:
|
|
500
|
+
"""Make a RETURNING value hashable: ARRAY values come back as lists."""
|
|
501
|
+
if isinstance(value, list | tuple):
|
|
502
|
+
return tuple(_hashable(item) for item in value)
|
|
503
|
+
return value
|
|
504
|
+
|
|
505
|
+
|
|
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}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _sqlstate(error: DBAPIError) -> str | None:
|
|
512
|
+
"""Driver error SQLSTATE: ``sqlstate`` on asyncpg and psycopg, ``pgcode`` on psycopg2."""
|
|
513
|
+
return getattr(error.orig, "sqlstate", None) or getattr(error.orig, "pgcode", None)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
async def _upsert_chunk(
|
|
517
|
+
session: AsyncSession,
|
|
518
|
+
chunk: list[dict[str, Any]],
|
|
519
|
+
stmt: Any,
|
|
520
|
+
make_stmt: Callable[[list[dict[str, Any]]], Any],
|
|
521
|
+
*,
|
|
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))
|
|
527
|
+
try:
|
|
528
|
+
async with session.begin_nested():
|
|
529
|
+
return _row_keys(await session.execute(stmt))
|
|
530
|
+
except DBAPIError as e:
|
|
531
|
+
if _sqlstate(e) != _CARDINALITY_VIOLATION:
|
|
532
|
+
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
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
async def _execute_bulk_insert(
|
|
542
|
+
func_name: str,
|
|
543
|
+
session: AsyncSession,
|
|
544
|
+
model: type[SQLModel],
|
|
545
|
+
rows: Sequence[Mapping[str, Any]],
|
|
546
|
+
fields: set[str],
|
|
547
|
+
make_stmt: Callable[[list[dict[str, Any]]], Any],
|
|
548
|
+
*,
|
|
549
|
+
upsert: bool = False,
|
|
550
|
+
) -> int:
|
|
551
|
+
"""Execute chunks without committing: total rowcount, or distinct RETURNING PKs for upsert."""
|
|
552
|
+
start = time.perf_counter()
|
|
553
|
+
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"
|
|
558
|
+
|
|
559
|
+
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
|
|
566
|
+
await session.flush()
|
|
567
|
+
except MutationError:
|
|
568
|
+
raise
|
|
569
|
+
except IntegrityError as e:
|
|
570
|
+
logger.warning("%s(%s) — integrity error: %s", func_name, model_name, e.orig)
|
|
571
|
+
msg = f"Integrity error inserting {model_name}: {e.orig}"
|
|
572
|
+
raise MutationError(msg) from e
|
|
573
|
+
except Exception as e:
|
|
574
|
+
logger.exception("%s(%s) — unexpected error", func_name, model_name)
|
|
575
|
+
msg = f"Database error inserting {model_name}: {e}"
|
|
576
|
+
raise DatabaseError(msg) from e
|
|
577
|
+
|
|
578
|
+
if upsert:
|
|
579
|
+
affected = len(written)
|
|
580
|
+
logger.debug(
|
|
581
|
+
"%s(%s) → %d of %d rows, %.3fs", func_name, model_name, affected, len(rows), time.perf_counter() - start
|
|
582
|
+
)
|
|
583
|
+
return affected
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def insert_objects_ignore_conflicts[T: SQLModel](
|
|
587
|
+
session: AsyncSession,
|
|
588
|
+
model: type[T],
|
|
589
|
+
rows: Sequence[Mapping[str, Any]],
|
|
590
|
+
*,
|
|
591
|
+
conflict_columns: Sequence[str],
|
|
592
|
+
) -> int:
|
|
593
|
+
"""
|
|
594
|
+
Insert rows, skipping duplicates by the unique key ``conflict_columns``.
|
|
595
|
+
|
|
596
|
+
Executes ``INSERT ... ON CONFLICT (...) DO NOTHING``. Replayed and concurrent batches
|
|
597
|
+
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.
|
|
601
|
+
|
|
602
|
+
Values are passed without Pydantic validation and must already have the right types.
|
|
603
|
+
Omitted columns get their column defaults: ``Field(default=...)``/``default_factory``
|
|
604
|
+
of a plain field and ``server_default``. ``Field(default)`` of a field with
|
|
605
|
+
``sa_column`` does not reach the column.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
session: Async SQLAlchemy session (caller manages transaction).
|
|
609
|
+
model: SQLModel class to insert into.
|
|
610
|
+
rows: ``field → value`` mappings, all with the same keys.
|
|
611
|
+
conflict_columns: Columns of the unique index or constraint that defines a duplicate.
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
Number of inserted rows, excluding skipped duplicates.
|
|
615
|
+
|
|
616
|
+
Raises:
|
|
617
|
+
MutationError: Invalid fields, conflict key or row keys, unsupported dialect,
|
|
618
|
+
a single row over the bind-parameter limit, or another constraint violation.
|
|
619
|
+
DatabaseError: On unexpected database errors.
|
|
620
|
+
|
|
621
|
+
"""
|
|
622
|
+
if not rows:
|
|
623
|
+
return 0
|
|
624
|
+
|
|
625
|
+
func_name = "insert_objects_ignore_conflicts"
|
|
626
|
+
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)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
async def upsert_objects[T: SQLModel](
|
|
636
|
+
session: AsyncSession,
|
|
637
|
+
model: type[T],
|
|
638
|
+
rows: Sequence[Mapping[str, Any]],
|
|
639
|
+
*,
|
|
640
|
+
conflict_columns: Sequence[str],
|
|
641
|
+
update_columns: Sequence[str] | None = None,
|
|
642
|
+
set_extra: Mapping[str, Any] | None = None,
|
|
643
|
+
) -> int:
|
|
644
|
+
"""
|
|
645
|
+
Insert new rows and update existing ones by the key ``conflict_columns``.
|
|
646
|
+
|
|
647
|
+
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.
|
|
657
|
+
|
|
658
|
+
Values are passed without Pydantic validation. Defaults of omitted columns apply on
|
|
659
|
+
insert; on conflict those columns keep their values unless set in ``set_extra``.
|
|
660
|
+
``Column.onupdate`` does not fire on conflict — pass it in ``set_extra``.
|
|
661
|
+
Objects already loaded into the session are not refreshed.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
session: Async SQLAlchemy session (caller manages transaction).
|
|
665
|
+
model: SQLModel class to insert into or update.
|
|
666
|
+
rows: ``field → value`` mappings, all with the same keys.
|
|
667
|
+
conflict_columns: Columns of the unique index or constraint.
|
|
668
|
+
update_columns: Row columns updated on conflict. Defaults to all input fields
|
|
669
|
+
except the conflict key.
|
|
670
|
+
set_extra: Extra values or SQL expressions applied only on update,
|
|
671
|
+
e.g. ``{"updated_at": func.now()}``.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Number of distinct table rows (by primary key from RETURNING) inserted or updated;
|
|
675
|
+
a key repeated in the input counts once.
|
|
676
|
+
|
|
677
|
+
Raises:
|
|
678
|
+
MutationError: Invalid input, a single row over the bind-parameter limit,
|
|
679
|
+
unsupported dialect, or a database constraint violation.
|
|
680
|
+
DatabaseError: On unexpected database errors.
|
|
681
|
+
|
|
682
|
+
"""
|
|
683
|
+
if not rows:
|
|
684
|
+
return 0
|
|
685
|
+
|
|
686
|
+
func_name = "upsert_objects"
|
|
687
|
+
model_name = model.__name__
|
|
688
|
+
insert_fn, columns, fields = _prepare_bulk_insert(func_name, session, model, rows, conflict_columns)
|
|
689
|
+
|
|
690
|
+
if any(name not in fields for name in conflict_columns):
|
|
691
|
+
msg = f"{func_name}({model_name}): conflict_columns must be present in rows"
|
|
692
|
+
raise MutationError(msg)
|
|
693
|
+
|
|
694
|
+
to_update = (
|
|
695
|
+
list(update_columns) if update_columns is not None else [f for f in rows[0] if f not in conflict_columns]
|
|
696
|
+
)
|
|
697
|
+
extra = dict(set_extra or {})
|
|
698
|
+
for name in extra:
|
|
699
|
+
if name not in columns:
|
|
700
|
+
msg = f"Model {model_name} has no field {name}"
|
|
701
|
+
raise MutationError(msg)
|
|
702
|
+
for name in to_update:
|
|
703
|
+
if name not in fields or name in conflict_columns:
|
|
704
|
+
msg = f"{func_name}({model_name}): update column {name} must be a row field outside conflict_columns"
|
|
705
|
+
raise MutationError(msg)
|
|
706
|
+
for name in extra:
|
|
707
|
+
if name in conflict_columns or name in to_update:
|
|
708
|
+
msg = f"{func_name}({model_name}): set_extra column {name} is a conflict or update column"
|
|
709
|
+
raise MutationError(msg)
|
|
710
|
+
if not to_update and not extra:
|
|
711
|
+
msg = f"{func_name}({model_name}): nothing to update, use insert_objects_ignore_conflicts"
|
|
712
|
+
raise MutationError(msg)
|
|
713
|
+
|
|
714
|
+
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)
|
|
725
|
+
|
|
726
|
+
|
|
419
727
|
async def check_for_related_records[T: SQLModel](
|
|
420
728
|
session: AsyncSession,
|
|
421
729
|
model: type[T],
|