storepy 4.2.0__tar.gz → 4.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {storepy-4.2.0/src/storepy.egg-info → storepy-4.3.0}/PKG-INFO +1 -1
  2. {storepy-4.2.0 → storepy-4.3.0}/pyproject.toml +1 -1
  3. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/__init__.py +10 -1
  4. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/crud/exec.py +20 -0
  5. storepy-4.3.0/src/py_store/crud/triggers.py +191 -0
  6. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/crud/write.py +44 -10
  7. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/resource/__init__.py +4 -2
  8. {storepy-4.2.0 → storepy-4.3.0/src/storepy.egg-info}/PKG-INFO +1 -1
  9. {storepy-4.2.0 → storepy-4.3.0}/src/storepy.egg-info/SOURCES.txt +2 -0
  10. {storepy-4.2.0 → storepy-4.3.0}/tests/test_resource.py +41 -0
  11. storepy-4.3.0/tests/test_triggers.py +348 -0
  12. {storepy-4.2.0 → storepy-4.3.0}/LICENSE +0 -0
  13. {storepy-4.2.0 → storepy-4.3.0}/README.md +0 -0
  14. {storepy-4.2.0 → storepy-4.3.0}/setup.cfg +0 -0
  15. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/ask.py +0 -0
  16. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/ask_knowledge.md +0 -0
  17. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/bootstrap.py +0 -0
  18. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/cache.py +0 -0
  19. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/core.py +0 -0
  20. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/crud/__init__.py +0 -0
  21. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/crud/id.py +0 -0
  22. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/crud/mutation.py +0 -0
  23. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/crud/query.py +0 -0
  24. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/datasource.py +0 -0
  25. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/ddl.py +0 -0
  26. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/executors/__init__.py +0 -0
  27. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/executors/_values.py +0 -0
  28. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/executors/mongo.py +0 -0
  29. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/executors/mysql.py +0 -0
  30. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/executors/postgres.py +0 -0
  31. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/executors/sqlite.py +0 -0
  32. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/feedback.py +0 -0
  33. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/introspect/__init__.py +0 -0
  34. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/introspect/mysql.py +0 -0
  35. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/introspect/postgres.py +0 -0
  36. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/introspect/sqlite.py +0 -0
  37. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/llm.py +0 -0
  38. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/load.py +0 -0
  39. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/metadef.py +0 -0
  40. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/naming.py +0 -0
  41. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/permission.py +0 -0
  42. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/resource/providers/__init__.py +0 -0
  43. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/resource/providers/local.py +0 -0
  44. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/resource/providers/s3.py +0 -0
  45. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/schema.py +0 -0
  46. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/sync.py +0 -0
  47. {storepy-4.2.0 → storepy-4.3.0}/src/py_store/workflow.py +0 -0
  48. {storepy-4.2.0 → storepy-4.3.0}/src/storepy.egg-info/dependency_links.txt +0 -0
  49. {storepy-4.2.0 → storepy-4.3.0}/src/storepy.egg-info/requires.txt +0 -0
  50. {storepy-4.2.0 → storepy-4.3.0}/src/storepy.egg-info/top_level.txt +0 -0
  51. {storepy-4.2.0 → storepy-4.3.0}/tests/test_ask.py +0 -0
  52. {storepy-4.2.0 → storepy-4.3.0}/tests/test_cache.py +0 -0
  53. {storepy-4.2.0 → storepy-4.3.0}/tests/test_coverage_margin.py +0 -0
  54. {storepy-4.2.0 → storepy-4.3.0}/tests/test_federation_e2e.py +0 -0
  55. {storepy-4.2.0 → storepy-4.3.0}/tests/test_feedback_flush.py +0 -0
  56. {storepy-4.2.0 → storepy-4.3.0}/tests/test_fns_binding.py +0 -0
  57. {storepy-4.2.0 → storepy-4.3.0}/tests/test_host_contract.py +0 -0
  58. {storepy-4.2.0 → storepy-4.3.0}/tests/test_host_paths.py +0 -0
  59. {storepy-4.2.0 → storepy-4.3.0}/tests/test_load.py +0 -0
  60. {storepy-4.2.0 → storepy-4.3.0}/tests/test_metadef.py +0 -0
  61. {storepy-4.2.0 → storepy-4.3.0}/tests/test_migration.py +0 -0
  62. {storepy-4.2.0 → storepy-4.3.0}/tests/test_mongo_executor.py +0 -0
  63. {storepy-4.2.0 → storepy-4.3.0}/tests/test_mongo_session.py +0 -0
  64. {storepy-4.2.0 → storepy-4.3.0}/tests/test_multi_datasource.py +0 -0
  65. {storepy-4.2.0 → storepy-4.3.0}/tests/test_parity_deny.py +0 -0
  66. {storepy-4.2.0 → storepy-4.3.0}/tests/test_profile.py +0 -0
  67. {storepy-4.2.0 → storepy-4.3.0}/tests/test_py_store.py +0 -0
  68. {storepy-4.2.0 → storepy-4.3.0}/tests/test_raw_sql_and_ddl.py +0 -0
  69. {storepy-4.2.0 → storepy-4.3.0}/tests/test_rbac_host.py +0 -0
  70. {storepy-4.2.0 → storepy-4.3.0}/tests/test_real_backends_e2e.py +0 -0
  71. {storepy-4.2.0 → storepy-4.3.0}/tests/test_require_context.py +0 -0
  72. {storepy-4.2.0 → storepy-4.3.0}/tests/test_resource_e2e.py +0 -0
  73. {storepy-4.2.0 → storepy-4.3.0}/tests/test_resource_units.py +0 -0
  74. {storepy-4.2.0 → storepy-4.3.0}/tests/test_scenario_course_platform.py +0 -0
  75. {storepy-4.2.0 → storepy-4.3.0}/tests/test_scenario_manager_transaction.py +0 -0
  76. {storepy-4.2.0 → storepy-4.3.0}/tests/test_session.py +0 -0
  77. {storepy-4.2.0 → storepy-4.3.0}/tests/test_sql_commit_and_registry.py +0 -0
  78. {storepy-4.2.0 → storepy-4.3.0}/tests/test_workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: storepy
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
5
5
  Author-email: leo <coen_ddt@qq.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "storepy"
