storepy 4.2.1__tar.gz → 4.4.2__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 (90) hide show
  1. {storepy-4.2.1/src/storepy.egg-info → storepy-4.4.2}/PKG-INFO +28 -11
  2. {storepy-4.2.1 → storepy-4.4.2}/README.md +25 -8
  3. {storepy-4.2.1 → storepy-4.4.2}/pyproject.toml +3 -2
  4. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/__init__.py +34 -2
  5. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/ask.py +61 -44
  6. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/exec.py +23 -0
  7. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/mutation.py +5 -5
  8. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/query.py +10 -11
  9. storepy-4.4.2/src/py_store/crud/triggers.py +191 -0
  10. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/write.py +67 -22
  11. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/datasource.py +65 -8
  12. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/feedback.py +25 -5
  13. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/load.py +2 -2
  14. storepy-4.4.2/src/py_store/local/__init__.py +105 -0
  15. storepy-4.4.2/src/py_store/local/handle.py +139 -0
  16. storepy-4.4.2/src/py_store/local/store.py +73 -0
  17. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/permission.py +23 -18
  18. storepy-4.4.2/src/py_store/resource/__init__.py +316 -0
  19. storepy-4.4.2/src/py_store/scheduler/__init__.py +176 -0
  20. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/schema.py +39 -23
  21. storepy-4.4.2/src/py_store/scope.py +34 -0
  22. storepy-4.4.2/src/py_store/secure.py +95 -0
  23. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/workflow.py +6 -6
  24. {storepy-4.2.1 → storepy-4.4.2/src/storepy.egg-info}/PKG-INFO +28 -11
  25. {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/SOURCES.txt +13 -0
  26. storepy-4.4.2/tests/test_local_store.py +477 -0
  27. {storepy-4.2.1 → storepy-4.4.2}/tests/test_mongo_executor.py +2 -2
  28. storepy-4.4.2/tests/test_parity_local.py +61 -0
  29. storepy-4.4.2/tests/test_parity_scope.py +111 -0
  30. storepy-4.4.2/tests/test_policy_isolation.py +382 -0
  31. {storepy-4.2.1 → storepy-4.4.2}/tests/test_py_store.py +3 -3
  32. {storepy-4.2.1 → storepy-4.4.2}/tests/test_require_context.py +74 -0
  33. {storepy-4.2.1 → storepy-4.4.2}/tests/test_resource.py +135 -0
  34. storepy-4.4.2/tests/test_scheduler.py +231 -0
  35. {storepy-4.2.1 → storepy-4.4.2}/tests/test_session.py +3 -2
  36. storepy-4.4.2/tests/test_triggers.py +451 -0
  37. storepy-4.2.1/src/py_store/resource/__init__.py +0 -168
  38. {storepy-4.2.1 → storepy-4.4.2}/LICENSE +0 -0
  39. {storepy-4.2.1 → storepy-4.4.2}/setup.cfg +0 -0
  40. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/ask_knowledge.md +0 -0
  41. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/bootstrap.py +0 -0
  42. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/cache.py +0 -0
  43. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/core.py +0 -0
  44. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/__init__.py +0 -0
  45. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/id.py +0 -0
  46. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/ddl.py +0 -0
  47. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/__init__.py +0 -0
  48. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/_values.py +0 -0
  49. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/mongo.py +0 -0
  50. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/mysql.py +0 -0
  51. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/postgres.py +0 -0
  52. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/sqlite.py +0 -0
  53. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/__init__.py +0 -0
  54. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/mysql.py +0 -0
  55. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/postgres.py +0 -0
  56. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/sqlite.py +0 -0
  57. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/llm.py +0 -0
  58. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/metadef.py +0 -0
  59. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/naming.py +0 -0
  60. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/resource/providers/__init__.py +0 -0
  61. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/resource/providers/local.py +0 -0
  62. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/resource/providers/s3.py +0 -0
  63. {storepy-4.2.1 → storepy-4.4.2}/src/py_store/sync.py +0 -0
  64. {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/dependency_links.txt +0 -0
  65. {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/requires.txt +0 -0
  66. {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/top_level.txt +0 -0
  67. {storepy-4.2.1 → storepy-4.4.2}/tests/test_ask.py +0 -0
  68. {storepy-4.2.1 → storepy-4.4.2}/tests/test_cache.py +0 -0
  69. {storepy-4.2.1 → storepy-4.4.2}/tests/test_coverage_margin.py +0 -0
  70. {storepy-4.2.1 → storepy-4.4.2}/tests/test_federation_e2e.py +0 -0
  71. {storepy-4.2.1 → storepy-4.4.2}/tests/test_feedback_flush.py +0 -0
  72. {storepy-4.2.1 → storepy-4.4.2}/tests/test_fns_binding.py +0 -0
  73. {storepy-4.2.1 → storepy-4.4.2}/tests/test_host_contract.py +0 -0
  74. {storepy-4.2.1 → storepy-4.4.2}/tests/test_host_paths.py +0 -0
  75. {storepy-4.2.1 → storepy-4.4.2}/tests/test_load.py +0 -0
  76. {storepy-4.2.1 → storepy-4.4.2}/tests/test_metadef.py +0 -0
  77. {storepy-4.2.1 → storepy-4.4.2}/tests/test_migration.py +0 -0
  78. {storepy-4.2.1 → storepy-4.4.2}/tests/test_mongo_session.py +0 -0
  79. {storepy-4.2.1 → storepy-4.4.2}/tests/test_multi_datasource.py +0 -0
  80. {storepy-4.2.1 → storepy-4.4.2}/tests/test_parity_deny.py +0 -0
  81. {storepy-4.2.1 → storepy-4.4.2}/tests/test_profile.py +0 -0
  82. {storepy-4.2.1 → storepy-4.4.2}/tests/test_raw_sql_and_ddl.py +0 -0
  83. {storepy-4.2.1 → storepy-4.4.2}/tests/test_rbac_host.py +0 -0
  84. {storepy-4.2.1 → storepy-4.4.2}/tests/test_real_backends_e2e.py +0 -0
  85. {storepy-4.2.1 → storepy-4.4.2}/tests/test_resource_e2e.py +0 -0
  86. {storepy-4.2.1 → storepy-4.4.2}/tests/test_resource_units.py +0 -0
  87. {storepy-4.2.1 → storepy-4.4.2}/tests/test_scenario_course_platform.py +0 -0
  88. {storepy-4.2.1 → storepy-4.4.2}/tests/test_scenario_manager_transaction.py +0 -0
  89. {storepy-4.2.1 → storepy-4.4.2}/tests/test_sql_commit_and_registry.py +0 -0
  90. {storepy-4.2.1 → storepy-4.4.2}/tests/test_workflow.py +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: storepy
3
- Version: 4.2.1
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
3
+ Version: 4.4.2
4
+ Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL, local disk): 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
7
7
  Project-URL: Homepage, https://github.com/coenddt/py-store
8
8
  Project-URL: Documentation, https://coenddt.github.io/py-store/
9
9
  Project-URL: Repository, https://github.com/coenddt/py-store
10
10
  Project-URL: Issues, https://github.com/coenddt/py-store/issues
11
- Keywords: mongodb,mysql,sqlite,postgresql,postgres,asyncio,async,data-layer,query-builder,odm,orm-alternative,sqlalchemy-alternative,motor-alternative,beanie-alternative,gql,json-schema,pymongo,multi-database,cross-database,multi-tenant,acl,rbac,access-control,row-level-security,aggregation,group-by,computed-columns,soft-delete,fastapi,ai-agent,llm-tool,query-validation,natural-language-query
11
+ Keywords: mongodb,mysql,sqlite,postgresql,postgres,local-disk,embedded,asyncio,async,data-layer,query-builder,odm,orm-alternative,sqlalchemy-alternative,motor-alternative,beanie-alternative,gql,json-schema,pymongo,multi-database,cross-database,multi-tenant,acl,rbac,access-control,row-level-security,aggregation,group-by,computed-columns,soft-delete,fastapi,ai-agent,llm-tool,query-validation,natural-language-query
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Programming Language :: Python :: 3
@@ -42,15 +42,15 @@ Dynamic: license-file
42
42
 
43
43
  # py-store
44
44
 
45
- **One data layer for MongoDB, MySQL, SQLite and PostgreSQL in Python asyncio — define models as pure JSON, query them with a MongoDB-style GQL tree syntax, and get role-based access control, computed columns and soft-delete out of the box.**
45
+ **One data layer for MongoDB, MySQL, SQLite, PostgreSQL and local disk in Python asyncio — define models as pure JSON, query them with a MongoDB-style GQL tree syntax, and get role-based access control, computed columns and soft-delete out of the box.**
46
46
 
47
47
  ![PyPI version](https://img.shields.io/pypi/v/storepy)
48
48
  ![license](https://img.shields.io/pypi/l/storepy)
49
49
  ![python versions](https://img.shields.io/pypi/pyversions/storepy)
50
- ![backends](https://img.shields.io/badge/backends-MongoDB%20%7C%20MySQL%20%7C%20SQLite%20%7C%20PostgreSQL-blue)
50
+ ![backends](https://img.shields.io/badge/backends-MongoDB%20%7C%20MySQL%20%7C%20SQLite%20%7C%20PostgreSQL%20%7C%20Local-blue)
51
51
  ![query dialect](https://img.shields.io/badge/query%20dialect-GQL%20(MongoDB--flavoured)-green)
52
52
 
53
- `py-store` lets a Python service talk to MongoDB (native aggregation), MySQL, PostgreSQL and SQLite through a **single schema definition and a single query dialect**. Nested relations compile to **one native query per backend** — you never hand-write `$lookup` or raw SQL.
53
+ `py-store` lets a Python service talk to MongoDB (native aggregation), MySQL, PostgreSQL, SQLite and local disk through a **single schema definition and a single query dialect**. Nested relations compile to **one native query per backend** — you never hand-write `$lookup` or raw SQL.
54
54
 
55
55
  > Also looking for the Node.js version? See [`nodejs-store`](https://github.com/coenddt/nodejs-store) (npm `nodejs-store`). Both are thin hosts over the shared Rust engine [`rust-store`](https://github.com/coenddt/rust-store).
56
56
  > 中文文档见 [README.zh-CN.md](README.zh-CN.md)。
@@ -164,7 +164,7 @@ General positioning, not a benchmark — always verify against each tool's curre
164
164
  | | py-store | SQLAlchemy | Beanie / Motor | Tortoise ORM | SQLModel | Django ORM |
165
165
  | --- | --- | --- | --- | --- | --- | --- |
166
166
  | Primary shape | JSON schema + GQL data layer | SQL toolkit + ORM | Async MongoDB ODM / driver | Async ORM | Pydantic + SQLAlchemy | ORM bundled with Django |
167
- | Backends | MongoDB, MySQL, SQLite, PostgreSQL | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | MongoDB | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | PostgreSQL, MySQL, SQLite, … | PostgreSQL, MySQL, SQLite, Oracle |
167
+ | Backends | MongoDB, MySQL, SQLite, PostgreSQL, local | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | MongoDB | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | PostgreSQL, MySQL, SQLite, … | PostgreSQL, MySQL, SQLite, Oracle |
168
168
  | One query dialect across Mongo **and** SQL | ✅ (MongoDB-flavoured GQL) | ➖ (SQL only) | ➖ (Mongo only) | ➖ (SQL only) | ➖ (SQL only) | ➖ (SQL only) |
169
169
  | Nested relation reads in one query | ✅ declarative relations → `$lookup` / `JOIN` | ⚠️ manual `selectinload`/joins | ✅ `Link`/`fetch_links` | ✅ `prefetch_related` | ⚠️ via SQLAlchemy | ✅ `prefetch_related` |
170
170
  | Built-in role / field-level RBAC + owner injection | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ (permissions are app-level) |
@@ -194,7 +194,7 @@ pip install storepy
194
194
  > The distribution name is `storepy`; the import package is `py_store`:
195
195
  > `from py_store import init, store`.
196
196
 
197
- Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL).
197
+ Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL / local disk).
198
198
 
199
199
  Optional driver extras:
200
200
 
@@ -255,21 +255,38 @@ items = await store.query("Post($condition:@c0) { title, status }", {"c0": {"sta
255
255
  | MySQL | parameterized SQL, `information_schema` introspection (`asyncmy`) |
256
256
  | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection (`aiosqlite`) |
257
257
  | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support (`asyncpg`) |
258
+ | local (local disk) | collections persisted as JSON files, evaluated directly by the core's local evaluator (no SQL, no driver); see [Local disk data source (local)](#local-disk-data-source-local) |
258
259
 
259
- GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
260
+ GQL tree queries compile to a single native query per backend (except the local source — commands are evaluated directly by the core's local evaluator) — never hand-write `$lookup` or raw SQL again.
261
+
262
+ ### Local disk data source (local)
263
+
264
+ Zero external services, zero native DB engine: collections are persisted as JSON files and commands are evaluated directly inside the Rust core's local evaluator (semantics match the MongoDB driver).
265
+
266
+ ```python
267
+ from py_store import init, local
268
+
269
+ await init({'default': local.connect({'dir': './data/store'})})
270
+ ```
271
+
272
+ - **Usage**: `local.connect({'dir': …})` returns a connection descriptor (`kind: 'local'`); pass it to `await init()` like any Mongo/SQL connection.
273
+ - **On-disk format**: `<dir>/<physical collection>.json` (an array of documents); writes go through a `.tmp` temp file and an atomic `rename`.
274
+ - **Transactions**: snapshot isolation — during a transaction reads/writes hit an in-memory snapshot; `commit()` persists the whole directory, `rollback()` discards it. Atomic by construction.
275
+ - **Guardrails**: hard cap of 100,000 documents per collection (explicit error, never silent truncation); no indexes (sequential scan only) — declaring `schema.indexes` emits a `local_indexes_ignored` feedback event.
276
+ - **Concurrency limit**: single process only — in-process writes are serialized by a directory lock; cross-process concurrency is outside the v1 guarantee.
260
277
 
261
278
  ## Features
262
279
 
263
280
  - **Pure JSON schemas, zero code** — a model is just a dict: fields, relations, computes, indexes.
264
281
  - **GQL tree queries → one native query** — nested relations resolve in a single query; never hand-write `$lookup` again.
265
- - **Normalized aggregation** — root-level `$group` / `$having` and relation aggregate predicates (semi/anti-join) in the same GQL, pushed down to all four backends.
282
+ - **Normalized aggregation** — root-level `$group` / `$having` and relation aggregate predicates (semi/anti-join) in the same GQL, pushed down to all five backends.
266
283
  - **Read-time defaults & computed columns** — writes store only user data; reads fill defaults and run `fn` / `asyncFn` / relation-`agg` computes.
267
284
  - **Smart mutation** — `mutation()` auto-detects upsert by `_id` + unique index and recursively fills relation children.
268
285
  - **Soft-delete built in** — every schema auto-registers a `<Model>Deleted` archive collection/table; `remove()` archives before deleting.
269
286
  - **Permission context** — `ContextVar`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
270
287
  - **Multi-datasource & multi-tenant** — locate a schema by `(source, database, schema, collection)`; re-target per request with a route override.
271
288
  - **Async-first, Rust core** — built on PyMongo's `AsyncMongoClient` and a shared Rust core with SQL dialects.
272
- - **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all four backends (previously a silent no-op on MongoDB).
289
+ - **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all five backends (previously a silent no-op on MongoDB).
273
290
  - **Autoincrement primary keys** — declare `_id` as `{"type": "int", "strategy": "autoincrement"}` for database-assigned integer IDs, with explicit errors where autoincrement is impossible.
274
291
  - **Index DDL** — `schema.indexes` compiles to real `CREATE [UNIQUE] INDEX` statements (per backend, byte-identical); the generator still only emits text.
275
292
 
@@ -1,14 +1,14 @@
1
1
  # py-store
2
2
 
3
- **One data layer for MongoDB, MySQL, SQLite and PostgreSQL in Python asyncio — define models as pure JSON, query them with a MongoDB-style GQL tree syntax, and get role-based access control, computed columns and soft-delete out of the box.**
3
+ **One data layer for MongoDB, MySQL, SQLite, PostgreSQL and local disk in Python asyncio — define models as pure JSON, query them with a MongoDB-style GQL tree syntax, and get role-based access control, computed columns and soft-delete out of the box.**
4
4
 
5
5
  ![PyPI version](https://img.shields.io/pypi/v/storepy)
6
6
  ![license](https://img.shields.io/pypi/l/storepy)
7
7
  ![python versions](https://img.shields.io/pypi/pyversions/storepy)
8
- ![backends](https://img.shields.io/badge/backends-MongoDB%20%7C%20MySQL%20%7C%20SQLite%20%7C%20PostgreSQL-blue)
8
+ ![backends](https://img.shields.io/badge/backends-MongoDB%20%7C%20MySQL%20%7C%20SQLite%20%7C%20PostgreSQL%20%7C%20Local-blue)
9
9
  ![query dialect](https://img.shields.io/badge/query%20dialect-GQL%20(MongoDB--flavoured)-green)
10
10
 
11
- `py-store` lets a Python service talk to MongoDB (native aggregation), MySQL, PostgreSQL and SQLite through a **single schema definition and a single query dialect**. Nested relations compile to **one native query per backend** — you never hand-write `$lookup` or raw SQL.
11
+ `py-store` lets a Python service talk to MongoDB (native aggregation), MySQL, PostgreSQL, SQLite and local disk through a **single schema definition and a single query dialect**. Nested relations compile to **one native query per backend** — you never hand-write `$lookup` or raw SQL.
12
12
 
13
13
  > Also looking for the Node.js version? See [`nodejs-store`](https://github.com/coenddt/nodejs-store) (npm `nodejs-store`). Both are thin hosts over the shared Rust engine [`rust-store`](https://github.com/coenddt/rust-store).
14
14
  > 中文文档见 [README.zh-CN.md](README.zh-CN.md)。
@@ -122,7 +122,7 @@ General positioning, not a benchmark — always verify against each tool's curre
122
122
  | | py-store | SQLAlchemy | Beanie / Motor | Tortoise ORM | SQLModel | Django ORM |
123
123
  | --- | --- | --- | --- | --- | --- | --- |
124
124
  | Primary shape | JSON schema + GQL data layer | SQL toolkit + ORM | Async MongoDB ODM / driver | Async ORM | Pydantic + SQLAlchemy | ORM bundled with Django |
125
- | Backends | MongoDB, MySQL, SQLite, PostgreSQL | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | MongoDB | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | PostgreSQL, MySQL, SQLite, … | PostgreSQL, MySQL, SQLite, Oracle |
125
+ | Backends | MongoDB, MySQL, SQLite, PostgreSQL, local | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | MongoDB | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | PostgreSQL, MySQL, SQLite, … | PostgreSQL, MySQL, SQLite, Oracle |
126
126
  | One query dialect across Mongo **and** SQL | ✅ (MongoDB-flavoured GQL) | ➖ (SQL only) | ➖ (Mongo only) | ➖ (SQL only) | ➖ (SQL only) | ➖ (SQL only) |
127
127
  | Nested relation reads in one query | ✅ declarative relations → `$lookup` / `JOIN` | ⚠️ manual `selectinload`/joins | ✅ `Link`/`fetch_links` | ✅ `prefetch_related` | ⚠️ via SQLAlchemy | ✅ `prefetch_related` |
128
128
  | Built-in role / field-level RBAC + owner injection | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ (permissions are app-level) |
@@ -152,7 +152,7 @@ pip install storepy
152
152
  > The distribution name is `storepy`; the import package is `py_store`:
153
153
  > `from py_store import init, store`.
154
154
 
155
- Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL).
155
+ Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL / local disk).
156
156
 
157
157
  Optional driver extras:
158
158
 
@@ -213,21 +213,38 @@ items = await store.query("Post($condition:@c0) { title, status }", {"c0": {"sta
213
213
  | MySQL | parameterized SQL, `information_schema` introspection (`asyncmy`) |
214
214
  | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection (`aiosqlite`) |
215
215
  | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support (`asyncpg`) |
216
+ | local (local disk) | collections persisted as JSON files, evaluated directly by the core's local evaluator (no SQL, no driver); see [Local disk data source (local)](#local-disk-data-source-local) |
216
217
 
217
- GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
218
+ GQL tree queries compile to a single native query per backend (except the local source — commands are evaluated directly by the core's local evaluator) — never hand-write `$lookup` or raw SQL again.
219
+
220
+ ### Local disk data source (local)
221
+
222
+ Zero external services, zero native DB engine: collections are persisted as JSON files and commands are evaluated directly inside the Rust core's local evaluator (semantics match the MongoDB driver).
223
+
224
+ ```python
225
+ from py_store import init, local
226
+
227
+ await init({'default': local.connect({'dir': './data/store'})})
228
+ ```
229
+
230
+ - **Usage**: `local.connect({'dir': …})` returns a connection descriptor (`kind: 'local'`); pass it to `await init()` like any Mongo/SQL connection.
231
+ - **On-disk format**: `<dir>/<physical collection>.json` (an array of documents); writes go through a `.tmp` temp file and an atomic `rename`.
232
+ - **Transactions**: snapshot isolation — during a transaction reads/writes hit an in-memory snapshot; `commit()` persists the whole directory, `rollback()` discards it. Atomic by construction.
233
+ - **Guardrails**: hard cap of 100,000 documents per collection (explicit error, never silent truncation); no indexes (sequential scan only) — declaring `schema.indexes` emits a `local_indexes_ignored` feedback event.
234
+ - **Concurrency limit**: single process only — in-process writes are serialized by a directory lock; cross-process concurrency is outside the v1 guarantee.
218
235
 
219
236
  ## Features
220
237
 
221
238
  - **Pure JSON schemas, zero code** — a model is just a dict: fields, relations, computes, indexes.
222
239
  - **GQL tree queries → one native query** — nested relations resolve in a single query; never hand-write `$lookup` again.
223
- - **Normalized aggregation** — root-level `$group` / `$having` and relation aggregate predicates (semi/anti-join) in the same GQL, pushed down to all four backends.
240
+ - **Normalized aggregation** — root-level `$group` / `$having` and relation aggregate predicates (semi/anti-join) in the same GQL, pushed down to all five backends.
224
241
  - **Read-time defaults & computed columns** — writes store only user data; reads fill defaults and run `fn` / `asyncFn` / relation-`agg` computes.
225
242
  - **Smart mutation** — `mutation()` auto-detects upsert by `_id` + unique index and recursively fills relation children.
226
243
  - **Soft-delete built in** — every schema auto-registers a `<Model>Deleted` archive collection/table; `remove()` archives before deleting.
227
244
  - **Permission context** — `ContextVar`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
228
245
  - **Multi-datasource & multi-tenant** — locate a schema by `(source, database, schema, collection)`; re-target per request with a route override.
229
246
  - **Async-first, Rust core** — built on PyMongo's `AsyncMongoClient` and a shared Rust core with SQL dialects.
230
- - **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all four backends (previously a silent no-op on MongoDB).
247
+ - **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all five backends (previously a silent no-op on MongoDB).
231
248
  - **Autoincrement primary keys** — declare `_id` as `{"type": "int", "strategy": "autoincrement"}` for database-assigned integer IDs, with explicit errors where autoincrement is impossible.
232
249
  - **Index DDL** — `schema.indexes` compiles to real `CREATE [UNIQUE] INDEX` statements (per backend, byte-identical); the generator still only emits text.
233
250
 
@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "storepy"
7
- version = "4.2.1"
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"
7
+ version = "4.4.2"
8
+ description = "Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL, local disk): 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"
11
11
  license = "MIT"
12
12
  license-files = ["LICENSE"]
13
13
  keywords = [
14
14
  "mongodb", "mysql", "sqlite", "postgresql", "postgres",
15
+ "local-disk", "embedded",
15
16
  "asyncio", "async", "data-layer", "query-builder", "odm",
16
17
  "orm-alternative", "sqlalchemy-alternative", "motor-alternative", "beanie-alternative",
17
18
  "gql", "json-schema", "pymongo",
@@ -36,6 +36,7 @@ from . import (
36
36
  permission,
37
37
  resource,
38
38
  schema,
39
+ secure,
39
40
  workflow,
40
41
  )
41
42
  from . import (
@@ -47,8 +48,14 @@ from . import (
47
48
  from . import (
48
49
  load as load,
49
50
  )
51
+ from . import (
52
+ local as local,
53
+ )
50
54
  from .ask import AskExhausted as AskExhausted
51
55
  from .ask import AskResult as AskResult
56
+ from .crud import triggers as triggers
57
+ from .crud.triggers import assert_trigger_fns_covered as assert_trigger_fns_covered
58
+ from .crud.triggers import set_trigger_fn as set_trigger_fn
52
59
  from .datasource import NativeCommandError as NativeCommandError
53
60
  from .datasource import NonAtomicWriteError as NonAtomicWriteError
54
61
  from .datasource import RawSqlError as RawSqlError
@@ -60,12 +67,14 @@ from .load import load_defs as load_defs
60
67
  from .schema import assert_fns_covered as assert_fns_covered
61
68
  from .schema import set_fn as set_fn
62
69
  from .schema import text2query
70
+ from .scope import current_scope as current_scope
71
+ from .scope import with_scope as with_scope
63
72
  from .sync import sync_schema
64
73
 
65
74
 
66
75
  def _build_pipeline(gql, params=None):
67
76
  """解析 GQL 并构建 pipeline,返回 `{tokens, ast, pipeline, projection}`"""
68
- return schema.core.build_pipeline(
77
+ return schema.get_core().build_pipeline(
69
78
  gql, params if params is not None else {}, permission.get_context())
70
79
 
71
80
 
@@ -324,9 +333,12 @@ class Store:
324
333
  register = staticmethod(schema.register)
325
334
  # 运行期目录装载(读 store.config.json → 收集定义 → core 纯规划 → 带定位批量注册)
326
335
  load_defs = staticmethod(load.load_defs)
327
- # 公开回调注入与启动期缺实现校验(对齐 nodejs-store store.set_fn / store.assert_fns_covered)
336
+ # 公开回调注入与启动期缺实现校验(对齐 nodejs-store store.set_fn / assert_fns_covered)
328
337
  set_fn = staticmethod(schema.set_fn)
329
338
  assert_fns_covered = staticmethod(schema.assert_fns_covered)
339
+ # 触发器回调注入与启动期缺实现校验(对齐 nodejs-store store.setTriggerFn / assertTriggerFnsCovered)
340
+ set_trigger_fn = staticmethod(triggers.set_trigger_fn)
341
+ assert_trigger_fns_covered = staticmethod(triggers.assert_trigger_fns_covered)
330
342
  has = staticmethod(schema.has)
331
343
  get = staticmethod(schema.get)
332
344
  # 数据源连接(多后端路由)
@@ -358,6 +370,11 @@ class Store:
358
370
  set_unconfigured_policy = staticmethod(permission.set_unconfigured_policy)
359
371
  # 定义层门禁策略(判决唯一在 core):closed 时仅 internal/白名单可注册或覆盖
360
372
  set_meta_policy = staticmethod(schema.set_meta_policy)
373
+ # 统一安全模式(fail-secure 一键入口;见 secure.py):同时翻转上下文强制 +
374
+ # 未配白名单全拒 + 定义注册门禁;admin_roles 为允许改定义的角色(默认仅 internal)
375
+ secure_mode = staticmethod(secure.secure_mode)
376
+ is_secure = staticmethod(secure.is_secure)
377
+ relax_mode = staticmethod(secure.relax_mode)
361
378
  # 查询档位(判决唯一在 core):standard 默认放开 / text2query 功能收缩
362
379
  # 进入档即等效强制 ctx;未知档由 core 抛 ValueError 上抛(禁静默回落默认档)
363
380
  set_profile = staticmethod(schema.set_profile)
@@ -374,6 +391,9 @@ class Store:
374
391
 
375
392
  store = Store()
376
393
 
394
+ # 触发链执行器装配:回调实现经门面(host['store'])消费 CRUD API
395
+ triggers.set_store(store)
396
+
377
397
  # 索引名对齐 MongoDB 自动命名(k1_v1_k2_v2),用于幂等创建
378
398
  async def _create_indexes_if_needed():
379
399
  """按数据源分派:Mongo 源执行索引创建;SQL 后端**不建索引**(indexes 仅元数据)"""
@@ -385,6 +405,18 @@ async def _create_indexes_if_needed():
385
405
  # 其余配置错误(database 形态不匹配等)按 fail-fast 由 db_of_schema 上抛
386
406
  if not datasource.has_connection(datasource.source_of_schema(name)):
387
407
  continue
408
+ # local 源 v1 无索引(仅顺序扫描):声明 indexes 即告警(禁静默忽略),直接跳过建索引
409
+ if datasource.is_local(datasource.get_connection(datasource.source_of_schema(name))):
410
+ if s.get('indexes'):
411
+ feedback.emit({
412
+ 'type': 'local_indexes_ignored',
413
+ 'code': 'localIndexesIgnored',
414
+ 'layer': 'local',
415
+ 'message': f'schema {name} 声明了 indexes,但 local 数据源 v1 不建索引(仅顺序扫描)',
416
+ 'hint': 'local 源请勿声明 indexes;需要索引请改用 sqlite/mongo 等后端',
417
+ 'schema': name,
418
+ })
419
+ continue
388
420
  db = datasource.db_of_schema(name) # Mongo 按 (datasource, database) 解析;SQL 源返回 None
389
421
  if db is None:
390
422
  continue # SQL 后端不建索引(铁律 6)
@@ -4,12 +4,12 @@
4
4
 
5
5
  用户问题 → ① describe_for_ai(ctx) 权限过滤摘要
6
6
  → ② LLM(注入式客户端)翻译为 {"gql","params"}
7
- → ③ with text2query(): 规划期校验(core 判决:语法/档位/权限/硬限)
7
+ → ③ text2query 档位视图内规划期校验(core 判决:语法/档位/权限/硬限)
8
8
  → ④ crud.query 执行(只读)
9
9
  → ⑤ 失败结构化回喂 LLM 重试(≤ max_retries 次),耗尽抛 AskExhausted
10
10
 
11
11
  护栏面(D5,服务端硬编码,LLM 零可触):
12
- - 档位 = text2query:本模块硬编码 ``with text2query():`` 包裹全部执行;
12
+ - 档位 = text2query:本模块硬编码以作用域档位视图(with_scope + with_policy)包裹全部执行;
13
13
  - 用户上下文 = ctx:服务端注入参数,经 ``permission.scoped_context`` 进执行面,
14
14
  绝不进入任何 LLM 消息;core 档位门禁强制无 ctx 即拒(fail-secure,A4);
15
15
  - route_override = None:硬编码(CWE-639;Host 兜底 ``_guard_route_override`` 双保险);
@@ -21,8 +21,10 @@ LLM 输出永远当不可信输入:唯一产出形状 ``{"gql","params"}`` 单
21
21
  不可能变成不受控命令。一切失败结构化显式暴露(no-error-masking:是错就是错,
22
22
  禁降级、禁返回空结果——「问数失败」就是失败,交上层裁决,D4)。
23
23
 
24
- 并发限制(如实声明):``text2query`` 档位(core 单例)与反馈 sink 为进程级全局,
25
- 同一进程内并发调用 ``ask()`` 会互相串扰,宿主需串行化(或每任务独享进程/事件循环)。
24
+ 并发隔离(R2):``text2query`` 档位与反馈 sink 不再全局切换,改由 ``with_scope`` 承载
25
+ 「一请求一档位视图 / 一 sink」(派生视图见 rust-store 02,作用域形态见 03 §4.4);
26
+ 同一进程内并发调用 ``ask()`` 各自独立、互不串扰——「宿主须串行化 / 每任务独享进程」
27
+ 的要求随之解除(fail-open 姿态与 core 判决语义均不变)。
26
28
  """
27
29
 
28
30
  from __future__ import annotations
@@ -32,9 +34,8 @@ import json
32
34
  from dataclasses import dataclass, field
33
35
 
34
36
  from . import crud, feedback, permission, schema
35
- from .core import core
36
37
  from .llm import get_llm as _get_llm
37
- from .schema import text2query
38
+ from .scope import with_scope
38
39
 
39
40
  __all__ = ['AskExhausted', 'AskResult', 'ask', 'describe_for_ai']
40
41
 
@@ -128,16 +129,16 @@ def describe_for_ai(ctx=None) -> list[dict]:
128
129
  if ctx is None:
129
130
  summaries.append({'name': name, 'fields': sorted(mirror['fields'])})
130
131
  continue
131
- if not core.can_read(name, ctx):
132
+ if not permission.can_read_schema(name, ctx):
132
133
  continue
133
- readable = set(core.readable_fields(name, ctx))
134
+ readable = set(permission.get_readable_fields(name, ctx))
134
135
  fields = {fname: _field_type(fdef)
135
136
  for fname, fdef in mirror['fields'].items() if fname in readable}
136
137
  for fname in sorted(readable - set(mirror['fields'])):
137
138
  # core 自动补的时间戳字段(createdAt/updatedAt)不在 Host 镜像——类型显式留白
138
139
  fields[fname] = None
139
140
  relations = {}
140
- readable_rels = set(core.readable_relations(name, ctx))
141
+ readable_rels = set(permission.get_readable_relations(name, ctx))
141
142
  for rname, rdef in mirror['relations'].items():
142
143
  if rname in readable_rels:
143
144
  relations[rname] = {'model': rdef.get('model'), 'type': rdef.get('type')}
@@ -239,6 +240,45 @@ def _error_from_exception(e, round_events):
239
240
  return {'code': 'planError', 'message': str(e)}
240
241
 
241
242
 
243
+ async def _ask_body(*, client, messages, attempts, events, round_events, max_retries):
244
+ """ask 执行主体(作用域内运行:档位视图 / 反馈 sink / 用户 ctx 均已就位)。
245
+
246
+ R2 起本函数(及整个执行面)**不得**出现 ``set_profile`` / ``set_sink`` / ``set_meta``
247
+ 全局切换——档位与 sink 由 ``with_scope`` 承载(03 §4.4,grep 核销为零)。
248
+ """
249
+ for _ in range(1 + max_retries):
250
+ round_events.clear()
251
+ # LLM 客户端异常(llmNetworkError/llmHttpError/llmEmptyContent)原样穿透
252
+ raw = await client(messages)
253
+ attempt = {'llm_raw': raw}
254
+ try:
255
+ gql, params = _parse_llm_output(raw)
256
+ except _BadLlmOutput as e:
257
+ attempt['error'] = e.detail
258
+ attempts.append(attempt)
259
+ messages.append({'role': 'assistant', 'content': raw})
260
+ messages.append({'role': 'user', 'content': json.dumps(
261
+ {'error': e.detail}, ensure_ascii=False)})
262
+ continue
263
+ attempt['gql'] = gql
264
+ attempt['params'] = params
265
+ try:
266
+ # route_override 硬编码 None(受信参数,禁 AI 侧指定,D5/CWE-639)
267
+ data = await crud.query(gql, params, None)
268
+ except Exception as e:
269
+ attempt['error'] = _error_from_exception(e, round_events)
270
+ attempts.append(attempt)
271
+ messages.append({'role': 'assistant', 'content': raw})
272
+ messages.append({'role': 'user', 'content': json.dumps(
273
+ {'error': attempt['error']}, ensure_ascii=False)})
274
+ continue
275
+ attempt['rows'] = len(data)
276
+ attempts.append(attempt)
277
+ return AskResult(data=data, attempts=attempts, events=events)
278
+ # 循环走完(重试耗尽)→ None,由入口抛 AskExhausted
279
+ return None
280
+
281
+
242
282
  async def ask(question: str, *, llm, ctx: dict, max_retries: int = 3,
243
283
  knowledge: str | None = None) -> AskResult:
244
284
  """AI 问数唯一入口(L1 只读):自然语言 → LLM 翻译 → 受控沙箱执行 → 结构化回喂。
@@ -284,39 +324,16 @@ async def ask(question: str, *, llm, ctx: dict, max_retries: int = 3,
284
324
  events.append(event)
285
325
  round_events.append(event)
286
326
 
287
- prev_sink = feedback.get_sink()
288
- feedback.set_sink(_collect)
289
- try:
290
- with text2query(), permission.scoped_context(ctx):
291
- for _ in range(1 + max_retries):
292
- round_events.clear()
293
- # LLM 客户端异常(llmNetworkError/llmHttpError/llmEmptyContent)原样穿透
294
- raw = await client(messages)
295
- attempt = {'llm_raw': raw}
296
- try:
297
- gql, params = _parse_llm_output(raw)
298
- except _BadLlmOutput as e:
299
- attempt['error'] = e.detail
300
- attempts.append(attempt)
301
- messages.append({'role': 'assistant', 'content': raw})
302
- messages.append({'role': 'user', 'content': json.dumps(
303
- {'error': e.detail}, ensure_ascii=False)})
304
- continue
305
- attempt['gql'] = gql
306
- attempt['params'] = params
307
- try:
308
- # route_override 硬编码 None(受信参数,禁 AI 侧指定,D5/CWE-639)
309
- data = await crud.query(gql, params, None)
310
- except Exception as e:
311
- attempt['error'] = _error_from_exception(e, round_events)
312
- attempts.append(attempt)
313
- messages.append({'role': 'assistant', 'content': raw})
314
- messages.append({'role': 'user', 'content': json.dumps(
315
- {'error': attempt['error']}, ensure_ascii=False)})
316
- continue
317
- attempt['rows'] = len(data)
318
- attempts.append(attempt)
319
- return AskResult(data=data, attempts=attempts, events=events)
320
- finally:
321
- feedback.set_sink(prev_sink)
327
+ # R2(03 §4.4):一次性派生 text2query 档位视图,档位与反馈 sink 随作用域承载——
328
+ # 不再全局 set_profile / set_sink,「进入即改进程档位 + 退出恢复」的串扰根源随之消除。
329
+ view = schema.get_core().with_policy({'profile': 'text2query'})
330
+ # sink:本作用域 emit 的事件只进本次 events(feedback.emit 优先取 current_scope().sink);
331
+ # meta(ns 标签)不在此伪造——ask 无 tenant/env 入参,沿用进程级 feedback.set_meta 注入值。
332
+ # ctx 经 permission.scoped_context 注入执行面(core 档位门禁强制 ctx,fail-secure,A4)
333
+ with with_scope(view, sink=_collect), permission.scoped_context(ctx):
334
+ result = await _ask_body(
335
+ client=client, messages=messages, attempts=attempts,
336
+ events=events, round_events=round_events, max_retries=max_retries)
337
+ if result is not None:
338
+ return result
322
339
  raise AskExhausted(attempts, events)
@@ -118,6 +118,9 @@ async def _exec_on(source, cmd):
118
118
  """在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec;
119
119
  事务 / 会话作用域内经 datasource.resolve_connection 落到事务专用连接)"""
120
120
  connection = await _datasource.resolve_connection(source, _datasource.is_write_cmd(cmd))
121
+ if _datasource.is_local(connection):
122
+ # 本地磁盘源:与 Mongo 同路径(复用 exec_mongo;handle 已绑定事务快照或直连 IO)
123
+ return _to_logical(await _exec_mongo(connection['handle'], _to_mongo(cmd)), cmd)
121
124
  if isinstance(connection, Mapping) and connection.get('kind') == 'mongo':
122
125
  # Mongo 事务视图:db 按命令 database 解析,session 透传给驱动
123
126
  db = _datasource.mongo_db(connection['conn'], source, cmd.get('database'))
@@ -149,9 +152,29 @@ def _sources_of(plan):
149
152
  cmd = (step or {}).get('command') or {}
150
153
  if cmd:
151
154
  out.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
155
+ # 触发链命令的源(plan_insert 直接产出 triggers;plan_update 二次规划才产出)
156
+ for t in (plan or {}).get('triggers') or []:
157
+ cmd = (t or {}).get('command') or {}
158
+ if cmd:
159
+ out.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
152
160
  return out or {_datasource.DEFAULT_SOURCE}
153
161
 
154
162
 
163
+ def declare_trigger_sources(base_sources, triggers):
164
+ """触发链触及源的原子性声明(update 专用:其 triggers 在事务内二次规划后才产出,
165
+ 顶层 ``_sources_of`` 已不及)——触发源超出已声明源集时发 ``non_atomic_write``
166
+ (含全部涉及源),此后顺序执行;会话内不发声明(跨源写由既有 fail-closed 拒绝)。
167
+ 对齐 nodejs-store ``declareTriggerSources``。"""
168
+ uniq = set(base_sources)
169
+ for t in triggers or ():
170
+ cmd = (t or {}).get('command') or {}
171
+ if cmd:
172
+ uniq.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
173
+ if len(uniq) > 1 and _datasource.current_session() is None:
174
+ _warn_multi_source(uniq)
175
+ return uniq
176
+
177
+
155
178
  def _warn_multi_source(sources):
156
179
  """多源写:无法原子 → 程序化声明 nonAtomic(允许顺序执行,禁止静默)"""
157
180
  listed = sorted(sources)
@@ -1,8 +1,8 @@
1
1
  """Mutation / Upsert / 原生聚合 —— 规划步骤序列 → 依序执行 + 父子 _id 占位符回填"""
2
2
 
3
3
  from ..feedback import emit as _emit_feedback
4
- from ..schema import core as _core
5
4
  from ..schema import get as _get_schema
5
+ from ..schema import get_core
6
6
  from .exec import (
7
7
  _call,
8
8
  _ctx,
@@ -17,7 +17,7 @@ from .id import _generate_id, _new_id_pool
17
17
 
18
18
  async def _mutation_one(schema_name, data, now, route_override=None):
19
19
  """mutation 单条:规划步骤序列 → 依序执行 + 父子 _id 占位符回填"""
20
- plan = _call(lambda: _core.plan_mutation(
20
+ plan = _call(lambda: get_core().plan_mutation(
21
21
  schema_name, data, now, _new_id_pool(schema_name, data), _ctx(), route_override))
22
22
 
23
23
  # §11.4 静默点收口:规划期降级(如关系不可读被跳过)走统一反馈通道,禁止静默失守
@@ -36,7 +36,7 @@ async def _mutation_one(schema_name, data, now, route_override=None):
36
36
 
37
37
  if not root_result:
38
38
  return None
39
- return _call(lambda: _core.apply_write_defaults(schema_name, root_result))
39
+ return _call(lambda: get_core().apply_write_defaults(schema_name, root_result))
40
40
 
41
41
  # 单一 SQL 源 → 步骤序列整体事务化(同连接同事务,任一步失败整体回滚);
42
42
  # Mongo 源 / 跨源步骤按原样顺序执行(非原子边界见 README「事务边界」)
@@ -75,8 +75,8 @@ async def upsert(schema_name, condition, data, options=None, route_override=None
75
75
  与 mutation 不同,upsert 需要调用方显式提供 match 条件,不处理父子关系。
76
76
  """
77
77
  s = _get_schema(schema_name)
78
- plan = _call(lambda: _core.plan_upsert(
78
+ plan = _call(lambda: get_core().plan_upsert(
79
79
  schema_name, condition, data, options, _now_for(schema_name),
80
80
  _generate_id(s) if s['idPrefix'] else '', _ctx(), route_override))
81
81
  result = await _exec(plan['command'])
82
- return _call(lambda: _core.apply_write_defaults(schema_name, result)) if result else None
82
+ return _call(lambda: get_core().apply_write_defaults(schema_name, result)) if result else None
@@ -5,8 +5,7 @@ import asyncio
5
5
  import inspect
6
6
 
7
7
  from ..feedback import emit as _emit_feedback
8
- from ..schema import core as _core
9
- from ..schema import get_async_fn, get_profile
8
+ from ..schema import get_async_fn, get_core, get_profile
10
9
  from .exec import (
11
10
  _PROFILE_HINT,
12
11
  ProfileViolation,
@@ -50,7 +49,7 @@ async def _run_query_plan(plan):
50
49
  return []
51
50
  cmd2 = resolve_placeholders(plan['commands'][1], ids=ids)
52
51
  items = await _exec(cmd2)
53
- return _core.restore_sort_order(items, ids, plan.get('sort'))['items']
52
+ return get_core().restore_sort_order(items, ids, plan.get('sort'))['items']
54
53
  return await _exec(plan['commands'][0])
55
54
 
56
55
 
@@ -59,7 +58,7 @@ async def _finalize(plan, items):
59
58
  post = plan.get('postprocess')
60
59
  if not post:
61
60
  return items
62
- prepared = _core.prepare_query(post, items, _ctx())
61
+ prepared = get_core().prepare_query(post, items, _ctx())
63
62
  for ref in prepared['fnRefs']:
64
63
  fn = get_async_fn(ref)
65
64
  if not fn:
@@ -67,7 +66,7 @@ async def _finalize(plan, items):
67
66
  out = fn(prepared['items'], _ctx())
68
67
  if inspect.isawaitable(out):
69
68
  await out
70
- return _core.strip_query(post, prepared['items'])['items']
69
+ return get_core().strip_query(post, prepared['items'])['items']
71
70
 
72
71
 
73
72
  async def query(gql, params=None, route_override=None):
@@ -82,7 +81,7 @@ async def query(gql, params=None, route_override=None):
82
81
  注意:``route_override`` 为**受信服务端参数**,禁止透传用户输入(否则可被用于跨源路由,CWE-639)。
83
82
  """
84
83
  _guard_route_override(route_override)
85
- plan = _call(lambda: _core.plan_query(
84
+ plan = _call(lambda: get_core().plan_query(
86
85
  gql, params if params is not None else {}, _ctx(), route_override))
87
86
  return await _finalize(plan, await _run_query_plan(plan))
88
87
 
@@ -94,7 +93,7 @@ async def query_one(gql, params=None, route_override=None):
94
93
  大集合不再全量取回后取首条(对齐 PyMongo ``find_one`` 的 limit-1 语义)。
95
94
  """
96
95
  _guard_route_override(route_override)
97
- plan = _call(lambda: _core.plan_query_one(
96
+ plan = _call(lambda: get_core().plan_query_one(
98
97
  gql, params if params is not None else {}, _ctx(), route_override))
99
98
  items = await _finalize(plan, await _run_query_plan(plan))
100
99
  return items[0] if items else None
@@ -113,7 +112,7 @@ async def _run_federated_unit(unit):
113
112
  return []
114
113
  cmd2 = resolve_placeholders(commands[1], ids=ids)
115
114
  items = await _exec_on(unit['source'], cmd2)
116
- return _core.restore_sort_order(items, ids, unit.get('sort'))['items']
115
+ return get_core().restore_sort_order(items, ids, unit.get('sort'))['items']
117
116
  return await _exec_on(unit['source'], commands[0])
118
117
 
119
118
 
@@ -128,7 +127,7 @@ async def query_federated(gql, params=None):
128
127
  每源取数上限 ``MAX_FEDERATION_ROWS`` 由 core 强制(超限即报错,拒绝静默全表拉取);
129
128
  无法下推的分页/排序进 ``plan['degraded']`` 并告警,不阻断查询。
130
129
  """
131
- plan = _call(lambda: _core.plan_federated(
130
+ plan = _call(lambda: get_core().plan_federated(
132
131
  gql, params if params is not None else {}, _ctx()))
133
132
 
134
133
  for d in plan.get('degraded') or []:
@@ -138,7 +137,7 @@ async def query_federated(gql, params=None):
138
137
  results = await asyncio.gather(
139
138
  *(_run_federated_unit(u) for u in plan.get('sources') or []))
140
139
 
141
- merged = _call(lambda: _core.merge_federated(plan, results))
140
+ merged = _call(lambda: get_core().merge_federated(plan, results))
142
141
  return await _finalize(plan, merged)
143
142
 
144
143
 
@@ -152,7 +151,7 @@ async def query_with_count(gql, params=None, route_override=None):
152
151
  pageSize 上限 5000,防止拖库。
153
152
  """
154
153
  _guard_route_override(route_override)
155
- plan = _call(lambda: _core.plan_query_with_count(
154
+ plan = _call(lambda: get_core().plan_query_with_count(
156
155
  gql, params if params is not None else {}, _ctx(), None, route_override))
157
156
  items = await _finalize(plan, await _run_query_plan(plan))
158
157
  total = await _exec(plan['countCommand'])