storepy 0.1.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 (36) hide show
  1. storepy-0.1.0/LICENSE +21 -0
  2. storepy-0.1.0/PKG-INFO +195 -0
  3. storepy-0.1.0/README.md +161 -0
  4. storepy-0.1.0/pyproject.toml +46 -0
  5. storepy-0.1.0/setup.cfg +4 -0
  6. storepy-0.1.0/src/py_store/__init__.py +210 -0
  7. storepy-0.1.0/src/py_store/core.py +62 -0
  8. storepy-0.1.0/src/py_store/crud/__init__.py +20 -0
  9. storepy-0.1.0/src/py_store/crud/exec.py +108 -0
  10. storepy-0.1.0/src/py_store/crud/id.py +67 -0
  11. storepy-0.1.0/src/py_store/crud/mutation.py +63 -0
  12. storepy-0.1.0/src/py_store/crud/query.py +121 -0
  13. storepy-0.1.0/src/py_store/crud/write.py +93 -0
  14. storepy-0.1.0/src/py_store/datasource.py +119 -0
  15. storepy-0.1.0/src/py_store/executors/__init__.py +84 -0
  16. storepy-0.1.0/src/py_store/executors/mongo.py +43 -0
  17. storepy-0.1.0/src/py_store/executors/mysql.py +88 -0
  18. storepy-0.1.0/src/py_store/executors/postgres.py +41 -0
  19. storepy-0.1.0/src/py_store/executors/sqlite.py +49 -0
  20. storepy-0.1.0/src/py_store/introspect/__init__.py +21 -0
  21. storepy-0.1.0/src/py_store/introspect/mysql.py +96 -0
  22. storepy-0.1.0/src/py_store/introspect/postgres.py +108 -0
  23. storepy-0.1.0/src/py_store/introspect/sqlite.py +69 -0
  24. storepy-0.1.0/src/py_store/permission.py +111 -0
  25. storepy-0.1.0/src/py_store/schema.py +140 -0
  26. storepy-0.1.0/src/py_store/sync.py +36 -0
  27. storepy-0.1.0/src/py_store/types.py +35 -0
  28. storepy-0.1.0/src/storepy.egg-info/PKG-INFO +195 -0
  29. storepy-0.1.0/src/storepy.egg-info/SOURCES.txt +34 -0
  30. storepy-0.1.0/src/storepy.egg-info/dependency_links.txt +1 -0
  31. storepy-0.1.0/src/storepy.egg-info/requires.txt +11 -0
  32. storepy-0.1.0/src/storepy.egg-info/top_level.txt +1 -0
  33. storepy-0.1.0/tests/test_federation_e2e.py +249 -0
  34. storepy-0.1.0/tests/test_host_contract.py +141 -0
  35. storepy-0.1.0/tests/test_py_store.py +355 -0
  36. storepy-0.1.0/tests/test_real_backends_e2e.py +489 -0