7
- version = "4.2.0"
7
+ version = "4.3.0"
8
8
  description = "Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -49,6 +49,9 @@ from . import (
49
49
  )
50
50
  from .ask import AskExhausted as AskExhausted
51
51
  from .ask import AskResult as AskResult
52
+ from .crud import triggers as triggers
53
+ from .crud.triggers import assert_trigger_fns_covered as assert_trigger_fns_covered
54
+ from .crud.triggers import set_trigger_fn as set_trigger_fn
52
55
  from .datasource import NativeCommandError as NativeCommandError
53
56
  from .datasource import NonAtomicWriteError as NonAtomicWriteError
54
57
  from .datasource import RawSqlError as RawSqlError
@@ -324,9 +327,12 @@ class Store:
324
327
  register = staticmethod(schema.register)
325
328
  # 运行期目录装载(读 store.config.json → 收集定义 → core 纯规划 → 带定位批量注册)
326
329
  load_defs = staticmethod(load.load_defs)
327
- # 公开回调注入与启动期缺实现校验(对齐 nodejs-store store.set_fn / store.assert_fns_covered)
330
+ # 公开回调注入与启动期缺实现校验(对齐 nodejs-store store.set_fn / assert_fns_covered)
328
331
  set_fn = staticmethod(schema.set_fn)
329
332
  assert_fns_covered = staticmethod(schema.assert_fns_covered)
333
+ # 触发器回调注入与启动期缺实现校验(对齐 nodejs-store store.setTriggerFn / assertTriggerFnsCovered)
334
+ set_trigger_fn = staticmethod(triggers.set_trigger_fn)
335
+ assert_trigger_fns_covered = staticmethod(triggers.assert_trigger_fns_covered)
330
336
  has = staticmethod(schema.has)
331
337
  get = staticmethod(schema.get)
332
338
  # 数据源连接(多后端路由)
@@ -374,6 +380,9 @@ class Store:
374
380
 
375
381
  store = Store()
376
382
 
383
+ # 触发链执行器装配:回调实现经门面(host['store'])消费 CRUD API
384
+ triggers.set_store(store)
385
+
377
386
  # 索引名对齐 MongoDB 自动命名(k1_v1_k2_v2),用于幂等创建
378
387
  async def _create_indexes_if_needed():
379
388
  """按数据源分派:Mongo 源执行索引创建;SQL 后端**不建索引**(indexes 仅元数据)"""
@@ -149,9 +149,29 @@ def _sources_of(plan):
149
149
  cmd = (step or {}).get('command') or {}
150
150
  if cmd:
151
151
  out.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
152
+ # 触发链命令的源(plan_insert 直接产出 triggers;plan_update 二次规划才产出)
153
+ for t in (plan or {}).get('triggers') or []:
154
+ cmd = (t or {}).get('command') or {}
155
+ if cmd:
156
+ out.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
152
157
  return out or {_datasource.DEFAULT_SOURCE}
153
158
 
154
159
 
160
+ def declare_trigger_sources(base_sources, triggers):
161
+ """触发链触及源的原子性声明(update 专用:其 triggers 在事务内二次规划后才产出,
162
+ 顶层 ``_sources_of`` 已不及)——触发源超出已声明源集时发 ``non_atomic_write``
163
+ (含全部涉及源),此后顺序执行;会话内不发声明(跨源写由既有 fail-closed 拒绝)。
164
+ 对齐 nodejs-store ``declareTriggerSources``。"""
165
+ uniq = set(base_sources)
166
+ for t in triggers or ():
167
+ cmd = (t or {}).get('command') or {}
168
+ if cmd:
169
+ uniq.add(cmd.get('source') or _datasource.DEFAULT_SOURCE)
170
+ if len(uniq) > 1 and _datasource.current_session() is None:
171
+ _warn_multi_source(uniq)
172
+ return uniq
173
+
174
+
155
175
  def _warn_multi_source(sources):
156
176
  """多源写:无法原子 → 程序化声明 nonAtomic(允许顺序执行,禁止静默)"""
157
177
  listed = sorted(sources)
