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.
Files changed (55) hide show
  1. {storepy-2.4.0/src/storepy.egg-info → storepy-2.6.0}/PKG-INFO +49 -8
  2. {storepy-2.4.0 → storepy-2.6.0}/README.md +48 -7
  3. {storepy-2.4.0 → storepy-2.6.0}/pyproject.toml +1 -1
  4. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/__init__.py +50 -5
  5. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/exec.py +62 -2
  6. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/mutation.py +11 -8
  7. storepy-2.6.0/src/py_store/crud/write.py +176 -0
  8. storepy-2.6.0/src/py_store/datasource.py +836 -0
  9. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/ddl.py +45 -8
  10. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/__init__.py +61 -1
  11. storepy-2.6.0/src/py_store/executors/mongo.py +169 -0
  12. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/mysql.py +75 -14
  13. storepy-2.6.0/src/py_store/executors/postgres.py +116 -0
  14. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/sqlite.py +66 -8
  15. {storepy-2.4.0 → storepy-2.6.0/src/storepy.egg-info}/PKG-INFO +49 -8
  16. {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/SOURCES.txt +3 -0
  17. {storepy-2.4.0 → storepy-2.6.0}/tests/test_coverage_margin.py +118 -3
  18. storepy-2.6.0/tests/test_mongo_session.py +399 -0
  19. storepy-2.6.0/tests/test_raw_sql_and_ddl.py +461 -0
  20. storepy-2.6.0/tests/test_scenario_manager_transaction.py +149 -0
  21. storepy-2.6.0/tests/test_session.py +706 -0
  22. storepy-2.4.0/src/py_store/crud/write.py +0 -111
  23. storepy-2.4.0/src/py_store/datasource.py +0 -268
  24. storepy-2.4.0/src/py_store/executors/mongo.py +0 -99
  25. storepy-2.4.0/src/py_store/executors/postgres.py +0 -57
  26. storepy-2.4.0/tests/test_raw_sql_and_ddl.py +0 -224
  27. {storepy-2.4.0 → storepy-2.6.0}/LICENSE +0 -0
  28. {storepy-2.4.0 → storepy-2.6.0}/setup.cfg +0 -0
  29. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/core.py +0 -0
  30. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/__init__.py +0 -0
  31. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/id.py +0 -0
  32. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/crud/query.py +0 -0
  33. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/executors/_values.py +0 -0
  34. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/feedback.py +0 -0
  35. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/__init__.py +0 -0
  36. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/mysql.py +0 -0
  37. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/postgres.py +0 -0
  38. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/introspect/sqlite.py +0 -0
  39. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/permission.py +0 -0
  40. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/schema.py +0 -0
  41. {storepy-2.4.0 → storepy-2.6.0}/src/py_store/sync.py +0 -0
  42. {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/dependency_links.txt +0 -0
  43. {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/requires.txt +0 -0
  44. {storepy-2.4.0 → storepy-2.6.0}/src/storepy.egg-info/top_level.txt +0 -0
  45. {storepy-2.4.0 → storepy-2.6.0}/tests/test_federation_e2e.py +0 -0
  46. {storepy-2.4.0 → storepy-2.6.0}/tests/test_host_contract.py +0 -0
  47. {storepy-2.4.0 → storepy-2.6.0}/tests/test_host_paths.py +0 -0
  48. {storepy-2.4.0 → storepy-2.6.0}/tests/test_mongo_executor.py +0 -0
  49. {storepy-2.4.0 → storepy-2.6.0}/tests/test_multi_datasource.py +0 -0
  50. {storepy-2.4.0 → storepy-2.6.0}/tests/test_profile.py +0 -0
  51. {storepy-2.4.0 → storepy-2.6.0}/tests/test_py_store.py +0 -0
  52. {storepy-2.4.0 → storepy-2.6.0}/tests/test_real_backends_e2e.py +0 -0
  53. {storepy-2.4.0 → storepy-2.6.0}/tests/test_require_context.py +0 -0
  54. {storepy-2.4.0 → storepy-2.6.0}/tests/test_scenario_course_platform.py +0 -0
  55. {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.4.0
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 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.
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
- - **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.
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
- - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
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 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.
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
- - **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.
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
- - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
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.4.0"
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
- 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):
@@ -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)