1use serde_json::{json, Value};
31
32use crate::command::cmd::{cmd_claim, cmd_find};
33use crate::command::mutate::{build_raw_update, build_set_data, has_raw_operators};
34use crate::command::write_links::attach_write_links;
35use crate::command::{
36 ensure_context, forbid_t2q, CLAIMED_IDS, ERR_NO_BATCH_WRITE, MODE_ATOMIC_COMMANDS,
37};
38use crate::permission::{can_write_schema, Context};
39use crate::rbac::{ensure_write, merge_row_condition, WriteAction};
40use crate::schema::{Registry, Schema};
41use crate::types::{validate_condition, validate_condition_shape};
42
43#[allow(clippy::too_many_arguments)]
48pub fn plan_claim(
49 schema_name: &str,
50 registry: &Registry,
51 ctx: Option<&Context>,
52 filter: &Value,
53 update: &Value,
54 sort: Option<&Value>,
55 limit: i64,
56 now: i64,
57) -> Result<Value, String> {
58 ensure_context(registry, ctx)?;
59 forbid_t2q(registry, "claim")?;
61 let schema = registry.get(schema_name)?;
62 validate_condition(filter)?;
63 validate_condition_shape(schema, filter, registry.profile())?;
64 if limit <= 0 {
65 return Err(format!(
66 "claim 的 limit 必须 > 0,实际 {limit}(禁静默取 1)"
67 ));
68 }
69 if update.as_object().map(|o| o.is_empty()).unwrap_or(true) {
70 return Err("claim 的 update 必须为非空对象(领取必须写入标记)".to_string());
71 }
72 if let Some(c) = ctx {
74 let deny_write =
75 crate::permission::has_any_role(c, ®istry.role_rules().deny_write_roles);
76 if deny_write || !can_write_schema(registry.role_rules(), schema, ctx) {
77 return Err(ERR_NO_BATCH_WRITE.to_string());
78 }
79 }
80 ensure_write(registry, schema, ctx, WriteAction::Update)?;
83 let eff_condition = merge_row_condition(registry, schema, ctx, "update", Some(filter.clone()))
84 .unwrap_or_else(|| json!({}));
85
86 let update_doc = if has_raw_operators(update) {
87 build_raw_update(registry, schema, ctx, update, now)
88 } else {
89 let mut set_data = build_set_data(registry, schema, ctx, update);
90 if schema.timestamps {
91 set_data.insert("updatedAt".to_string(), json!(now));
92 }
93 json!({ "$set": Value::Object(set_data) })
94 };
95 ensure_fields_declared(schema, &update_doc)?;
96
97 let sort_v = sort.cloned().unwrap_or_else(|| json!([["_id", 1]]));
98 let claim_cmd = cmd_claim(schema, &eff_condition, &sort_v, limit, &update_doc, now);
99 let mut find_cmd = cmd_find(schema, &json!({ "_id": { "$in": CLAIMED_IDS } }), None);
100 if let Some(o) = find_cmd.as_object_mut() {
101 o.insert("placeholder".to_string(), json!(CLAIMED_IDS));
102 }
103 let plan = json!({
104 "mode": MODE_ATOMIC_COMMANDS,
105 "schema": schema_name,
106 "commands": [claim_cmd, find_cmd],
107 });
108 attach_write_links(plan, registry, &[schema_name], registry.write_link_policy())
109}
110
111pub(super) fn ensure_fields_declared(schema: &Schema, update_doc: &Value) -> Result<(), String> {
116 let exempt =
117 |f: &str| f == "_id" || (schema.timestamps && (f == "createdAt" || f == "updatedAt"));
118 let Some(ops) = update_doc.as_object() else {
119 return Ok(());
120 };
121 for (op, body) in ops {
122 if !matches!(op.as_str(), "$set" | "$inc" | "$unset") {
124 continue;
125 }
126 let Some(fields) = body.as_object() else {
127 continue;
128 };
129 for k in fields.keys() {
130 if !exempt(k) && !schema.fields.contains_key(k) {
131 return Err(format!(
132 "并发原语(claim/compareAndSet)的 update 字段 \"{k}\" 未在 schema 声明(D2:规划期拒绝,不落库报错)"
133 ));
134 }
135 }
136 }
137 Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::command::plan_compare_and_set;
144 use crate::schema::Registry;
145 use serde_json::json;
146
147 fn registry() -> Registry {
148 let mut r = Registry::new();
149 r.register(&json!({
150 "name": "Outbox", "collection": "outbox", "timestamps": false,
151 "fields": {
152 "status": { "type": "string" },
153 "lockedBy": { "type": "string" },
154 "lockedAt": { "type": "int" }
155 },
156 "relations": {}
157 }))
158 .expect("schema 注册失败");
159 r
160 }
161
162 #[test]
165 fn plan_claim_shape() {
166 let r = registry();
167 let plan = plan_claim(
168 "Outbox",
169 &r,
170 None,
171 &json!({ "status": "pending" }),
172 &json!({ "lockedBy": "w1", "lockedAt": 1 }),
173 None,
174 10,
175 123,
176 )
177 .expect("plan");
178 assert_eq!(plan["mode"], json!(MODE_ATOMIC_COMMANDS));
179 assert_eq!(plan["schema"], json!("Outbox"));
180 let cmds = plan["commands"].as_array().unwrap();
181 assert_eq!(cmds.len(), 2);
182 assert_eq!(cmds[0]["kind"], json!("claim"));
184 assert_eq!(cmds[0]["collection"], json!("outbox"));
185 assert_eq!(cmds[0]["sort"], json!([["_id", 1]]), "缺省排序 FIFO");
186 assert_eq!(cmds[0]["limit"], json!(10));
187 assert_eq!(cmds[0]["nowStamp"], json!(123));
188 assert!(cmds[0]["source"].is_string());
189 assert_eq!(cmds[1]["kind"], json!("find"));
191 assert_eq!(
192 cmds[1]["filter"]["_id"]["$in"],
193 json!(CLAIMED_IDS),
194 "占位符逐字 ___claimed_ids___"
195 );
196 assert_eq!(cmds[1]["placeholder"], json!(CLAIMED_IDS));
197 }
198
199 #[test]
200 fn plan_cas_shape() {
201 let r = registry();
202 let plan = plan_compare_and_set(
203 "Outbox",
204 &r,
205 None,
206 &json!({ "_id": "l1", "lockedBy": "w1" }),
207 &json!({ "lockedAt": 2 }),
208 456,
209 )
210 .expect("plan");
211 assert_eq!(plan["mode"], json!(MODE_ATOMIC_COMMANDS));
212 let cmds = plan["commands"].as_array().unwrap();
213 assert_eq!(cmds.len(), 1, "CAS 单命令也走 atomic_commands");
214 assert_eq!(cmds[0]["kind"], json!("compareAndSet"));
215 assert_eq!(cmds[0]["nowStamp"], json!(456));
216 }
217
218 #[test]
221 fn plan_claim_unknown_schema_errs() {
222 let r = registry();
223 let err = plan_claim(
224 "NoSuch",
225 &r,
226 None,
227 &json!({}),
228 &json!({ "lockedBy": "w1" }),
229 None,
230 1,
231 0,
232 )
233 .unwrap_err();
234 assert!(!err.is_empty());
235 }
236
237 #[test]
238 fn plan_claim_nonpositive_limit_errs() {
239 let r = registry();
240 for limit in [0i64, -3] {
241 let err = plan_claim(
242 "Outbox",
243 &r,
244 None,
245 &json!({}),
246 &json!({ "lockedBy": "w1" }),
247 None,
248 limit,
249 0,
250 )
251 .unwrap_err();
252 assert!(err.contains("limit 必须 > 0"), "{err}");
253 }
254 }
255
256 #[test]
257 fn plan_claim_empty_update_errs() {
258 let r = registry();
259 let err = plan_claim("Outbox", &r, None, &json!({}), &json!({}), None, 1, 0).unwrap_err();
260 assert!(err.contains("非空对象"), "{err}");
261 }
262
263 #[test]
264 fn plan_claim_undeclared_update_field_errs() {
265 let r = registry();
266 let err = plan_claim(
267 "Outbox",
268 &r,
269 None,
270 &json!({}),
271 &json!({ "noSuchField": 1 }),
272 None,
273 1,
274 0,
275 )
276 .unwrap_err();
277 assert!(err.contains("未在 schema 声明"), "{err}");
278 }
279
280 #[test]
281 fn plan_cas_undeclared_update_field_errs() {
282 let r = registry();
283 let err = plan_compare_and_set(
284 "Outbox",
285 &r,
286 None,
287 &json!({}),
288 &json!({ "noSuchField": 1 }),
289 0,
290 )
291 .unwrap_err();
292 assert!(err.contains("未在 schema 声明"), "{err}");
293 }
294}