@@ -0,0 +1,191 @@
1
+ """触发链执行器(Host 侧):占位符替换 → 命中判定 → 依序执行(命令式 / 回调式)。
2
+
3
+ 边界:core 已产出 steps(含占位符、onFields、when);本模块只做**运行时事实判定**
4
+ (before/after 实际值)与执行,落在调用方的 ``run_atomic`` 作用域内 → 单源同事务。
5
+ 去重:同一次顶层调用内 ``(step.name, _id)`` 只执行一次(防重复触发)。
6
+ 不做级联:触发写不再触发任何触发器(首批契约,见总纲)。
7
+
8
+ 对齐 ``nodejs-store/src/crud/triggers.js``(同语义蛇形实现)。
9
+ """
10
+
11
+ import re
12
+ from typing import Set
13
+
14
+ from .exec import _exec
15
+
16
+ _store = None # 装配期由 py_store.__init__ 注入(避免循环导入)
17
+ _trigger_fns: dict = {}
18
+ _fn_refs: Set[str] = set()
19
+
20
+
21
+ def set_store(s):
22
+ """装配门面(``py_store.__init__`` 在 ``store = Store()`` 后调用一次)"""
23
+ global _store
24
+ _store = s
25
+
26
+
27
+ def set_trigger_fn(fn_ref, impl):
28
+ """回调注入:``fn_ref → impl(args, ctx, {'store': store})``(可为 async)"""
29
+ if not isinstance(fn_ref, str) or not fn_ref:
30
+ raise ValueError('ERR_TRIGGER_FN_REF:fnRef 须为非空字符串')
31
+ if not callable(impl):
32
+ raise ValueError('ERR_TRIGGER_FN_IMPL:impl 须为函数')
33
+ _trigger_fns[fn_ref] = impl
34
+ _fn_refs.add(fn_ref)
35
+
36
+
37
+ def assert_trigger_fns_covered(defns):
38
+ """启动期校验:schema 的 triggers 里声明的 fnRef 必须都有实现(缺则显式抛错,不静默)。
39
+
40
+ 注意:声明形状的 fnRef 在顶层(规划展开后才包成 ``callback:{fnRef,args}``),
41
+ 见 core ``schema/triggers.rs``。
42
+ """
43
+ missing = []
44
+ for defn in defns or ():
45
+ for trigger_list in ((defn or {}).get('triggers') or {}).values():
46
+ for t in trigger_list or ():
47
+ ref = t.get('fnRef') if isinstance(t, dict) else None
48
+ if isinstance(ref, str) and ref and ref not in _fn_refs:
49
+ missing.append(ref)
50
+ if missing:
51
+ raise RuntimeError(f'ERR_TRIGGER_FN_MISSING:未注入触发器回调实现 {", ".join(missing)}')
52
+
53
+
54
+ # ─── 占位符(整值替换;禁内嵌拼接) ───────────────────────────
55
+
56
+ _ROOT_PH = re.compile(r'^\{\{root\.([A-Za-z0-9_.]+)\}\}$')
57
+ _BEFORE_PH = re.compile(r'^\{\{before\.([A-Za-z0-9_.]+)\}\}$')
58
+ _NOW_PH = re.compile(r'^\{\{now\}\}$')
59
+
60
+
61
+ def _dig(obj, path):
62
+ """点路径取值;缺失 → None(不抛)"""
63
+ if obj is None:
64
+ return None
65
+ cur = obj
66
+ for seg in str(path).split('.'):
67
+ if cur is None:
68
+ return None
69
+ if not isinstance(cur, dict):
70
+ return None
71
+ cur = cur.get(seg)
72
+ return cur
73
+
74
+
75
+ def _deep_eq(a, b):
76
+ """深比较(结构相等;顺序无关的键集合比较)"""
77
+ if a is b:
78
+ return True
79
+ if a is None or b is None or not isinstance(a, dict) or not isinstance(b, dict):
80
+ return a == b
81
+ if list(a.keys()) != list(b.keys()) and set(a.keys()) != set(b.keys()):
82
+ return False
83
+ return all(_deep_eq(a[k], b[k]) for k in a)
84
+
85
+
86
+ def resolve_trigger_placeholders(value, scope):
87
+ """深替换触发器步骤中的占位符。
88
+
89
+ 未命中的占位符**显式报错**(``ERR_TRIGGER_PLACEHOLDER``)——占位符必须独占字符串值,
90
+ 不支持 ``"order-{{root._id}}"`` 这类内嵌拼接(禁静默漂移)。
91
+ """
92
+ if isinstance(value, str):
93
+ if _NOW_PH.match(value):
94
+ return scope['now']
95
+ m = _ROOT_PH.match(value)
96
+ if m:
97
+ return _dig(scope['root'], m.group(1))
98
+ m = _BEFORE_PH.match(value)
99
+ if m:
100
+ return _dig(scope['before'], m.group(1))
101
+ if '{{root.' in value or '{{before.' in value or '{{now}}' in value:
102
+ raise ValueError(
103
+ f'ERR_TRIGGER_PLACEHOLDER:占位符必须独占字符串值(不支持内嵌拼接):{value}')
104
+ return value
105
+ if isinstance(value, list):
106
+ return [resolve_trigger_placeholders(v, scope) for v in value]
107
+ if isinstance(value, dict):
108
+ return {k: resolve_trigger_placeholders(v, scope) for k, v in value.items()}
109
+ return value
110
+
111
+
112
+ # ─── when 求值(极简文法;未知算子显式 Err) ──────────────────
113
+
114
+ _WHEN_OPS = frozenset({'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'and', 'or', 'not'})
115
+
116
+
117
+ def eval_when(when, scope):
118
+ if when is None:
119
+ return True
120
+ keys = list(when.keys())
121
+ if len(keys) != 1:
122
+ raise ValueError('ERR_TRIGGER_WHEN:when 必须且只能有一个算子键')
123
+ op = keys[0]
124
+ if op not in _WHEN_OPS:
125
+ raise ValueError(f'ERR_TRIGGER_WHEN:未知算子 {op}')
126
+ arg = when[op]
127
+
128
+ def rv(v):
129
+ return resolve_trigger_placeholders(v, scope)
130
+
131
+ if op == 'eq':
132
+ return rv(arg[0]) == rv(arg[1])
133
+ if op == 'ne':
134
+ return rv(arg[0]) != rv(arg[1])
135
+ if op == 'gt':
136
+ return rv(arg[0]) > rv(arg[1])
137
+ if op == 'gte':
138
+ return rv(arg[0]) >= rv(arg[1])
139
+ if op == 'lt':
140
+ return rv(arg[0]) < rv(arg[1])
141
+ if op == 'lte':
142
+ return rv(arg[0]) <= rv(arg[1])
143
+ if op == 'in':
144
+ return rv(arg[0]) in (rv(arg[1]) or [])
145
+ if op == 'and':
146
+ return all(eval_when(w, scope) for w in (arg or []))
147
+ if op == 'or':
148
+ return any(eval_when(w, scope) for w in (arg or []))
149
+ return not eval_when(arg, scope) # not
150
+
151
+
152
+ # ─── 命中判定 + 执行器 ───────────────────────────────────────
153
+
154
+ def hit_trigger(step, *, before, after, scope):
155
+ """命中判定:``onFields 值真的变化`` → ``when 成立``。
156
+
157
+ ``onFields`` 为空(记录级)或事件为 insert(无 before)时跳过字段级检查。
158
+ """
159
+ on = step.get('onFields') or []
160
+ if on and before is not None:
161
+ changed = any(not _deep_eq(_dig(before, f), _dig(after, f)) for f in on) # no-op 抑制
162
+ if not changed:
163
+ return False
164
+ return eval_when(step.get('when'), scope)
165
+
166
+
167
+ async def run_triggers(steps, *, root, before, now, ctx, executed):
168
+ """依序执行触发链。
169
+
170
+ ``steps`` 为 core 产出的 triggers 数组;``executed`` 为本次顶层调用的去重集合
171
+ (键 ``name#_id``);``_store`` 为门面,回调经 ``host['store']`` 消费。
172
+ """
173
+ for step in steps or ():
174
+ rid = _dig(root, '_id')
175
+ key = f"{step['name']}#{'' if rid is None else rid}"
176
+ if key in executed:
177
+ continue # 同事务去重
178
+ scope = {'root': root, 'before': before, 'now': now}
179
+ if not hit_trigger(step, before=before, after=root, scope=scope):
180
+ continue
181
+ executed.add(key)
182
+ if step.get('command'):
183
+ await _exec(resolve_trigger_placeholders(step['command'], scope))
184
+ elif step.get('callback'):
185
+ fn_ref = step['callback'].get('fnRef')
186
+ impl = _trigger_fns.get(fn_ref)
187
+ if impl is None:
188
+ raise RuntimeError(f'ERR_TRIGGER_FN_MISSING:未注入触发器回调 {fn_ref}')
189
+ args = resolve_trigger_placeholders(step['callback'].get('args'), scope)
190
+ # 回调内 store.* 经 resolve_connection 落到当前事务连接 → 与主写同事务
191
+ await impl(args, ctx, {'store': _store})
@@ -2,8 +2,17 @@
2
2
 
3
3
  from ..schema import core as _core
4
4
  from ..schema import get as _get_schema
5
- from .exec import _call, _ctx, _exec, _now_for, _sources_of, run_atomic
5
+ from .exec import (
6
+ _call,
7
+ _ctx,
8
+ _exec,
9
+ _now_for,
10
+ _sources_of,
11
+ declare_trigger_sources,
12
+ run_atomic,
13
+ )
6
14
  from .id import _generate_id
15
+ from .triggers import run_triggers
7
16
 
8
17
 
9
18
  async def _plan_with_probe(plan_fn):
@@ -18,15 +27,32 @@ async def _plan_with_probe(plan_fn):
18
27
  async def insert(schema_name, data, route_override=None):
19
28
  """插入一条(``route_override`` 可选:多租户路由 ``{'source', 'database', 'schema'}``)"""
20
29
  s = _get_schema(schema_name)
30
+ now = _now_for(schema_name)
21
31
  plan = _call(lambda: _core.plan_insert(
22
- schema_name, data, _now_for(schema_name), _generate_id(s) if s['idPrefix'] else '', _ctx(),
32
+ schema_name, data, now, _generate_id(s) if s['idPrefix'] else '', _ctx(),
23
33
  route_override))
24
- result = await _exec(plan['command'])
25
- returns = plan['returns']
26
- # 阶段2:autoincrement 主键 —— 执行器已回读自增值,returns 补 `_id`
27
- if isinstance(returns, dict) and not returns.get('_id') and isinstance(result, dict) and result.get('_id') is not None:
28
- returns = {**returns, '_id': result['_id']}
29
- return returns
34
+
35
+ def _finish(result):
36
+ returns = plan['returns']
37
+ # 阶段2:autoincrement 主键 —— 执行器已回读自增值,returns 补 `_id`
38
+ if (isinstance(returns, dict) and not returns.get('_id')
39
+ and isinstance(result, dict) and result.get('_id') is not None):
40
+ return {**returns, '_id': result['_id']}
41
+ return returns
42
+
43
+ # 无触发器:保持原路径(零回归)
44
+ if not plan.get('triggers'):
45
+ return _finish(await _exec(plan['command']))
46
+ # 有触发器:主写 + 触发链同一原子作用域(单源真事务 / 跨源发 non_atomic_write)
47
+ ctx = _ctx()
48
+
49
+ async def _do():
50
+ result = await _exec(plan['command'])
51
+ await run_triggers(plan['triggers'], root=result, before=None, now=now, ctx=ctx,
52
+ executed=set())
53
+ return _finish(result)
54
+
55
+ return await run_atomic(_sources_of(plan), _do)
30
56
 
31
57
 
32
58
  async def insert_many(schema_name, docs, route_override=None):
@@ -74,12 +100,20 @@ async def update(schema_name, condition, data, options=None, route_override=None
74
100
 
75
101
  async def _do():
76
102
  out = first
103
+ before = None
77
104
  if out.get('needsProbe'):
78
- probe_doc = await _exec(out['needsProbe'])
105
+ before = await _exec(out['needsProbe']) # 探针文档 = before(含 onFields 投影)
79
106
  out = _call(lambda: _core.plan_update(
80
107
  schema_name, condition, data, options, now, ctx,
81
- probe_doc is not None, probe_doc, route_override))
108
+ before is not None, before, route_override))
109
+ # 触发链触及源并入原子性声明(update 的 triggers 二次规划才产出;跨源 → non_atomic_write)
110
+ if out.get('triggers'):
111
+ declare_trigger_sources(sources, out['triggers'])
82
112
  result = await _exec(out['command'])
113
+ # 主写有命中才触发(0 行命中 = 无 after,无从引用;配触发器的 update 由 core 强制发探针)
114
+ if out.get('triggers') and result:
115
+ await run_triggers(out['triggers'], root=result, before=before, now=now, ctx=ctx,
116
+ executed=set())
83
117
  return _call(lambda: _core.apply_write_defaults(schema_name, result)) if result else None
84
118
 
85
119
  return await run_atomic(sources, _do)
@@ -130,8 +130,10 @@ async def open(resource_id, *, order=None):
130
130
  "message": f'资源副本读取失败,降级到下一副本(backend={loc["backend"]}, id={resource_id}):{e}',
131
131
  "hint": "检查该 provider 可用性;该副本可能需要重建"})
