storepy 0.1.0__py3-none-any.whl

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.
py_store/__init__.py ADDED
@@ -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
py_store/core.py ADDED
@@ -0,0 +1,62 @@
1
+ """
2
+ rust-store 原生绑定加载器(唯一原生模块入口)
3
+
4
+ mongo-store 采用「Rust 单核心 + 双绑定」架构:schema/GQL/权限/计算列/命令规划
5
+ 全部在 Rust core 实现,本目录 src/py_store/*.py 只是薄 Host 适配层
6
+ (驱动 IO + 回调 + 占位符)。
7
+
8
+ 生产环境**只**从 pip 依赖 `rust-store-py` 加载原生扩展。
9
+ 开发期若需从相邻 rust-store 仓库的调试产物加载,必须显式设置:
10
+ LOCAL_CORE=1 且 NODE_ENV != 'production'
11
+ """
12
+
13
+ import importlib
14
+ import os
15
+ import pathlib
16
+ import sys
17
+
18
+ # 相邻 rust-store 仓库的 cargo/maturin 产物目录(含 rust_store_py.pyd)
19
+ _DEV_DIST = (
20
+ pathlib.Path(__file__).resolve().parent.parent.parent.parent
21
+ / 'rust-store'
22
+ / 'core-py'
23
+ / 'dist'
24
+ )
25
+
26
+
27
+ def _dev_fallback():
28
+ """开发期兜底:仅 LOCAL_CORE=1 且非 production 时启用,避免生产环境从相邻目录加载。"""
29
+ if os.environ.get('LOCAL_CORE') != '1' or os.environ.get('NODE_ENV') == 'production':
30
+ return None
31
+ if str(_DEV_DIST) not in sys.path:
32
+ sys.path.insert(0, str(_DEV_DIST))
33
+ try:
34
+ return importlib.import_module('rust_store_py')
35
+ except Exception as e: # noqa: BLE001
36
+ return {'__error': f'{_DEV_DIST}: {e}'}
37
+
38
+
39
+ def _load():
40
+ try:
41
+ return importlib.import_module('rust_store_py')
42
+ except ImportError as e:
43
+ fallback = _dev_fallback()
44
+ if fallback is not None and not isinstance(fallback, dict):
45
+ return fallback
46
+
47
+ hints = [f'rust_store_py: {e}']
48
+ if isinstance(fallback, dict):
49
+ hints.append(fallback['__error'])
50
+ raise ImportError(
51
+ '无法加载 rust-store 原生核心(rust-store-py 绑定产物)。\n'
52
+ '请安装 pip 依赖 rust-store-py;开发期如需从相邻 rust-store 仓库加载,\n'
53
+ "请设置 LOCAL_CORE=1(且 NODE_ENV != 'production')并在 rust-store 仓库构建:\n"
54
+ ' python -m maturin develop --manifest-path rust-store/core-py/Cargo.toml\n'
55
+ + '\n'.join(hints)
56
+ )
57
+
58
+
59
+ native = _load()
60
+
61
+ # Rust core 注册表(全项目共享单例;对齐 nodejs-store/src/schema.js 的 `new native.Registry()`)
62
+ core = native.Registry()
@@ -0,0 +1,20 @@
1
+ """
2
+ CRUD 包 —— 薄 Host 适配层(对齐 nodejs-store/src/crud.js 的分工)
3
+
4
+ 核心原则:
5
+ - 全部纯逻辑(GQL 解析、权限、命令规划、结果后处理)在 Rust core
6
+ - 本包只做 Host 三件事:命令执行(唯一 IO)、占位符替换、原生回调(asyncFn)
7
+ - 写入时不补默认值(DB 存最少数据);读取时由 core 补默认值 + 计算列
8
+ """
9
+
10
+ from .exec import _get_db, set_connections, set_db
11
+ from .mutation import aggregate, mutation, upsert
12
+ from .query import query, query_federated, query_one, query_with_count
13
+ from .write import count, exists, insert, insert_many, remove, update, update_many
14
+
15
+ __all__ = [
16
+ 'set_db', 'set_connections', '_get_db',
17
+ 'query', 'query_one', 'query_with_count', 'query_federated',
18
+ 'insert', 'insert_many', 'update', 'update_many', 'remove', 'exists', 'count',
19
+ 'mutation', 'upsert', 'aggregate',
20
+ ]
py_store/crud/exec.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ 命令执行(唯一 IO 边界) + 占位符替换 + core 调用包装
3
+
4
+ 全部纯逻辑(GQL 解析、权限、命令规划、结果后处理)都在 Rust core;
5
+ 本模块只做 Host 三件事里最底层的一件:把 core 产出的 Command JSON
6
+ 路由到对应数据源连接并执行。不确定性输入由本层供给(now 时钟、newId 随机 ID)。
7
+
8
+ 路由规则见 ``..datasource``:按命令的 ``collection`` 找 schema 绑定的数据源,
9
+ Mongo 走原生驱动,SQL 走 ``translate → exec``。对齐 ``nodejs-store/src/crud/exec.js``。
10
+ """
11
+
12
+ import re
13
+ import time
14
+
15
+ from .. import datasource as _datasource
16
+ from ..executors.mongo import exec_mongo as _exec_mongo
17
+ from ..permission import PermissionError, get_context
18
+
19
+ _PHASE1_IDS = re.compile(r'^\{\{phase1\.ids\}\}$')
20
+ _STEP_PH = re.compile(r'^\{\{step\.(\d+)\._id\}\}$')
21
+
22
+ # core 权限类错误消息 → PermissionError(消息与 core 常量保持一致)
23
+ _PERMISSION_MSGS = frozenset(['无访问权限', '无写入权限', '无删除权限', '无批量写入权限'])
24
+
25
+
26
+ def set_db(db):
27
+ """单库简写:等价于 ``set_connections({'default': db})``"""
28
+ _datasource.set_connections({_datasource.DEFAULT_SOURCE: db})
29
+
30
+
31
+ def set_connections(connections):
32
+ """设置数据源连接映射(见 ``..datasource.set_connections``)"""
33
+ _datasource.set_connections(connections)
34
+
35
+
36
+ def _get_db(source=None):
37
+ """取指定数据源连接(缺省 ``default``)"""
38
+ return _datasource.get_connection(source or _datasource.DEFAULT_SOURCE)
39
+
40
+
41
+ def _now():
42
+ """毫秒时间戳(Host 时钟源)"""
43
+ return int(time.time() * 1000)
44
+
45
+
46
+ def _ctx():
47
+ return get_context()
48
+
49
+
50
+ def _call(fn):
51
+ """绑定层调用包装:权限类错误映射为 PermissionError"""
52
+ try:
53
+ return fn()
54
+ except Exception as e: # noqa: BLE001
55
+ if str(e) in _PERMISSION_MSGS:
56
+ raise PermissionError(str(e)) from e
57
+ raise
58
+
59
+
60
+ # ─── 命令执行(唯一 IO 边界) ────────────────────────────────
61
+
62
+ async def _exec_on(source, cmd):
63
+ """在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec)"""
64
+ connection = _datasource.get_connection(source)
65
+ if _datasource.is_sql(connection):
66
+ return await _datasource.exec_sql(source, connection, cmd)
67
+ return await _exec_mongo(connection, cmd)
68
+
69
+
70
+ async def _exec(cmd):
71
+ """Command JSON → 按 collection 绑定路由到 Mongo 原生 / SQL 翻译执行"""
72
+ return await _exec_on(_datasource.source_of_collection(cmd['collection']), cmd)
73
+
74
+
75
+ def _substitute(value, resolver):
76
+ """深度替换命令中的占位符(命中 resolver 返回非字符串时替换)"""
77
+ if isinstance(value, str):
78
+ return resolver(value)
79
+ if isinstance(value, list):
80
+ return [_substitute(v, resolver) for v in value]
81
+ if isinstance(value, dict):
82
+ return {k: _substitute(v, resolver) for k, v in value.items()}
83
+ return value
84
+
85
+
86
+ def resolve_placeholders(command, ids=None, steps=None):
87
+ """Host 契约:把命令中的占位符替换为执行结果
88
+
89
+ - ``{{phase1.ids}}`` → 两阶段查询第一步取回的 id 数组(整值替换)
90
+ - ``{{step.<N>._id}}`` → mutation 第 N 步执行结果的 _id
91
+
92
+ 未命中的占位符原样保留(便于定位 core 与 Host 的契约漂移)。
93
+ Node 侧 ``nodejs-store/src/crud.js`` 的 ``resolvePlaceholders`` 为同语义实现,
94
+ 两侧共测 ``rust-store/fixtures/host/placeholders.json``。
95
+ """
96
+ steps = steps or []
97
+
98
+ def resolver(s):
99
+ if _PHASE1_IDS.match(s):
100
+ return ids if ids is not None else s
101
+ m = _STEP_PH.match(s)
102
+ if m:
103
+ idx = int(m.group(1))
104
+ if idx < len(steps):
105
+ return steps[idx]
106
+ return s
107
+
108
+ return _substitute(command, resolver)
py_store/crud/id.py ADDED
@@ -0,0 +1,67 @@
1
+ """ID 供给(Host 随机源) —— 与 core `needs_new_id` 语义对齐"""
2
+
3
+ import random
4
+ import string
5
+ import time
6
+
7
+ from ..schema import get as _get_schema
8
+
9
+ _ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789'
10
+ _BASE36_DIGITS = string.digits + string.ascii_lowercase
11
+
12
+
13
+ def _to_base36(n):
14
+ if n == 0:
15
+ return '0'
16
+ out = []
17
+ while n:
18
+ n, r = divmod(n, 36)
19
+ out.append(_BASE36_DIGITS[r])
20
+ return ''.join(reversed(out))
21
+
22
+
23
+ def _generate_id(schema):
24
+ """按 schema.idPrefix 生成唯一 ID(时间戳36进制 + 随机4位)"""
25
+ ts = _to_base36(int(time.time() * 1000)).upper()
26
+ rnd = ''.join(random.choice(_ID_CHARS).upper() for _ in range(4))
27
+ return schema['idPrefix'] + ts + rnd
28
+
29
+
30
+ def _truthy(v):
31
+ """对齐 core `is_truthy`(字符串仅判空,不 trim)"""
32
+ if v is None or v is False:
33
+ return False
34
+ if isinstance(v, bool):
35
+ return v
36
+ if isinstance(v, (int, float)):
37
+ return v != 0
38
+ if isinstance(v, str):
39
+ return len(v) > 0
40
+ return True
41
+
42
+
43
+ def _new_id_pool(schema_name, data):
44
+ """预生成 mutation 的 ID 池:按数据树逐节点判断是否需要新 _id
45
+
46
+ (与 core `needs_new_id` 一致:无有效 _id 且 schema 配了 idPrefix),
47
+ 保证游标消费顺序与节点顺序对齐(父子 schema 前缀不同也能取对 ID)。
48
+ """
49
+ pool = []
50
+
51
+ def walk(name, node):
52
+ s = _get_schema(name)
53
+ if not _truthy((node or {}).get('_id')) and s['idPrefix']:
54
+ pool.append(_generate_id(s))
55
+ for key, val in (node or {}).items():
56
+ rel = (s.get('relations') or {}).get(key)
57
+ if not rel or val is None:
58
+ continue
59
+ if isinstance(val, list):
60
+ for child in val:
61
+ if child is not None:
62
+ walk(rel.get('model'), child)
63
+ else:
64
+ walk(rel.get('model'), val)
65
+
66
+ walk(schema_name, data)
67
+ return pool
@@ -0,0 +1,63 @@
1
+ """Mutation / Upsert / 原生聚合 —— 规划步骤序列 → 依序执行 + 父子 _id 占位符回填"""
2
+
3
+ from ..schema import core as _core, get as _get_schema
4
+ from .exec import _call, _ctx, _exec, _now, resolve_placeholders
5
+ from .id import _generate_id, _new_id_pool
6
+
7
+
8
+ async def _mutation_one(schema_name, data):
9
+ """mutation 单条:规划步骤序列 → 依序执行 + 父子 _id 占位符回填"""
10
+ plan = _call(lambda: _core.plan_mutation(
11
+ schema_name, data, _now(), _new_id_pool(schema_name, data), _ctx()))
12
+
13
+ resolved = []
14
+ root_result = None
15
+ for i, step in enumerate(plan['steps']):
16
+ cmd = resolve_placeholders(step['command'], steps=resolved)
17
+ result = await _exec(cmd)
18
+ resolved.append(result.get('_id') if result else None)
19
+ if i == 0:
20
+ root_result = result # 首步即根写入
21
+
22
+ if not root_result:
23
+ return None
24
+ return _call(lambda: _core.apply_write_defaults(schema_name, root_result))
25
+
26
+
27
+ async def mutation(schema_name, data):
28
+ """
29
+ mutation — 智能持久化
30
+
31
+ 自动判断 upsert/insert,支持父子文档关联填充。
32
+ """
33
+ is_array = isinstance(data, list)
34
+ items = data if is_array else [data]
35
+
36
+ if not items:
37
+ return [] if is_array else None
38
+
39
+ results = []
40
+ for item in items:
41
+ results.append(await _mutation_one(schema_name, item))
42
+
43
+ return results if is_array else results[0]
44
+
45
+
46
+ async def upsert(schema_name, condition, data, options=None):
47
+ """
48
+ upsert — 显式条件 upsert
49
+
50
+ 与 mutation 不同,upsert 需要调用方显式提供 match 条件,不处理父子关系。
51
+ """
52
+ s = _get_schema(schema_name)
53
+ plan = _call(lambda: _core.plan_upsert(
54
+ schema_name, condition, data, options, _now(),
55
+ _generate_id(s) if s['idPrefix'] else '', _ctx()))
56
+ result = await _exec(plan['command'])
57
+ return _call(lambda: _core.apply_write_defaults(schema_name, result)) if result else None
58
+
59
+
60
+ async def aggregate(schema_name, pipeline):
61
+ """对指定 schema 执行 MongoDB 原生聚合查询"""
62
+ cmd = _call(lambda: _core.plan_aggregate(schema_name, pipeline or []))
63
+ return await _exec(cmd)
py_store/crud/query.py ADDED
@@ -0,0 +1,121 @@
1
+ """读路径 —— find 快路径 / 两阶段(取 ID → 关联 → 还原排序)/ 标准聚合 + asyncFn 尾处理
2
+ / 跨库联邦(逐源执行 → 内存 hash join)"""
3
+
4
+ import inspect
5
+ import sys
6
+
7
+ from ..schema import core as _core, get_async_fn
8
+ from .exec import _call, _ctx, _exec, _exec_on, resolve_placeholders
9
+
10
+
11
+ async def _run_query_plan(plan):
12
+ """执行读命令序列"""
13
+ if plan['mode'] == 'two_phase':
14
+ id_docs = await _exec(plan['commands'][0])
15
+ ids = [d['_id'] for d in id_docs]
16
+ if not ids:
17
+ return []
18
+ cmd2 = resolve_placeholders(plan['commands'][1], ids=ids)
19
+ items = await _exec(cmd2)
20
+ return _core.restore_sort_order(items, ids, plan.get('sort'))['items']
21
+ return await _exec(plan['commands'][0])
22
+
23
+
24
+ async def _finalize(plan, items):
25
+ """读路径尾处理两段式:core 后处理 → Host 执行 asyncFn → core 剥离注入依赖"""
26
+ post = plan.get('postprocess')
27
+ if not post:
28
+ return items
29
+ prepared = _core.prepare_query(post, items, _ctx())
30
+ for ref in prepared['fnRefs']:
31
+ fn = get_async_fn(ref)
32
+ if not fn:
33
+ raise RuntimeError(f'asyncFn 计算列 {ref} 未注册实现')
34
+ out = fn(prepared['items'], _ctx())
35
+ if inspect.isawaitable(out):
36
+ await out
37
+ return _core.strip_query(post, prepared['items'])['items']
38
+
39
+
40
+ async def query(gql, params=None):
41
+ """
42
+ GQL 查询(返回数组)
43
+
44
+ 支持的 params 键(通过 GQL 的 @key 引用):
45
+ $condition / $sort / $skip / $limit / $pipeline
46
+ 使用 $pipeline 时,框架不追加 compute 层、不补默认值、不裁剪,完全由用户控制。
47
+ """
48
+ plan = _call(lambda: _core.plan_query(
49
+ gql, params if params is not None else {}, _ctx()))
50
+ return await _finalize(plan, await _run_query_plan(plan))
51
+
52
+
53
+ async def query_one(gql, params=None):
54
+ """GQL 查询(返回单条)"""
55
+ items = await query(gql, params)
56
+ return items[0] if items else None
57
+
58
+
59
+ async def _run_federated_unit(unit):
60
+ """执行单个联邦取数单元(按 ``sources[].source`` 精确路由;two_phase 走两阶段)
61
+
62
+ 与单库 ``_run_query_plan`` 同形:只差路由键(单元自带 source,不按 collection 反查)。
63
+ """
64
+ commands = unit.get('commands') or []
65
+ if unit.get('mode') == 'two_phase':
66
+ id_docs = await _exec_on(unit['source'], commands[0])
67
+ ids = [d['_id'] for d in id_docs]
68
+ if not ids:
69
+ return []
70
+ cmd2 = resolve_placeholders(commands[1], ids=ids)
71
+ items = await _exec_on(unit['source'], cmd2)
72
+ return _core.restore_sort_order(items, ids, unit.get('sort'))['items']
73
+ return await _exec_on(unit['source'], commands[0])
74
+
75
+
76
+ async def query_federated(gql, params=None):
77
+ """
78
+ 跨库联邦查询(返回嵌套文档数组)
79
+
80
+ Host 四步:core ``plan_federated`` 拆源 → 逐源执行 → core ``merge_federated``
81
+ 内存 hash join → 统一后处理(``_finalize``,与单库同一路径)。
82
+
83
+ ``postprocess`` 取自根单元快照(含全部关系),因此结果形状与单库 ``query`` 完全一致。
84
+ 每源取数上限 ``MAX_FEDERATION_ROWS`` 由 core 强制(超限即报错,拒绝静默全表拉取);
85
+ 无法下推的分页/排序进 ``plan['degraded']`` 并告警,不阻断查询。
86
+ """
87
+ plan = _call(lambda: _core.plan_federated(
88
+ gql, params if params is not None else {}, _ctx()))
89
+
90
+ for d in plan.get('degraded') or []:
91
+ d = d or {}
92
+ print(f"[federation] 降级 {d.get('code') or ''}: {d.get('message') or ''}", file=sys.stderr)
93
+
94
+ results = []
95
+ for unit in plan.get('sources') or []:
96
+ results.append(await _run_federated_unit(unit))
97
+
98
+ merged = _call(lambda: _core.merge_federated(plan, results))
99
+ return await _finalize(plan, merged)
100
+
101
+
102
+ async def query_with_count(gql, params=None):
103
+ """
104
+ GQL 查询(返回 items + total + 分页元数据)
105
+
106
+ 支持两种分页参数方式:
107
+ 1. page/pageSize(推荐)— 自动计算 skip/limit,page 默认 0,pageSize 默认 50
108
+ 2. 传统 $skip/$limit — 从 GQL 参数推导 page/pageSize
109
+ pageSize 上限 5000,防止拖库。
110
+ """
111
+ plan = _call(lambda: _core.plan_query_with_count(
112
+ gql, params if params is not None else {}, _ctx(), None))
113
+ items = await _finalize(plan, await _run_query_plan(plan))
114
+ total = await _exec(plan['countCommand'])
115
+ return {
116
+ 'items': items,
117
+ 'total': total,
118
+ 'hasMore': (plan['page'] + 1) * plan['pageSize'] < total,
119
+ 'page': plan['page'],
120
+ 'pageSize': plan['pageSize'],
121
+ }