storepy 2.3.1__tar.gz → 2.5.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.3.1/src/storepy.egg-info → storepy-2.5.0}/PKG-INFO +33 -8
- {storepy-2.3.1 → storepy-2.5.0}/README.md +32 -7
- {storepy-2.3.1 → storepy-2.5.0}/pyproject.toml +1 -1
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/__init__.py +50 -5
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/crud/exec.py +62 -2
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/crud/mutation.py +11 -8
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/crud/write.py +23 -15
- storepy-2.5.0/src/py_store/datasource.py +836 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/executors/__init__.py +48 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/executors/mongo.py +69 -11
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/executors/mysql.py +69 -13
- storepy-2.5.0/src/py_store/executors/postgres.py +114 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/executors/sqlite.py +61 -7
- {storepy-2.3.1 → storepy-2.5.0/src/storepy.egg-info}/PKG-INFO +33 -8
- {storepy-2.3.1 → storepy-2.5.0}/src/storepy.egg-info/SOURCES.txt +2 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_coverage_margin.py +118 -3
- storepy-2.5.0/tests/test_mongo_session.py +399 -0
- storepy-2.5.0/tests/test_raw_sql_and_ddl.py +460 -0
- storepy-2.5.0/tests/test_session.py +706 -0
- storepy-2.3.1/src/py_store/datasource.py +0 -268
- storepy-2.3.1/src/py_store/executors/postgres.py +0 -57
- storepy-2.3.1/tests/test_raw_sql_and_ddl.py +0 -224
- {storepy-2.3.1 → storepy-2.5.0}/LICENSE +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/setup.cfg +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/core.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/crud/__init__.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/crud/id.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/crud/query.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/ddl.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/executors/_values.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/feedback.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/introspect/__init__.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/introspect/mysql.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/introspect/postgres.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/permission.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/schema.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/py_store/sync.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/storepy.egg-info/requires.txt +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_federation_e2e.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_host_contract.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_host_paths.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_mongo_executor.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_multi_datasource.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_profile.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_py_store.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_real_backends_e2e.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_require_context.py +0 -0
- {storepy-2.3.1 → storepy-2.5.0}/tests/test_scenario_course_platform.py +0 -0
- {storepy-2.3.1 → storepy-2.5.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.5.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
|
|
@@ -370,11 +370,27 @@ async def transfer():
|
|
|
370
370
|
await store.transaction("default", transfer)
|
|
371
371
|
```
|
|
372
372
|
|
|
373
|
-
- `store.transaction(source, fn)` opens a transaction scope on one
|
|
374
|
-
- `store.execute_raw(source, sql, params=None, is_write=
|
|
375
|
-
- `is_write
|
|
373
|
+
- `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`.
|
|
374
|
+
- `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`).
|
|
375
|
+
- 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.
|
|
376
|
+
- `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
377
|
- **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
378
|
|
|
379
|
+
### Session (Unit of Work)
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
async with store.session() as s:
|
|
383
|
+
await s.insert("Order", {...})
|
|
384
|
+
await s.update("Account", cond, {...})
|
|
385
|
+
await s.execute_raw("pg_main", "SELECT ... FOR UPDATE", [1])
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
- 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.
|
|
389
|
+
- **Lazy transaction start**: a session with no commands never checks out a connection.
|
|
390
|
+
- **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).
|
|
391
|
+
- 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.
|
|
392
|
+
- 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.
|
|
393
|
+
|
|
378
394
|
### DDL generation
|
|
379
395
|
|
|
380
396
|
```python
|
|
@@ -517,11 +533,20 @@ Boundary rules worth knowing up front (all **fail explicitly**, never silently d
|
|
|
517
533
|
|
|
518
534
|
## Transaction boundary
|
|
519
535
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
-
|
|
536
|
+
| Scenario | Atomicity |
|
|
537
|
+
|---|---|
|
|
538
|
+
| 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 |
|
|
539
|
+
| `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) |
|
|
540
|
+
| `store.session(...)` | Atomic **across multiple calls** on one SQL source inside the session; cross-source writes are rejected explicitly (`NonAtomicWriteError`) |
|
|
541
|
+
| 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) |
|
|
542
|
+
| 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` |
|
|
543
|
+
|
|
544
|
+
- **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).
|
|
545
|
+
- **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).
|
|
546
|
+
- **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
547
|
- **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
|
|
524
|
-
-
|
|
548
|
+
- Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
|
|
549
|
+
- **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).
|
|
525
550
|
|
|
526
551
|
## FAQ
|
|
527
552
|
|
|
@@ -330,11 +330,27 @@ async def transfer():
|
|
|
330
330
|
await store.transaction("default", transfer)
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
-
- `store.transaction(source, fn)` opens a transaction scope on one
|
|
334
|
-
- `store.execute_raw(source, sql, params=None, is_write=
|
|
335
|
-
- `is_write
|
|
333
|
+
- `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`.
|
|
334
|
+
- `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`).
|
|
335
|
+
- 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.
|
|
336
|
+
- `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
337
|
- **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
338
|
|
|
339
|
+
### Session (Unit of Work)
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
async with store.session() as s:
|
|
343
|
+
await s.insert("Order", {...})
|
|
344
|
+
await s.update("Account", cond, {...})
|
|
345
|
+
await s.execute_raw("pg_main", "SELECT ... FOR UPDATE", [1])
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
- 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.
|
|
349
|
+
- **Lazy transaction start**: a session with no commands never checks out a connection.
|
|
350
|
+
- **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).
|
|
351
|
+
- 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.
|
|
352
|
+
- 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.
|
|
353
|
+
|
|
338
354
|
### DDL generation
|
|
339
355
|
|
|
340
356
|
```python
|
|
@@ -477,11 +493,20 @@ Boundary rules worth knowing up front (all **fail explicitly**, never silently d
|
|
|
477
493
|
|
|
478
494
|
## Transaction boundary
|
|
479
495
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
-
|
|
496
|
+
| Scenario | Atomicity |
|
|
497
|
+
|---|---|
|
|
498
|
+
| 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 |
|
|
499
|
+
| `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) |
|
|
500
|
+
| `store.session(...)` | Atomic **across multiple calls** on one SQL source inside the session; cross-source writes are rejected explicitly (`NonAtomicWriteError`) |
|
|
501
|
+
| 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) |
|
|
502
|
+
| 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` |
|
|
503
|
+
|
|
504
|
+
- **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).
|
|
505
|
+
- **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).
|
|
506
|
+
- **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
507
|
- **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
|
|
484
|
-
-
|
|
508
|
+
- Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
|
|
509
|
+
- **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).
|
|
485
510
|
|
|
486
511
|
## FAQ
|
|
487
512
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "storepy"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.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):
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"""写路径 —— 单条/批量插入、更新、删除归档、存在性与计数"""
|
|
2
2
|
|
|
3
|
-
from .. import datasource as _datasource
|
|
4
3
|
from ..schema import core as _core
|
|
5
4
|
from ..schema import get as _get_schema
|
|
6
|
-
from .exec import _call, _ctx, _exec, _now_for
|
|
5
|
+
from .exec import _call, _ctx, _exec, _now_for, _sources_of, run_atomic
|
|
7
6
|
from .id import _generate_id
|
|
8
7
|
|
|
9
8
|
|
|
@@ -52,12 +51,27 @@ async def update(schema_name, condition, data, options=None, route_override=None
|
|
|
52
51
|
|
|
53
52
|
data 的 key 以 '$' 开头 → 原生 MongoDB 操作符($set/$inc/$unset 等)直接透传。
|
|
54
53
|
否则自动包装为 $set 模式。``route_override`` 可选(多租户路由)。
|
|
54
|
+
|
|
55
|
+
「权限探针 + 写」整体纳入同一原子作用域(``run_atomic``):单一 SQL 源时探针与写
|
|
56
|
+
同连接同事务,消除二者之间的并发窗口;``now`` 只取一次,两次规划共用(调用级确定性)。
|
|
55
57
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
now = _now_for(schema_name)
|
|
59
|
+
ctx = _ctx()
|
|
60
|
+
first = _call(lambda: _core.plan_update(
|
|
61
|
+
schema_name, condition, data, options, now, ctx, None, None, route_override))
|
|
62
|
+
sources = _sources_of(first)
|
|
63
|
+
|
|
64
|
+
async def _do():
|
|
65
|
+
out = first
|
|
66
|
+
if out.get('needsProbe'):
|
|
67
|
+
probe_doc = await _exec(out['needsProbe'])
|
|
68
|
+
out = _call(lambda: _core.plan_update(
|
|
69
|
+
schema_name, condition, data, options, now, ctx,
|
|
70
|
+
probe_doc is not None, probe_doc, route_override))
|
|
71
|
+
result = await _exec(out['command'])
|
|
72
|
+
return _call(lambda: _core.apply_write_defaults(schema_name, result)) if result else None
|
|
73
|
+
|
|
74
|
+
return await run_atomic(sources, _do)
|
|
61
75
|
|
|
62
76
|
|
|
63
77
|
async def update_many(schema_name, condition, data, route_override=None):
|
|
@@ -88,14 +102,8 @@ async def remove(schema_name, condition, route_override=None):
|
|
|
88
102
|
result = await _exec(out['deleteCommand'])
|
|
89
103
|
return {'deletedCount': result.deleted_count, 'archivedCount': archived_count}
|
|
90
104
|
|
|
91
|
-
sources =
|
|
92
|
-
|
|
93
|
-
sources.add(out['findCommand'].get('source') or _datasource.DEFAULT_SOURCE)
|
|
94
|
-
if len(sources) == 1:
|
|
95
|
-
source = next(iter(sources))
|
|
96
|
-
if _datasource.is_sql_source(source):
|
|
97
|
-
return await _datasource.run_in_transaction(source, _do_remove)
|
|
98
|
-
return await _do_remove()
|
|
105
|
+
sources = _sources_of(out)
|
|
106
|
+
return await run_atomic(sources, _do_remove)
|
|
99
107
|
|
|
100
108
|
|
|
101
109
|
async def exists(schema_name, condition, route_override=None):
|