Skip to main content

rust_store_core/schema/
triggers.rs

1//! Schema 触发器声明:解析与校验(规划见 `crate::command::triggers`)。
2//!
3//! 声明形态(schema JSON 顶层 `triggers` 键):
4//!   "triggers": { "insert": [T...], "update": [T...] }
5//! T 字段契约见执行总纲「常驻契约卡」。本模块只做**结构校验**,不做运行时判定。
6
7use std::collections::HashMap;
8
9use serde_json::{Map, Value};
10
11use crate::types::str_list;
12
13/// 事件名(insert / update / remove / schedule;其余键注册期 Err)。
14/// `schedule` 仅注册期接受 —— 写链永不 expand("schedule"),由宿主定时任务插件
15/// 经 [`crate::command::triggers::expand_schedule_triggers`] 枚举执行。
16pub const EVENTS: [&str; 4] = ["insert", "update", "remove", "schedule"];
17
18/// 命令式触发支持的 op 白名单(首批不含 upsert)
19pub const OPS: [&str; 3] = ["insert", "update", "remove"];
20
21/// `triggers` = 事件名 → 触发列表
22pub type Triggers = HashMap<String, Vec<TriggerDef>>;
23
24#[derive(Debug, Clone)]
25pub struct TriggerDef {
26    /// 显式名(空 = 缺省 `<Schema>.<event>[<i>]`)
27    pub name: String,
28    /// 运行时条件(占位符替换后判定);`None` = 恒真
29    pub when: Option<Value>,
30    /// 字段级:仅这些字段「值真的变化」才进入判定;空 = 记录级
31    pub on_fields: Vec<String>,
32    /// 5 段 cron(分 时 日 月 周);仅 `schedule` 事件必填,其余事件禁给
33    pub cron: Option<String>,
34    pub body: TriggerBody,
35}
36
37#[derive(Debug, Clone)]
38pub enum TriggerBody {
39    /// 命令式:写目标 schema
40    Command {
41        into: String,
42        op: String,               // ∈ OPS
43        condition: Option<Value>, // op=update 必填
44        data: Value,              // 必填
45    },
46    /// 回调式:Host 侧 fnRef 实现
47    Callback { fn_ref: String, args: Value },
48}
49
50/// 解析并校验 schema 顶层的 `triggers`;任一项非法 → Err(零静默)。
51///
52/// 校验清单(全部命中即 Err):
53///  1. `triggers` 非对象;
54///  2. 事件键不在 `EVENTS`;
55///  3. 事件值非数组 / 元素非对象;
56///  4. `into`/`op`/`data`(命令式)与 `fnRef`(回调式)**互斥且必居其一**;
57///  5. `op` 不在 `OPS`;
58///  6. 命令式缺 `data`(`op=remove` 相反:出现 `data` 即 Err),或 `op=update`/`op=remove` 缺 `condition`;
59///  7. `onFields` 非字符串数组,或字段未在 `fields` 中声明(`$` 前缀键除外);
60///  8. `when` 非对象;出现 `cascade` 键(首批不支持级联);
61///  9. 命令式 `data` 的键未在**目标 schema** 中声明(在展开期校验,见 `command::triggers`);
62/// 10. `schedule` 事件缺 `cron` / cron 非法,或 body/when 出现 `{{root.`/`{{before.`
63///     (schedule 无 root/before 上下文,只允许 `{{now}}`);非 `schedule` 事件出现 `cron` 键。
64pub fn parse_triggers(
65    obj: &Map<String, Value>,
66    owner: &str,
67    fields: &HashMap<String, crate::schema::FieldDef>,
68) -> Result<Triggers, String> {
69    let mut out: Triggers = HashMap::new();
70    let Some(Value::Object(tm)) = obj.get("triggers") else {
71        return Ok(out);
72    };
73    for (event, val) in tm {
74        if !EVENTS.contains(&event.as_str()) {
75            return Err(format!(
76                "schema \"{owner}\" 的 triggers 事件键 \"{event}\" 非法(仅 insert/update/remove/schedule)"
77            ));
78        }
79        let arr = val
80            .as_array()
81            .ok_or_else(|| format!("schema \"{owner}\" 的 triggers.{event} 必须是数组"))?;
82        let mut list = Vec::new();
83        for (i, t) in arr.iter().enumerate() {
84            let to = t
85                .as_object()
86                .ok_or_else(|| format!("schema \"{owner}\" 的 triggers.{event}[{i}] 必须是对象"))?;
87            list.push(parse_one(owner, event, i, to, fields)?);
88        }
89        out.insert(event.clone(), list);
90    }
91    Ok(out)
92}
93
94fn parse_one(
95    owner: &str,
96    event: &str,
97    idx: usize,
98    to: &Map<String, Value>,
99    fields: &HashMap<String, crate::schema::FieldDef>,
100) -> Result<TriggerDef, String> {
101    let at = format!("{owner}.triggers.{event}[{idx}]");
102    let has_cmd = to.contains_key("into") || to.contains_key("op") || to.contains_key("data");
103    let has_cb = to.contains_key("fnRef");
104    if has_cmd == has_cb {
105        return Err(format!(
106            "{at} 必须二选一:命令式(into+op+data)或回调式(fnRef),不可同给或同缺"
107        ));
108    }
109    // onFields 字段声明校验(仅 update 有意义;insert/remove 声明了也接受但忽略)
110    let on_fields = str_list(to.get("onFields")).unwrap_or_default();
111    for f in &on_fields {
112        if !f.starts_with('$') && !fields.contains_key(f) {
113            return Err(format!("{at} 的 onFields 字段 \"{f}\" 未在 schema 中声明"));
114        }
115    }
116    if let Some(w) = to.get("when") {
117        if !w.is_object() {
118            return Err(format!("{at} 的 when 必须是对象"));
119        }
120    }
121    if to.contains_key("cascade") {
122        return Err(format!("{at} 不支持 cascade(首批触发链只展开一层)"));
123    }
124    // cron 键判定:schedule 必填且须合法 5 段;其余事件禁给(避免歧义)
125    let cron = match to.get("cron") {
126        Some(Value::String(s)) => Some(s.clone()),
127        Some(_) => return Err(format!("{at} 的 cron 必须是字符串")),
128        None => None,
129    };
130    if event == "schedule" {
131        let c = cron
132            .as_deref()
133            .ok_or_else(|| format!("{at} 的 schedule 触发器缺少 cron"))?;
134        validate_cron(c).map_err(|e| format!("{at} 的 cron \"{c}\" 非法:{e}"))?;
135        // schedule 无 root/before 上下文(只有 {{now}})——出现即注册期 Err,禁运行时留空替换
136        forbid_root_before_refs(to.get("when"), &at)?;
137        if let Some(w) = to.get("args") {
138            forbid_root_before_refs(Some(w), &at)?;
139        }
140        if let Some(d) = to.get("data") {
141            forbid_root_before_refs(Some(d), &at)?;
142        }
143        if let Some(c) = to.get("condition") {
144            forbid_root_before_refs(Some(c), &at)?;
145        }
146    } else if cron.is_some() {
147        return Err(format!("{at} 的 cron 仅 schedule 事件可配"));
148    }
149    let name = to
150        .get("name")
151        .and_then(|v| v.as_str())
152        .filter(|s| !s.is_empty())
153        .unwrap_or("")
154        .to_string();
155
156    let body = if has_cb {
157        TriggerBody::Callback {
158            fn_ref: to
159                .get("fnRef")
160                .and_then(|v| v.as_str())
161                .filter(|s| !s.is_empty())
162                .ok_or_else(|| format!("{at} 的 fnRef 须为非空字符串"))?
163                .to_string(),
164            args: to.get("args").cloned().unwrap_or(Value::Null),
165        }
166    } else {
167        let into = to
168            .get("into")
169            .and_then(|v| v.as_str())
170            .filter(|s| !s.is_empty())
171            .ok_or_else(|| format!("{at} 缺少 into"))?
172            .to_string();
173        let op = to
174            .get("op")
175            .and_then(|v| v.as_str())
176            .ok_or_else(|| format!("{at} 缺少 op"))?
177            .to_string();
178        if !OPS.contains(&op.as_str()) {
179            return Err(format!(
180                "{at} 的 op \"{op}\" 不在白名单(insert/update/remove)"
181            ));
182        }
183        let data = match op.as_str() {
184            // remove:删除语义由 condition 圈定目标,不接受 data(出现即 Err,零静默)
185            "remove" => {
186                if to.get("data").map(|d| !d.is_null()).unwrap_or(false) {
187                    return Err(format!("{at} 的 op=remove 不接受 data"));
188                }
189                Value::Null
190            }
191            _ => to
192                .get("data")
193                .filter(|v| v.is_object())
194                .cloned()
195                .ok_or_else(|| format!("{at} 的命令式触发缺 data(须为对象)"))?,
196        };
197        let condition = to.get("condition").cloned();
198        if (op == "update" || op == "remove")
199            && !condition.as_ref().map(|v| v.is_object()).unwrap_or(false)
200        {
201            return Err(format!("{at} 的 op={op} 必须提供 condition(对象)"));
202        }
203        TriggerBody::Command {
204            into,
205            op,
206            condition,
207            data,
208        }
209    };
210    Ok(TriggerDef {
211        name,
212        when: to.get("when").cloned(),
213        on_fields,
214        cron,
215        body,
216    })
217}
218
219/// 5 段 cron(分 时 日 月 周)校验:段内仅 `*` `,` `-` `/` 与数字;数字范围分校验;步进须为正整数。
220pub fn validate_cron(expr: &str) -> Result<(), String> {
221    let parts: Vec<&str> = expr.split_whitespace().collect();
222    if parts.len() != 5 {
223        return Err(format!(
224            "必须为 5 段(分 时 日 月 周),实为 {} 段",
225            parts.len()
226        ));
227    }
228    // (min, max):分(0-59) 时(0-23) 日(1-31) 月(1-12) 周(0-7,0/7 均为周日)
229    let ranges = [(0i64, 59), (0, 23), (1, 31), (1, 12), (0, 7)];
230    for (seg, (lo, hi)) in parts.iter().zip(ranges) {
231        if !seg
232            .chars()
233            .all(|c| c.is_ascii_digit() || matches!(c, '*' | ',' | '-' | '/'))
234        {
235            return Err(format!("段 \"{seg}\" 含非法字符(仅 * , - / 与数字)"));
236        }
237        for piece in seg.split(',') {
238            if piece.is_empty() {
239                return Err(format!("段 \"{seg}\" 存在空列表项"));
240            }
241            let body = match piece.split_once('/') {
242                Some((b, s)) => {
243                    if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) {
244                        return Err(format!("段 \"{seg}\" 的步进 \"{s}\" 须为正整数"));
245                    }
246                    let n: i64 = s
247                        .parse()
248                        .map_err(|_| format!("段 \"{seg}\" 的步进 \"{s}\" 越界"))?;
249                    if n <= 0 {
250                        return Err(format!("段 \"{seg}\" 的步进 \"{s}\" 须为正整数"));
251                    }
252                    b
253                }
254                None => piece,
255            };
256            if body == "*" {
257                continue;
258            }
259            let (a, b) = match body.split_once('-') {
260                Some((x, y)) => (x, y),
261                None => (body, body),
262            };
263            let parse_bound = |s: &str| -> Result<i64, String> {
264                if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) {
265                    return Err(format!("段 \"{seg}\" 含非法形态 \"{piece}\""));
266                }
267                let n: i64 = s
268                    .parse()
269                    .map_err(|_| format!("段 \"{seg}\" 的 \"{s}\" 越界"))?;
270                if n < lo || n > hi {
271                    return Err(format!("段 \"{seg}\" 的 {n} 超出范围 {lo}-{hi}"));
272                }
273                Ok(n)
274            };
275            let x = parse_bound(a)?;
276            let y = parse_bound(b)?;
277            if x > y {
278                return Err(format!("段 \"{seg}\" 区间 \"{piece}\" 起点大于终点"));
279            }
280        }
281    }
282    Ok(())
283}
284
285/// schedule body/when 递归禁 `{{root.` / `{{before.`(只允许 `{{now}}`)
286fn forbid_root_before_refs(v: Option<&Value>, at: &str) -> Result<(), String> {
287    match v {
288        Some(Value::String(s)) => {
289            if s.contains("{{root.") || s.contains("{{before.") {
290                return Err(format!(
291                    "{at} 的 schedule 触发器不支持 {{{{root.}}}}/{{{{before.}}}} 占位符(仅 {{{{now}}}})"
292                ));
293            }
294            Ok(())
295        }
296        Some(Value::Array(a)) => a
297            .iter()
298            .try_for_each(|x| forbid_root_before_refs(Some(x), at)),
299        Some(Value::Object(o)) => o
300            .values()
301            .try_for_each(|x| forbid_root_before_refs(Some(x), at)),
302        _ => Ok(()),
303    }
304}