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.
- {storepy-4.2.1/src/storepy.egg-info → storepy-4.4.2}/PKG-INFO +28 -11
- {storepy-4.2.1 → storepy-4.4.2}/README.md +25 -8
- {storepy-4.2.1 → storepy-4.4.2}/pyproject.toml +3 -2
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/__init__.py +34 -2
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/ask.py +61 -44
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/exec.py +23 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/mutation.py +5 -5
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/query.py +10 -11
- storepy-4.4.2/src/py_store/crud/triggers.py +191 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/write.py +67 -22
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/datasource.py +65 -8
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/feedback.py +25 -5
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/load.py +2 -2
- storepy-4.4.2/src/py_store/local/__init__.py +105 -0
- storepy-4.4.2/src/py_store/local/handle.py +139 -0
- storepy-4.4.2/src/py_store/local/store.py +73 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/permission.py +23 -18
- storepy-4.4.2/src/py_store/resource/__init__.py +316 -0
- storepy-4.4.2/src/py_store/scheduler/__init__.py +176 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/schema.py +39 -23
- storepy-4.4.2/src/py_store/scope.py +34 -0
- storepy-4.4.2/src/py_store/secure.py +95 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/workflow.py +6 -6
- {storepy-4.2.1 → storepy-4.4.2/src/storepy.egg-info}/PKG-INFO +28 -11
- {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/SOURCES.txt +13 -0
- storepy-4.4.2/tests/test_local_store.py +477 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_mongo_executor.py +2 -2
- storepy-4.4.2/tests/test_parity_local.py +61 -0
- storepy-4.4.2/tests/test_parity_scope.py +111 -0
- storepy-4.4.2/tests/test_policy_isolation.py +382 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_py_store.py +3 -3
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_require_context.py +74 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_resource.py +135 -0
- storepy-4.4.2/tests/test_scheduler.py +231 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_session.py +3 -2
- storepy-4.4.2/tests/test_triggers.py +451 -0
- storepy-4.2.1/src/py_store/resource/__init__.py +0 -168
- {storepy-4.2.1 → storepy-4.4.2}/LICENSE +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/setup.cfg +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/ask_knowledge.md +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/bootstrap.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/cache.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/core.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/__init__.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/crud/id.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/ddl.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/__init__.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/_values.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/mongo.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/mysql.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/postgres.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/executors/sqlite.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/__init__.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/mysql.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/postgres.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/llm.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/metadef.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/naming.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/resource/providers/__init__.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/resource/providers/local.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/resource/providers/s3.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/py_store/sync.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/requires.txt +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_ask.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_cache.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_coverage_margin.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_federation_e2e.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_feedback_flush.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_fns_binding.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_host_contract.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_host_paths.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_load.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_metadef.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_migration.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_mongo_session.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_multi_datasource.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_parity_deny.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_profile.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_raw_sql_and_ddl.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_rbac_host.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_real_backends_e2e.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_resource_e2e.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_resource_units.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_scenario_course_platform.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_scenario_manager_transaction.py +0 -0
- {storepy-4.2.1 → storepy-4.4.2}/tests/test_sql_commit_and_registry.py +0 -0
- {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
|
|
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
|
|
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
|

|
|
48
48
|

|
|
49
49
|

|
|
50
|
-

|
|
50
|
+

|
|
51
51
|
-green)
|
|
52
52
|
|
|
53
|
-
`py-store` lets a Python service talk to MongoDB (native aggregation), MySQL, PostgreSQL and
|
|
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
|
|
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
|
|
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
|
|
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
|

|
|
6
6
|

|
|
7
7
|

|
|
8
|
-

|
|
8
|
+

|
|
9
9
|
-green)
|
|
10
10
|
|
|
11
|
-
`py-store` lets a Python service talk to MongoDB (native aggregation), MySQL, PostgreSQL and
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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 /
|
|
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
|
-
→ ③
|
|
7
|
+
→ ③ text2query 档位视图内规划期校验(core 判决:语法/档位/权限/硬限)
|
|
8
8
|
→ ④ crud.query 执行(只读)
|
|
9
9
|
→ ⑤ 失败结构化回喂 LLM 重试(≤ max_retries 次),耗尽抛 AskExhausted
|
|
10
10
|
|
|
11
11
|
护栏面(D5,服务端硬编码,LLM 零可触):
|
|
12
|
-
- 档位 = 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
|
-
|
|
25
|
-
|
|
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 .
|
|
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
|
|
132
|
+
if not permission.can_read_schema(name, ctx):
|
|
132
133
|
continue
|
|
133
|
-
readable = set(
|
|
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(
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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'])
|