132
132
  if last_err is not None:
133
- raise last_err
134
- raise FileNotFoundError(f"资源不存在或无可读副本: {resource_id}")
133
+ raise last_err # 有副本行但读取失败:原样重抛(500 透传),禁改语义
134
+ # core 稳定前缀(与 ERR_PERMISSION: / ERR_GQL_PARSE: 同构):适配层按前缀判定 → 404,
135
+ # 禁按中文文案匹配。仅「按 resource_id 查到零 ResourceLocation 行」时抛出。
136
+ raise FileNotFoundError(f"ERR_RESOURCE_NOT_FOUND:资源不存在或无可读副本: {resource_id}")
135
137
 
136
138
 
137
139
  async def remove(resource_id):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: storepy
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
5
5
  Author-email: leo <coen_ddt@qq.com>
6
6
  License-Expression: MIT
@@ -23,6 +23,7 @@ src/py_store/crud/exec.py
23
23
  src/py_store/crud/id.py
24
24
  src/py_store/crud/mutation.py
25
25
  src/py_store/crud/query.py
26
+ src/py_store/crud/triggers.py
26
27
  src/py_store/crud/write.py
27
28
  src/py_store/executors/__init__.py
28
29
  src/py_store/executors/_values.py
@@ -71,4 +72,5 @@ tests/test_scenario_course_platform.py
71
72
  tests/test_scenario_manager_transaction.py
