store-graphql-py 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.
- store_graphql_py-0.1.0/PKG-INFO +29 -0
- store_graphql_py-0.1.0/README.md +13 -0
- store_graphql_py-0.1.0/pyproject.toml +23 -0
- store_graphql_py-0.1.0/setup.cfg +4 -0
- store_graphql_py-0.1.0/src/store_graphql/__init__.py +26 -0
- store_graphql_py-0.1.0/src/store_graphql/adapter.py +576 -0
- store_graphql_py-0.1.0/src/store_graphql_py.egg-info/PKG-INFO +29 -0
- store_graphql_py-0.1.0/src/store_graphql_py.egg-info/SOURCES.txt +12 -0
- store_graphql_py-0.1.0/src/store_graphql_py.egg-info/dependency_links.txt +1 -0
- store_graphql_py-0.1.0/src/store_graphql_py.egg-info/requires.txt +9 -0
- store_graphql_py-0.1.0/src/store_graphql_py.egg-info/top_level.txt +1 -0
- store_graphql_py-0.1.0/tests/test_conformance.py +88 -0
- store_graphql_py-0.1.0/tests/test_e2e_real.py +89 -0
- store_graphql_py-0.1.0/tests/test_smoke.py +306 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-graphql-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GraphQL adapter that auto-generates standard GraphQL APIs for py-store schemas (schema-driven CRUD over GQL, projection pushdown)
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,graphql,crud,gql
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: graphql-core>=3.2
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
15
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# store-graphql-py
|
|
18
|
+
|
|
19
|
+
GraphQL adapter that auto-generates standard GraphQL APIs for **py-store** schemas (schema-driven CRUD over GQL, projection pushdown).
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from store_graphql import build_graphql_schema, create_app, export_sdl
|
|
23
|
+
|
|
24
|
+
schema = build_graphql_schema(store) # 反射 py-store 已注册的 JSON schema
|
|
25
|
+
app = create_app(store, schema=schema) # FastAPI 承载(可选依赖);或 graphql(schema, query) 自行执行
|
|
26
|
+
export_sdl(schema) # SDL 导出,喂客户端 codegen
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
完整文档(双语主文档 / spec / conformance):见仓库根 [README.zh-CN.md](https://github.com/coenddt/store-graphql#readme)。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# store-graphql-py
|
|
2
|
+
|
|
3
|
+
GraphQL adapter that auto-generates standard GraphQL APIs for **py-store** schemas (schema-driven CRUD over GQL, projection pushdown).
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from store_graphql import build_graphql_schema, create_app, export_sdl
|
|
7
|
+
|
|
8
|
+
schema = build_graphql_schema(store) # 反射 py-store 已注册的 JSON schema
|
|
9
|
+
app = create_app(store, schema=schema) # FastAPI 承载(可选依赖);或 graphql(schema, query) 自行执行
|
|
10
|
+
export_sdl(schema) # SDL 导出,喂客户端 codegen
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
完整文档(双语主文档 / spec / conformance):见仓库根 [README.zh-CN.md](https://github.com/coenddt/store-graphql#readme)。
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "store-graphql-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "GraphQL adapter that auto-generates standard GraphQL APIs for py-store schemas (schema-driven CRUD over GQL, projection pushdown)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
keywords = ["py-store", "storepy", "graphql", "crud", "gql"]
|
|
9
|
+
dependencies = ["graphql-core>=3.2"]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
fastapi = ["fastapi>=0.110"]
|
|
13
|
+
dev = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=68"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""store-graphql-py — 为 py-store 已注册 schema 自动生成标准 GraphQL API。
|
|
2
|
+
|
|
3
|
+
语义依据:../../spec/*.md(多端 parity,改动先改 spec)。
|
|
4
|
+
设计哲学:GraphQL 只是 GQL 的又一层 HTTP 皮 —— 适配层零语义发明。
|
|
5
|
+
|
|
6
|
+
分层:
|
|
7
|
+
build_graphql_schema(store, **opts) → 纯 graphql-core GraphQLSchema
|
|
8
|
+
create_app(store, **opts) → FastAPI 应用(可选依赖 fastapi)
|
|
9
|
+
export_sdl(schema) → SDL 字符串(喂客户端 codegen)
|
|
10
|
+
|
|
11
|
+
store 端口契约见 spec/00:list/get/query/insert/update/remove/set_context。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .adapter import (
|
|
15
|
+
build_graphql_schema,
|
|
16
|
+
create_app,
|
|
17
|
+
export_sdl,
|
|
18
|
+
filter_archived,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"build_graphql_schema",
|
|
23
|
+
"create_app",
|
|
24
|
+
"export_sdl",
|
|
25
|
+
"filter_archived",
|
|
26
|
+
]
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""adapter — py 端核心实现(语义依据 ../../spec/*.md,与 node 端镜像)。"""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from graphql import (
|
|
7
|
+
graphql,
|
|
8
|
+
parse,
|
|
9
|
+
GraphQLArgument,
|
|
10
|
+
GraphQLBoolean,
|
|
11
|
+
GraphQLField,
|
|
12
|
+
GraphQLFloat,
|
|
13
|
+
GraphQLID,
|
|
14
|
+
GraphQLInt,
|
|
15
|
+
GraphQLList,
|
|
16
|
+
GraphQLNonNull,
|
|
17
|
+
GraphQLObjectType,
|
|
18
|
+
GraphQLScalarType,
|
|
19
|
+
GraphQLSchema,
|
|
20
|
+
GraphQLString,
|
|
21
|
+
print_schema,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# GraphQL AST kind 字符串(graphql-core 3.x 的 node.kind 即字符串,无 Kind 常量类)
|
|
25
|
+
K_FIELD = "field"
|
|
26
|
+
K_INLINE_FRAGMENT = "inline_fragment"
|
|
27
|
+
K_FRAGMENT_SPREAD = "fragment_spread"
|
|
28
|
+
K_VARIABLE = "variable"
|
|
29
|
+
K_BOOLEAN = "boolean"
|
|
30
|
+
|
|
31
|
+
ARCHIVE_SUFFIX = "Deleted"
|
|
32
|
+
|
|
33
|
+
# spec/02 limit 守卫常量:core 的行数封顶仅 text2query 档生效(standard 档原样返回),
|
|
34
|
+
# 适配层守上界;调整先改 spec 再三端同步。
|
|
35
|
+
LIST_LIMIT_DEFAULT = 50
|
|
36
|
+
LIST_LIMIT_MAX = 1000
|
|
37
|
+
|
|
38
|
+
# spec/04 查询深度守卫:深度 = 从 operation 顶层 selectionSet 起的最大字段嵌套层数,
|
|
39
|
+
# FragmentSpread 按定义递归(visiting 防环);超限 HTTP 400 + ERR_DEPTH: 稳定前缀。
|
|
40
|
+
MAX_QUERY_DEPTH = 10
|
|
41
|
+
|
|
42
|
+
# spec/04 复杂度守卫:AST 字段节点总数(别名不单列——字段计数已覆盖别名堆叠)。
|
|
43
|
+
MAX_QUERY_FIELDS = 300
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def query_field_count(document):
|
|
47
|
+
"""字段计数 + introspection 使用检测(fragment 展开计入,防环;__typename 放行)。"""
|
|
48
|
+
fragments = {
|
|
49
|
+
d.name.value: d
|
|
50
|
+
for d in document.definitions
|
|
51
|
+
if d.kind == "fragment_definition"
|
|
52
|
+
}
|
|
53
|
+
out = {"fields": 0, "introspection_used": False}
|
|
54
|
+
|
|
55
|
+
def walk_sel_set(sel_set, visiting):
|
|
56
|
+
if sel_set is None:
|
|
57
|
+
return
|
|
58
|
+
for sel in sel_set.selections:
|
|
59
|
+
if sel.kind == K_FIELD:
|
|
60
|
+
out["fields"] += 1
|
|
61
|
+
name = sel.name.value
|
|
62
|
+
if not out["introspection_used"] and name in ("__schema", "__type"):
|
|
63
|
+
out["introspection_used"] = True
|
|
64
|
+
walk_sel_set(sel.selection_set, visiting)
|
|
65
|
+
elif sel.kind == K_INLINE_FRAGMENT:
|
|
66
|
+
walk_sel_set(sel.selection_set, visiting)
|
|
67
|
+
elif sel.kind == K_FRAGMENT_SPREAD:
|
|
68
|
+
if sel.name.value in visiting:
|
|
69
|
+
continue
|
|
70
|
+
frag = fragments.get(sel.name.value)
|
|
71
|
+
if frag:
|
|
72
|
+
walk_sel_set(frag.selection_set, visiting | {sel.name.value})
|
|
73
|
+
|
|
74
|
+
for d in document.definitions:
|
|
75
|
+
if d.kind == "operation_definition":
|
|
76
|
+
walk_sel_set(d.selection_set, set())
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def query_depth(document):
|
|
81
|
+
fragments = {
|
|
82
|
+
d.name.value: d
|
|
83
|
+
for d in document.definitions
|
|
84
|
+
if d.kind == "fragment_definition"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def depth_of_sel_set(sel_set, visiting):
|
|
88
|
+
if sel_set is None:
|
|
89
|
+
return 0
|
|
90
|
+
max_d = 0
|
|
91
|
+
for sel in sel_set.selections:
|
|
92
|
+
if sel.kind == K_FIELD:
|
|
93
|
+
d = 1 + depth_of_sel_set(sel.selection_set, visiting)
|
|
94
|
+
elif sel.kind == K_INLINE_FRAGMENT:
|
|
95
|
+
d = depth_of_sel_set(sel.selection_set, visiting)
|
|
96
|
+
elif sel.kind == K_FRAGMENT_SPREAD:
|
|
97
|
+
if sel.name.value in visiting:
|
|
98
|
+
continue
|
|
99
|
+
frag = fragments.get(sel.name.value)
|
|
100
|
+
d = (
|
|
101
|
+
depth_of_sel_set(frag.selection_set, visiting | {sel.name.value})
|
|
102
|
+
if frag
|
|
103
|
+
else 0
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
d = 0
|
|
107
|
+
if d > max_d:
|
|
108
|
+
max_d = d
|
|
109
|
+
return max_d
|
|
110
|
+
|
|
111
|
+
max_d = 0
|
|
112
|
+
for d in document.definitions:
|
|
113
|
+
if d.kind == "operation_definition":
|
|
114
|
+
od = depth_of_sel_set(d.selection_set, set())
|
|
115
|
+
if od > max_d:
|
|
116
|
+
max_d = od
|
|
117
|
+
return max_d
|
|
118
|
+
|
|
119
|
+
# spec/05:GraphiQL 文档页(CDN 版,GET /graphql 返回;POST 才执行查询)
|
|
120
|
+
GRAPHIQL_HTML = """<!doctype html>
|
|
121
|
+
<html lang="en">
|
|
122
|
+
<head>
|
|
123
|
+
<title>store-graphql GraphiQL</title>
|
|
124
|
+
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" />
|
|
125
|
+
<style>body { margin: 0; } #graphiql { height: 100vh; }</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<div id="graphiql">Loading GraphiQL...</div>
|
|
129
|
+
<script crossorigin src="https://unpkg.com/react/umd/react.production.min.js"></script>
|
|
130
|
+
<script crossorigin src="https://unpkg.com/react-dom/umd/react-dom.production.min.js"></script>
|
|
131
|
+
<script crossorigin src="https://unpkg.com/graphiql/graphiql.min.js"></script>
|
|
132
|
+
<script>
|
|
133
|
+
function graphQLFetcher(graphQLParams) {
|
|
134
|
+
return fetch('/graphql', {
|
|
135
|
+
method: 'post',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(graphQLParams),
|
|
138
|
+
}).then(function (r) { return r.json(); });
|
|
139
|
+
}
|
|
140
|
+
ReactDOM.createRoot(document.getElementById('graphiql')).render(
|
|
141
|
+
React.createElement(GraphiQL, { fetcher: graphQLFetcher })
|
|
142
|
+
);
|
|
143
|
+
</script>
|
|
144
|
+
</body>
|
|
145
|
+
</html>
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
# ── spec/01:归档表过滤(与 store-api 三端逐字一致)──
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def filter_archived(names):
|
|
152
|
+
s = set(names)
|
|
153
|
+
return [
|
|
154
|
+
n
|
|
155
|
+
for n in names
|
|
156
|
+
if not (n.endswith(ARCHIVE_SUFFIX) and n[: -len(ARCHIVE_SUFFIX)] in s)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── spec/01:JSON 标量(只接受 variables 传入)──
|
|
161
|
+
|
|
162
|
+
GraphQLJSON = GraphQLScalarType(
|
|
163
|
+
name="JSON",
|
|
164
|
+
description="任意 JSON 值(条件/排序/写入文档)。只接受 variables 形式(spec/02)。",
|
|
165
|
+
serialize=lambda v: v,
|
|
166
|
+
parse_value=lambda v: v,
|
|
167
|
+
parse_literal=lambda _node, *_a: (_ for _ in ()).throw(
|
|
168
|
+
ValueError("JSON 标量只接受 variables 传入(spec/02)")
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── spec/01:defn 字段类型 → GraphQL 输出类型 ──
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _scalar_for(type_name):
|
|
177
|
+
return {
|
|
178
|
+
"string": GraphQLString,
|
|
179
|
+
"datetime": GraphQLString,
|
|
180
|
+
"int": GraphQLInt,
|
|
181
|
+
"float": GraphQLFloat,
|
|
182
|
+
"bool": GraphQLBoolean,
|
|
183
|
+
}.get(type_name)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _out_type(field_defn, type_name):
|
|
187
|
+
if field_defn.get("type") == "object" and "fields" in field_defn:
|
|
188
|
+
return GraphQLObjectType(
|
|
189
|
+
name=type_name,
|
|
190
|
+
description=field_defn.get("description"), # spec/05:description 透传不改写
|
|
191
|
+
fields=lambda: _map_fields(field_defn["fields"], type_name),
|
|
192
|
+
)
|
|
193
|
+
return _scalar_for(field_defn.get("type")) or GraphQLJSON
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _map_fields(fields, type_name):
|
|
197
|
+
return {
|
|
198
|
+
k: GraphQLField(
|
|
199
|
+
GraphQLNonNull(GraphQLID) if k == "_id" else _out_type(v, f"{type_name}_{k}"),
|
|
200
|
+
description=v.get("description"), # spec/05:含 computes 与嵌套字段
|
|
201
|
+
)
|
|
202
|
+
for k, v in (fields or {}).items()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _model_fields(defn):
|
|
207
|
+
merged = dict(defn.get("fields") or {})
|
|
208
|
+
merged.update(defn.get("computes") or {}) # spec/01:computes 视同字段
|
|
209
|
+
return merged
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── spec/02:selection → GQL 投影串 ──
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _should_include(node, variables):
|
|
216
|
+
for d in node.directives or ():
|
|
217
|
+
if d.name.value not in ("skip", "include"):
|
|
218
|
+
continue
|
|
219
|
+
if_arg = next((a for a in d.arguments or () if a.name.value == "if"), None)
|
|
220
|
+
if if_arg is None:
|
|
221
|
+
continue
|
|
222
|
+
if if_arg.value.kind == K_VARIABLE:
|
|
223
|
+
val = (variables or {}).get(if_arg.value.name.value)
|
|
224
|
+
elif if_arg.value.kind == K_BOOLEAN:
|
|
225
|
+
val = if_arg.value.value
|
|
226
|
+
else:
|
|
227
|
+
continue
|
|
228
|
+
if val is None:
|
|
229
|
+
continue
|
|
230
|
+
if d.name.value == "skip" and val:
|
|
231
|
+
return False
|
|
232
|
+
if d.name.value == "include" and not val:
|
|
233
|
+
return False
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def projection_from_info(info):
|
|
238
|
+
root = next((n for n in info.field_nodes if n.kind == K_FIELD), None)
|
|
239
|
+
if root is None or root.selection_set is None:
|
|
240
|
+
return "_id"
|
|
241
|
+
variables = info.variable_values
|
|
242
|
+
if not isinstance(variables, dict): # graphql-core 3.3 的 VariableValues 对象,强制值在 .coerced
|
|
243
|
+
variables = getattr(variables, "coerced", None) or {}
|
|
244
|
+
|
|
245
|
+
def walk(sel):
|
|
246
|
+
if sel.kind == K_FIELD:
|
|
247
|
+
if not _should_include(sel, variables):
|
|
248
|
+
return None
|
|
249
|
+
head = sel.name.value # alias v0 忽略(spec/02)
|
|
250
|
+
if sel.selection_set:
|
|
251
|
+
inner = ", ".join(
|
|
252
|
+
filter(None, (walk(s) for s in sel.selection_set.selections))
|
|
253
|
+
)
|
|
254
|
+
if inner:
|
|
255
|
+
return f"{head} {{ {inner} }}"
|
|
256
|
+
return head
|
|
257
|
+
if sel.kind == K_INLINE_FRAGMENT:
|
|
258
|
+
return ", ".join(
|
|
259
|
+
filter(None, (walk(s) for s in sel.selection_set.selections))
|
|
260
|
+
)
|
|
261
|
+
if sel.kind == K_FRAGMENT_SPREAD:
|
|
262
|
+
frag = (info.fragments or {}).get(sel.name.value)
|
|
263
|
+
if frag is None:
|
|
264
|
+
return None
|
|
265
|
+
return ", ".join(
|
|
266
|
+
filter(None, (walk(s) for s in frag.selection_set.selections))
|
|
267
|
+
)
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
return ", ".join(filter(None, (walk(s) for s in root.selection_set.selections))) or "_id"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── spec/02:根 resolver(投影下推,无 N+1)──
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _make_get(store, name, id_field):
|
|
277
|
+
async def resolve(_src, info, *, id):
|
|
278
|
+
projection = projection_from_info(info)
|
|
279
|
+
rows = await store.query(
|
|
280
|
+
f"{name}($condition:@c0) {{ {projection} }}", {"c0": {id_field: id}}
|
|
281
|
+
)
|
|
282
|
+
return rows[0] if rows else None
|
|
283
|
+
|
|
284
|
+
return resolve
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _make_list(store, name):
|
|
288
|
+
async def resolve(_src, info, *, condition=None, sort=None, limit=None):
|
|
289
|
+
# spec/02 limit 守卫:缺省 50 防全表;超上限抛错(ERR_LIMIT: 稳定前缀),不静默截断
|
|
290
|
+
if limit is None:
|
|
291
|
+
limit = LIST_LIMIT_DEFAULT
|
|
292
|
+
if limit > LIST_LIMIT_MAX:
|
|
293
|
+
raise ValueError(f"ERR_LIMIT:list limit 上限 {LIST_LIMIT_MAX},收到 {limit}")
|
|
294
|
+
projection = projection_from_info(info)
|
|
295
|
+
head = name
|
|
296
|
+
parts, params = [], {}
|
|
297
|
+
if condition is not None:
|
|
298
|
+
parts.append("$condition:@c0")
|
|
299
|
+
params["c0"] = condition
|
|
300
|
+
if sort is not None:
|
|
301
|
+
parts.append("$sort:@s1")
|
|
302
|
+
params["s1"] = sort
|
|
303
|
+
parts.append("$limit:@l")
|
|
304
|
+
params["l"] = limit
|
|
305
|
+
head += f"({','.join(parts)})"
|
|
306
|
+
return await store.query(f"{head} {{ {projection} }}", params)
|
|
307
|
+
|
|
308
|
+
return resolve
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _make_create(store, name):
|
|
312
|
+
async def resolve(_src, _info, *, input):
|
|
313
|
+
return await store.insert(name, input)
|
|
314
|
+
|
|
315
|
+
return resolve
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _make_update(store, name, id_field):
|
|
319
|
+
async def resolve(_src, info, *, id, set):
|
|
320
|
+
await store.update(name, {id_field: id}, set)
|
|
321
|
+
projection = projection_from_info(info)
|
|
322
|
+
rows = await store.query(
|
|
323
|
+
f"{name}($condition:@c0) {{ {projection} }}", {"c0": {id_field: id}}
|
|
324
|
+
)
|
|
325
|
+
return rows[0] if rows else None
|
|
326
|
+
|
|
327
|
+
return resolve
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _make_delete(store, name, id_field):
|
|
331
|
+
async def resolve(_src, _info, *, id):
|
|
332
|
+
await store.remove(name, {id_field: id})
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
return resolve
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ── schema 构建 ──
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def build_graphql_schema(
|
|
342
|
+
store,
|
|
343
|
+
*,
|
|
344
|
+
resources=None,
|
|
345
|
+
overrides=None,
|
|
346
|
+
extensions=None,
|
|
347
|
+
id_field="_id",
|
|
348
|
+
):
|
|
349
|
+
overrides = dict(overrides or {})
|
|
350
|
+
extensions = dict(extensions or {})
|
|
351
|
+
names = resources if resources is not None else filter_archived(store.list())
|
|
352
|
+
|
|
353
|
+
query_fields = {}
|
|
354
|
+
mutation_fields = {}
|
|
355
|
+
used_override_keys = set()
|
|
356
|
+
|
|
357
|
+
def resolve_with_override(key, fallback):
|
|
358
|
+
fn = overrides.get(key)
|
|
359
|
+
if fn is not None:
|
|
360
|
+
used_override_keys.add(key)
|
|
361
|
+
return fn or fallback
|
|
362
|
+
|
|
363
|
+
for name in names:
|
|
364
|
+
defn = store.get(name)
|
|
365
|
+
xg = defn.get("x-graphql") or {}
|
|
366
|
+
if xg.get("hidden"): # spec/03 钩子 1:模型级 hidden
|
|
367
|
+
continue
|
|
368
|
+
model_type = GraphQLObjectType(
|
|
369
|
+
name=name,
|
|
370
|
+
description=defn.get("description"), # spec/05
|
|
371
|
+
# n=name 显式捕获:延迟 thunk 求值时循环变量已到末值,会造成嵌套类型重名
|
|
372
|
+
fields=lambda d=defn, n=name: _map_fields(_model_fields(d), n),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
query_fields[f"get_{name}"] = GraphQLField(
|
|
376
|
+
model_type,
|
|
377
|
+
args={"id": GraphQLArgument(GraphQLNonNull(GraphQLID))},
|
|
378
|
+
resolve=resolve_with_override(f"Query.get_{name}", _make_get(store, name, id_field)),
|
|
379
|
+
)
|
|
380
|
+
query_fields[f"list_{name}"] = GraphQLField(
|
|
381
|
+
GraphQLNonNull(GraphQLList(GraphQLNonNull(model_type))),
|
|
382
|
+
args={
|
|
383
|
+
"condition": GraphQLArgument(GraphQLJSON),
|
|
384
|
+
"sort": GraphQLArgument(GraphQLJSON),
|
|
385
|
+
"limit": GraphQLArgument(GraphQLInt),
|
|
386
|
+
},
|
|
387
|
+
resolve=resolve_with_override(f"Query.list_{name}", _make_list(store, name)),
|
|
388
|
+
)
|
|
389
|
+
if not xg.get("readonly"): # spec/03 钩子 1:模型级 readonly → 只出 Query
|
|
390
|
+
mutation_fields[f"create_{name}"] = GraphQLField(
|
|
391
|
+
model_type,
|
|
392
|
+
args={"input": GraphQLArgument(GraphQLNonNull(GraphQLJSON))},
|
|
393
|
+
resolve=resolve_with_override(
|
|
394
|
+
f"Mutation.create_{name}", _make_create(store, name)
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
mutation_fields[f"update_{name}"] = GraphQLField(
|
|
398
|
+
model_type,
|
|
399
|
+
args={
|
|
400
|
+
"id": GraphQLArgument(GraphQLNonNull(GraphQLID)),
|
|
401
|
+
"set": GraphQLArgument(GraphQLNonNull(GraphQLJSON)),
|
|
402
|
+
},
|
|
403
|
+
resolve=resolve_with_override(
|
|
404
|
+
f"Mutation.update_{name}", _make_update(store, name, id_field)
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
mutation_fields[f"delete_{name}"] = GraphQLField(
|
|
408
|
+
GraphQLNonNull(GraphQLBoolean),
|
|
409
|
+
args={"id": GraphQLArgument(GraphQLNonNull(GraphQLID))},
|
|
410
|
+
resolve=resolve_with_override(
|
|
411
|
+
f"Mutation.delete_{name}", _make_delete(store, name, id_field)
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# spec/03 钩子 3:extend(SDL 同步追加)
|
|
416
|
+
for key, fields in extensions.items():
|
|
417
|
+
if key == "Query":
|
|
418
|
+
query_fields.update(fields)
|
|
419
|
+
elif key == "Mutation":
|
|
420
|
+
mutation_fields.update(fields)
|
|
421
|
+
else:
|
|
422
|
+
raise ValueError(f'extensions 仅支持 Query/Mutation,收到 "{key}"(spec/03)')
|
|
423
|
+
|
|
424
|
+
# spec/03:override 键构建期校验,未知路径报错不静默
|
|
425
|
+
for key in overrides:
|
|
426
|
+
if key not in used_override_keys:
|
|
427
|
+
raise ValueError(f'override 路径 "{key}" 未命中任何生成字段(spec/03)')
|
|
428
|
+
|
|
429
|
+
return GraphQLSchema(
|
|
430
|
+
query=GraphQLObjectType(name="Query", fields=lambda: query_fields),
|
|
431
|
+
mutation=(
|
|
432
|
+
GraphQLObjectType(name="Mutation", fields=lambda: mutation_fields)
|
|
433
|
+
if mutation_fields
|
|
434
|
+
else None
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def export_sdl(schema):
|
|
440
|
+
"""spec/01:SDL 导出(喂客户端 codegen)。"""
|
|
441
|
+
return print_schema(schema)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ── spec/04:HTTP 承载(FastAPI,可选依赖)──
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def create_app(
|
|
448
|
+
store,
|
|
449
|
+
*,
|
|
450
|
+
schema=None,
|
|
451
|
+
path="/graphql",
|
|
452
|
+
context_provider=None,
|
|
453
|
+
permission_error=None,
|
|
454
|
+
resources=None,
|
|
455
|
+
overrides=None,
|
|
456
|
+
extensions=None,
|
|
457
|
+
id_field="_id",
|
|
458
|
+
max_query_depth=None,
|
|
459
|
+
max_query_fields=None,
|
|
460
|
+
introspection=True,
|
|
461
|
+
):
|
|
462
|
+
try:
|
|
463
|
+
from fastapi import FastAPI, Request
|
|
464
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
465
|
+
except ImportError as e: # noqa: F841 — 报错信息自身已含原因
|
|
466
|
+
raise ImportError(
|
|
467
|
+
"create_app 需要安装 fastapi:pip install 'store-graphql-py[fastapi]';"
|
|
468
|
+
"或仅用 build_graphql_schema 自行承载"
|
|
469
|
+
) from e
|
|
470
|
+
|
|
471
|
+
app = FastAPI(title="store-graphql")
|
|
472
|
+
gql_schema = schema or build_graphql_schema(
|
|
473
|
+
store,
|
|
474
|
+
resources=resources,
|
|
475
|
+
overrides=overrides,
|
|
476
|
+
extensions=extensions,
|
|
477
|
+
id_field=id_field,
|
|
478
|
+
)
|
|
479
|
+
if permission_error is None:
|
|
480
|
+
permission_error = getattr(store, "PermissionError", None)
|
|
481
|
+
|
|
482
|
+
@app.get(path)
|
|
483
|
+
async def graphiql_page():
|
|
484
|
+
# spec/05:GET 返回 GraphiQL 文档页(执行走 POST;GET 查询执行列 v1)
|
|
485
|
+
return HTMLResponse(GRAPHIQL_HTML)
|
|
486
|
+
|
|
487
|
+
@app.post(path)
|
|
488
|
+
async def graphql_endpoint(request: Request):
|
|
489
|
+
raw = await request.body()
|
|
490
|
+
# spec/04:请求体上限 1MB(对齐 store-api 三端,防大 body 撑内存)
|
|
491
|
+
if len(raw) > (1 << 20):
|
|
492
|
+
return JSONResponse(
|
|
493
|
+
status_code=413,
|
|
494
|
+
content={"errors": [{"message": "请求体超过 1MB 上限(spec/04)"}]},
|
|
495
|
+
)
|
|
496
|
+
try:
|
|
497
|
+
body = json.loads(raw) if raw else {}
|
|
498
|
+
if not isinstance(body, dict):
|
|
499
|
+
raise ValueError("body 不是 JSON 对象")
|
|
500
|
+
except Exception: # noqa: BLE001 — 非 JSON 请求体按 400 明确反馈,不静默
|
|
501
|
+
return JSONResponse(
|
|
502
|
+
status_code=400, content={"errors": [{"message": "请求体必须是 JSON"}]}
|
|
503
|
+
)
|
|
504
|
+
if context_provider is not None:
|
|
505
|
+
try:
|
|
506
|
+
ctx = context_provider(request)
|
|
507
|
+
if inspect.isawaitable(ctx):
|
|
508
|
+
ctx = await ctx
|
|
509
|
+
except Exception as e: # noqa: BLE001 — spec/04:权限类 ⇒ 403;其余 ⇒ 401
|
|
510
|
+
status = 403 if (permission_error and isinstance(e, permission_error)) else 401
|
|
511
|
+
return JSONResponse(
|
|
512
|
+
status_code=status,
|
|
513
|
+
content={"errors": [{"message": str(e) or "CONTEXT_ERROR"}]},
|
|
514
|
+
)
|
|
515
|
+
# spec/04:None 同样显式注入(清除语义必须落地,防身份跨请求残留)
|
|
516
|
+
store.set_context(ctx)
|
|
517
|
+
# spec/04 深度/复杂度/introspection 守卫:执行前独立 parse 检查
|
|
518
|
+
# (语法错不在此拦,维持执行器原路径;双 parse 成本微秒级,如实标注 spec/04)
|
|
519
|
+
depth_limit = MAX_QUERY_DEPTH if max_query_depth is None else max_query_depth
|
|
520
|
+
fields_limit = MAX_QUERY_FIELDS if max_query_fields is None else max_query_fields
|
|
521
|
+
query_str = body.get("query") or ""
|
|
522
|
+
try:
|
|
523
|
+
doc = parse(query_str)
|
|
524
|
+
except Exception: # noqa: BLE001 — 语法解析失败交给执行器原路径报错
|
|
525
|
+
doc = None
|
|
526
|
+
if doc is not None:
|
|
527
|
+
depth = query_depth(doc)
|
|
528
|
+
if depth > depth_limit:
|
|
529
|
+
return JSONResponse(
|
|
530
|
+
status_code=400,
|
|
531
|
+
content={
|
|
532
|
+
"errors": [
|
|
533
|
+
{
|
|
534
|
+
"message": f"ERR_DEPTH:查询深度 {depth} 超过上限 {depth_limit}",
|
|
535
|
+
"extensions": {"code": "ERR_DEPTH"},
|
|
536
|
+
}
|
|
537
|
+
]
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
usage = query_field_count(doc)
|
|
541
|
+
if usage["introspection_used"] and not introspection:
|
|
542
|
+
return JSONResponse(
|
|
543
|
+
status_code=400,
|
|
544
|
+
content={
|
|
545
|
+
"errors": [
|
|
546
|
+
{
|
|
547
|
+
"message": "ERR_INTROSPECTION:introspection 已禁用",
|
|
548
|
+
"extensions": {"code": "ERR_INTROSPECTION"},
|
|
549
|
+
}
|
|
550
|
+
]
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
if usage["fields"] > fields_limit:
|
|
554
|
+
return JSONResponse(
|
|
555
|
+
status_code=400,
|
|
556
|
+
content={
|
|
557
|
+
"errors": [
|
|
558
|
+
{
|
|
559
|
+
"message": f"ERR_COMPLEXITY:查询字段数 {usage['fields']} 超过上限 {fields_limit}",
|
|
560
|
+
"extensions": {"code": "ERR_COMPLEXITY"},
|
|
561
|
+
}
|
|
562
|
+
]
|
|
563
|
+
},
|
|
564
|
+
)
|
|
565
|
+
result = await graphql(
|
|
566
|
+
gql_schema,
|
|
567
|
+
query_str,
|
|
568
|
+
variable_values=body.get("variables"),
|
|
569
|
+
operation_name=body.get("operationName"),
|
|
570
|
+
)
|
|
571
|
+
payload = {"data": result.data}
|
|
572
|
+
if result.errors:
|
|
573
|
+
payload["errors"] = [e.formatted for e in result.errors]
|
|
574
|
+
return JSONResponse(payload)
|
|
575
|
+
|
|
576
|
+
return app
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-graphql-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GraphQL adapter that auto-generates standard GraphQL APIs for py-store schemas (schema-driven CRUD over GQL, projection pushdown)
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,graphql,crud,gql
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: graphql-core>=3.2
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
15
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# store-graphql-py
|
|
18
|
+
|
|
19
|
+
GraphQL adapter that auto-generates standard GraphQL APIs for **py-store** schemas (schema-driven CRUD over GQL, projection pushdown).
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from store_graphql import build_graphql_schema, create_app, export_sdl
|
|
23
|
+
|
|
24
|
+
schema = build_graphql_schema(store) # 反射 py-store 已注册的 JSON schema
|
|
25
|
+
app = create_app(store, schema=schema) # FastAPI 承载(可选依赖);或 graphql(schema, query) 自行执行
|
|
26
|
+
export_sdl(schema) # SDL 导出,喂客户端 codegen
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
完整文档(双语主文档 / spec / conformance):见仓库根 [README.zh-CN.md](https://github.com/coenddt/store-graphql#readme)。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/store_graphql/__init__.py
|
|
4
|
+
src/store_graphql/adapter.py
|
|
5
|
+
src/store_graphql_py.egg-info/PKG-INFO
|
|
6
|
+
src/store_graphql_py.egg-info/SOURCES.txt
|
|
7
|
+
src/store_graphql_py.egg-info/dependency_links.txt
|
|
8
|
+
src/store_graphql_py.egg-info/requires.txt
|
|
9
|
+
src/store_graphql_py.egg-info/top_level.txt
|
|
10
|
+
tests/test_conformance.py
|
|
11
|
+
tests/test_e2e_real.py
|
|
12
|
+
tests/test_smoke.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_graphql
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""conformance runner — 直接消费 ../../conformance/cases/*.json 执行断言。
|
|
2
|
+
|
|
3
|
+
三端 runner 同构:同一份用例,同一套 expect 契约(dataKeys/gql/paramsLimit/errorCodePrefix)。
|
|
4
|
+
语义变更必须先改 spec/,再改 cases/,再三端 runner——三者不一致即为缺陷。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from graphql import execute as gql_execute, parse
|
|
12
|
+
|
|
13
|
+
from store_graphql import build_graphql_schema
|
|
14
|
+
|
|
15
|
+
CASE = json.loads(
|
|
16
|
+
(Path(__file__).resolve().parents[2] / "conformance" / "cases" / "users-graphql.json").read_text(
|
|
17
|
+
encoding="utf-8"
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CaseStore:
|
|
23
|
+
def __init__(self, schema_defn):
|
|
24
|
+
self.defn = schema_defn
|
|
25
|
+
self.rows = []
|
|
26
|
+
self.gql_log = []
|
|
27
|
+
|
|
28
|
+
def list(self):
|
|
29
|
+
return [self.defn["name"]]
|
|
30
|
+
|
|
31
|
+
def get(self, _name):
|
|
32
|
+
return self.defn
|
|
33
|
+
|
|
34
|
+
async def query(self, gql, params=None):
|
|
35
|
+
self.gql_log.append({"gql": gql, "params": params})
|
|
36
|
+
cond = (params or {}).get("c0")
|
|
37
|
+
return [r for r in self.rows if not cond or all(r.get(k) == v for k, v in cond.items())]
|
|
38
|
+
|
|
39
|
+
async def insert(self, _name, data):
|
|
40
|
+
doc = {"_id": "u1", **data}
|
|
41
|
+
self.rows.append(doc)
|
|
42
|
+
return doc
|
|
43
|
+
|
|
44
|
+
async def update(self, _name, cond, data):
|
|
45
|
+
row = next(r for r in self.rows if all(r.get(k) == v for k, v in cond.items()))
|
|
46
|
+
row.update(data)
|
|
47
|
+
return row
|
|
48
|
+
|
|
49
|
+
async def remove(self, _name, cond):
|
|
50
|
+
self.rows = [r for r in self.rows if not all(r.get(k) == v for k, v in cond.items())]
|
|
51
|
+
|
|
52
|
+
def set_context(self, _ctx):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_conformance():
|
|
57
|
+
store = CaseStore(CASE["schema"])
|
|
58
|
+
schema = build_graphql_schema(store)
|
|
59
|
+
|
|
60
|
+
async def run_step(step):
|
|
61
|
+
result = await gql_execute(
|
|
62
|
+
schema, parse(step["query"]), variable_values=step.get("variables") or {}
|
|
63
|
+
)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
async def run_all():
|
|
67
|
+
for i, step in enumerate(CASE["steps"]):
|
|
68
|
+
result = await run_step(step)
|
|
69
|
+
expect = step["expect"]
|
|
70
|
+
errors = result.errors or ()
|
|
71
|
+
if expect.get("errorCodePrefix"):
|
|
72
|
+
assert errors, f"step#{i} 应抛 {expect['errorCodePrefix']}"
|
|
73
|
+
assert errors[0].message.startswith(expect["errorCodePrefix"]), (
|
|
74
|
+
f"step#{i} 错误前缀不符: {errors[0].message}"
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
assert not errors, f"step#{i} 不应有错误: {[e.message for e in errors]}"
|
|
78
|
+
data = result.data or {}
|
|
79
|
+
for key in expect.get("dataKeys") or []:
|
|
80
|
+
assert key in data, f"step#{i} data 缺少 {key}"
|
|
81
|
+
if "gql" in expect:
|
|
82
|
+
assert store.gql_log[-1]["gql"] == expect["gql"], f"step#{i} 投影下推串不符"
|
|
83
|
+
if "paramsLimit" in expect:
|
|
84
|
+
assert store.gql_log[-1]["params"]["l"] == expect["paramsLimit"], (
|
|
85
|
+
f"step#{i} params.l 不符"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
asyncio.run(run_all())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""e2e(真实库)— py-store(真实 rust core)+ aiosqlite 内存库跑 GraphQL 全链路。
|
|
2
|
+
|
|
3
|
+
验证点:适配层契约(store.list()/get() 真实返回结构)、投影下推串被真实 core 接受、
|
|
4
|
+
CRUD 全链路真实落库(含归档编排)。依赖缺失 ⇒ pytest.skip 显式标注(不静默)。
|
|
5
|
+
|
|
6
|
+
真实契约点(与 node 端 e2e 同款,出处见各断言注释):
|
|
7
|
+
- SQL 源须传 executors.create_connection('sqlite', db) 描述符,裸传驱动实例被当 Mongo
|
|
8
|
+
- py-store 不写 DDL,generate_ddl 纯文本由应用执行(aiosqlite 的 executescript 须 await)
|
|
9
|
+
- 归档表 UserDeleted 由 core 自动注册,remove 的归档编排需要其 DDL
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from store_graphql import build_graphql_schema
|
|
17
|
+
|
|
18
|
+
pytest.importorskip("py_store", reason="py-store 未安装(pip install -e py-store)")
|
|
19
|
+
pytest.importorskip("aiosqlite", reason="aiosqlite 未安装(pip install aiosqlite)")
|
|
20
|
+
|
|
21
|
+
DEFN = {
|
|
22
|
+
"name": "User",
|
|
23
|
+
"collection": "users",
|
|
24
|
+
"idPrefix": "u",
|
|
25
|
+
"fields": {
|
|
26
|
+
"_id": {"type": "string"},
|
|
27
|
+
"name": {"type": "string"},
|
|
28
|
+
"age": {"type": "int"},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _run_case():
|
|
34
|
+
import aiosqlite
|
|
35
|
+
import py_store
|
|
36
|
+
|
|
37
|
+
async with aiosqlite.connect(":memory:") as db:
|
|
38
|
+
conn = py_store.executors.create_connection("sqlite", db)
|
|
39
|
+
await py_store.init({"default": conn})
|
|
40
|
+
py_store.store.register(DEFN)
|
|
41
|
+
# py-store 不写 DDL;aiosqlite 的 executescript 是协程,必须 await
|
|
42
|
+
await db.executescript(py_store.store.generate_ddl("sqlite", ["User"]))
|
|
43
|
+
await db.executescript(py_store.store.generate_ddl("sqlite", ["UserDeleted"]))
|
|
44
|
+
|
|
45
|
+
schema = build_graphql_schema(py_store.store)
|
|
46
|
+
|
|
47
|
+
async def run(query, variable_values=None):
|
|
48
|
+
from graphql import parse, execute as gql_execute
|
|
49
|
+
|
|
50
|
+
result = await gql_execute(
|
|
51
|
+
schema, parse(query), variable_values=variable_values or {}
|
|
52
|
+
)
|
|
53
|
+
if result.errors:
|
|
54
|
+
raise AssertionError(f"GraphQL 执行失败: {[e.message for e in result.errors]}")
|
|
55
|
+
return result.data
|
|
56
|
+
|
|
57
|
+
# create:真实 core 生成 _id + timestamps,真实落库
|
|
58
|
+
created = await run(
|
|
59
|
+
"mutation($input: JSON!) { create_User(input: $input) { _id name age } }",
|
|
60
|
+
{"input": {"name": "alice", "age": 30}},
|
|
61
|
+
)
|
|
62
|
+
real_id = created["create_User"]["_id"]
|
|
63
|
+
assert real_id.startswith("u"), f"应返回真实生成的 u 前缀 _id,实际 {real_id}"
|
|
64
|
+
|
|
65
|
+
# list:投影下推串经真实 core 解析并走 SQLite
|
|
66
|
+
listed = await run("{ list_User { _id name } }")
|
|
67
|
+
assert len(listed["list_User"]) == 1
|
|
68
|
+
assert listed["list_User"][0]["name"] == "alice"
|
|
69
|
+
|
|
70
|
+
# get:条件投影
|
|
71
|
+
got = await run(f'{{ get_User(id: "{real_id}") {{ name age }} }}')
|
|
72
|
+
assert got["get_User"] == {"name": "alice", "age": 30}
|
|
73
|
+
|
|
74
|
+
# update:写入 + 回读
|
|
75
|
+
updated = await run(
|
|
76
|
+
"mutation($id: ID!, $set: JSON!) { update_User(id: $id, set: $set) { name age } }",
|
|
77
|
+
{"id": real_id, "set": {"age": 31}},
|
|
78
|
+
)
|
|
79
|
+
assert updated["update_User"]["age"] == 31
|
|
80
|
+
|
|
81
|
+
# delete:归档编排(deletedCount + archivedCount)后列表为空
|
|
82
|
+
deleted = await run(f'mutation {{ delete_User(id: "{real_id}") }}')
|
|
83
|
+
assert deleted["delete_User"] is True
|
|
84
|
+
after = await run("{ list_User { _id } }")
|
|
85
|
+
assert after["list_User"] == []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_e2e_real_sqlite():
|
|
89
|
+
asyncio.run(_run_case())
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""冒烟测试 — mock store(不连真实库,不依赖 rust core 绑定)。
|
|
2
|
+
|
|
3
|
+
用例语义与 ../../conformance/cases/users-graphql.json 及 node 端 smoke.test.js 对齐。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from graphql import GraphQLBoolean, GraphQLField, graphql
|
|
10
|
+
|
|
11
|
+
from store_graphql import build_graphql_schema, export_sdl, filter_archived
|
|
12
|
+
|
|
13
|
+
DEFN = {
|
|
14
|
+
"name": "User",
|
|
15
|
+
"description": "用户表:平台账号主档",
|
|
16
|
+
"fields": {
|
|
17
|
+
"_id": {"type": "string", "description": "主键,u 前缀"},
|
|
18
|
+
"name": {"type": "string"},
|
|
19
|
+
"age": {"type": "int"},
|
|
20
|
+
"profile": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"description": "个人资料",
|
|
23
|
+
"fields": {"bio": {"type": "string"}},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MockStore:
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.rows = []
|
|
32
|
+
self.gql_log = []
|
|
33
|
+
|
|
34
|
+
def list(self):
|
|
35
|
+
return ["User"]
|
|
36
|
+
|
|
37
|
+
def get(self, _name):
|
|
38
|
+
return DEFN
|
|
39
|
+
|
|
40
|
+
async def query(self, gql, params=None):
|
|
41
|
+
self.gql_log.append({"gql": gql, "params": params})
|
|
42
|
+
cond = (params or {}).get("c0")
|
|
43
|
+
return [
|
|
44
|
+
r
|
|
45
|
+
for r in self.rows
|
|
46
|
+
if not cond or all(r.get(k) == v for k, v in cond.items())
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
async def insert(self, _name, data):
|
|
50
|
+
doc = {"_id": f"u{len(self.rows) + 1}", **data}
|
|
51
|
+
self.rows.append(doc)
|
|
52
|
+
return doc
|
|
53
|
+
|
|
54
|
+
async def update(self, _name, cond, data):
|
|
55
|
+
row = next(r for r in self.rows if all(r.get(k) == v for k, v in cond.items()))
|
|
56
|
+
row.update(data)
|
|
57
|
+
return row
|
|
58
|
+
|
|
59
|
+
async def remove(self, _name, cond):
|
|
60
|
+
self.rows = [r for r in self.rows if not all(r.get(k) == v for k, v in cond.items())]
|
|
61
|
+
|
|
62
|
+
def set_context(self, _ctx):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def run(schema, query, variable_values=None):
|
|
67
|
+
result = await graphql(schema, query, variable_values=variable_values)
|
|
68
|
+
if result.errors:
|
|
69
|
+
raise AssertionError(f"GraphQL 执行失败: {[e.message for e in result.errors]}")
|
|
70
|
+
return result.data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_filter_archived():
|
|
74
|
+
assert filter_archived(["User", "UserDeleted", "Log"]) == ["User", "Log"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_sdl_generation():
|
|
78
|
+
sdl = export_sdl(build_graphql_schema(MockStore()))
|
|
79
|
+
assert "type User {" in sdl
|
|
80
|
+
assert "get_User(id: ID!): User" in sdl
|
|
81
|
+
assert "list_User(condition: JSON, sort: JSON, limit: Int): [User!]!" in sdl
|
|
82
|
+
assert "create_User(input: JSON!): User" in sdl
|
|
83
|
+
assert "profile: User_profile" in sdl
|
|
84
|
+
# description 管道(spec/05):模型/字段/嵌套类型三层透传
|
|
85
|
+
assert '"""用户表:平台账号主档"""' in sdl
|
|
86
|
+
assert '"""主键,u 前缀"""' in sdl
|
|
87
|
+
assert '"""个人资料"""' in sdl
|
|
88
|
+
assert "SecretLog" not in sdl
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_create_list_pushdown():
|
|
92
|
+
store = MockStore()
|
|
93
|
+
schema = build_graphql_schema(store)
|
|
94
|
+
created = asyncio.run(
|
|
95
|
+
run(
|
|
96
|
+
schema,
|
|
97
|
+
"mutation($input: JSON!) { create_User(input: $input) { _id name age } }",
|
|
98
|
+
{"input": {"name": "a", "age": 1}},
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
assert created["create_User"]["name"] == "a"
|
|
102
|
+
|
|
103
|
+
listed = asyncio.run(
|
|
104
|
+
run(
|
|
105
|
+
schema,
|
|
106
|
+
"query($c: JSON, $l: Int) { list_User(condition: $c, limit: $l) { name profile { bio } } }",
|
|
107
|
+
{"c": {"name": "a"}, "l": 10},
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
assert len(listed["list_User"]) == 1
|
|
111
|
+
assert listed["list_User"][0]["name"] == "a"
|
|
112
|
+
|
|
113
|
+
# 投影下推断言:selection 编入 GQL 串,参数按需拼接(spec/02 映射表)
|
|
114
|
+
last = store.gql_log[-1]
|
|
115
|
+
assert last["gql"] == "User($condition:@c0,$limit:@l) { name, profile { bio } }"
|
|
116
|
+
assert last["params"] == {"c0": {"name": "a"}, "l": 10}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_get_update_delete():
|
|
120
|
+
store = MockStore()
|
|
121
|
+
schema = build_graphql_schema(store)
|
|
122
|
+
asyncio.run(
|
|
123
|
+
run(schema, "mutation($i: JSON!) { create_User(input: $i) { _id } }", {"i": {"name": "b"}})
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
got = asyncio.run(run(schema, '{ get_User(id: "u1") { name } }'))
|
|
127
|
+
assert got["get_User"]["name"] == "b"
|
|
128
|
+
|
|
129
|
+
updated = asyncio.run(
|
|
130
|
+
run(
|
|
131
|
+
schema,
|
|
132
|
+
"mutation($id: ID!, $set: JSON!) { update_User(id: $id, set: $set) { name age } }",
|
|
133
|
+
{"id": "u1", "set": {"age": 2}},
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
assert updated["update_User"]["age"] == 2
|
|
137
|
+
|
|
138
|
+
deleted = asyncio.run(run(schema, 'mutation { delete_User(id: "u1") }'))
|
|
139
|
+
assert deleted["delete_User"] is True
|
|
140
|
+
listed = asyncio.run(run(schema, "{ list_User { _id } }"))
|
|
141
|
+
assert listed["list_User"] == []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_skip_and_fragment():
|
|
145
|
+
store = MockStore()
|
|
146
|
+
schema = build_graphql_schema(store)
|
|
147
|
+
asyncio.run(
|
|
148
|
+
run(schema, "mutation($i: JSON!) { create_User(input: $i) { _id } }", {"i": {"name": "c"}})
|
|
149
|
+
)
|
|
150
|
+
asyncio.run(
|
|
151
|
+
run(
|
|
152
|
+
schema,
|
|
153
|
+
"query($noAge: Boolean!) { list_User { name age @skip(if: $noAge) ...U } }"
|
|
154
|
+
"fragment U on User { name }",
|
|
155
|
+
{"noAge": True},
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
assert store.gql_log[-1]["gql"] == "User($limit:@l) { name, name }"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_limit_guard():
|
|
162
|
+
# spec/02:缺省 50 / 超限 ERR_LIMIT: / 边界 1000
|
|
163
|
+
store = MockStore()
|
|
164
|
+
schema = build_graphql_schema(store)
|
|
165
|
+
asyncio.run(run(schema, "{ list_User { _id } }"))
|
|
166
|
+
last = store.gql_log[-1]
|
|
167
|
+
assert last["gql"] == "User($limit:@l) { _id }"
|
|
168
|
+
assert last["params"]["l"] == 50
|
|
169
|
+
|
|
170
|
+
asyncio.run(run(schema, "query($l: Int) { list_User(limit: $l) { _id } }", {"l": 1000}))
|
|
171
|
+
assert store.gql_log[-1]["params"]["l"] == 1000
|
|
172
|
+
|
|
173
|
+
result = asyncio.run(
|
|
174
|
+
graphql(schema, "query($l: Int) { list_User(limit: $l) { _id } }", variable_values={"l": 1001})
|
|
175
|
+
)
|
|
176
|
+
assert result.errors and "ERR_LIMIT:" in result.errors[0].message
|
|
177
|
+
assert result.data is None # list_User 非空 ⇒ 错误冒泡至根,data 整体为 None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_annotations_hidden_readonly():
|
|
181
|
+
store = MockStore()
|
|
182
|
+
hidden = {**DEFN, "name": "SecretLog", "x-graphql": {"hidden": True}}
|
|
183
|
+
readonly = {**DEFN, "name": "AuditEvent", "x-graphql": {"readonly": True}}
|
|
184
|
+
store.list = lambda: ["User", "SecretLog", "AuditEvent"]
|
|
185
|
+
store.get = lambda n: {"User": DEFN, "SecretLog": hidden, "AuditEvent": readonly}[n]
|
|
186
|
+
sdl = export_sdl(build_graphql_schema(store))
|
|
187
|
+
assert "SecretLog" not in sdl
|
|
188
|
+
assert "get_AuditEvent" in sdl
|
|
189
|
+
assert "create_AuditEvent" not in sdl
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_graphiql_page():
|
|
193
|
+
# spec/05:GET /graphql 返回 GraphiQL 文档页
|
|
194
|
+
from fastapi.testclient import TestClient
|
|
195
|
+
|
|
196
|
+
from store_graphql import create_app
|
|
197
|
+
|
|
198
|
+
client = TestClient(create_app(MockStore()))
|
|
199
|
+
resp = client.get("/graphql")
|
|
200
|
+
assert resp.status_code == 200
|
|
201
|
+
assert resp.headers["content-type"].startswith("text/html")
|
|
202
|
+
assert "graphiql" in resp.text.lower()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class _PermissionError(Exception):
|
|
206
|
+
"""模拟 store.PermissionError(RBAC 拒绝)。"""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_security_matrix():
|
|
210
|
+
# spec/04 安全矩阵:403/401 分类、400 非 JSON、413 体限
|
|
211
|
+
from fastapi.testclient import TestClient
|
|
212
|
+
|
|
213
|
+
from store_graphql import create_app
|
|
214
|
+
|
|
215
|
+
store = MockStore()
|
|
216
|
+
store.PermissionError = _PermissionError
|
|
217
|
+
|
|
218
|
+
def provider(request):
|
|
219
|
+
user = request.headers.get("x-user")
|
|
220
|
+
if user == "bad":
|
|
221
|
+
raise _PermissionError("ERR_PERMISSION:无访问权限")
|
|
222
|
+
if user == "broken":
|
|
223
|
+
raise RuntimeError("上下文钩子故障")
|
|
224
|
+
return {"user": user}
|
|
225
|
+
|
|
226
|
+
client = TestClient(create_app(store, context_provider=provider))
|
|
227
|
+
post = lambda u, body, **kw: client.post( # noqa: E731
|
|
228
|
+
"/graphql", content=body, headers={"x-user": u, "Content-Type": "application/json"}, **kw
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
assert post("bad", '{"query":"{ __typename }"}').status_code == 403
|
|
232
|
+
assert post("broken", '{"query":"{ __typename }"}').status_code == 401
|
|
233
|
+
assert post("alice", "not-json").status_code == 400
|
|
234
|
+
assert post("alice", '{"query":"' + "x" * ((1 << 20) + 10) + '"}').status_code == 413
|
|
235
|
+
ok = post("alice", '{"query":"{ __typename }"}')
|
|
236
|
+
assert ok.status_code == 200
|
|
237
|
+
assert ok.json()["data"]["__typename"] == "Query"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_query_depth_guard():
|
|
241
|
+
# spec/04:深度算法单元 + HTTP 层小阈值 400
|
|
242
|
+
from graphql import parse
|
|
243
|
+
|
|
244
|
+
from store_graphql.adapter import MAX_QUERY_DEPTH, query_depth
|
|
245
|
+
|
|
246
|
+
deep = "{ " + "a { " * 10 + "x " + "}" * 10 + " }"
|
|
247
|
+
assert MAX_QUERY_DEPTH == 10
|
|
248
|
+
assert query_depth(parse(deep)) == 11
|
|
249
|
+
assert query_depth(parse("{ list_User { _id } }")) == 2
|
|
250
|
+
# fragment 深度计入(非环)+ 环引用给有限值不崩
|
|
251
|
+
assert query_depth(parse("query { ...A } fragment A on Query { list_User { _id } }")) == 2
|
|
252
|
+
assert query_depth(parse("query { ...A } fragment A on Query { list_User { ...A } }")) == 1
|
|
253
|
+
|
|
254
|
+
from fastapi.testclient import TestClient
|
|
255
|
+
|
|
256
|
+
from store_graphql import create_app
|
|
257
|
+
|
|
258
|
+
client = TestClient(create_app(MockStore(), max_query_depth=2))
|
|
259
|
+
ok = client.post("/graphql", json={"query": "{ list_User { _id } }"})
|
|
260
|
+
assert ok.status_code == 200
|
|
261
|
+
deep3 = client.post("/graphql", json={"query": "{ list_User { profile { bio } } }"})
|
|
262
|
+
assert deep3.status_code == 400
|
|
263
|
+
assert "ERR_DEPTH:" in deep3.json()["errors"][0]["message"]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_complexity_and_introspection_guard():
|
|
267
|
+
# spec/04:字段计数单元 + max_query_fields 超限 + introspection 开关
|
|
268
|
+
from graphql import parse
|
|
269
|
+
|
|
270
|
+
from store_graphql.adapter import query_field_count
|
|
271
|
+
|
|
272
|
+
assert query_field_count(parse("{ list_User { _id } }"))["fields"] == 2
|
|
273
|
+
assert query_field_count(parse("{ __schema { queryType { name } } }"))["introspection_used"] is True
|
|
274
|
+
|
|
275
|
+
from fastapi.testclient import TestClient
|
|
276
|
+
|
|
277
|
+
from store_graphql import create_app
|
|
278
|
+
|
|
279
|
+
client = TestClient(create_app(MockStore(), max_query_fields=2, introspection=False))
|
|
280
|
+
post = lambda q: client.post("/graphql", json={"query": q}) # noqa: E731
|
|
281
|
+
|
|
282
|
+
assert post("{ list_User { _id } }").status_code == 200
|
|
283
|
+
over = post("{ list_User { _id name } }")
|
|
284
|
+
assert over.status_code == 400 and "ERR_COMPLEXITY:" in over.json()["errors"][0]["message"]
|
|
285
|
+
assert post("{ __typename }").status_code == 200
|
|
286
|
+
intro = post("{ __schema { queryType { name } } }")
|
|
287
|
+
assert intro.status_code == 400 and "ERR_INTROSPECTION:" in intro.json()["errors"][0]["message"]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_override_and_extend():
|
|
291
|
+
store = MockStore()
|
|
292
|
+
|
|
293
|
+
async def override_list(_src, _info, **_args):
|
|
294
|
+
return [{"_id": "x", "name": "override", "age": 0, "profile": None}]
|
|
295
|
+
|
|
296
|
+
schema = build_graphql_schema(
|
|
297
|
+
store,
|
|
298
|
+
overrides={"Query.list_User": override_list},
|
|
299
|
+
extensions={"Query": {"ping": GraphQLField(GraphQLBoolean, resolve=lambda *_a: True)}},
|
|
300
|
+
)
|
|
301
|
+
data = asyncio.run(run(schema, "{ list_User { name } ping }"))
|
|
302
|
+
assert data["list_User"][0]["name"] == "override"
|
|
303
|
+
assert data["ping"] is True
|
|
304
|
+
|
|
305
|
+
with pytest.raises(ValueError, match="未命中"):
|
|
306
|
+
build_graphql_schema(store, overrides={"Query.list_Nope": override_list})
|