python-mapper 0.3.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.
@@ -0,0 +1,885 @@
1
+ """XML mapper execution runtime backed directly by asyncpg.
2
+
3
+ 对外 API 从 `python_mapper` 导入(本模块是实现)。
4
+
5
+ XML keeps readable ``:name`` parameters. They are compiled to asyncpg ``$1``
6
+ parameters after Jinja has selected SQL structure. Collection parameters retain
7
+ the established ``IN :names`` expansion behavior.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import inspect
13
+ import re
14
+ import threading
15
+ import time
16
+ import xml.etree.ElementTree as ET
17
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, TypeVar, cast, get_origin, get_type_hints
21
+
22
+ from jinja2 import Environment, Template, meta, nodes
23
+
24
+ from python_mapper import mapping as result_mapping
25
+ from python_mapper.base import MapperBase
26
+ from python_mapper.compiler import (
27
+ compile_query,
28
+ contains_sql_keyword,
29
+ contains_top_level_keyword,
30
+ named_parameter_names,
31
+ positional_parameter_numbers,
32
+ sql_token_parenthesis_depths,
33
+ top_level_sql_word_positions,
34
+ )
35
+ from python_mapper.database import ConnectionLike
36
+ from python_mapper.errors import PaginationConflictError
37
+ from python_mapper.pagination import (
38
+ PAGE_MARKER,
39
+ Page,
40
+ PageMetadata,
41
+ PaginationOptions,
42
+ PaginationPlugin,
43
+ PaginationSpec,
44
+ QueryResult,
45
+ )
46
+ from python_mapper.plugins import (
47
+ StatementContext,
48
+ StatementPlugin,
49
+ StatementResult,
50
+ run_plugin_chain,
51
+ )
52
+
53
+ validate_result_types = result_mapping.validate_result_types
54
+
55
+ _SQL_CONTAINER: dict[str, Template] = {}
56
+ _NS_FILE: dict[str, Path] = {} # namespace → 所属 XML 文件(1 XML ↔ 1 mapper, 不交叉)
57
+ _JINJA_VARS: dict[str, set[str]] = {} # full_id → 模板 {% if %} 引用的变量名(签名比对用)
58
+ _JINJA_ENV = Environment(autoescape=False) # noqa: S701 - 产物是 SQL 不是 HTML, 且值一律走绑参
59
+
60
+ # namespace → (Mapper 类 qualname, 已绑定的方法名集合) —— 与 XML 条目双向对齐校验用
61
+ _NS_METHODS: dict[str, tuple[str, set[str]]] = {}
62
+ # full_id → XML 命名绑参 / Mapper 声明参数。契约只校验到 Mapper, 不追踪上层调用方。
63
+ _BIND_VARS: dict[str, set[str]] = {}
64
+ _METHOD_PARAMS: dict[str, set[str]] = {}
65
+ _STATEMENT_KINDS: dict[str, str] = {}
66
+ _PAGINATION_SPECS: dict[str, PaginationSpec] = {}
67
+ _INTERNAL_IDS: set[str] = set()
68
+ _PLUGINS: tuple[StatementPlugin, ...] = ()
69
+ _DecoratedT = TypeVar("_DecoratedT")
70
+ _MAPPER_PATHS: tuple[Path, ...] = ()
71
+ # "配置的全部路径已完整加载"的显式标志。⚠ 不能拿 _SQL_CONTAINER 非空当这个判据:
72
+ # load_mapper(单文件) 是公开原语, 局部加载后容器非空, 旧实现会让 load_all_mappers
73
+ # 变 no-op —— 配置目录里其余 XML 永远不加载, 报出来的却是"条目不存在"。
74
+ _FULLY_LOADED = False
75
+
76
+
77
+ # Keep casts explicit so named-parameter parsing remains unambiguous.
78
+ _CAST_SUFFIX_PARAM = re.compile(r"(?<![:\w\\]):(\w+)::")
79
+
80
+ # jinja2 只负责结构条件。值插值、输出表达式及其他语句都在 AST 层拒绝,
81
+ # 由 jinja 自己识别 {%- / {%+ 等词法变体,避免安全边界依赖正则追语法。
82
+ _EXECUTABLE_MAPPER_TAGS = frozenset({"select", "insert", "update", "delete"})
83
+ _DECLARATION_MAPPER_TAGS = frozenset({"sql", "resultMap"})
84
+ _ALLOWED_MAPPER_TAGS = _EXECUTABLE_MAPPER_TAGS | _DECLARATION_MAPPER_TAGS
85
+
86
+ # SQL 片段: <sql id="x">…</sql> 声明, <include refid="x"/> 引用(与 MyBatis 同语法)。
87
+ # 用途: list 与 count 共用同一套 WHERE, 改一处两边生效。
88
+ # 安全性: 只在**加载期**按 refid 从本 namespace 片段表取文本拼接, 运行期不接受任何输入,
89
+ # refid 必须已声明 —— 无法通过参数影响, 与注入无关。
90
+ _MAX_FRAGMENT_DEPTH = 5 # 片段可嵌套引用片段, 限深防环
91
+
92
+
93
+ # 加载的 check-then-act 不是原子的。本框架的执行面是纯 asyncio(加载函数无 await, 任务间
94
+ # 不可打断), 但消费方可能从线程里触发惰性加载(sync 端点跑在线程池等) —— RLock 三行钱,
95
+ # 把"两个线程同时看见空容器、各自加载一遍"这种半加载态直接买断。RLock 而非 Lock:
96
+ # _ensure_loaded 持锁调 load_all_mappers, 普通锁会自锁死。
97
+ _LOAD_LOCK = threading.RLock()
98
+
99
+
100
+ def configure_mapper_paths(paths: list[str | Path] | tuple[str | Path, ...]) -> None:
101
+ """Register application-owned mapper XML roots without importing application code.
102
+
103
+ 重新配置会**清掉已加载的 SQL**: 否则 configure(A)→load→configure(B) 后,
104
+ load_all_mappers 见容器非空直接返回旧计数, B 的 XML 永远不加载 ——
105
+ factory 是新的、SQL 是旧的, 这种不一致还无声。清了, 下次加载走新路径。
106
+ """
107
+ normalized = tuple(Path(path).resolve() for path in paths)
108
+ if not normalized:
109
+ raise ValueError("mapper_paths must contain at least one file or directory")
110
+ with _LOAD_LOCK:
111
+ global _MAPPER_PATHS
112
+ _MAPPER_PATHS = normalized
113
+ _clear_loaded_sql()
114
+
115
+
116
+ def configure_plugins(plugins: tuple[StatementPlugin, ...] | list[StatementPlugin]) -> None:
117
+ """Replace the process-local statement plugin chain.
118
+
119
+ Plugins are package-level runtime configuration, not application imports. An
120
+ empty sequence preserves the original direct execution path.
121
+ """
122
+ global _PLUGINS
123
+ _PLUGINS = tuple(plugins)
124
+
125
+
126
+ def load_all_mappers() -> int:
127
+ """Load every configured mapper XML root and return executable statement count.
128
+
129
+ 幂等键是 _FULLY_LOADED 标志, 不是容器非空; 未完整加载时**先清再整体重建** ——
130
+ 局部 load_mapper 留下的条目一律丢弃, 加载结果只由 mapper_paths 决定
131
+ (锁内重建, 对线程等效于"编译成功后原子替换")。所有 XML 都应放进 mapper_paths;
132
+ load_mapper 只是加载原语, 不参与"已加载"的判定。
133
+ """
134
+ with _LOAD_LOCK:
135
+ global _FULLY_LOADED
136
+ if _FULLY_LOADED:
137
+ return len(_SQL_CONTAINER)
138
+ if not _MAPPER_PATHS:
139
+ raise RuntimeError(
140
+ "python-mapper is not configured: call configure(database_url=..., mapper_paths=...)"
141
+ )
142
+ _clear_loaded_sql()
143
+ try:
144
+ for path in _MAPPER_PATHS:
145
+ if not path.exists():
146
+ raise FileNotFoundError(f"mapper path does not exist: {path}")
147
+ load_mapper(path)
148
+ except Exception:
149
+ _clear_loaded_sql()
150
+ raise
151
+ result_mapping.validate_result_types()
152
+ _FULLY_LOADED = True
153
+ return len(_SQL_CONTAINER)
154
+
155
+
156
+ def reset_state() -> None:
157
+ """Reset mapper registries and configured XML paths.
158
+
159
+ 数据库池拥有独立的异步生命周期,不能由同步的 mapper 重置函数关闭或清空;调用方
160
+ 必须先 await close_database(),确有需要时再清数据库配置。
161
+ ⚠ 已被 @amapper 装饰的类不受影响: wrapper 闭包与其"首次调用已校验"标记都还在。
162
+ reset 后若 XML 契约变了, 需要重新 import(重新装饰)mapper 类才能重跑校验。
163
+ """
164
+ with _LOAD_LOCK:
165
+ _clear_loaded_sql()
166
+ _NS_METHODS.clear()
167
+ _METHOD_PARAMS.clear()
168
+ global _MAPPER_PATHS, _PLUGINS
169
+ _MAPPER_PATHS = ()
170
+ _PLUGINS = ()
171
+
172
+
173
+ def _clear_loaded_sql() -> None:
174
+ """Clear XML-derived state after a failed load so the next attempt reports the root cause."""
175
+ global _FULLY_LOADED
176
+ _FULLY_LOADED = False
177
+ _SQL_CONTAINER.clear()
178
+ _NS_FILE.clear()
179
+ _JINJA_VARS.clear()
180
+ result_mapping.clear_result_mappings()
181
+ _BIND_VARS.clear()
182
+ _STATEMENT_KINDS.clear()
183
+ _PAGINATION_SPECS.clear()
184
+ _INTERNAL_IDS.clear()
185
+
186
+
187
+ def load_mapper(path: str | Path) -> None:
188
+ """加载 mapper XML(文件或目录)。格式与 batisx 相同:
189
+ <mapper namespace="..."><select id="...">SQL</select>...</mapper>
190
+ """
191
+ path = Path(path)
192
+ files = [path] if path.is_file() else sorted(path.rglob("*.xml"))
193
+ for file in files:
194
+ root = ET.parse(file).getroot()
195
+ namespace = root.attrib.get("namespace", "")
196
+ # 与 MyBatis 同约定: 一个 XML 映射一个 mapper(namespace), 不交叉。
197
+ # 同一 namespace 出现在第二个文件 = 条目散落, 直接拒绝加载。
198
+ if namespace in _NS_FILE and _NS_FILE[namespace] != file:
199
+ raise ValueError(
200
+ f"namespace '{namespace}' 已属于 {_NS_FILE[namespace]}, "
201
+ f"不允许再出现在 {file} (1 XML ↔ 1 mapper, 不交叉)")
202
+ _NS_FILE[namespace] = file
203
+
204
+ for child in root:
205
+ if child.tag not in _ALLOWED_MAPPER_TAGS:
206
+ raise ValueError(
207
+ f"不支持的 mapper 标签 <{child.tag}>: {file}; "
208
+ "只允许 select/insert/update/delete/sql/resultMap"
209
+ )
210
+
211
+ # 第一轮: 收声明块 —— <sql> 复用片段 与 <resultMap> 结果映射(都不是可执行条目)
212
+ frag_els: dict[str, ET.Element] = {}
213
+ for child in root:
214
+ if child.tag == "sql":
215
+ frag_id = child.attrib.get("id")
216
+ if not frag_id:
217
+ raise ValueError(f"<sql> 片段缺 id: {file}")
218
+ if frag_id in frag_els:
219
+ raise ValueError(f"<sql> 片段 id 重复: {namespace}.{frag_id}")
220
+ frag_els[frag_id] = child
221
+ elif child.tag == "resultMap":
222
+ result_mapping.load_result_map(namespace, child, file)
223
+
224
+ # 第二轮: 可执行条目, 展开 <include> 后编译
225
+ for child in root:
226
+ if child.tag in ("sql", "resultMap"):
227
+ continue
228
+ sql_id = child.attrib.get("id")
229
+ if not sql_id:
230
+ raise ValueError(f"mapper 条目缺 id: {file}")
231
+ full_id = f"{namespace}.{sql_id}"
232
+ if full_id in _SQL_CONTAINER:
233
+ raise ValueError(f"sql_id 重复: {full_id}")
234
+ sql = _element_sql(child, frag_els, full_id, file)
235
+ _reject_interpolation(full_id, sql, file)
236
+ _reject_cast_suffix(full_id, sql, file)
237
+ native_positions = sorted(positional_parameter_numbers(sql))
238
+ if native_positions:
239
+ rendered_positions = [f"${position}" for position in native_positions]
240
+ raise ValueError(
241
+ f"mapper 条目 '{full_id}' ({file.name}) 使用了原生位置参数 "
242
+ f"{rendered_positions} —— XML SQL 只允许命名绑参 :name"
243
+ )
244
+ marker_count = sql.count(PAGE_MARKER)
245
+ marker_depths = sql_token_parenthesis_depths(sql, PAGE_MARKER)
246
+ if marker_count and (
247
+ len(marker_depths) != marker_count
248
+ or any(depth != 0 for depth in marker_depths)
249
+ ):
250
+ raise PaginationConflictError(
251
+ f"mapper '{full_id}' ({file.name}) 的 <page/> 必须位于 SQL 顶层"
252
+ )
253
+ if marker_count > 1:
254
+ raise PaginationConflictError(
255
+ f"mapper '{full_id}' ({file.name}) contains more than one <page/>"
256
+ )
257
+ if marker_count and child.tag.lower() != "select":
258
+ raise PaginationConflictError(
259
+ f"mapper '{full_id}' ({file.name}) uses <page/> outside <select>"
260
+ )
261
+ if marker_count and any(
262
+ contains_top_level_keyword(sql, keyword)
263
+ for keyword in ("LIMIT", "OFFSET", "FETCH")
264
+ ):
265
+ raise PaginationConflictError(
266
+ f"mapper '{full_id}' ({file.name}) contains both <page/> and "
267
+ "top-level LIMIT/OFFSET/FETCH"
268
+ )
269
+ if marker_count:
270
+ marker_offset = sql.index(PAGE_MARKER)
271
+ positioned_words = top_level_sql_word_positions(sql)
272
+ words_before_marker = tuple(
273
+ word for word, offset in positioned_words if offset < marker_offset
274
+ )
275
+ words_after_marker = tuple(
276
+ word for word, offset in positioned_words if offset > marker_offset
277
+ )
278
+ has_order_by_before_marker = any(
279
+ words_before_marker[index:index + 2] == ("ORDER", "BY")
280
+ for index in range(max(0, len(words_before_marker) - 1))
281
+ )
282
+ if (
283
+ not has_order_by_before_marker
284
+ or (words_after_marker and words_after_marker[0] != "FOR")
285
+ ):
286
+ raise PaginationConflictError(
287
+ f"mapper '{full_id}' ({file.name}) 的 <page/> 必须位于完整的"
288
+ "顶层 ORDER BY 子句之后;其后仅可保留 FOR 锁定子句"
289
+ )
290
+ _SQL_CONTAINER[full_id] = Template(sql)
291
+ _STATEMENT_KINDS[full_id] = child.tag.lower()
292
+ raw_count_ref = child.attrib.get("countRef")
293
+ count_ref = raw_count_ref.strip() if raw_count_ref is not None else None
294
+ if count_ref is not None and not count_ref:
295
+ raise ValueError(
296
+ f"mapper '{full_id}' ({file.name}) countRef must not be empty"
297
+ )
298
+ if count_ref is not None and "." in count_ref:
299
+ raise ValueError(
300
+ f"mapper '{full_id}' ({file.name}) countRef must be a local statement id"
301
+ )
302
+ if count_ref is not None and child.tag != "select":
303
+ raise ValueError(
304
+ f"mapper '{full_id}' ({file.name}) countRef is only valid on <select>"
305
+ )
306
+ count_statement_id = f"{namespace}.{count_ref}" if count_ref else None
307
+ if count_statement_id is not None or marker_count:
308
+ _PAGINATION_SPECS[full_id] = PaginationSpec(
309
+ statement_id=full_id,
310
+ count_statement_id=count_statement_id,
311
+ marker_count=marker_count,
312
+ )
313
+ expose = child.attrib.get("expose", "true").strip().lower()
314
+ if expose not in {"true", "false"}:
315
+ raise ValueError(
316
+ f"mapper '{full_id}' ({file.name}) expose must be true or false"
317
+ )
318
+ if expose == "false":
319
+ _INTERNAL_IDS.add(full_id)
320
+ result_mapping.load_result_spec(namespace, full_id, child, file)
321
+ # 模板里 {% if xxx %} 引用的变量名(不含 :name 绑参) —— 首次调用时与方法签名比对,
322
+ # 抓"XML 写了签名没声明的名字"这类拼写错。
323
+ # ⚠ 不用 StrictUndefined 实现: 它会让 render_sql() 这个调试/自省入口无法只传部分参数,
324
+ # 而且拦不住 `x is not none`(identity 测试不触发 Undefined 报错)。
325
+ _JINJA_VARS[full_id] = meta.find_undeclared_variables(_JINJA_ENV.parse(sql))
326
+ _BIND_VARS[full_id] = named_parameter_names(sql)
327
+ _verify_bind_contract(full_id)
328
+
329
+ # 该 namespace 的 Mapper 类若已 import, 立刻做双向对齐(见 _verify_namespace)
330
+ for statement_id, pagination_spec in _PAGINATION_SPECS.items():
331
+ if not statement_id.startswith(namespace + "."):
332
+ continue
333
+ count_statement_id = pagination_spec.count_statement_id
334
+ if count_statement_id is None:
335
+ continue
336
+ if count_statement_id == statement_id:
337
+ raise ValueError(
338
+ f"mapper '{statement_id}' ({file.name}) countRef must not reference itself"
339
+ )
340
+ if count_statement_id not in _SQL_CONTAINER:
341
+ raise ValueError(
342
+ f"mapper '{statement_id}' ({file.name}) countRef points to missing "
343
+ f"statement '{count_statement_id}'"
344
+ )
345
+ if _STATEMENT_KINDS.get(count_statement_id) != "select":
346
+ raise ValueError(
347
+ f"mapper '{statement_id}' ({file.name}) countRef must point to <select>"
348
+ )
349
+ _verify_count_ref_contract(statement_id)
350
+ _verify_namespace(namespace)
351
+
352
+
353
+ def _element_sql(elem: ET.Element, frag_els: dict[str, ET.Element], full_id: str,
354
+ file: Path, depth: int = 0) -> str:
355
+ """把一个条目/片段元素的内容拼成 SQL 文本, 就地展开 <include refid="x"/>。
356
+
357
+ 按文档顺序取 text 与各子元素的 tail, 所以 `SQL <include/> SQL` 前后文本都不丢。
358
+ 片段可嵌套引用片段(限深 _MAX_FRAGMENT_DEPTH 防环)。加载期一次性完成, 运行期零开销。
359
+ """
360
+ if depth > _MAX_FRAGMENT_DEPTH:
361
+ raise ValueError(
362
+ f"mapper 条目 '{full_id}' ({file.name}) <include> 嵌套超过 "
363
+ f"{_MAX_FRAGMENT_DEPTH} 层, 疑似循环引用")
364
+ parts = [elem.text or ""]
365
+ for sub in elem:
366
+ if sub.tag == "page":
367
+ if (sub.text or "").strip() or list(sub) or sub.attrib:
368
+ raise ValueError(
369
+ f"mapper 条目 '{full_id}' ({file.name}) 的 <page/> 不接受属性或内容"
370
+ )
371
+ parts.append(PAGE_MARKER)
372
+ parts.append(sub.tail or "")
373
+ continue
374
+ if sub.tag != "include":
375
+ raise ValueError(
376
+ f"mapper 条目 '{full_id}' ({file.name}) 含不支持的子元素 <{sub.tag}> "
377
+ "(只支持 <include refid=\"…\"/> 与 <page/>; 条件判断请用 jinja2 {% if %})")
378
+ refid = sub.attrib.get("refid")
379
+ if not refid or refid not in frag_els:
380
+ raise ValueError(
381
+ f"mapper 条目 '{full_id}' ({file.name}) 的 <include refid=\"{refid}\"/> "
382
+ f"未声明 (可用片段: {sorted(frag_els)})")
383
+ parts.append(_element_sql(frag_els[refid], frag_els, full_id, file, depth + 1))
384
+ parts.append(sub.tail or "")
385
+ return "".join(parts).strip()
386
+
387
+
388
+ def _reject_interpolation(full_id: str, sql: str, file: Path) -> None:
389
+ """Allow structural conditions only; all SQL values must use binds."""
390
+ parsed_template = _JINJA_ENV.parse(sql)
391
+
392
+ def validate_statements(statements: Sequence[nodes.Node]) -> None:
393
+ for statement in statements:
394
+ if isinstance(statement, nodes.Output):
395
+ if all(isinstance(value, nodes.TemplateData) for value in statement.nodes):
396
+ continue
397
+ raise ValueError(
398
+ f"mapper 条目 '{full_id}' ({file.name}) 含 jinja 值插值或输出表达式 —— "
399
+ "这会把值拼进 SQL 造成注入。值请改用命名绑参 :name; "
400
+ "jinja 只允许 if/elif/else/endif 控制 SQL 结构。"
401
+ )
402
+ if isinstance(statement, nodes.If):
403
+ validate_statements(statement.body)
404
+ validate_statements(statement.elif_)
405
+ validate_statements(statement.else_)
406
+ continue
407
+ raise ValueError(
408
+ f"mapper 条目 '{full_id}' ({file.name}) 使用了不安全的 jinja 标签 "
409
+ f"'{type(statement).__name__}' —— jinja 只允许 if/elif/else/endif 控制 SQL "
410
+ "结构,所有值必须使用命名绑参 :name。"
411
+ )
412
+
413
+ validate_statements(parsed_template.body)
414
+
415
+
416
+ def _reject_cast_suffix(full_id: str, sql: str, file: Path) -> None:
417
+ """Require ``CAST(:name AS type)`` instead of ambiguous suffix casts."""
418
+ hits = sorted(set(_CAST_SUFFIX_PARAM.findall(sql)))
419
+ if not hits:
420
+ return
421
+ raise ValueError(
422
+ f"mapper 条目 '{full_id}' ({file.name}) 写了 {[f':{name}::' for name in hits]} —— "
423
+ "命名参数后直接接 PostgreSQL cast 容易产生解析歧义,改写成 `CAST(:name AS type)`。")
424
+
425
+
426
+ def render_sql(full_id: str, **kwargs: Any) -> tuple[str, dict[str, Any]]:
427
+ """渲染动态块 → 抽取渲染后仍存在的 :name → 只保留命中的参数。"""
428
+ _ensure_loaded()
429
+ if full_id not in _SQL_CONTAINER:
430
+ raise KeyError(
431
+ f"mapper 条目 '{full_id}' 不存在 —— 检查 XML 的 namespace/id 是否与 "
432
+ f"@amapper 类的 模块路径.类名.方法名 一致 (已加载 {len(_SQL_CONTAINER)} 条)")
433
+ sql = _SQL_CONTAINER[full_id].render(**kwargs)
434
+ sql = "\n".join(line for line in sql.splitlines() if line.strip())
435
+ names = named_parameter_names(sql)
436
+ params = {
437
+ parameter_name: parameter_value
438
+ for parameter_name, parameter_value in kwargs.items()
439
+ if parameter_name in names
440
+ }
441
+ return sql, params
442
+
443
+
444
+ def _ensure_loaded() -> None:
445
+ """惰性确保 mapper XML 已加载。
446
+
447
+ 生产走 main.py lifespan 显式加载; 本函数覆盖不走 lifespan 的入口(pytest 的
448
+ ASGITransport、脚本、REPL) —— 让 mapper 声明文件不必自己调 load_all_mappers,
449
+ 保持"只声明接口"。幂等且只在未完整加载时扫盘, 正常调用零开销。
450
+ check-then-act 交给 load_all_mappers 里的 _LOAD_LOCK 保原子, 这里的快路径只是免锁。
451
+ """
452
+ if _FULLY_LOADED:
453
+ return
454
+ try:
455
+ load_all_mappers()
456
+ except Exception:
457
+ # 加载中途抛错(XML 语法/绑参契约不符)会留下半加载的容器, 而本函数只在容器为空时
458
+ # 才重试 —— 后续调用会拿着不完整的条目表报"条目不存在", 把真正的加载错误盖掉。
459
+ # 这里失败即清空: 每次调用都重新加载、重新报同一个真实错误。
460
+ _clear_loaded_sql()
461
+ raise
462
+
463
+
464
+ @dataclass(frozen=True, slots=True)
465
+ class _StatementExecution:
466
+ result: StatementResult
467
+ context: StatementContext
468
+
469
+
470
+ @dataclass(frozen=True, slots=True)
471
+ class _ExecutionOptions:
472
+ plugins: Sequence[StatementPlugin] | None = None
473
+ operation: str = "query"
474
+ parent_statement_id: str | None = None
475
+ connection_wait_ms: float = 0.0
476
+
477
+
478
+ def _command_rowcount(status: str) -> int:
479
+ """Extract affected rows from asyncpg command tags such as ``UPDATE 3``."""
480
+ for token in reversed(status.split()):
481
+ if token.isdigit():
482
+ return int(token)
483
+ return 0
484
+
485
+
486
+ async def _execute_with_context(
487
+ connection: ConnectionLike,
488
+ full_id: str,
489
+ mapper_parameters: Mapping[str, Any],
490
+ execution_options: _ExecutionOptions | None = None,
491
+ ) -> _StatementExecution:
492
+ """Render and execute one statement through the configured plugin chain."""
493
+ options = execution_options or _ExecutionOptions()
494
+ _ensure_loaded()
495
+ input_parameters = dict(mapper_parameters)
496
+ sql, params = render_sql(full_id, **input_parameters)
497
+ statement_kind = _STATEMENT_KINDS.get(full_id)
498
+
499
+ async def execute_related(
500
+ related_statement_id: str,
501
+ related_parameters: Mapping[str, Any],
502
+ related_operation: str,
503
+ related_parent_statement_id: str | None,
504
+ ) -> StatementResult:
505
+ related_execution = await _execute_with_context(
506
+ connection,
507
+ related_statement_id,
508
+ related_parameters,
509
+ _ExecutionOptions(
510
+ plugins=_PLUGINS,
511
+ operation=related_operation,
512
+ parent_statement_id=related_parent_statement_id,
513
+ ),
514
+ )
515
+ return related_execution.result
516
+
517
+ context = StatementContext(
518
+ statement_id=full_id,
519
+ statement_kind=statement_kind,
520
+ sql=sql,
521
+ input_parameters=input_parameters,
522
+ parameters=params,
523
+ connection=connection,
524
+ execute_related=execute_related,
525
+ operation=options.operation,
526
+ parent_statement_id=options.parent_statement_id,
527
+ connection_wait_ms=options.connection_wait_ms,
528
+ )
529
+
530
+ async def terminal(current: StatementContext) -> StatementResult:
531
+ # <page/> only declares where an enabled pagination plugin inserts its
532
+ # clause. Direct calls and explicitly disabled pagination are valid
533
+ # unpaged executions, so the internal marker must never reach asyncpg.
534
+ if PAGE_MARKER in current.sql:
535
+ current.sql = current.sql.replace(PAGE_MARKER, "")
536
+ compiled = compile_query(current.sql, current.parameters)
537
+ current.compiled_sql = compiled.sql
538
+ current.compiled_args = compiled.args
539
+ # XML entries carry an explicit statement kind. Direct registrations used
540
+ # by extensions/tests do not, so infer only from the first executable word.
541
+ first_keyword = re.match(r"\s*([A-Za-z]+)", current.sql)
542
+ returns_rows = (
543
+ current.statement_kind == "select"
544
+ or (
545
+ current.statement_kind is None
546
+ and first_keyword is not None
547
+ and first_keyword.group(1).upper()
548
+ in {"SELECT", "SHOW", "VALUES", "EXPLAIN"}
549
+ )
550
+ or contains_sql_keyword(current.sql, "RETURNING")
551
+ )
552
+ if returns_rows:
553
+ rows = list(await current.connection.fetch(compiled.sql, *compiled.args))
554
+ return StatementResult(rows=rows, rowcount=len(rows))
555
+ status = await current.connection.execute(compiled.sql, *compiled.args)
556
+ return StatementResult(rows=None, rowcount=_command_rowcount(status))
557
+
558
+ result = await run_plugin_chain(
559
+ context,
560
+ _PLUGINS if options.plugins is None else options.plugins,
561
+ terminal,
562
+ )
563
+ return _StatementExecution(result=result, context=context)
564
+
565
+
566
+ async def _execute(
567
+ connection: ConnectionLike,
568
+ full_id: str,
569
+ **kwargs: Any,
570
+ ) -> StatementResult:
571
+ """Backward-compatible raw execution primitive without result materialization."""
572
+ execution = await _execute_with_context(connection, full_id, kwargs)
573
+ return execution.result
574
+
575
+
576
+ def _declared_defaults(func) -> dict[str, Any]:
577
+ """返回声明参数的默认值快照;调用方省略的参数统一按 None 处理。"""
578
+ declared: dict[str, Any] = {}
579
+ for name, param in inspect.signature(func).parameters.items():
580
+ if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
581
+ continue
582
+ declared[name] = None if param.default is inspect.Parameter.empty else param.default
583
+ return declared
584
+
585
+
586
+ def _make_wrapper(full_id: str, func, owner: MapperBase | type[MapperBase]):
587
+ # 签名快照在装饰期取一次(运行期零反射开销)。
588
+ # XML 变量名 vs 签名的比对**不在这里做**: 它在 _verify_bind_contract(加载期+装饰期
589
+ # 各触发一次), wrapper 只管每次调用的 kwargs 合法性 —— 旧版的首调闭包标记在
590
+ # reset/重载后失效, 已废弃。
591
+ defaults = _declared_defaults(func)
592
+ returns_page = get_origin(get_type_hints(func).get("return")) is Page
593
+
594
+ async def invoke(
595
+ kwargs: Mapping[str, Any],
596
+ plugins: Sequence[StatementPlugin] | None = None,
597
+ ) -> tuple[Any, StatementContext]:
598
+ unknown = sorted(set(kwargs) - set(defaults))
599
+ if unknown:
600
+ raise TypeError(
601
+ f"mapper '{full_id}': 传入了签名未声明的参数 {unknown} "
602
+ f"(可用: {sorted(defaults)})")
603
+ # ⚠ 必须按签名补全: jinja2 里未传的名字是 Undefined, 而 `Undefined is not none`
604
+ # 求值为 True —— 不补全会导致"省略参数"仍渲染出该 SET/WHERE 子句, 但绑参缺失而报错
605
+ # (甚至在 `{% if x %}` 写法下静默生成错 SQL)。补全后 jinja 永远看到显式值。
606
+ merged = {**defaults, **dict(kwargs)}
607
+ connection_started_at = time.perf_counter()
608
+ async with owner.acquire_connection() as connection:
609
+ connection_wait_ms = (time.perf_counter() - connection_started_at) * 1000
610
+ execution = await _execute_with_context(
611
+ connection,
612
+ full_id,
613
+ merged,
614
+ _ExecutionOptions(
615
+ plugins=plugins,
616
+ connection_wait_ms=connection_wait_ms,
617
+ ),
618
+ )
619
+ result = execution.result
620
+ if result.returns_rows:
621
+ shaped = result_mapping.shape_rows(full_id, result.rows or [])
622
+ return shaped, execution.context
623
+ return result.rowcount, execution.context
624
+
625
+ @functools.wraps(func)
626
+ async def wrapper(**kwargs: Any):
627
+ if returns_page:
628
+ _ensure_loaded()
629
+ pagination = PaginationOptions(
630
+ enabled=True,
631
+ page_number=kwargs.get("page", defaults.get("page", 1)),
632
+ page_size=kwargs.get(
633
+ "page_size",
634
+ defaults.get("page_size", 30),
635
+ ),
636
+ include_total=True,
637
+ )
638
+ pagination_spec = _PAGINATION_SPECS.get(
639
+ full_id,
640
+ PaginationSpec(statement_id=full_id),
641
+ )
642
+ value, context = await invoke(
643
+ kwargs,
644
+ (PaginationPlugin(pagination, pagination_spec), *_PLUGINS),
645
+ )
646
+ metadata = cast(PageMetadata, context.attributes["pagination"])
647
+ return Page(
648
+ items=cast(list[Any], value),
649
+ total=cast(int, metadata.total),
650
+ page=metadata.page_number,
651
+ page_size=metadata.page_size,
652
+ pages=cast(int, metadata.total_pages),
653
+ )
654
+ value, _ = await invoke(kwargs)
655
+ return value
656
+
657
+ mapper_wrapper = cast(Any, wrapper)
658
+ mapper_wrapper.__pymapper_invoke__ = invoke
659
+ mapper_wrapper.__pymapper_statement_id__ = full_id
660
+ return wrapper
661
+
662
+
663
+ async def query[QueryItemT](
664
+ statement: Callable[..., Awaitable[list[QueryItemT]]],
665
+ *,
666
+ pagination: PaginationOptions,
667
+ **kwargs: Any,
668
+ ) -> QueryResult[QueryItemT]:
669
+ """Execute a list mapper with optional, per-call pagination.
670
+
671
+ ``enabled=False`` adds no pagination clause and performs no count. An XML
672
+ ``<page/>`` insertion marker is removed before an unpaged statement reaches
673
+ the database. Enabled pagination is available only for functions produced by
674
+ ``@amapper`` so the plugin can share the mapper's connection and metadata.
675
+ """
676
+ if not pagination.enabled:
677
+ raw_value = await statement(**kwargs)
678
+ if not isinstance(raw_value, list):
679
+ raise TypeError("query() requires a mapper that returns a list")
680
+ return QueryResult(items=raw_value)
681
+
682
+ _ensure_loaded()
683
+ invoke = getattr(statement, "__pymapper_invoke__", None)
684
+ statement_id = getattr(statement, "__pymapper_statement_id__", None)
685
+ if invoke is None or not isinstance(statement_id, str):
686
+ raise TypeError("enabled pagination requires a function produced by @amapper")
687
+ pagination_spec = _PAGINATION_SPECS.get(
688
+ statement_id,
689
+ PaginationSpec(statement_id=statement_id),
690
+ )
691
+ selected_plugins = (PaginationPlugin(pagination, pagination_spec), *_PLUGINS)
692
+ value, context = await invoke(kwargs, selected_plugins)
693
+ if not isinstance(value, list):
694
+ raise TypeError("query() requires a mapper that returns a list")
695
+ metadata = context.attributes.get("pagination")
696
+ return QueryResult(items=value, pagination=metadata)
697
+
698
+
699
+ def _verify_bind_contract(full_id: str) -> None:
700
+ """校验 XML 只引用 Mapper 已声明的名字(不追踪 Mapper 的调用方), 两条腿都查:
701
+
702
+ - :name 绑定参数 —— 拼写错会在运行期缺绑参
703
+ - {% if %} 引用的 jinja 变量 —— 这条**必须在加载期查**而不是首次调用查:
704
+ 拼写错的变量渲染成 Undefined(假值), 它守着的 WHERE 子句被静默吞掉,
705
+ `UPDATE ... {% if valeu %}WHERE ...{% endif %}` 直接退化成全表更新。
706
+ 旧实现放在 wrapper 首调、闭包标记只查一次 —— reset_state()/重载 XML 后
707
+ 标记还是 True, 新 XML 的拼写错就再也没人查了(2026-08-17 外评抓出)。
708
+ 加载期与装饰期各调一次本函数(哪边后到哪边查), 两种 import 顺序都覆盖。
709
+ """
710
+ method_params = _METHOD_PARAMS.get(full_id)
711
+ if method_params is None:
712
+ return
713
+ bind_vars = _BIND_VARS.get(full_id)
714
+ if bind_vars is not None:
715
+ undeclared = sorted(bind_vars - method_params)
716
+ if undeclared:
717
+ raise ValueError(
718
+ f"mapper '{full_id}': XML 绑定参数未在 Mapper 方法签名声明 {undeclared} "
719
+ f"(签名: {sorted(method_params)})")
720
+ jinja_vars = _JINJA_VARS.get(full_id)
721
+ if jinja_vars is not None:
722
+ undeclared = sorted(jinja_vars - method_params)
723
+ if undeclared:
724
+ raise ValueError(
725
+ f"mapper '{full_id}': XML 的 {{% if %}} 引用了签名没有的变量 {undeclared} "
726
+ f"—— 拼写错或签名漏了参数 (签名: {sorted(method_params)})")
727
+
728
+
729
+ def _verify_count_ref_contract(statement_id: str) -> None:
730
+ """Validate a hidden count statement against its paginated list signature."""
731
+ pagination_spec = _PAGINATION_SPECS.get(statement_id)
732
+ method_params = _METHOD_PARAMS.get(statement_id)
733
+ if pagination_spec is None or method_params is None:
734
+ return
735
+ count_statement_id = pagination_spec.count_statement_id
736
+ if count_statement_id is None or count_statement_id not in _SQL_CONTAINER:
737
+ return
738
+ count_variables = (
739
+ _BIND_VARS.get(count_statement_id, set())
740
+ | _JINJA_VARS.get(count_statement_id, set())
741
+ )
742
+ undeclared = sorted(count_variables - method_params)
743
+ if undeclared:
744
+ raise ValueError(
745
+ f"mapper '{statement_id}': countRef '{count_statement_id}' uses parameters "
746
+ f"not declared by the paginated Mapper method {undeclared} "
747
+ f"(signature: {sorted(method_params)})"
748
+ )
749
+
750
+
751
+ def _xml_ids(namespace: str) -> set[str]:
752
+ """该 namespace 下已加载的可执行条目 id(不含 <sql> 片段与 <resultMap>, 它们不进容器)。"""
753
+ prefix = f"{namespace}."
754
+ return {
755
+ key[len(prefix):]
756
+ for key in _SQL_CONTAINER
757
+ if key.startswith(prefix) and key not in _INTERNAL_IDS
758
+ }
759
+
760
+
761
+ def _verify_namespace(namespace: str) -> None:
762
+ """XML 条目 ↔ Mapper 方法 双向对齐。两边都已知时才跑, 否则直接返回。
763
+
764
+ 不校验的后果: 方法漏了 XML 条目, 要等它**第一次被真调用**才 KeyError ——
765
+ 冷路径方法(如 retry_outbox)可能上线数周后才炸一个 500。这里改成启动期就抓:
766
+ load_mapper 里 XML 落地后调一次, amapper 装饰类时再调一次(覆盖两种 import 顺序)。
767
+ """
768
+ registered = _NS_METHODS.get(namespace)
769
+ if registered is None or namespace not in _NS_FILE:
770
+ return
771
+ owner, methods = registered
772
+ xml_ids = _xml_ids(namespace)
773
+ missing_sql = sorted(methods - xml_ids)
774
+ missing_method = sorted(xml_ids - methods)
775
+ if not missing_sql and not missing_method:
776
+ return
777
+ raise ValueError(
778
+ f"mapper 绑定不对齐: {owner} ↔ {_NS_FILE[namespace].name}\n"
779
+ f" 方法有、XML 缺条目: {missing_sql or '无'}\n"
780
+ f" XML 有、类缺方法 : {missing_method or '无'}")
781
+
782
+
783
+ def _check_signature(full_id: str, func) -> None:
784
+ """装饰期校验声明签名: Mapper 不暴露 session, 参数必须 keyword-only。"""
785
+ params = list(inspect.signature(func).parameters.values())
786
+ for param in params:
787
+ if param.kind is not param.KEYWORD_ONLY:
788
+ raise TypeError(
789
+ f"mapper '{full_id}': 参数 '{param.name}' 是 {param.kind.description}, "
790
+ "必须改成 keyword-only —— 参数前加一个 `*`")
791
+
792
+
793
+ def _check_namespace(namespace: str | None, owner: str) -> str:
794
+ if not namespace or namespace == "__main__":
795
+ raise RuntimeError(
796
+ f"amapper: {owner} 定义在直接运行的脚本里, 拿不到模块名 —— "
797
+ "把它移进可 import 的模块, 或显式传 namespace")
798
+ return namespace
799
+
800
+
801
+ class AMapper(MapperBase):
802
+ """Async mapper enhancer backed by implicit asyncpg connections.
803
+
804
+ 挂函数: namespace 缺省 = 模块路径(func.__module__), fullId = 模块.函数名。
805
+ 挂类(仿 MyBatis Mapper 接口): namespace 缺省 = 模块路径.类名, 类里所有公开方法
806
+ 自动绑定(方法名 = XML id), 不用逐个写装饰器或 @staticmethod;包装结果固定为类级方法,
807
+ 直接 `OrdersRepo.list_xxx(...)` 类级调用, 不需要实例化, 方法签名不写 self/session。
808
+ XML 对应 <mapper namespace="app.repositories.orders_mapper.OrdersMapper">。
809
+
810
+ AMapper 继承 MapperBase;被装饰的业务 Mapper 只声明接口,不持有连接。
811
+ SELECT 返回 list[RowMapping](dict 风格行); 其他语句返回受影响行数。
812
+ ⚠ 直接运行的脚本里 __module__ 是 "__main__", mapper 必须定义在可 import 的模块里。
813
+
814
+ 装饰期硬校验(都属于"不查就要等首次调用才炸"的类型):
815
+ - @classmethod / @property 直接拒(Mapper 只允许普通 async def 声明)
816
+ - 不暴露 session, 全部参数必须 keyword-only(见 _check_signature)
817
+ - 方法集合与 XML 条目双向对齐(见 _verify_namespace)
818
+ """
819
+
820
+ def __init__(self, namespace: str | None = None, sql_id: str | None = None):
821
+ self.namespace = namespace
822
+ self.sql_id = sql_id
823
+
824
+ def __call__(self, target: _DecoratedT) -> _DecoratedT:
825
+ """Preserve the decorated class/function type for Pyright and IDE callers."""
826
+ return cast(_DecoratedT, self._decorate(target))
827
+
828
+ def _decorate(self, target):
829
+ if inspect.isclass(target):
830
+ if self.sql_id is not None:
831
+ raise ValueError("amapper 挂类时不接受 sql_id(方法名即 id)")
832
+ namespace = _check_namespace(
833
+ self.namespace or f"{target.__module__}.{target.__qualname__}",
834
+ f"类 {target.__qualname__}",
835
+ )
836
+ bound: set[str] = set()
837
+ for name, attr in list(vars(target).items()):
838
+ if name.startswith("_"):
839
+ continue
840
+ # @classmethod / @property 不是普通接口声明,直接拒绝,避免方法体 `...`
841
+ # 被静默保留后调用返回 None。
842
+ if isinstance(attr, (classmethod, property)):
843
+ raise TypeError(
844
+ f"amapper: {target.__qualname__}.{name} 声明成 "
845
+ f"@{type(attr).__name__} —— mapper 方法只写普通 async def,"
846
+ "不使用方法级装饰器")
847
+ # 声明层只写普通 async def;兼容已有 staticmethod,但最终都统一替换为
848
+ # staticmethod 包装结果,避免实例绑定并保证 Mapper 无状态。
849
+ declared_func = attr.__func__ if isinstance(attr, staticmethod) else attr
850
+ if not inspect.isfunction(declared_func):
851
+ continue
852
+ full_id = f"{namespace}.{name}"
853
+ _check_signature(full_id, declared_func)
854
+ _METHOD_PARAMS[full_id] = set(_declared_defaults(declared_func))
855
+ _verify_bind_contract(full_id)
856
+ _verify_count_ref_contract(full_id)
857
+ setattr(target, name, staticmethod(_make_wrapper(full_id, declared_func, self)))
858
+ bound.add(name)
859
+ _NS_METHODS[namespace] = (target.__qualname__, bound)
860
+ _verify_namespace(namespace) # XML 已加载则立刻对齐; 未加载则由 load_mapper 侧补跑
861
+ return target
862
+
863
+ namespace = _check_namespace(
864
+ self.namespace or target.__module__,
865
+ f"函数 {target.__name__}",
866
+ )
867
+ full_id = f"{namespace}.{self.sql_id or target.__name__}"
868
+ _check_signature(full_id, target)
869
+ _METHOD_PARAMS[full_id] = set(_declared_defaults(target))
870
+ _verify_bind_contract(full_id)
871
+ _verify_count_ref_contract(full_id)
872
+ return _make_wrapper(full_id, target, self)
873
+
874
+
875
+ # 保留既有小写装饰器用法:@amapper();该符号本身现在就是 MapperBase 子类。
876
+ amapper = AMapper
877
+
878
+
879
+ async def scalar(namespace_sql_id: str, **kwargs: Any) -> Any:
880
+ """便捷标量查询(如 count): 返回首行首列。"""
881
+ async with MapperBase.acquire_connection() as connection:
882
+ result = await _execute(connection, namespace_sql_id, **kwargs)
883
+ if not result.rows:
884
+ raise LookupError(f"mapper 标量查询 '{namespace_sql_id}' 没有返回行")
885
+ return next(iter(dict(result.rows[0]).values()))