storepy-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 py-store contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
storepy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: storepy
3
+ Version: 0.1.0
4
+ Summary: Lightweight multi-backend data layer (MongoDB / MySQL / SQLite / PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single query, computed columns, soft-delete and role-based access control
5
+ Author-email: leo <coen_ddt@qq.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/coenddt/py-store
8
+ Project-URL: Repository, https://github.com/coenddt/py-store
9
+ Project-URL: Issues, https://github.com/coenddt/py-store/issues
10
+ Keywords: mongodb,mysql,sqlite,postgresql,asyncio,data-layer,query-builder,odm,pymongo,acl
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Framework :: AsyncIO
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Operating System :: OS Independent
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: pymongo>=4.9
26
+ Requires-Dist: rust-store-py<0.2.0,>=0.1.0
27
+ Provides-Extra: mysql
28
+ Requires-Dist: asyncmy>=0.2.9; extra == "mysql"
29
+ Provides-Extra: postgres
30
+ Requires-Dist: asyncpg>=0.29; extra == "postgres"
31
+ Provides-Extra: sqlite
32
+ Requires-Dist: aiosqlite>=0.19; extra == "sqlite"
33
+ Dynamic: license-file
34
+
35
+ # py-store
36
+
37
+ A lightweight multi-backend data layer for Python asyncio apps — define your models as pure JSON schemas, query with GQL tree syntax, and get role-based access control out of the box. One unified MongoDB-style dialect runs on **MongoDB, MySQL, SQLite and PostgreSQL**.
38
+
39
+ ## Supported backends
40
+
41
+ | Backend | Notes |
42
+ | --- | --- |
43
+ | MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`) |
44
+ | MySQL | parameterized SQL, `information_schema` introspection |
45
+ | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection |
46
+ | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support |
47
+
48
+ GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
49
+
50
+ ## Features
51
+
52
+ - **Pure JSON schemas, zero code** — a model is just a dict: fields, relations, computes, indexes.
53
+ - **Read-time defaults & computed columns** — writes store only user data; reads fill defaults and run `fn`/`asyncFn` computes.
54
+ - **GQL tree queries → one native query** — nested relations resolve in a single query; never hand-write `$lookup` again.
55
+ - **Smart mutation** — `mutation()` auto-detects upsert by `_id` + unique index and recursively fills relation children.
56
+ - **Soft-delete built in** — every schema auto-registers a `<Model>Deleted` archive collection/table; `remove()` archives before deleting.
57
+ - **Permission context** — ContextVar-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
58
+ - **Async-first** — built on PyMongo's `AsyncMongoClient` (pymongo >= 4.9).
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install storepy
64
+ ```
65
+
66
+ > The distribution name is `storepy`; the import package is `py_store`:
67
+ > `from py_store import init, store`.
68
+
69
+ Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL).
70
+
71
+ ## Quick start
72
+
73
+ ```python
74
+ from pymongo import AsyncMongoClient
75
+ from py_store import init, store
76
+
77
+ client = AsyncMongoClient("mongodb://localhost:27017")
78
+ await init(client["mydb"]) # idempotently creates indexes for registered schemas
79
+
80
+ # Register a schema (pure JSON)
81
+ store.register({
82
+ "name": "Post", # model name used in GQL
83
+ "collection": "posts", # optional, defaults to name
84
+ "idPrefix": "PT", # string _id: prefix + base36 timestamp + random
85
+ "fields": {
86
+ "title": {"type": "string", "default": ""},
87
+ "status": {"type": "string", "default": "draft"},
88
+ "tags": {"type": "array", "default": []},
89
+ },
90
+ "computes": {
91
+ "statusLabel": {"type": "string", "depends": ["status"],
92
+ "fn": lambda doc: doc["status"].upper()},
93
+ },
94
+ "indexes": [{"keys": {"status": 1, "createdAt": -1}}],
95
+ })
96
+
97
+ # Write — only user data; defaults are filled on read
98
+ doc = await store.insert("Post", {"title": "Hello"})
99
+
100
+ # Query — GQL tree syntax, values referenced from params via @key
101
+ items = await store.query(
102
+ "Post($condition:@c0,$sort:@s1,$limit:@l) { title, status, statusLabel }",
103
+ {"c0": {"status": "draft"}, "s1": {"createdAt": -1}, "l": 20},
104
+ )
105
+ ```
106
+
107
+ ## GQL syntax
108
+
109
+ ```text
110
+ Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
111
+ field1, field2, obj.subField,
112
+ Relation($condition:@c2,$sort:@s3,$limit:@l2) { f3, Nested { f4 } }
113
+ }
114
+ ```
115
+
116
+ - Values come from the params dict: `{"c0": {...}, "s1": {...}}`.
117
+ - Object sub-fields use dot notation; relations are declared in the schema (`type: "many" | "one"`) and resolved automatically — **do not hand-write `$lookup`**.
118
+ - `$pipeline` passes a raw aggregation through as-is (no compute/defaults/permission trimming) — use with care; prefer `store.aggregate(model, pipeline)` for group/sum needs.
119
+
120
+ ## Query & write API
121
+
122
+ ```python
123
+ items = await store.query(gql, params) # list[dict]
124
+ one = await store.query_one(gql, params) # dict | None
125
+ page = await store.query_with_count(gql, params) # {'items','total','hasMore','page','pageSize'} (pageSize capped at 5000)
126
+ exists = await store.exists("Post", {"_id": pid})
127
+ n = await store.count("Post", {"status": "active"})
128
+
129
+ doc = await store.insert("Post", {...}) # auto _id / createdAt / updatedAt
130
+ docs = await store.insert_many("Post", [{...}, ...])
131
+ await store.update("Post", {"_id": pid}, {"status": "live"}) # plain fields → $set
132
+ await store.update("Post", {"_id": pid}, {"$inc": {"views": 1}}) # '$'-prefixed keys pass through as operators
133
+ await store.update_many("Post", {"type": t}, {"status": "live"})
134
+ r = await store.remove("Post", {"_id": pid}) # archives to <collection>_deleted first
135
+ await store.mutation("Post", {...}) # smart upsert + recursive relation children
136
+ await store.upsert("Post", {"code": "A1"}, {...}) # explicit-condition upsert (no relation handling)
137
+ rows = await store.aggregate("Post", pipeline) # native aggregation
138
+ ```
139
+
140
+ Notes:
141
+
142
+ - `None` values are stripped before persisting; `_id` cannot be changed via `update`.
143
+ - `createdAt`/`updatedAt` (ms) are framework-maintained — do not set them manually.
144
+ - Snake-case aliases available: `query_one`, `insert_many`, `update_many`, `parse_gql`, `build_pipeline`, ...
145
+
146
+ ## Permission context
147
+
148
+ ```python
149
+ # Set once per request (in router/dependency layer)
150
+ store.set_context({"userId": uid, "roles": ["editor"]})
151
+
152
+ # Internal/cron jobs — bypass permission checks
153
+ await store.run_as_internal(lambda: store.remove("Post", {"_id": pid}))
154
+ ```
155
+
156
+ - `super_admin`/`admin`/`internal` roles pass everything; other roles are checked against schema-level and field-level `read`/`write` whitelists; `guest` can never write.
157
+ - `creator` is a pseudo-role resolved by `doc.createdBy == ctx.userId`; schemas granting it automatically get owner conditions injected on queries and ownership checks on update/remove.
158
+ - No context set → permission checks disabled (backward compatible).
159
+ - Denied access raises `store.PermissionError` (with `status = 403`).
160
+
161
+ ## Schema reference
162
+
163
+ ```python
164
+ {
165
+ "name": "Order",
166
+ "collection": "orders",
167
+ "idPrefix": "OD",
168
+ "timestamps": True, # default: auto-maintain createdAt/updatedAt (ms)
169
+ "fields": {
170
+ "_id": "string", # shorthand
171
+ "title": {"type": "string", "default": ""},
172
+ "meta": {"type": "object", "default": {}, "fields": {...}}, # nested object fields
173
+ },
174
+ "relations": {
175
+ "items": {"model": "OrderItem", "type": "many",
176
+ "localField": "_id", "foreignField": "orderId"},
177
+ },
178
+ "computes": {
179
+ "total": {"type": "float", "depends": ["amount"], "fn": lambda d: d["amount"] * 1.1},
180
+ "itemCount": {"type": "int", "lookup": {"$size": {"$ifNull": ["$items", []]}}},
181
+ },
182
+ "indexes": [
183
+ {"keys": {"status": 1}},
184
+ {"keys": {"code": 1}, "options": {"unique": True}},
185
+ ],
186
+ "read": ["editor", "viewer"], # optional schema-level role whitelists
187
+ "write": ["editor"],
188
+ }
189
+ ```
190
+
191
+ Types: `string | int | long | float | double | boolean | array | object | date | any`.
192
+
193
+ ## License
194
+
195
+ [MIT](LICENSE)
@@ -0,0 +1,161 @@
1
+ # py-store
2
+
3
+ A lightweight multi-backend data layer for Python asyncio apps — define your models as pure JSON schemas, query with GQL tree syntax, and get role-based access control out of the box. One unified MongoDB-style dialect runs on **MongoDB, MySQL, SQLite and PostgreSQL**.
4
+
5
+ ## Supported backends
6
+
7
+ | Backend | Notes |
8
+ | --- | --- |
9
+ | MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`) |
10
+ | MySQL | parameterized SQL, `information_schema` introspection |
11
+ | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection |
12
+ | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support |
13
+
14
+ GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
15
+
16
+ ## Features
17
+
18
+ - **Pure JSON schemas, zero code** — a model is just a dict: fields, relations, computes, indexes.
19
+ - **Read-time defaults & computed columns** — writes store only user data; reads fill defaults and run `fn`/`asyncFn` computes.
20
+ - **GQL tree queries → one native query** — nested relations resolve in a single query; never hand-write `$lookup` again.
21
+ - **Smart mutation** — `mutation()` auto-detects upsert by `_id` + unique index and recursively fills relation children.
22
+ - **Soft-delete built in** — every schema auto-registers a `<Model>Deleted` archive collection/table; `remove()` archives before deleting.
23
+ - **Permission context** — ContextVar-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
24
+ - **Async-first** — built on PyMongo's `AsyncMongoClient` (pymongo >= 4.9).
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install storepy
30
+ ```
31
+
32
+ > The distribution name is `storepy`; the import package is `py_store`:
33
+ > `from py_store import init, store`.
34
+
35
+ Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL).
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from pymongo import AsyncMongoClient
41
+ from py_store import init, store
42
+
43
+ client = AsyncMongoClient("mongodb://localhost:27017")
44
+ await init(client["mydb"]) # idempotently creates indexes for registered schemas
45
+
46
+ # Register a schema (pure JSON)
47
+ store.register({
48
+ "name": "Post", # model name used in GQL
49
+ "collection": "posts", # optional, defaults to name
50
+ "idPrefix": "PT", # string _id: prefix + base36 timestamp + random
51
+ "fields": {
52
+ "title": {"type": "string", "default": ""},
53
+ "status": {"type": "string", "default": "draft"},
54
+ "tags": {"type": "array", "default": []},
55
+ },
56
+ "computes": {
57
+ "statusLabel": {"type": "string", "depends": ["status"],
58
+ "fn": lambda doc: doc["status"].upper()},
59
+ },
60
+ "indexes": [{"keys": {"status": 1, "createdAt": -1}}],
61
+ })
62
+
63
+ # Write — only user data; defaults are filled on read
64
+ doc = await store.insert("Post", {"title": "Hello"})
65
+
66
+ # Query — GQL tree syntax, values referenced from params via @key
67
+ items = await store.query(
68
+ "Post($condition:@c0,$sort:@s1,$limit:@l) { title, status, statusLabel }",
69
+ {"c0": {"status": "draft"}, "s1": {"createdAt": -1}, "l": 20},
70
+ )
71
+ ```
72
+
73
+ ## GQL syntax
74
+
75
+ ```text
76
+ Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
77
+ field1, field2, obj.subField,
78
+ Relation($condition:@c2,$sort:@s3,$limit:@l2) { f3, Nested { f4 } }
79
+ }
80
+ ```
81
+
82
+ - Values come from the params dict: `{"c0": {...}, "s1": {...}}`.
83
+ - Object sub-fields use dot notation; relations are declared in the schema (`type: "many" | "one"`) and resolved automatically — **do not hand-write `$lookup`**.
84
+ - `$pipeline` passes a raw aggregation through as-is (no compute/defaults/permission trimming) — use with care; prefer `store.aggregate(model, pipeline)` for group/sum needs.
85
+
86
+ ## Query & write API
87
+
88
+ ```python
89
+ items = await store.query(gql, params) # list[dict]
90
+ one = await store.query_one(gql, params) # dict | None
91
+ page = await store.query_with_count(gql, params) # {'items','total','hasMore','page','pageSize'} (pageSize capped at 5000)
92
+ exists = await store.exists("Post", {"_id": pid})
93
+ n = await store.count("Post", {"status": "active"})
94
+
95
+ doc = await store.insert("Post", {...}) # auto _id / createdAt / updatedAt
96
+ docs = await store.insert_many("Post", [{...}, ...])
97
+ await store.update("Post", {"_id": pid}, {"status": "live"}) # plain fields → $set
98
+ await store.update("Post", {"_id": pid}, {"$inc": {"views": 1}}) # '$'-prefixed keys pass through as operators
99
+ await store.update_many("Post", {"type": t}, {"status": "live"})
100
+ r = await store.remove("Post", {"_id": pid}) # archives to <collection>_deleted first
101
+ await store.mutation("Post", {...}) # smart upsert + recursive relation children
102
+ await store.upsert("Post", {"code": "A1"}, {...}) # explicit-condition upsert (no relation handling)
103
+ rows = await store.aggregate("Post", pipeline) # native aggregation
104
+ ```
105
+
106
+ Notes:
107
+
108
+ - `None` values are stripped before persisting; `_id` cannot be changed via `update`.
109
+ - `createdAt`/`updatedAt` (ms) are framework-maintained — do not set them manually.
110
+ - Snake-case aliases available: `query_one`, `insert_many`, `update_many`, `parse_gql`, `build_pipeline`, ...
111
+
112
+ ## Permission context
113
+
114
+ ```python
115
+ # Set once per request (in router/dependency layer)
116
+ store.set_context({"userId": uid, "roles": ["editor"]})
117
+
118
+ # Internal/cron jobs — bypass permission checks
119
+ await store.run_as_internal(lambda: store.remove("Post", {"_id": pid}))
120
+ ```
121
+
122
+ - `super_admin`/`admin`/`internal` roles pass everything; other roles are checked against schema-level and field-level `read`/`write` whitelists; `guest` can never write.
123
+ - `creator` is a pseudo-role resolved by `doc.createdBy == ctx.userId`; schemas granting it automatically get owner conditions injected on queries and ownership checks on update/remove.
124
+ - No context set → permission checks disabled (backward compatible).
125
+ - Denied access raises `store.PermissionError` (with `status = 403`).
126
+
127
+ ## Schema reference
128
+
129
+ ```python
130
+ {
131
+ "name": "Order",
132
+ "collection": "orders",
133
+ "idPrefix": "OD",
134
+ "timestamps": True, # default: auto-maintain createdAt/updatedAt (ms)
135
+ "fields": {
136
+ "_id": "string", # shorthand
137
+ "title": {"type": "string", "default": ""},
138
+ "meta": {"type": "object", "default": {}, "fields": {...}}, # nested object fields
139
+ },
140
+ "relations": {
141
+ "items": {"model": "OrderItem", "type": "many",
142
+ "localField": "_id", "foreignField": "orderId"},
143
+ },
144
+ "computes": {
145
+ "total": {"type": "float", "depends": ["amount"], "fn": lambda d: d["amount"] * 1.1},
146
+ "itemCount": {"type": "int", "lookup": {"$size": {"$ifNull": ["$items", []]}}},
147
+ },
148
+ "indexes": [
149
+ {"keys": {"status": 1}},
150
+ {"keys": {"code": 1}, "options": {"unique": True}},
151
+ ],
152
+ "read": ["editor", "viewer"], # optional schema-level role whitelists
153
+ "write": ["editor"],
154
+ }
155
+ ```
156
+
157
+ Types: `string | int | long | float | double | boolean | array | object | date | any`.
158
+
159
+ ## License
160
+
161
+ [MIT](LICENSE)
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "storepy"
7
+ version = "0.1.0"
8
+ description = "Lightweight multi-backend data layer (MongoDB / MySQL / SQLite / PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single query, computed columns, soft-delete and role-based access control"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["mongodb", "mysql", "sqlite", "postgresql", "asyncio", "data-layer", "query-builder", "odm", "pymongo", "acl"]
14
+ authors = [
15
+ { name = "leo", email = "coen_ddt@qq.com" },
16
+ ]
17
+ dependencies = [
18
+ "pymongo>=4.9",
19
+ "rust-store-py>=0.1.0,<0.2.0",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Framework :: AsyncIO",
30
+ "Topic :: Database",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Operating System :: OS Independent",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ mysql = ["asyncmy>=0.2.9"]
37
+ postgres = ["asyncpg>=0.29"]
38
+ sqlite = ["aiosqlite>=0.19"]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/coenddt/py-store"
42
+ Repository = "https://github.com/coenddt/py-store"
43
+ Issues = "https://github.com/coenddt/py-store/issues"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,210 @@
1
+ """
2
+ py-store — 轻量多后端数据层(Python 版,Rust 单核心架构;支持 MongoDB / MySQL / SQLite / PostgreSQL)
3
+
4
+ 核心理念:
5
+ 1. 纯 JSON schema 定义,零代码
6
+ 2. Rust core 统一实现 GQL 解析 / 权限 / 计算列 / 命令规划(core-py 绑定)
7
+ 3. src/py_store/*.py 为薄 Host 适配层:驱动 IO + 回调 + 占位符替换
8
+ 4. Node 侧(core-node)复用同一 Rust core,双端语义天然一致
9
+
10
+ 用法:
11
+ from py_store import init, store
12
+
13
+ await init(db) # 单库简写(Mongo db 实例)
14
+ await init({ # 多数据源(schema 的 datasource 绑定路由)
15
+ 'default': mongo_db,
16
+ 'mysql_a': executors.create_connection('mysql', mysql_pool),
17
+ })
18
+
19
+ items = await store.query(`Model($condition:@c0) { field1, field2 }`, {'c0': {...}})
20
+ """
21
+
22
+ from collections.abc import Mapping
23
+ from typing import Any
24
+
25
+ from pymongo.errors import PyMongoError
26
+
27
+ from . import crud, datasource, executors, introspect, permission, schema
28
+ from .sync import sync_schema
29
+
30
+
31
+ def _build_pipeline(gql, params=None):
32
+ """解析 GQL 并构建 pipeline,返回 `{tokens, ast, pipeline, projection}`"""
33
+ return schema.core.build_pipeline(
34
+ gql, params if params is not None else {}, permission.get_context())
35
+
36
+
37
+ _store_map = {
38
+ # Schema 管理
39
+ 'register': schema.register,
40
+ 'get': schema.get,
41
+ 'has': schema.has,
42
+ 'list': schema.list,
43
+ # CRUD
44
+ 'query': crud.query,
45
+ 'queryOne': crud.query_one,
46
+ 'queryWithCount': crud.query_with_count,
47
+ 'queryFederated': crud.query_federated,
48
+ 'exists': crud.exists,
49
+ 'count': crud.count,
50
+ 'insert': crud.insert,
51
+ 'insertMany': crud.insert_many,
52
+ 'update': crud.update,
53
+ 'updateMany': crud.update_many,
54
+ 'remove': crud.remove,
55
+ # Mutation — 智能持久化(upsert/insert + 父子关联填充)
56
+ 'mutation': crud.mutation,
57
+ # Upsert — 显式条件 upsert(不处理父子关系)
58
+ 'upsert': crud.upsert,
59
+ # 底层工具(调试/高级用法)
60
+ 'buildPipeline': _build_pipeline,
61
+ 'build_pipeline': _build_pipeline,
62
+ # 原生聚合查询
63
+ 'aggregate': crud.aggregate,
64
+ # 结构同步(SQL 数据源:introspect → schemaFromRows → mergeSchema → register)
65
+ 'syncSchema': sync_schema,
66
+ 'sync_schema': sync_schema,
67
+ # 数据源连接(多后端路由)
68
+ 'setConnections': crud.set_connections,
69
+ 'set_connections': crud.set_connections,
70
+ # 权限控制(ContextVar 上下文)
71
+ 'setContext': permission.set_context,
72
+ 'getContext': permission.get_context,
73
+ 'set_context': permission.set_context,
74
+ 'get_context': permission.get_context,
75
+ 'scopedRoles': permission.scoped_roles,
76
+ 'scoped_roles': permission.scoped_roles,
77
+ 'runAsInternal': permission.run_as_internal,
78
+ 'run_as_internal': permission.run_as_internal,
79
+ 'PermissionError': permission.PermissionError,
80
+ # 蛇形命名别名(Python 风格调用)
81
+ 'query_one': crud.query_one,
82
+ 'query_with_count': crud.query_with_count,
83
+ 'query_federated': crud.query_federated,
84
+ 'insert_many': crud.insert_many,
85
+ 'update_many': crud.update_many,
86
+ }
87
+
88
+
89
+ class Store:
90
+ """以属性方式访问 _store_map,支持 store.query(...) 调用形态"""
91
+
92
+ async def query(self, gql: str, params: dict | None = None) -> list[dict[str, Any]]:
93
+ return await crud.query(gql, params)
94
+
95
+ async def query_one(self, gql: str, params: dict | None = None) -> dict[str, Any] | None:
96
+ return await crud.query_one(gql, params)
97
+
98
+ async def query_with_count(self, gql: str, params: dict | None = None) -> dict[str, Any]:
99
+ return await crud.query_with_count(gql, params)
100
+
101
+ async def query_federated(self, gql: str, params: dict | None = None) -> list[dict[str, Any]]:
102
+ """跨库联邦查询(一条 GQL 跨多数据源:各源取数 → 内存 join → 统一后处理)"""
103
+ return await crud.query_federated(gql, params)
104
+
105
+ async def insert(self, schema_name: str, data: dict) -> dict[str, Any]:
106
+ return await crud.insert(schema_name, data)
107
+
108
+ async def insert_many(self, schema_name: str, docs: list[dict]) -> list[dict[str, Any]]:
109
+ return await crud.insert_many(schema_name, docs)
110
+
111
+ async def update(self, schema_name: str, condition: dict, data: dict,
112
+ options: dict | None = None) -> dict[str, Any] | None:
113
+ return await crud.update(schema_name, condition, data, options)
114
+
115
+ async def update_many(self, schema_name: str, condition: dict, data: dict) -> dict[str, Any]:
116
+ return await crud.update_many(schema_name, condition, data)
117
+
118
+ async def remove(self, schema_name: str, condition: dict) -> dict[str, Any]:
119
+ return await crud.remove(schema_name, condition)
120
+
121
+ async def exists(self, schema_name: str, condition: dict) -> bool:
122
+ return await crud.exists(schema_name, condition)
123
+
124
+ async def count(self, schema_name: str, filter: dict | None = None) -> int:
125
+ return await crud.count(schema_name, filter)
126
+
127
+ async def mutation(self, schema_name: str, data: dict | list[dict]) -> Any:
128
+ return await crud.mutation(schema_name, data)
129
+
130
+ async def upsert(self, schema_name: str, condition: dict, data: dict,
131
+ options: dict | None = None) -> dict[str, Any] | None:
132
+ return await crud.upsert(schema_name, condition, data, options)
133
+
134
+ async def aggregate(self, schema_name: str, pipeline: list[dict[str, Any]]) -> list[dict[str, Any]]:
135
+ return await crud.aggregate(schema_name, pipeline)
136
+
137
+ async def sync_schema(self, backend: str, driver: Any, introspect_options: dict | None = None,
138
+ overlay: list | None = None, datasource: str | None = None,
139
+ register_defs: bool = True) -> list[dict[str, Any]]:
140
+ return await sync_schema(backend, driver, introspect_options, overlay,
141
+ datasource, register_defs)
142
+
143
+ def build_pipeline(self, gql: str, params: dict | None = None) -> dict[str, Any]:
144
+ return _build_pipeline(gql, params)
145
+
146
+ def __getattr__(self, name):
147
+ return _store_map[name]
148
+
149
+
150
+ # 自定义权限错误(实例可被 store.PermissionError 捕获)
151
+ Store.PermissionError = permission.PermissionError
152
+
153
+ store = Store()
154
+
155
+ # 索引名对齐 MongoDB 自动命名(k1_v1_k2_v2),用于幂等创建
156
+ async def _create_indexes_if_needed():
157
+ """按数据源分派:Mongo 源执行索引创建;SQL 后端**不建索引**(indexes 仅元数据)"""
158
+ names = schema.list()
159
+ for name in names:
160
+ s = schema.get(name)
161
+ db = datasource.connection_of_schema(name)
162
+ if datasource.is_sql(db):
163
+ continue # SQL 后端不建索引(铁律 6)
164
+ coll = db[s['collection']]
165
+ try:
166
+ index_cursor = await coll.list_indexes()
167
+ existing_indexes = await index_cursor.to_list(length=None)
168
+ except PyMongoError:
169
+ existing_indexes = []
170
+
171
+ for idx in s.get('indexes') or []:
172
+ try:
173
+ keys = idx.get('keys')
174
+ if not keys:
175
+ continue
176
+ # 合并 inline 选项(unique/sparse/expireAfterSeconds 等)与显式 options
177
+ explicit_options = idx.get('options') or {}
178
+ final_options = {k: v for k, v in idx.items() if k not in ('keys', 'options')}
179
+ final_options.update(explicit_options)
180
+
181
+ # 检查是否已有同 key 模式的索引(忽略选项差异)
182
+ name_from_keys = '_'.join(f'{k}_{v}' for k, v in keys.items())
183
+ if any(ei.get('name') == name_from_keys for ei in existing_indexes):
184
+ continue
185
+
186
+ await coll.create_index(list(keys.items()), **final_options)
187
+ except PyMongoError as e:
188
+ import sys
189
+ print(f'[py-store] 创建索引失败 {s["collection"]}: {e}', file=sys.stderr)
190
+
191
+
192
+ async def init(connections):
193
+ """
194
+ 初始化 store — 传入数据源连接映射
195
+
196
+ - 多源:``init({'default': db, 'mysql_a': {'kind': 'mysql', 'exec': ...}})``
197
+ - 单源简写:``init(db)``(PyMongo async 的 db 实例,自动归一为 ``{'default': db}``)
198
+
199
+ 连接按 schema 的 ``datasource`` 绑定路由;缺省绑定回落 ``default``。
200
+ """
201
+ if connections is None or (
202
+ not isinstance(connections, Mapping)
203
+ and not callable(getattr(connections, '__getitem__', None))):
204
+ raise TypeError('init(connections) 需要数据源连接映射(或单个 PyMongo 的 db 实例)')
205
+ datasource.set_connections(connections)
206
+
207
+ # 自动创建索引(仅 Mongo 源)— 幂等安全
208
+ await _create_indexes_if_needed()
209
+
210
+ return store