72
73
  tests/test_session.py
73
74
  tests/test_sql_commit_and_registry.py
75
+ tests/test_triggers.py
74
76
  tests/test_workflow.py
@@ -10,6 +10,8 @@ import asyncio
10
10
  import re
11
11
  import tempfile
12
12
 
13
+ import pytest
14
+
13
15
  from py_store import feedback, resource
14
16
 
15
17
 
@@ -156,6 +158,45 @@ def test_open_degrade_on_provider_failure():
156
158
  _run(scenario())
157
159
 
158
160
 
161
+ def test_open_missing_resource_raises_prefixed_error():
162
+ """零副本行 → 抛 FileNotFoundError 且带 ERR_RESOURCE_NOT_FOUND: 稳定前缀(spec/03 判定顺序第 4 层)。"""
163
+ async def scenario():
164
+ store = _FakeStore()
165
+ resource.register_provider('memory', _MemoryProvider)
166
+ resource.configure({'store': store, 'providers': [{'kind': 'memory'}], 'url': {}})
167
+
168
+ rid = '0' * 40
169
+ prefix = 'ERR_RESOURCE_NOT_FOUND:'
170
+ with pytest.raises(FileNotFoundError) as ei:
171
+ await resource.open(rid)
172
+ assert str(ei.value).startswith(prefix), str(ei.value)
173
+ assert str(ei.value)[len(prefix):] == f'资源不存在或无可读副本: {rid}'
174
+
175
+ _run(scenario())
176
+
177
+
178
+ def test_open_provider_failure_reraises_without_prefix():
179
+ """有副本行但 provider 读取失败 → 原样重抛、不带前缀(保持 500 语义,禁伪装成「不存在」)。"""
180
+ async def scenario():
181
+ store = _FakeStore()
182
+ prev = feedback.get_sink()
183
+ feedback.set_sink(lambda e: None)
184
+ try:
185
+ resource.register_provider('badget', _BrokenGetProvider)
186
+ resource.configure({'store': store, 'providers': [{'kind': 'badget'}], 'url': {}})
187
+ store.rows.append({'_schema': 'ResourceLocation', 'resourceId': 'a' * 40,
188
+ 'backend': 'badget', 'key': 'k', 'status': 'ok', 'priority': 0})
189
+
190
+ with pytest.raises(RuntimeError) as ei:
191
+ await resource.open('a' * 40)
192
+ assert str(ei.value) == 'disk boom'
193
+ assert not str(ei.value).startswith('ERR_RESOURCE_NOT_FOUND:')
194
+ finally:
195
+ feedback.set_sink(prev)
196
+
197
+ _run(scenario())
198
+
199
+
159
200
  def test_partial_write_failure_persists_failed_and_feedback():
160
201
  async def scenario():
161
202
  store = _FakeStore()
