storepy 2.4.0__tar.gz → 2.6.0__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.
- {storepy-2.4.0/src/storepy.egg-info → storepy-2.6.0}/PKG-INFO +49 -8
- {storepy-2.4.0 → storepy-2.6.0}/README.md +48 -7
- {storepy-2.4.0 → storepy-2.6.0}/pyproject.toml +1 -1
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/__init__.py +50 -5
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/exec.py +62 -2
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/mutation.py +11 -8
- storepy-2.6.0/src/py_store/crud/write.py +176 -0
- storepy-2.6.0/src/py_store/datasource.py +836 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/ddl.py +45 -8
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/__init__.py +61 -1
- storepy-2.6.0/src/py_store/executors/mongo.py +169 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/mysql.py +75 -14
- storepy-2.6.0/src/py_store/executors/postgres.py +116 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/sqlite.py +66 -8
- {storepy-2.4.0 → storepy-2.6.0/src/storepy.egg-info}/PKG-INFO +49 -8
- {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/SOURCES.txt +3 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_coverage_margin.py +118 -3
- storepy-2.6.0/tests/test_mongo_session.py +399 -0
- storepy-2.6.0/tests/test_raw_sql_and_ddl.py +461 -0
- storepy-2.6.0/tests/test_scenario_manager_transaction.py +149 -0
- storepy-2.6.0/tests/test_session.py +706 -0
- storepy-2.4.0/src/py_store/crud/write.py +0 -111
- storepy-2.4.0/src/py_store/datasource.py +0 -268
- storepy-2.4.0/src/py_store/executors/mongo.py +0 -99
- storepy-2.4.0/src/py_store/executors/postgres.py +0 -57
- storepy-2.4.0/tests/test_raw_sql_and_ddl.py +0 -224
- {storepy-2.4.0 → storepy-2.6.0}/LICENSE +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/setup.cfg +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/core.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/__init__.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/id.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/query.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/_values.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/feedback.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/__init__.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/mysql.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/postgres.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/permission.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/schema.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/py_store/sync.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/requires.txt +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_federation_e2e.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_host_contract.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_host_paths.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_mongo_executor.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_multi_datasource.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_profile.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_py_store.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_real_backends_e2e.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_require_context.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_scenario_course_platform.py +0 -0
- {storepy-2.4.0 → storepy-2.6.0}/tests/test_sql_commit_and_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: storepy
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
|
|
5
5
|
Author-email: leo <coen_ddt@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -85,6 +85,7 @@ from py_store import init, store
|
|
|
85
85
|
- [Feedback events](#feedback-events)
|
|
86
86
|
- [Schema reference](#schema-reference)
|
|
87
87
|
- [Transactions](#transaction-boundary)
|
|
88
|
+
- [Transactional capabilities](#transactional-capabilities)
|
|
88
89
|
- [FAQ](#faq)
|
|
89
90
|
- [Related projects](#related-projects)
|
|
90
91
|
|
|
@@ -265,6 +266,9 @@ GQL tree queries compile to a single native query per backend — never hand-wri
|
|
|
265
266
|
- **Permission context** — `ContextVar`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
|
|
266
267
|
- **Multi-datasource & multi-tenant** — locate a schema by `(source, namespace, collection)`; re-target per request with a route override.
|
|
267
268
|
- **Async-first, Rust core** — built on PyMongo's `AsyncMongoClient` and a shared Rust core with SQL dialects.
|
|
269
|
+
- **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all four backends (previously a silent no-op on MongoDB).
|
|
270
|
+
- **Autoincrement primary keys** — declare `_id` as `{"type": "int", "strategy": "autoincrement"}` for database-assigned integer IDs, with explicit errors where autoincrement is impossible.
|
|
271
|
+
- **Index DDL** — `schema.indexes` compiles to real `CREATE [UNIQUE] INDEX` statements (per backend, byte-identical); the generator still only emits text.
|
|
268
272
|
|
|
269
273
|
## GQL syntax
|
|
270
274
|
|
|
@@ -370,11 +374,27 @@ async def transfer():
|
|
|
370
374
|
await store.transaction("default", transfer)
|
|
371
375
|
```
|
|
372
376
|
|
|
373
|
-
- `store.transaction(source, fn)` opens a transaction scope on one
|
|
374
|
-
- `store.execute_raw(source, sql, params=None, is_write=
|
|
375
|
-
- `is_write
|
|
377
|
+
- `store.transaction(source, fn)` opens a transaction scope on one source: every `execute_raw` / CRUD call inside `fn` lands on that source's transaction connection, with `commit` / `rollback` as one unit (reuses the internal `run_in_transaction`). Mongo sources are probed at runtime (replica set / sharded) and wrapped in a session transaction; on standalone or probe failure `fn` runs as-is and emits `mongo_transaction_unsupported` (`deployment: standalone|unknown`) — it never pretends to be atomic. Executors without `with_transaction` also run `fn` as-is and emit a `transaction_not_atomic` feedback event (degradation is allowed, silent pretence is not). A nested same-source transaction opens a savepoint (an inner failure rolls back only that scope); without savepoint primitives it degrades by joining the outer transaction and emits `nested_savepoint_unsupported`.
|
|
378
|
+
- `store.execute_raw(source, sql, params=None, is_write=None)` runs raw SQL, compiled by the core `raw_stmt_compile`. Two styles selected by the `params` type: **positional** (list/tuple/None) passes the SQL through as-is with native placeholders (`?` for MySQL / SQLite, `$1..$n` for PostgreSQL); **named** (dict) compiles `:name` tokens in the SQL into dialect placeholders (same-name reuse, `::` casts / quotes / comments kept intact; missing or unused names raise `RawSqlError`). SQL sources only — a Mongo source raises `RawSqlError` (`py_store.RawSqlError` / `store.RawSqlError`).
|
|
379
|
+
- When `is_write` is omitted it is inferred from the SQL's first word (SELECT / WITH / EXPLAIN / SHOW / PRAGMA / TABLE count as reads, everything else as a write — defaulting to write is the safe direction); passing it explicitly overrides the inference. Returns `{"rows", "affectedRows"}`: rows for reads, the affected-row count for writes.
|
|
380
|
+
- `store.execute_native(source, collection, pipeline=None, options=None)` runs a native aggregation pipeline on a Mongo source (the Mongo counterpart of the SQL-side `execute_raw` escape hatch): `pipeline` is a native aggregation pipeline, `options` uses driver-native keys (`allowDiskUse` / `batchSize` / `hint` / `maxTimeMS`..., no host-side whitelist). Inside a transaction / session the session is injected automatically (owned by the transaction; `options.session` cannot override it); resolution always follows the read path, so `$merge` / `$out` write stages require you to open a transaction yourself. Mongo sources only — a SQL source raises `NativeCommandError` pointing to `execute_raw`; the MongoClient form requires a schema-declared namespace. Returns `{"rows"}`.
|
|
376
381
|
- **The non-transactional path commits explicitly**: on SQL sources, a write plan that runs outside `store.transaction` is committed by the executor (`commit` on success; `rollback` then re-raise on failure). `aiosqlite` is not autocommit by default, so without that commit the write would be visible only on the current connection while `execute_raw` still reported success — a silent data-loss hazard. Multi-statement writes that must be atomic as a group belong inside `store.transaction`.
|
|
377
382
|
|
|
383
|
+
### Session (Unit of Work)
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
async with store.session() as s:
|
|
387
|
+
await s.insert("Order", {...})
|
|
388
|
+
await s.update("Account", cond, {...})
|
|
389
|
+
await s.execute_raw("pg_main", "SELECT ... FOR UPDATE", [1])
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
- Inside a session, **every command on the same SQL source lands on one transaction connection**: the session commits once on exit, and rolls back as one unit on any exception.
|
|
393
|
+
- **Lazy transaction start**: a session with no commands never checks out a connection.
|
|
394
|
+
- **Cross-source writes fail closed**: if a session writes to ≥2 datasources, it rolls everything back and raises `NonAtomicWriteError` on exit (no distributed transaction — it never commits a half-done unit of work).
|
|
395
|
+
- Mongo sources are probed at runtime (replica set / sharded) and made transactional; on standalone or probe failure they run as-is (non-atomic) and emit one `mongo_transaction_unsupported` (`deployment: standalone|unknown`) feedback event.
|
|
396
|
+
- Sessions nest: an inner scope opens a savepoint (`SAVEPOINT sp_<n>`) on the outer transaction and, on exit, `RELEASE`s it (success) or `ROLLBACK TO`s and releases it (failure) — **an inner failure rolls back only the inner scope while the outer one continues**. When the transaction handle has no savepoint primitives, the nested scope degrades by joining the outer one and emits one `nested_savepoint_unsupported` feedback event.
|
|
397
|
+
|
|
378
398
|
### DDL generation
|
|
379
399
|
|
|
380
400
|
```python
|
|
@@ -517,11 +537,32 @@ Boundary rules worth knowing up front (all **fail explicitly**, never silently d
|
|
|
517
537
|
|
|
518
538
|
## Transaction boundary
|
|
519
539
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
-
|
|
540
|
+
| Scenario | Atomicity |
|
|
541
|
+
|---|---|
|
|
542
|
+
| Single-command API (`insert` / `insert_many` / `update_many` / `upsert` / `remove` / `count` / `exists`) | Naturally atomic within one SQL source (a single statement); single documents are atomic on Mongo |
|
|
543
|
+
| `store.transaction(source, fn)` | Atomic within one SQL source: every command in the scope shares one connection and one transaction; nested same-source scopes use a savepoint (an inner failure rolls back only that scope) |
|
|
544
|
+
| `store.session(...)` | Atomic **across multiple calls** on one SQL source inside the session; cross-source writes are rejected explicitly (`NonAtomicWriteError`) |
|
|
545
|
+
| Cross-source multi-write without a session | Not atomic (no 2PC / Saga), executed datasource by datasource, and declares `nonAtomic` via the feedback channel (event `non_atomic_write`, with the sources) |
|
|
546
|
+
| Multi-step writes on Mongo | replica set / sharded: atomic on a single Mongo source (session transaction); standalone: non-atomic and explicitly declares `mongo_transaction_unsupported` |
|
|
547
|
+
|
|
548
|
+
- **Mongo sources**: made transactional inside a session according to the runtime probe; non-transactable ones (standalone / probe failure) run as-is and emit a `mongo_transaction_unsupported` feedback event (`deployment: standalone|unknown`) (degradation is allowed, silent pretence is not).
|
|
549
|
+
- **SQL executors without `open_transaction`**: commands run as-is inside a session and emit a `session_not_atomic` feedback event (degradation is allowed, silent pretence is not).
|
|
550
|
+
- **SQL executors without `with_transaction`**: commands run as-is inside a transaction scope (or the top-level atomic envelope) and emit a `transaction_not_atomic` feedback event (symmetric with `session_not_atomic`; degradation is allowed, silent pretence is not).
|
|
523
551
|
- **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
|
|
524
|
-
-
|
|
552
|
+
- Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
|
|
553
|
+
- **Cross-source writes (no session)**: a single write call touching ≥2 datasources **cannot be atomic**; it runs sequentially and emits one `non_atomic_write` feedback event (`code: nonAtomic`, with the source list) — degradation is allowed, silence is not. Converge writes onto a single source, or wrap them in `store.session()` (which fails closed on cross-source writes).
|
|
554
|
+
|
|
555
|
+
## Transactional capabilities
|
|
556
|
+
|
|
557
|
+
Capabilities aimed at transactional workloads (orders, inventory — write contention plus
|
|
558
|
+
complex reads). Full details, semantics and the explicit-error list:
|
|
559
|
+
**[doc/transaction-capabilities.md](doc/transaction-capabilities.md)** ·
|
|
560
|
+
[中文](doc/transaction-capabilities.zh-CN.md).
|
|
561
|
+
|
|
562
|
+
- **Relation predicates in mutations** — `update_many('Inventory', {'product': {'category': 'meat'}}, {'$inc': {'stock': 10}})`: condition keys matching a declared relation become a semi/anti-join, normalized into a preCommand (aggregate fetching `_id`s) plus `_id $in`.
|
|
563
|
+
- **`$group by` one-relation paths** — `by: ['product.category']` compiles to `$lookup`+`$unwind` (Mongo) / `LEFT JOIN` (SQL); `many` paths fail explicitly (fan-out breaks count semantics).
|
|
564
|
+
- **Autoincrement PKs** — `_id: {'type': 'int', 'strategy': 'autoincrement'}`; PG/SQLite read back via `INSERT…RETURNING`, MySQL via last-insert-id; MongoDB and `insert_many` fail explicitly with `AUTOINCREMENT_NOT_SUPPORTED` (no silent ObjectId substitution).
|
|
565
|
+
- **Index DDL** — `schema.indexes` (MongoDB shape) → `CREATE [UNIQUE] INDEX idx_<table>_<cols>` in `ddl.generate`, byte-identical across MySQL/PostgreSQL/SQLite.
|
|
525
566
|
|
|
526
567
|
## FAQ
|
|
527
568
|
|
|
@@ -45,6 +45,7 @@ from py_store import init, store
|
|
|
45
45
|
- [Feedback events](#feedback-events)
|
|
46
46
|
- [Schema reference](#schema-reference)
|
|
47
47
|
- [Transactions](#transaction-boundary)
|
|
48
|
+
- [Transactional capabilities](#transactional-capabilities)
|
|
48
49
|
- [FAQ](#faq)
|
|
49
50
|
- [Related projects](#related-projects)
|
|
50
51
|
|
|
@@ -225,6 +226,9 @@ GQL tree queries compile to a single native query per backend — never hand-wri
|
|
|
225
226
|
- **Permission context** — `ContextVar`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
|
|
226
227
|
- **Multi-datasource & multi-tenant** — locate a schema by `(source, namespace, collection)`; re-target per request with a route override.
|
|
227
228
|
- **Async-first, Rust core** — built on PyMongo's `AsyncMongoClient` and a shared Rust core with SQL dialects.
|
|
229
|
+
- **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all four backends (previously a silent no-op on MongoDB).
|
|
230
|
+
- **Autoincrement primary keys** — declare `_id` as `{"type": "int", "strategy": "autoincrement"}` for database-assigned integer IDs, with explicit errors where autoincrement is impossible.
|
|
231
|
+
- **Index DDL** — `schema.indexes` compiles to real `CREATE [UNIQUE] INDEX` statements (per backend, byte-identical); the generator still only emits text.
|
|
228
232
|
|
|
229
233
|
## GQL syntax
|
|
230
234
|
|
|
@@ -330,11 +334,27 @@ async def transfer():
|
|
|
330
334
|
await store.transaction("default", transfer)
|
|
331
335
|
```
|
|
332
336
|
|
|
333
|
-
- `store.transaction(source, fn)` opens a transaction scope on one
|
|
334
|
-
- `store.execute_raw(source, sql, params=None, is_write=
|
|
335
|
-
- `is_write
|
|
337
|
+
- `store.transaction(source, fn)` opens a transaction scope on one source: every `execute_raw` / CRUD call inside `fn` lands on that source's transaction connection, with `commit` / `rollback` as one unit (reuses the internal `run_in_transaction`). Mongo sources are probed at runtime (replica set / sharded) and wrapped in a session transaction; on standalone or probe failure `fn` runs as-is and emits `mongo_transaction_unsupported` (`deployment: standalone|unknown`) — it never pretends to be atomic. Executors without `with_transaction` also run `fn` as-is and emit a `transaction_not_atomic` feedback event (degradation is allowed, silent pretence is not). A nested same-source transaction opens a savepoint (an inner failure rolls back only that scope); without savepoint primitives it degrades by joining the outer transaction and emits `nested_savepoint_unsupported`.
|
|
338
|
+
- `store.execute_raw(source, sql, params=None, is_write=None)` runs raw SQL, compiled by the core `raw_stmt_compile`. Two styles selected by the `params` type: **positional** (list/tuple/None) passes the SQL through as-is with native placeholders (`?` for MySQL / SQLite, `$1..$n` for PostgreSQL); **named** (dict) compiles `:name` tokens in the SQL into dialect placeholders (same-name reuse, `::` casts / quotes / comments kept intact; missing or unused names raise `RawSqlError`). SQL sources only — a Mongo source raises `RawSqlError` (`py_store.RawSqlError` / `store.RawSqlError`).
|
|
339
|
+
- When `is_write` is omitted it is inferred from the SQL's first word (SELECT / WITH / EXPLAIN / SHOW / PRAGMA / TABLE count as reads, everything else as a write — defaulting to write is the safe direction); passing it explicitly overrides the inference. Returns `{"rows", "affectedRows"}`: rows for reads, the affected-row count for writes.
|
|
340
|
+
- `store.execute_native(source, collection, pipeline=None, options=None)` runs a native aggregation pipeline on a Mongo source (the Mongo counterpart of the SQL-side `execute_raw` escape hatch): `pipeline` is a native aggregation pipeline, `options` uses driver-native keys (`allowDiskUse` / `batchSize` / `hint` / `maxTimeMS`..., no host-side whitelist). Inside a transaction / session the session is injected automatically (owned by the transaction; `options.session` cannot override it); resolution always follows the read path, so `$merge` / `$out` write stages require you to open a transaction yourself. Mongo sources only — a SQL source raises `NativeCommandError` pointing to `execute_raw`; the MongoClient form requires a schema-declared namespace. Returns `{"rows"}`.
|
|
336
341
|
- **The non-transactional path commits explicitly**: on SQL sources, a write plan that runs outside `store.transaction` is committed by the executor (`commit` on success; `rollback` then re-raise on failure). `aiosqlite` is not autocommit by default, so without that commit the write would be visible only on the current connection while `execute_raw` still reported success — a silent data-loss hazard. Multi-statement writes that must be atomic as a group belong inside `store.transaction`.
|
|
337
342
|
|
|
343
|
+
### Session (Unit of Work)
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
async with store.session() as s:
|
|
347
|
+
await s.insert("Order", {...})
|
|
348
|
+
await s.update("Account", cond, {...})
|
|
349
|
+
await s.execute_raw("pg_main", "SELECT ... FOR UPDATE", [1])
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
- Inside a session, **every command on the same SQL source lands on one transaction connection**: the session commits once on exit, and rolls back as one unit on any exception.
|
|
353
|
+
- **Lazy transaction start**: a session with no commands never checks out a connection.
|
|
354
|
+
- **Cross-source writes fail closed**: if a session writes to ≥2 datasources, it rolls everything back and raises `NonAtomicWriteError` on exit (no distributed transaction — it never commits a half-done unit of work).
|
|
355
|
+
- Mongo sources are probed at runtime (replica set / sharded) and made transactional; on standalone or probe failure they run as-is (non-atomic) and emit one `mongo_transaction_unsupported` (`deployment: standalone|unknown`) feedback event.
|
|
356
|
+
- Sessions nest: an inner scope opens a savepoint (`SAVEPOINT sp_<n>`) on the outer transaction and, on exit, `RELEASE`s it (success) or `ROLLBACK TO`s and releases it (failure) — **an inner failure rolls back only the inner scope while the outer one continues**. When the transaction handle has no savepoint primitives, the nested scope degrades by joining the outer one and emits one `nested_savepoint_unsupported` feedback event.
|
|
357
|
+
|
|
338
358
|
### DDL generation
|
|
339
359
|
|
|
340
360
|
```python
|
|
@@ -477,11 +497,32 @@ Boundary rules worth knowing up front (all **fail explicitly**, never silently d
|
|
|
477
497
|
|
|
478
498
|
## Transaction boundary
|
|
479
499
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
-
|
|
500
|
+
| Scenario | Atomicity |
|
|
501
|
+
|---|---|
|
|
502
|
+
| Single-command API (`insert` / `insert_many` / `update_many` / `upsert` / `remove` / `count` / `exists`) | Naturally atomic within one SQL source (a single statement); single documents are atomic on Mongo |
|
|
503
|
+
| `store.transaction(source, fn)` | Atomic within one SQL source: every command in the scope shares one connection and one transaction; nested same-source scopes use a savepoint (an inner failure rolls back only that scope) |
|
|
504
|
+
| `store.session(...)` | Atomic **across multiple calls** on one SQL source inside the session; cross-source writes are rejected explicitly (`NonAtomicWriteError`) |
|
|
505
|
+
| Cross-source multi-write without a session | Not atomic (no 2PC / Saga), executed datasource by datasource, and declares `nonAtomic` via the feedback channel (event `non_atomic_write`, with the sources) |
|
|
506
|
+
| Multi-step writes on Mongo | replica set / sharded: atomic on a single Mongo source (session transaction); standalone: non-atomic and explicitly declares `mongo_transaction_unsupported` |
|
|
507
|
+
|
|
508
|
+
- **Mongo sources**: made transactional inside a session according to the runtime probe; non-transactable ones (standalone / probe failure) run as-is and emit a `mongo_transaction_unsupported` feedback event (`deployment: standalone|unknown`) (degradation is allowed, silent pretence is not).
|
|
509
|
+
- **SQL executors without `open_transaction`**: commands run as-is inside a session and emit a `session_not_atomic` feedback event (degradation is allowed, silent pretence is not).
|
|
510
|
+
- **SQL executors without `with_transaction`**: commands run as-is inside a transaction scope (or the top-level atomic envelope) and emit a `transaction_not_atomic` feedback event (symmetric with `session_not_atomic`; degradation is allowed, silent pretence is not).
|
|
483
511
|
- **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
|
|
484
|
-
-
|
|
512
|
+
- Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
|
|
513
|
+
- **Cross-source writes (no session)**: a single write call touching ≥2 datasources **cannot be atomic**; it runs sequentially and emits one `non_atomic_write` feedback event (`code: nonAtomic`, with the source list) — degradation is allowed, silence is not. Converge writes onto a single source, or wrap them in `store.session()` (which fails closed on cross-source writes).
|
|
514
|
+
|
|
515
|
+
## Transactional capabilities
|
|
516
|
+
|
|
517
|
+
Capabilities aimed at transactional workloads (orders, inventory — write contention plus
|
|
518
|
+
complex reads). Full details, semantics and the explicit-error list:
|
|
519
|
+
**[doc/transaction-capabilities.md](doc/transaction-capabilities.md)** ·
|
|
520
|
+
[中文](doc/transaction-capabilities.zh-CN.md).
|
|
521
|
+
|
|
522
|
+
- **Relation predicates in mutations** — `update_many('Inventory', {'product': {'category': 'meat'}}, {'$inc': {'stock': 10}})`: condition keys matching a declared relation become a semi/anti-join, normalized into a preCommand (aggregate fetching `_id`s) plus `_id $in`.
|
|
523
|
+
- **`$group by` one-relation paths** — `by: ['product.category']` compiles to `$lookup`+`$unwind` (Mongo) / `LEFT JOIN` (SQL); `many` paths fail explicitly (fan-out breaks count semantics).
|
|
524
|
+
- **Autoincrement PKs** — `_id: {'type': 'int', 'strategy': 'autoincrement'}`; PG/SQLite read back via `INSERT…RETURNING`, MySQL via last-insert-id; MongoDB and `insert_many` fail explicitly with `AUTOINCREMENT_NOT_SUPPORTED` (no silent ObjectId substitution).
|
|
525
|
+
- **Index DDL** — `schema.indexes` (MongoDB shape) → `CREATE [UNIQUE] INDEX idx_<table>_<cols>` in `ddl.generate`, byte-identical across MySQL/PostgreSQL/SQLite.
|
|
485
526
|
|
|
486
527
|
## FAQ
|
|
487
528
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "storepy"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.6.0"
|
|
8
8
|
description = "Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -39,7 +39,10 @@ from . import (
|
|
|
39
39
|
from . import (
|
|
40
40
|
introspect as introspect,
|
|
41
41
|
)
|
|
42
|
+
from .datasource import NativeCommandError as NativeCommandError
|
|
43
|
+
from .datasource import NonAtomicWriteError as NonAtomicWriteError
|
|
42
44
|
from .datasource import RawSqlError as RawSqlError
|
|
45
|
+
from .datasource import Session as Session
|
|
43
46
|
from .sync import sync_schema
|
|
44
47
|
|
|
45
48
|
|
|
@@ -145,18 +148,57 @@ class Store:
|
|
|
145
148
|
fn 内 execute_raw / CRUD 均落到该源的事务连接(commit/rollback 一体);
|
|
146
149
|
Mongo 源或执行器未实现事务时按原样执行(跨源无法原子),绝不静默假装已事务化。
|
|
147
150
|
单源场景 source 传 ``'default'``。
|
|
151
|
+
|
|
152
|
+
同源嵌套 transaction 会开保存点(内层失败只回滚本层);句柄无保存点原语时
|
|
153
|
+
降级并入外层并发 ``nested_savepoint_unsupported``。
|
|
148
154
|
"""
|
|
149
155
|
return await datasource.run_in_transaction(source, fn)
|
|
150
156
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
157
|
+
def session(self):
|
|
158
|
+
"""会话(工作单元):``async with`` 语法,退出统一提交 / 异常统一回滚
|
|
159
|
+
|
|
160
|
+
用法::
|
|
161
|
+
|
|
162
|
+
async with store.session() as s:
|
|
163
|
+
await s.insert('Order', {...})
|
|
164
|
+
await s.update('Account', cond, {...})
|
|
165
|
+
await s.execute_raw('pg_main', 'SELECT ... FOR UPDATE', [1])
|
|
166
|
+
|
|
167
|
+
约束:同一会话内写命令只允许落在**单一数据源**;跨源写退出时抛
|
|
168
|
+
``NonAtomicWriteError``(先全部回滚,绝不提交半截)。
|
|
154
169
|
|
|
155
|
-
|
|
156
|
-
|
|
170
|
+
嵌套:内层会话作为嵌套作用域在已有事务上开保存点,内层失败只回滚本层
|
|
171
|
+
(生命周期仍交外层)。
|
|
172
|
+
"""
|
|
173
|
+
return Session()
|
|
174
|
+
|
|
175
|
+
async def execute_raw(self, source: str, sql: str, params: list | tuple | dict | None = None,
|
|
176
|
+
is_write: bool | None = None) -> dict[str, Any]:
|
|
177
|
+
"""在指定 SQL 源执行原生 SQL(事务内可用;编译由 core raw_stmt_compile 完成)
|
|
178
|
+
|
|
179
|
+
两档参数风格:位置档(params 为 list/tuple/None)→ 占位符为各后端原生风格
|
|
180
|
+
(mysql/sqlite 用 ``?``,postgres 用 ``$1..$n``),SQL 原样透传;命名档
|
|
181
|
+
(params 为 dict)→ SQL 文本中的 ``:name`` 编译为方言占位符(同名复用、
|
|
182
|
+
跳过 ``::`` cast / 引号 / 注释边界;缺名 / 多余名显式报错)。
|
|
183
|
+
``is_write`` 缺省时按 SQL 首词推断(读白名单外一律按写——安全方向)。
|
|
184
|
+
仅支持 SQL 源(Mongo 源抛 RawSqlError)。返回 rows / affectedRows。
|
|
157
185
|
"""
|
|
158
186
|
return await datasource.execute_raw(source, sql, params, is_write)
|
|
159
187
|
|
|
188
|
+
async def execute_native(self, source: str, collection: str,
|
|
189
|
+
pipeline: list | None = None,
|
|
190
|
+
options: dict | None = None) -> dict[str, Any]:
|
|
191
|
+
"""在指定 Mongo 源执行原生聚合管道(事务内可用;对标 SQL 侧 execute_raw)
|
|
192
|
+
|
|
193
|
+
``pipeline`` 为原生聚合管道(list[dict]),``options`` 为驱动原生透传项
|
|
194
|
+
(allowDiskUse/batchSize/hint/maxTimeMS…,宿主不做白名单)。事务 / 会话
|
|
195
|
+
作用域内自动透传 session(由事务强制接管,``options.session`` 不可覆盖);
|
|
196
|
+
统一按读路径解析,``$merge``/``$out`` 写管道请自行开事务。
|
|
197
|
+
仅支持 Mongo 源(SQL 源抛 NativeCommandError 并指引 execute_raw)。
|
|
198
|
+
返回 ``{'rows': list}``。
|
|
199
|
+
"""
|
|
200
|
+
return await datasource.execute_native(source, collection, pipeline, options)
|
|
201
|
+
|
|
160
202
|
def generate_ddl(self, backend: str, names: list | None = None) -> str:
|
|
161
203
|
"""从已注册 schema def 生成指定后端 DDL 文本(纯函数,不连库、不回写;铁律 6)"""
|
|
162
204
|
return ddl.generate(backend, names)
|
|
@@ -170,6 +212,7 @@ class Store:
|
|
|
170
212
|
syncSchema = sync_schema
|
|
171
213
|
buildPipeline = build_pipeline
|
|
172
214
|
executeRaw = execute_raw
|
|
215
|
+
executeNative = execute_native
|
|
173
216
|
generateDdl = generate_ddl
|
|
174
217
|
|
|
175
218
|
# ── 其余 API 显式绑定(staticmethod:避免实例化后 self 注入)──
|
|
@@ -193,6 +236,8 @@ class Store:
|
|
|
193
236
|
PermissionError = permission.PermissionError
|
|
194
237
|
# 原生 SQL 入口错误(实例可被 store.RawSqlError 捕获)
|
|
195
238
|
RawSqlError = datasource.RawSqlError
|
|
239
|
+
# 原生 Mongo 命令入口错误(实例可被 store.NativeCommandError 捕获)
|
|
240
|
+
NativeCommandError = datasource.NativeCommandError
|
|
196
241
|
# 上下文强制开关(fail-secure:开启后 ctx 缺失报 ERR_NO_CONTEXT,内部调用走 run_as_internal)
|
|
197
242
|
setRequireContext = staticmethod(schema.set_require_context)
|
|
198
243
|
set_require_context = staticmethod(schema.set_require_context)
|
|
@@ -12,6 +12,7 @@ Mongo 走原生驱动,SQL 走 ``translate → exec``。对齐 ``nodejs-store/s
|
|
|
12
12
|
|
|
13
13
|
import re
|
|
14
14
|
import time
|
|
15
|
+
from collections.abc import Mapping
|
|
15
16
|
|
|
16
17
|
from .. import datasource as _datasource
|
|
17
18
|
from ..executors.mongo import exec_mongo as _exec_mongo
|
|
@@ -113,8 +114,12 @@ def _call(fn):
|
|
|
113
114
|
|
|
114
115
|
async def _exec_on(source, cmd):
|
|
115
116
|
"""在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec;
|
|
116
|
-
|
|
117
|
-
connection = _datasource.
|
|
117
|
+
事务 / 会话作用域内经 datasource.resolve_connection 落到事务专用连接)"""
|
|
118
|
+
connection = await _datasource.resolve_connection(source, _datasource.is_write_cmd(cmd))
|
|
119
|
+
if isinstance(connection, Mapping) and connection.get('kind') == 'mongo':
|
|
120
|
+
# Mongo 事务视图:db 按命令 namespace 解析,session 透传给驱动
|
|
121
|
+
db = _datasource.mongo_db(connection['conn'], source, cmd.get('namespace'))
|
|
122
|
+
return await _exec_mongo(db, cmd, session=connection.get('session'))
|
|
118
123
|
db = _datasource.mongo_db(connection, source, cmd.get('namespace'))
|
|
119
124
|
if db is not None:
|
|
120
125
|
return await _exec_mongo(db, cmd)
|
|
@@ -126,6 +131,61 @@ async def _exec(cmd):
|
|
|
126
131
|
return await _exec_on(cmd.get('source') or _datasource.DEFAULT_SOURCE, cmd)
|
|
127
132
|
|
|
128
133
|
|
|
134
|
+
def _sources_of(plan):
|
|
135
|
+
"""从规划结果中提取数据源集合(探针 / 写 / 查 / 删 / mutation 步骤命令)
|
|
136
|
+
|
|
137
|
+
``sources`` 必须由规划结果提取,不得写死 ``default``(多租户路由场景下的
|
|
138
|
+
源由 core 规划决定)。
|
|
139
|
+
"""
|
|
140
|
+
out = set()
|
|
141
|
+
for key in ('needsProbe', 'command', 'findCommand', 'deleteCommand'):
|
|
142
|
+
cmd = (plan or {}).get(key) or {}
|
|
143
|
+
if cmd:
|
|
144
|
+
out.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
|
|
145
|
+
for step in (plan or {}).get('steps') or []:
|
|
146
|
+
cmd = (step or {}).get('command') or {}
|
|
147
|
+
if cmd:
|
|
148
|
+
out.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
|
|
149
|
+
return out or {_datasource.DEFAULT_SOURCE}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _warn_multi_source(sources):
|
|
153
|
+
"""多源写:无法原子 → 程序化声明 nonAtomic(允许顺序执行,禁止静默)"""
|
|
154
|
+
listed = sorted(sources)
|
|
155
|
+
_emit_feedback({
|
|
156
|
+
'type': 'non_atomic_write',
|
|
157
|
+
'code': 'nonAtomic',
|
|
158
|
+
'layer': 'crud',
|
|
159
|
+
'message': ('本次写调用跨 %d 个数据源(%s):无法原子,按顺序执行(非原子)'
|
|
160
|
+
% (len(listed), ', '.join(listed))),
|
|
161
|
+
'hint': '把写操作收敛到单源;或在 store.session() 内执行以便跨源写被拒(fail-closed)',
|
|
162
|
+
'sources': listed,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def run_atomic(sources, fn):
|
|
167
|
+
"""顶层 API 调用的原子包络:无会话 + 单一源(SQL 或 Mongo)→ 包事务;否则原样执行
|
|
168
|
+
|
|
169
|
+
- 会话内:事务边界由会话统一管理,直接执行(不嵌套);
|
|
170
|
+
- 单一 SQL 源:包事务(原子);
|
|
171
|
+
- 多源:无法原子 → 程序化声明 ``nonAtomic``(反馈通道),再按顺序原样执行;
|
|
172
|
+
- 单一 Mongo 源:按探测结果包 session 事务或降级声明(见 ``run_in_transaction``);
|
|
173
|
+
- 未配置源:按原样执行;
|
|
174
|
+
- sources 由调用方从「规划结果」中提取(``_sources_of``),命令源与事务源一致。
|
|
175
|
+
"""
|
|
176
|
+
if _datasource.current_session() is not None:
|
|
177
|
+
return await fn()
|
|
178
|
+
uniq = {s or _datasource.DEFAULT_SOURCE for s in sources}
|
|
179
|
+
if len(uniq) == 1:
|
|
180
|
+
source = next(iter(uniq))
|
|
181
|
+
if _datasource.has_connection(source) and (
|
|
182
|
+
_datasource.is_sql_source(source) or _datasource.is_mongo_source(source)):
|
|
183
|
+
return await _datasource.run_in_transaction(source, fn)
|
|
184
|
+
elif len(uniq) > 1:
|
|
185
|
+
_warn_multi_source(uniq)
|
|
186
|
+
return await fn()
|
|
187
|
+
|
|
188
|
+
|
|
129
189
|
def _substitute(value, resolver):
|
|
130
190
|
"""深度替换命令中的占位符(命中 resolver 返回非字符串时替换)"""
|
|
131
191
|
if isinstance(value, str):
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
"""Mutation / Upsert / 原生聚合 —— 规划步骤序列 → 依序执行 + 父子 _id 占位符回填"""
|
|
2
2
|
|
|
3
|
-
from .. import datasource as _datasource
|
|
4
3
|
from ..feedback import emit as _emit_feedback
|
|
5
4
|
from ..schema import core as _core
|
|
6
5
|
from ..schema import get as _get_schema
|
|
7
|
-
from .exec import
|
|
6
|
+
from .exec import (
|
|
7
|
+
_call,
|
|
8
|
+
_ctx,
|
|
9
|
+
_exec,
|
|
10
|
+
_now_for,
|
|
11
|
+
_sources_of,
|
|
12
|
+
resolve_placeholders,
|
|
13
|
+
run_atomic,
|
|
14
|
+
)
|
|
8
15
|
from .id import _generate_id, _new_id_pool
|
|
9
16
|
|
|
10
17
|
|
|
@@ -33,12 +40,8 @@ async def _mutation_one(schema_name, data, now, route_override=None):
|
|
|
33
40
|
|
|
34
41
|
# 单一 SQL 源 → 步骤序列整体事务化(同连接同事务,任一步失败整体回滚);
|
|
35
42
|
# Mongo 源 / 跨源步骤按原样顺序执行(非原子边界见 README「事务边界」)
|
|
36
|
-
sources =
|
|
37
|
-
|
|
38
|
-
source = next(iter(sources))
|
|
39
|
-
if _datasource.is_sql_source(source):
|
|
40
|
-
return await _datasource.run_in_transaction(source, _run_steps)
|
|
41
|
-
return await _run_steps()
|
|
43
|
+
sources = _sources_of(plan)
|
|
44
|
+
return await run_atomic(sources, _run_steps)
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
async def mutation(schema_name, data, route_override=None):
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""写路径 —— 单条/批量插入、更新、删除归档、存在性与计数"""
|
|
2
|
+
|
|
3
|
+
from ..schema import core as _core
|
|
4
|
+
from ..schema import get as _get_schema
|
|
5
|
+
from .exec import _call, _ctx, _exec, _now_for, _sources_of, run_atomic
|
|
6
|
+
from .id import _generate_id
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _plan_with_probe(plan_fn):
|
|
10
|
+
"""creator 写权限探针:先规划,若 needsProbe 则执行探针命令后重入"""
|
|
11
|
+
out = plan_fn(None, None)
|
|
12
|
+
if out.get('needsProbe'):
|
|
13
|
+
probe_doc = await _exec(out['needsProbe'])
|
|
14
|
+
out = plan_fn(probe_doc is not None, probe_doc)
|
|
15
|
+
return out
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def insert(schema_name, data, route_override=None):
|
|
19
|
+
"""插入一条(``route_override`` 可选:多租户路由 ``{'source', 'namespace'}``)"""
|
|
20
|
+
s = _get_schema(schema_name)
|
|
21
|
+
plan = _call(lambda: _core.plan_insert(
|
|
22
|
+
schema_name, data, _now_for(schema_name), _generate_id(s) if s['idPrefix'] else '', _ctx(),
|
|
23
|
+
route_override))
|
|
24
|
+
result = await _exec(plan['command'])
|
|
25
|
+
returns = plan['returns']
|
|
26
|
+
# 阶段2:autoincrement 主键 —— 执行器已回读自增值,returns 补 `_id`
|
|
27
|
+
if isinstance(returns, dict) and not returns.get('_id') and isinstance(result, dict) and result.get('_id') is not None:
|
|
28
|
+
returns = {**returns, '_id': result['_id']}
|
|
29
|
+
return returns
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def insert_many(schema_name, docs, route_override=None):
|
|
33
|
+
"""批量插入(带权限检查,自动生成 _id 和时间戳;空数组直接返回空)"""
|
|
34
|
+
if not isinstance(docs, list) or not docs:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
s = _get_schema(schema_name)
|
|
38
|
+
# 阶段2(no-error-masking):autoincrement 的批量自增值回读不可靠(MySQL 批量
|
|
39
|
+
# lastrowid 仅首行、且并发插入会留间隙)→ 显式报错,不静默产出错误 _id
|
|
40
|
+
id_fdef = (s.get('fields') or {}).get('_id') or {}
|
|
41
|
+
if id_fdef.get('strategy') == 'autoincrement' and any(not (d or {}).get('_id') for d in docs):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
'AUTOINCREMENT_NOT_SUPPORTED: insert_many 不支持 autoincrement schema'
|
|
44
|
+
'(批量自增值回读不可靠);请逐条 insert 或显式提供 _id')
|
|
45
|
+
plan = _call(lambda: _core.plan_insert_many(
|
|
46
|
+
schema_name,
|
|
47
|
+
docs,
|
|
48
|
+
_now_for(schema_name),
|
|
49
|
+
# core 按需消费(仅无 _id 的文档取用),多备无害
|
|
50
|
+
[_generate_id(s) if s['idPrefix'] else '' for _ in docs],
|
|
51
|
+
_ctx(),
|
|
52
|
+
route_override,
|
|
53
|
+
))
|
|
54
|
+
if plan.get('command'):
|
|
55
|
+
await _exec(plan['command'])
|
|
56
|
+
return plan['returns']
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def update(schema_name, condition, data, options=None, route_override=None):
|
|
60
|
+
"""
|
|
61
|
+
更新一条(支持原生操作符,不触发默认值)
|
|
62
|
+
|
|
63
|
+
data 的 key 以 '$' 开头 → 原生 MongoDB 操作符($set/$inc/$unset 等)直接透传。
|
|
64
|
+
否则自动包装为 $set 模式。``route_override`` 可选(多租户路由)。
|
|
65
|
+
|
|
66
|
+
「权限探针 + 写」整体纳入同一原子作用域(``run_atomic``):单一 SQL 源时探针与写
|
|
67
|
+
同连接同事务,消除二者之间的并发窗口;``now`` 只取一次,两次规划共用(调用级确定性)。
|
|
68
|
+
"""
|
|
69
|
+
now = _now_for(schema_name)
|
|
70
|
+
ctx = _ctx()
|
|
71
|
+
first = _call(lambda: _core.plan_update(
|
|
72
|
+
schema_name, condition, data, options, now, ctx, None, None, route_override))
|
|
73
|
+
sources = _sources_of(first)
|
|
74
|
+
|
|
75
|
+
async def _do():
|
|
76
|
+
out = first
|
|
77
|
+
if out.get('needsProbe'):
|
|
78
|
+
probe_doc = await _exec(out['needsProbe'])
|
|
79
|
+
out = _call(lambda: _core.plan_update(
|
|
80
|
+
schema_name, condition, data, options, now, ctx,
|
|
81
|
+
probe_doc is not None, probe_doc, route_override))
|
|
82
|
+
result = await _exec(out['command'])
|
|
83
|
+
return _call(lambda: _core.apply_write_defaults(schema_name, result)) if result else None
|
|
84
|
+
|
|
85
|
+
return await run_atomic(sources, _do)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _exec_with_pre(command):
|
|
89
|
+
"""执行带 ``preCommand`` 的命令(阶段1:mutation 关系谓词归一)。
|
|
90
|
+
|
|
91
|
+
preCommand(aggregate 取命中 `_id`)先行执行,把结果 `_id` 列表回填进主命令
|
|
92
|
+
filter 的 `_id.$in` 占位(core 规划注入 ``"__REL_PRED_IDS__"``)。空集 → `$in: []`,
|
|
93
|
+
各后端语义一致 = 不命中任何行。同批可传 ``extra_targets``:其他携带同一占位的
|
|
94
|
+
命令(如 remove 的归档 findCommand)一并回填,避免二次执行 preCommand。"""
|
|
95
|
+
pre = command.get('preCommand')
|
|
96
|
+
if not pre:
|
|
97
|
+
return await _exec(command)
|
|
98
|
+
ids = []
|
|
99
|
+
for doc in await _exec(pre):
|
|
100
|
+
if isinstance(doc, dict) and '_id' in doc:
|
|
101
|
+
ids.append(doc['_id'])
|
|
102
|
+
return await _fill_pre_ids(command, ids)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fill_pre_ids(command, ids):
|
|
106
|
+
"""把 preCommand 取得的 `_id` 列表回填进命令 filter 的 `$in` 占位(递归查找后执行)。
|
|
107
|
+
|
|
108
|
+
core 注入的占位可能位于 `$and` 数组内(改写条件已有其他键时),故递归遍历。"""
|
|
109
|
+
main = {k: v for k, v in command.items() if k != 'preCommand'}
|
|
110
|
+
|
|
111
|
+
def _walk(node):
|
|
112
|
+
if isinstance(node, dict):
|
|
113
|
+
for k, v in list(node.items()):
|
|
114
|
+
if isinstance(v, dict) and v.get('$in') == '__REL_PRED_IDS__':
|
|
115
|
+
node[k] = {'$in': list(ids)}
|
|
116
|
+
else:
|
|
117
|
+
_walk(v)
|
|
118
|
+
elif isinstance(node, list):
|
|
119
|
+
for it in node:
|
|
120
|
+
_walk(it)
|
|
121
|
+
|
|
122
|
+
if 'filter' in main:
|
|
123
|
+
_walk(main['filter'])
|
|
124
|
+
return _exec(main)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def update_many(schema_name, condition, data, route_override=None):
|
|
128
|
+
"""批量更新(支持原生操作符)"""
|
|
129
|
+
out = _call(lambda: _core.plan_update_many(
|
|
130
|
+
schema_name, condition, data, _now_for(schema_name), _ctx(), route_override))
|
|
131
|
+
result = await _exec_with_pre(out['command'])
|
|
132
|
+
return {'modifiedCount': result.modified_count}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def remove(schema_name, condition, route_override=None):
|
|
136
|
+
"""删除 —— 原表数据先归档到对应 `_deleted` 附表(附 deletedAt),再物理删除原表数据。
|
|
137
|
+
归档命令带 ``upsertById``(幂等),重试不再因 _id 冲突整批失败;单一 SQL 源时
|
|
138
|
+
归档+删除整体事务化(Mongo / 跨源按顺序执行,非原子边界见 README「事务边界」)"""
|
|
139
|
+
out = await _plan_with_probe(lambda found, doc: _call(lambda: _core.plan_remove(
|
|
140
|
+
schema_name, condition, _ctx(), found, doc, route_override)))
|
|
141
|
+
|
|
142
|
+
async def _do_remove():
|
|
143
|
+
archived_count = 0
|
|
144
|
+
# 关系谓词:先执行 deleteCommand.preCommand 取命中 _id(归档 find 与删除共用同一列表)
|
|
145
|
+
pre = (out.get('deleteCommand') or {}).get('preCommand')
|
|
146
|
+
ids = None
|
|
147
|
+
if pre:
|
|
148
|
+
ids = [d['_id'] for d in await _exec(pre) if isinstance(d, dict) and '_id' in d]
|
|
149
|
+
if out.get('findCommand'):
|
|
150
|
+
find_cmd = out['findCommand']
|
|
151
|
+
docs = await (_fill_pre_ids(find_cmd, ids) if ids is not None else _exec(find_cmd))
|
|
152
|
+
if docs:
|
|
153
|
+
arch = _call(lambda: _core.plan_archive_docs(
|
|
154
|
+
schema_name, docs, _now_for(schema_name), route_override))
|
|
155
|
+
await _exec(arch['command'])
|
|
156
|
+
archived_count = len(docs)
|
|
157
|
+
|
|
158
|
+
result = await (_fill_pre_ids(out['deleteCommand'], ids)
|
|
159
|
+
if ids is not None else _exec(out['deleteCommand']))
|
|
160
|
+
return {'deletedCount': result.deleted_count, 'archivedCount': archived_count}
|
|
161
|
+
|
|
162
|
+
sources = _sources_of(out)
|
|
163
|
+
return await run_atomic(sources, _do_remove)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def exists(schema_name, condition, route_override=None):
|
|
167
|
+
"""判断是否存在"""
|
|
168
|
+
cmd = _call(lambda: _core.plan_exists(schema_name, condition, route_override))
|
|
169
|
+
doc = await _exec(cmd)
|
|
170
|
+
return doc is not None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def count(schema_name, filter=None, route_override=None):
|
|
174
|
+
"""统计符合条件的文档数量"""
|
|
175
|
+
cmd = _call(lambda: _core.plan_count(schema_name, filter, _ctx(), route_override))
|
|
176
|
+
return await _exec(cmd)
|