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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ graphql-core>=3.2
2
+
3
+ [dev]
4
+ pytest>=8
5
+ fastapi>=0.110
6
+ httpx>=0.27
7
+
8
+ [fastapi]
9
+ fastapi>=0.110
@@ -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})