@@ -0,0 +1,348 @@
1
+ """Schema 触发器真库 e2e(02 步骤 8;镜像 nodejs-store/tests/triggers.test.js)
2
+
3
+ 覆盖总纲 A2/A3/A4/A5/A6/A8/A9 + 回调式触发。全程只走 store 统一入口:
4
+ register(triggers) → insert/update → core 规划(plan 附 triggers)→ Host 触发链
5
+ 执行器(占位符/命中判定/去重)→ run_atomic 单源真事务 / 跨源 non_atomic_write 声明。
6
+ 真库 = aiosqlite 内存库。
7
+
8
+ 运行:LOCAL_CORE=1 python -m pytest tests/test_triggers.py -q
9
+ """
10
+
11
+ import asyncio
12
+
13
+ import aiosqlite
14
+ import pytest
15
+
16
+ from py_store import executors, feedback, init, permission, store
17
+ from py_store import schema as _sc
18
+ from py_store.crud.exec import ProfileViolation
19
+ from py_store.crud.triggers import resolve_trigger_placeholders
20
+
21
+ SRC_A = 'trg_a'
22
+ SRC_B = 'trg_b'
23
+
24
+ _DDL = """
25
+ CREATE TABLE t_order (_id TEXT PRIMARY KEY, amount REAL, status TEXT, note TEXT, __present TEXT);
26
+ CREATE TABLE t_order_deleted (_id TEXT PRIMARY KEY, amount REAL, status TEXT, note TEXT,
27
+ deleted_at INTEGER, __present TEXT);
28
+ CREATE TABLE t_audit (_id TEXT PRIMARY KEY, kind TEXT, ref TEXT, amount REAL, __present TEXT);
29
+ CREATE TABLE t_audit_deleted (_id TEXT PRIMARY KEY, kind TEXT, ref TEXT, amount REAL,
30
+ deleted_at INTEGER, __present TEXT);
31
+ CREATE TABLE t_boom (_id TEXT PRIMARY KEY, __present TEXT);
32
+ CREATE TABLE t_boom_deleted (_id TEXT PRIMARY KEY, deleted_at INTEGER, __present TEXT);
33
+ CREATE TABLE t_ghost (_id TEXT PRIMARY KEY, __present TEXT);
34
+ CREATE TABLE t_ghost_deleted (_id TEXT PRIMARY KEY, deleted_at INTEGER, __present TEXT);
35
+ CREATE TABLE t_cmd_fail (_id TEXT PRIMARY KEY, amount REAL, __present TEXT);
36
+ CREATE TABLE t_cmd_fail_deleted (_id TEXT PRIMARY KEY, amount REAL, deleted_at INTEGER,
37
+ __present TEXT);
38
+ CREATE TABLE t_x_order (_id TEXT PRIMARY KEY, amount REAL, __present TEXT);
39
+ CREATE TABLE t_x_order_deleted (_id TEXT PRIMARY KEY, amount REAL, deleted_at INTEGER,
40
+ __present TEXT);
41
+ CREATE TABLE t_x_audit (_id TEXT PRIMARY KEY, ref TEXT, __present TEXT);
42
+ CREATE TABLE t_x_audit_deleted (_id TEXT PRIMARY KEY, ref TEXT, deleted_at INTEGER,
43
+ __present TEXT);
44
+ """
45
+
46
+
47
+ def _register_test_schemas():
48
+ """测试 schema 注册(conftest 模块夹具重放;triggers 契约见总纲 T 字段表)"""
49
+ _sc.register({
50
+ 'name': 'TOrder', 'collection': 'tOrder', 'idPrefix': 'o_', 'timestamps': False,
51
+ 'datasource': SRC_A,
52
+ 'fields': {'amount': {'type': 'number'}, 'status': {'type': 'string'},
53
+ 'note': {'type': 'string'}},
54
+ 'triggers': {
55
+ 'insert': [
56
+ # A8:aud_cmd 重复同名 —— 同事务 (name, _id) 去重,只执行一次
57
+ # (若不去重,第二次同 _id insertOne 主键冲突 → 整体失败)
58
+ {'name': 'aud_cmd', 'into': 'TAudit', 'op': 'insert',
59
+ 'data': {'_id': '{{root._id}}', 'kind': 'cmd', 'ref': '{{root._id}}',
60
+ 'amount': '{{root.amount}}'}},
61
+ {'name': 'aud_cmd', 'into': 'TAudit', 'op': 'insert',
62
+ 'data': {'_id': '{{root._id}}', 'kind': 'cmd', 'ref': '{{root._id}}',
63
+ 'amount': '{{root.amount}}'}},
64
+ {'name': 'aud_cb', 'fnRef': 'audRecorder', 'args': {'ref': '{{root._id}}'}},
65
+ ],
66
+ 'update': [
67
+ {'name': 'aud_upd', 'onFields': ['amount'], 'into': 'TAudit', 'op': 'insert',
68
+ 'data': {'_id': '{{now}}', 'kind': 'upd', 'ref': '{{root._id}}',
69
+ 'amount': '{{root.amount}}'}},
70
+ ],
71
+ },
72
+ })
73
+ _sc.register({
74
+ 'name': 'TAudit', 'collection': 'tAudit', 'idPrefix': 'a_', 'timestamps': False,
75
+ 'datasource': SRC_A,
76
+ 'fields': {'kind': {'type': 'string'}, 'ref': {'type': 'string'},
77
+ 'amount': {'type': 'number'}},
78
+ })
79
+ _sc.register({
80
+ 'name': 'TBoom', 'collection': 'tBoom', 'idPrefix': 'b_', 'timestamps': False,
81
+ 'datasource': SRC_A, 'fields': {},
82
+ 'triggers': {'insert': [{'name': 'boom', 'fnRef': 'boom'}]},
83
+ })
84
+ _sc.register({
85
+ 'name': 'TGhost', 'collection': 'tGhost', 'idPrefix': 'g_', 'timestamps': False,
86
+ 'datasource': SRC_A, 'fields': {},
87
+ 'triggers': {'insert': [{'name': 'ghost', 'fnRef': 'ghost'}]},
88
+ })
89
+ _sc.register({
90
+ 'name': 'TCmdFail', 'collection': 'tCmdFail', 'idPrefix': 'cf_', 'timestamps': False,
91
+ 'datasource': SRC_A, 'fields': {'amount': {'type': 'number'}},
92
+ # A5:命令式触发写失败(data._id 固定字面量,与预置行冲突)
93
+ 'triggers': {
94
+ 'insert': [{'name': 'cf_aud', 'into': 'TAudit', 'op': 'insert',
95
+ 'data': {'_id': 'fixed-collision', 'kind': 'cf', 'ref': '{{root._id}}'}}],
96
+ },
97
+ })
98
+ _sc.register({
99
+ 'name': 'TXOrder', 'collection': 'tXOrder', 'idPrefix': 'x_', 'timestamps': False,
100
+ 'datasource': SRC_A, 'fields': {'amount': {'type': 'number'}},
101
+ # A6:跨源触发链(目标在 SRC_B)
102
+ 'triggers': {
103
+ 'insert': [{'name': 'x_aud', 'into': 'TXAudit', 'op': 'insert',
104
+ 'data': {'_id': '{{root._id}}', 'ref': '{{root._id}}'}}],
105
+ },
106
+ })
107
+ _sc.register({
108
+ 'name': 'TXAudit', 'collection': 'tXAudit', 'idPrefix': 'xa_', 'timestamps': False,
109
+ 'datasource': SRC_B, 'fields': {'ref': {'type': 'string'}},
110
+ })
111
+
112
+
113
+ # ─── 回调实现注入(启动期一次;运行期计数在用例内自管) ─────────
114
+
115
+ cb_calls = []
116
+
117
+
118
+ async def _aud_recorder(args, ctx, host):
119
+ cb_calls.append(args)
120
+ await host['store'].insert('TAudit', {'kind': 'cb', 'ref': args['ref'], 'amount': 0})
121
+
122
+
123
+ async def _boom(args, ctx, host):
124
+ raise RuntimeError('boom-err')
125
+
126
+
127
+ store.set_trigger_fn('audRecorder', _aud_recorder)
128
+ store.set_trigger_fn('boom', _boom)
129
+
130
+
131
+ def _run(coro):
132
+ return asyncio.run(coro)
133
+
134
+
135
+ async def _setup():
136
+ """环境复位(node beforeEach 镜像):sink / ctx / 档位 / 双源内存库"""
137
+ events.clear()
138
+ cb_calls.clear()
139
+ feedback.set_sink(events.append)
140
+ permission.set_context(None)
141
+ store.set_profile('standard')
142
+ db_a = await aiosqlite.connect(':memory:')
143
+ db_b = await aiosqlite.connect(':memory:')
144
+ db_a.row_factory = aiosqlite.Row
145
+ db_b.row_factory = aiosqlite.Row
146
+ await db_a.executescript(_DDL)
147
+ await db_b.executescript(_DDL)
148
+ await init({
149
+ SRC_A: executors.create_connection('sqlite', db_a),
150
+ SRC_B: executors.create_connection('sqlite', db_b),
151
+ })
152
+ return db_a, db_b
153
+
154
+
155
+ async def _fetchall(db, sql):
156
+ cur = await db.execute(sql)
157
+ rows = await cur.fetchall()
158
+ await cur.close()
159
+ return rows
160
+
161
+
162
+ async def _count(db, sql):
163
+ rows = await _fetchall(db, sql)
164
+ return rows[0][0]
165
+
166
+
167
+ events = []
168
+
169
+
170
+ # ─── 用例 ────────────────────────────────────────────────────
171
+
172
+ def test_a2_a3_insert_command_and_callback():
173
+ """A2/A3 insert 触发链:命令式 + 回调式依序执行,占位符整值替换"""
174
+
175
+ async def scenario():
176
+ db_a, _ = await _setup()
177
+ doc = await store.insert('TOrder', {'amount': 100, 'status': 'new', 'note': ''})
178
+ rows = await _fetchall(db_a, 'SELECT _id, kind, ref, amount FROM t_audit ORDER BY kind')
179
+ await db_a.close()
180
+ return doc, rows
181
+
182
+ doc, rows = _run(scenario())
183
+ # aud_cmd(与重复同名去重后一次)+ aud_cb
184
+ assert [r['kind'] for r in rows] == ['cb', 'cmd']
185
+ cmd = next(r for r in rows if r['kind'] == 'cmd')
186
+ assert cmd['_id'] == doc['_id'], '{{root._id}} 整值替换'
187
+ assert cmd['ref'] == doc['_id']
188
+ assert cmd['amount'] == 100, '{{root.amount}} 整值替换'
189
+ assert len(cb_calls) == 1, '回调执行一次'
190
+ assert cb_calls[0]['ref'] == doc['_id'], '回调 args 占位符替换'
191
+
192
+
193
+ def test_a3_a4_update_on_fields_hit_and_miss():
194
+ """A3/A4 update onFields 命中才触发;未命中(值未变/字段不在 onFields)不触发"""
195
+
196
+ async def scenario():
197
+ db_a, _ = await _setup()
198
+ doc = await store.insert('TOrder', {'amount': 100, 'status': 'new', 'note': ''})
199
+
200
+ upd_count = lambda: _count( # noqa: E731
201
+ db_a, "SELECT COUNT(*) FROM t_audit WHERE kind = 'upd'")
202
+ # 未命中:字段不在 onFields
203
+ await store.update('TOrder', {'_id': doc['_id']}, {'note': 'x'})
204
+ n_miss_field = await upd_count()
205
+ # 未命中:值未变(no-op 抑制)
206
+ await store.update('TOrder', {'_id': doc['_id']}, {'amount': 100})
207
+ n_miss_value = await upd_count()
208
+ # 命中:amount 真变
209
+ updated = await store.update('TOrder', {'_id': doc['_id']}, {'amount': 200})
210
+ rows = await _fetchall(db_a, "SELECT ref, amount FROM t_audit WHERE kind = 'upd'")
211
+ await db_a.close()
212
+ return n_miss_field, n_miss_value, updated, rows, doc
213
+
214
+ n_miss_field, n_miss_value, updated, rows, doc = _run(scenario())
215
+ assert n_miss_field == 0, 'onFields 外的字段变化不触发'
216
+ assert n_miss_value == 0, 'onFields 字段值未变不触发'
217
+ assert updated['amount'] == 200
218
+ assert len(rows) == 1
219
+ assert rows[0]['ref'] == doc['_id'], '{{root._id}}'
220
+ assert rows[0]['amount'] == 200, '{{root.amount}} = 主写后的新值'
221
+
222
+
223
+ def test_a5_rollback_callback_failure():
224
+ """A5 单源回滚:触发回调失败 → 主写整体回滚"""
225
+
226
+ async def scenario():
227
+ db_a, _ = await _setup()
228
+ with pytest.raises(RuntimeError, match='boom-err'):
229
+ await store.insert('TBoom', {})
230
+ n = await _count(db_a, 'SELECT COUNT(*) FROM t_boom')
231
+ await db_a.close()
232
+ return n
233
+
234
+ assert _run(scenario()) == 0, '主写已回滚'
235
+
236
+
237
+ def test_a5_rollback_command_failure():
238
+ """A5 单源回滚:命令式触发写失败 → 主写整体回滚"""
239
+
240
+ async def scenario():
241
+ db_a, _ = await _setup()
242
+ # 预置同 _id 审计行 → 触发 insertOne 主键冲突 → 触发链失败 → 主写回滚
243
+ await db_a.execute(
244
+ "INSERT INTO t_audit (_id, kind, ref, amount) VALUES"
245
+ " ('fixed-collision', 'seed', '', 0)")
246
+ await db_a.commit()
247
+ with pytest.raises(Exception, match='UNIQUE constraint failed'):
248
+ await store.insert('TCmdFail', {'amount': 1})
249
+ n = await _count(db_a, 'SELECT COUNT(*) FROM t_cmd_fail')
250
+ await db_a.close()
251
+ return n
252
+
253
+ assert _run(scenario()) == 0, '主写已回滚'
254
+
255
+
256
+ def test_a6_cross_source_non_atomic():
257
+ """A6 跨源触发链:发 non_atomic_write(含涉及源)并顺序执行落库"""
258
+
259
+ async def scenario():
260
+ db_a, db_b = await _setup()
261
+ doc = await store.insert('TXOrder', {'amount': 1})
262
+ rows = await _fetchall(db_b, f'SELECT COUNT(*) FROM t_x_audit WHERE ref = {doc["_id"]!r}')
263
+ await db_a.close()
264
+ await db_b.close()
265
+ return rows[0][0]
266
+
267
+ n = _run(scenario())
268
+ listed = [e for e in events if e.get('type') == 'non_atomic_write']
269
+ assert len(listed) == 1, '恰发一次 non_atomic_write 声明'
270
+ assert listed[0]['sources'] == sorted([SRC_A, SRC_B]), '声明含全部涉及源'
271
+ assert n == 1, '跨源触发写已落库(顺序执行,非原子)'
272
+
273
+
274
+ def test_a6_session_cross_source_fail_closed():
275
+ """A6 会话内跨源触发 fail-closed:NonAtomicWriteError 且全部回滚"""
276
+
277
+ async def scenario():
278
+ from py_store import NonAtomicWriteError
279
+ db_a, db_b = await _setup()
280
+ try:
281
+ async with store.session() as s:
282
+ await s.insert('TXOrder', {'amount': 1})
283
+ except NonAtomicWriteError:
284
+ pass
285
+ else:
286
+ raise AssertionError('应抛 NonAtomicWriteError')
287
+ n_order = await _count(db_a, 'SELECT COUNT(*) FROM t_x_order')
288
+ n_audit = await _count(db_b, 'SELECT COUNT(*) FROM t_x_audit')
289
+ await db_a.close()
290
+ await db_b.close()
291
+ return n_order, n_audit
292
+
293
+ n_order, n_audit = _run(scenario())
294
+ assert n_order == 0, '主写已回滚'
295
+ assert n_audit == 0, '触发写已回滚'
296
+
297
+
298
+ def test_a8_same_transaction_dedupe():
299
+ """A8 同事务去重:同名触发只执行一次(若不去重将主键冲突整体失败)"""
300
+
301
+ async def scenario():
302
+ db_a, _ = await _setup()
303
+ doc = await store.insert('TOrder', {'amount': 7, 'status': 'new', 'note': ''})
304
+ n = await _count(
305
+ db_a, f"SELECT COUNT(*) FROM t_audit WHERE kind = 'cmd' AND _id = {doc['_id']!r}")
306
+ await db_a.close()
307
+ return n
308
+
309
+ assert _run(scenario()) == 1, '同名 (aud_cmd) 触发去重后只执行一次'
310
+
311
+
312
+ def test_a9_text2query_rejects_triggers():
313
+ """A9 text2query 档:触发器显式拒绝(ProfileViolation + profile_blocked 反馈)"""
314
+
315
+ async def scenario():
316
+ await _setup()
317
+ store.set_profile('text2query')
318
+ with pytest.raises(ProfileViolation):
319
+ await store.insert('TOrder', {'amount': 1, 'status': 'new', 'note': ''})
320
+
321
+ _run(scenario())
322
+ blocked = [e for e in events if e.get('type') == 'profile_blocked']
323
+ assert len(blocked) == 1, '发 profile_blocked 反馈事件'
324
+
325
+
326
+ def test_trigger_fn_missing_start_and_runtime():
327
+ """回调缺实现:启动期 assert_trigger_fns_covered 与运行期均显式报 ERR_TRIGGER_FN_MISSING"""
328
+ with pytest.raises(RuntimeError, match='ERR_TRIGGER_FN_MISSING'):
329
+ store.assert_trigger_fns_covered(
330
+ [{'name': 'X', 'triggers': {'insert': [{'fnRef': 'ghost'}]}}])
331
+
332
+ async def scenario():
333
+ await _setup()
334
+ await store.insert('TGhost', {})
335
+
336
+ with pytest.raises(RuntimeError, match='ERR_TRIGGER_FN_MISSING'):
337
+ _run(scenario())
338
+
339
+
340
+ def test_placeholder_embed_is_err():
341
+ """占位符内嵌拼接:显式报 ERR_TRIGGER_PLACEHOLDER(禁静默漂移)"""
342
+ scope = {'root': {'_id': 'x'}, 'before': None, 'now': 1}
343
+ with pytest.raises(ValueError, match='ERR_TRIGGER_PLACEHOLDER'):
344
+ resolve_trigger_placeholders('order-{{root._id}}', scope)
345
+ with pytest.raises(ValueError, match='ERR_TRIGGER_PLACEHOLDER'):
346
+ resolve_trigger_placeholders('a{{now}}b', scope)
347
+ # 引用缺失字段 = 整值替换语义(取值缺失 → None),不抛
348
+ assert resolve_trigger_placeholders('{{root.missing}}', scope) is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes