storepy 2.4.0__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.
Files changed (52) hide show
  1. {storepy-2.4.0/src/storepy.egg-info → storepy-2.5.0}/PKG-INFO +33 -8
  2. {storepy-2.4.0 → storepy-2.5.0}/README.md +32 -7
  3. {storepy-2.4.0 → storepy-2.5.0}/pyproject.toml +1 -1
  4. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/__init__.py +50 -5
  5. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/crud/exec.py +62 -2
  6. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/crud/mutation.py +11 -8
  7. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/crud/write.py +23 -15
  8. storepy-2.5.0/src/py_store/datasource.py +836 -0
  9. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/executors/__init__.py +48 -0
  10. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/executors/mongo.py +69 -11
  11. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/executors/mysql.py +69 -13
  12. storepy-2.5.0/src/py_store/executors/postgres.py +114 -0
  13. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/executors/sqlite.py +61 -7
  14. {storepy-2.4.0 → storepy-2.5.0/src/storepy.egg-info}/PKG-INFO +33 -8
  15. {storepy-2.4.0 → storepy-2.5.0}/src/storepy.egg-info/SOURCES.txt +2 -0
  16. {storepy-2.4.0 → storepy-2.5.0}/tests/test_coverage_margin.py +118 -3
  17. storepy-2.5.0/tests/test_mongo_session.py +399 -0
  18. storepy-2.5.0/tests/test_raw_sql_and_ddl.py +460 -0
  19. storepy-2.5.0/tests/test_session.py +706 -0
  20. storepy-2.4.0/src/py_store/datasource.py +0 -268
  21. storepy-2.4.0/src/py_store/executors/postgres.py +0 -57
  22. storepy-2.4.0/tests/test_raw_sql_and_ddl.py +0 -224
  23. {storepy-2.4.0 → storepy-2.5.0}/LICENSE +0 -0
  24. {storepy-2.4.0 → storepy-2.5.0}/setup.cfg +0 -0
  25. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/core.py +0 -0
  26. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/crud/__init__.py +0 -0
  27. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/crud/id.py +0 -0
  28. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/crud/query.py +0 -0
  29. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/ddl.py +0 -0
  30. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/executors/_values.py +0 -0
  31. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/feedback.py +0 -0
  32. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/introspect/__init__.py +0 -0
  33. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/introspect/mysql.py +0 -0
  34. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/introspect/postgres.py +0 -0
  35. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/introspect/sqlite.py +0 -0
  36. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/permission.py +0 -0
  37. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/schema.py +0 -0
  38. {storepy-2.4.0 → storepy-2.5.0}/src/py_store/sync.py +0 -0
  39. {storepy-2.4.0 → storepy-2.5.0}/src/storepy.egg-info/dependency_links.txt +0 -0
  40. {storepy-2.4.0 → storepy-2.5.0}/src/storepy.egg-info/requires.txt +0 -0
  41. {storepy-2.4.0 → storepy-2.5.0}/src/storepy.egg-info/top_level.txt +0 -0
  42. {storepy-2.4.0 → storepy-2.5.0}/tests/test_federation_e2e.py +0 -0
  43. {storepy-2.4.0 → storepy-2.5.0}/tests/test_host_contract.py +0 -0
  44. {storepy-2.4.0 → storepy-2.5.0}/tests/test_host_paths.py +0 -0
  45. {storepy-2.4.0 → storepy-2.5.0}/tests/test_mongo_executor.py +0 -0
  46. {storepy-2.4.0 → storepy-2.5.0}/tests/test_multi_datasource.py +0 -0
  47. {storepy-2.4.0 → storepy-2.5.0}/tests/test_profile.py +0 -0
  48. {storepy-2.4.0 → storepy-2.5.0}/tests/test_py_store.py +0 -0
  49. {storepy-2.4.0 → storepy-2.5.0}/tests/test_real_backends_e2e.py +0 -0
  50. {storepy-2.4.0 → storepy-2.5.0}/tests/test_require_context.py +0 -0
  51. {storepy-2.4.0 → storepy-2.5.0}/tests/test_scenario_course_platform.py +0 -0
  52. {storepy-2.4.0 → 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.4.0
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 SQL 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 or executors without transactions run `fn` as-is — it never pretends to be atomic.
374
- - `store.execute_raw(source, sql, params=None, is_write=False)` runs raw SQL, bypassing GQL parsing and dialect translation. Placeholders follow each backend's native style: `?` for MySQL / SQLite, `$1..$n` for PostgreSQL. SQL sources only — a Mongo source raises `RawSqlError` (`py_store.RawSqlError` / `store.RawSqlError`).
375
- - `is_write=False` (default) returns `{"rows", "affectedRows"}` with the result-set rows; `is_write=True` returns the affected-row count.
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
- - **Single SQL source**: `mutation` parent-child step sequences and `remove` (archive + delete) run inside one driver transaction on one checked-out connection — any step failure rolls back the whole sequence.
521
- - **Each SQL write command** is itself atomic: multi-statement plans (e.g. MySQL write + readback) are transaction-wrapped in the executor.
522
- - **Mongo sources**: single-document writes are atomic; multi-step `mutation` and `remove` execute sequentially and are **not** atomic across steps (Mongo transactions require a replica set). If your consistency requirement spans steps on Mongo, either use an SQL source for those models or add application-level compensation.
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
- - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
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 SQL 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 or executors without transactions run `fn` as-is — it never pretends to be atomic.
334
- - `store.execute_raw(source, sql, params=None, is_write=False)` runs raw SQL, bypassing GQL parsing and dialect translation. Placeholders follow each backend's native style: `?` for MySQL / SQLite, `$1..$n` for PostgreSQL. SQL sources only — a Mongo source raises `RawSqlError` (`py_store.RawSqlError` / `store.RawSqlError`).
335
- - `is_write=False` (default) returns `{"rows", "affectedRows"}` with the result-set rows; `is_write=True` returns the affected-row count.
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
- - **Single SQL source**: `mutation` parent-child step sequences and `remove` (archive + delete) run inside one driver transaction on one checked-out connection — any step failure rolls back the whole sequence.
481
- - **Each SQL write command** is itself atomic: multi-statement plans (e.g. MySQL write + readback) are transaction-wrapped in the executor.
482
- - **Mongo sources**: single-document writes are atomic; multi-step `mutation` and `remove` execute sequentially and are **not** atomic across steps (Mongo transactions require a replica set). If your consistency requirement spans steps on Mongo, either use an SQL source for those models or add application-level compensation.
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
- - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
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.4.0"
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
- async def execute_raw(self, source: str, sql: str, params: list | None = None,
152
- is_write: bool = False) -> dict[str, Any]:
153
- """在指定 SQL 源执行原生 SQL(事务内可用;占位符按各后端原生风格)
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
- mysql/sqlite 用 ``?``,postgres 用 ``$1..$n``;仅支持 SQL 源(Mongo 源抛
156
- RawSqlError)。``is_write=False`` 取行(rows),``True`` 取影响行数(affectedRows)。
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
- 事务作用域内经 datasource.connection_for 落到事务专用连接)"""
117
- connection = _datasource.connection_for(source)
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 _call, _ctx, _exec, _now_for, resolve_placeholders
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 = {(s['command'].get('source') or _datasource.DEFAULT_SOURCE) for s in plan['steps']}
37
- if len(sources) == 1:
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
- out = await _plan_with_probe(lambda found, doc: _call(lambda: _core.plan_update(
57
- schema_name, condition, data, options, _now_for(schema_name), _ctx(), found, doc,
58
- route_override)))
59
- result = await _exec(out['command'])
60
- return _call(lambda: _core.apply_write_defaults(schema_name, result)) if result else None
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 = {out['deleteCommand'].get('source') or _datasource.DEFAULT_SOURCE}
92
- if out.get('findCommand'):
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):