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 =
84 merge_row_condition(registry, schema, ctx, "update", Some(filter.clone()))
85 .unwrap_or_else(|| json!({}));
86
87 let update_doc = if has_raw_operators(update) {
88 build_raw_update(registry, schema, ctx, update, now)
89 } else {
90 let mut set_data = build_set_data(registry, schema, ctx, update);
91 if schema.timestamps {
92 set_data.insert("updatedAt".to_string(), json!(now));
93 }
94 json!({ "$set": Value::Object(set_data) })
95 };
96 ensure_fields_declared(schema, &update_doc)?;
97
98 let sort_v = sort.cloned().unwrap_or_else(|| json!([["_id", 1]]));
99 let claim_cmd = cmd_claim(schema, &eff_condition, &sort_v, limit, &update_doc, now);
100 let mut find_cmd = cmd_find(
101 schema,
102 &json!({ "_id": { "$in": CLAIMED_IDS } }),
103 None,
104 );
105 if let Some(o) = find_cmd.as_object_mut() {
106 o.insert("placeholder".to_string(), json!(CLAIMED_IDS));
107 }
108 let plan = json!({
109 "mode": MODE_ATOMIC_COMMANDS,
110 "schema": schema_name,
111 "commands": [claim_cmd, find_cmd],
112 });
113 attach_write_links(plan, registry, &[schema_name], registry.write_link_policy())
114}
115
116pub(super) fn ensure_fields_declared(schema: &Schema, update_doc: &Value) -> Result<(), String> {
121 let exempt = |f: &str| {
122 f == "_id" || (schema.timestamps && (f == "createdAt" || f == "updatedAt"))
123 };
124 let Some(ops) = update_doc.as_object() else {
125 return Ok(());
126 };
127 for (op, body) in ops {
128 if !matches!(op.as_str(), "$set" | "$inc" | "$unset") {
130 continue;
131 }
132 let Some(fields) = body.as_object() else {
133 continue;
134 };
135 for k in fields.keys() {
136 if !exempt(k) && !schema.fields.contains_key(k) {
137 return Err(format!(
138 "并发原语(claim/compareAndSet)的 update 字段 \"{k}\" 未在 schema 声明(D2:规划期拒绝,不落库报错)"
139 ));
140 }
141 }
142 }
143 Ok(())
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::command::plan_compare_and_set;
150 use crate::schema::Registry;
151 use serde_json::json;
152
153 fn registry() -> Registry {
154 let mut r = Registry::new();
155 r.register(&json!({
156 "name": "Outbox", "collection": "outbox", "timestamps": false,
157 "fields": {
158 "status": { "type": "string" },
159 "lockedBy": { "type": "string" },
160 "lockedAt": { "type": "int" }
161 },
162 "relations": {}
163 }))
164 .expect("schema 注册失败");
165 r
166 }
167
168 #[test]
171 fn plan_claim_shape() {
172 let r = registry();
173 let plan = plan_claim(
174 "Outbox",
175 &r,
176 None,
177 &json!({ "status": "pending" }),
178 &json!({ "lockedBy": "w1", "lockedAt": 1 }),
179 None,
180 10,
181 123,
182 )
183 .expect("plan");
184 assert_eq!(plan["mode"], json!(MODE_ATOMIC_COMMANDS));
185 assert_eq!(plan["schema"], json!("Outbox"));
186 let cmds = plan["commands"].as_array().unwrap();
187 assert_eq!(cmds.len(), 2);
188 assert_eq!(cmds[0]["kind"], json!("claim"));
190 assert_eq!(cmds[0]["collection"], json!("outbox"));
191 assert_eq!(cmds[0]["sort"], json!([["_id", 1]]), "缺省排序 FIFO");
192 assert_eq!(cmds[0]["limit"], json!(10));
193 assert_eq!(cmds[0]["nowStamp"], json!(123));
194 assert!(cmds[0]["source"].is_string());
195 assert_eq!(cmds[1]["kind"], json!("find"));
197 assert_eq!(
198 cmds[1]["filter"]["_id"]["$in"],
199 json!(CLAIMED_IDS),
200 "占位符逐字 ___claimed_ids___"
201 );
202 assert_eq!(cmds[1]["placeholder"], json!(CLAIMED_IDS));
203 }
204
205 #[test]
206 fn plan_cas_shape() {
207 let r = registry();
208 let plan = plan_compare_and_set(
209 "Outbox",
210 &r,
211 None,
212 &json!({ "_id": "l1", "lockedBy": "w1" }),
213 &json!({ "lockedAt": 2 }),
214 456,
215 )
216 .expect("plan");
217 assert_eq!(plan["mode"], json!(MODE_ATOMIC_COMMANDS));
218 let cmds = plan["commands"].as_array().unwrap();
219 assert_eq!(cmds.len(), 1, "CAS 单命令也走 atomic_commands");
220 assert_eq!(cmds[0]["kind"], json!("compareAndSet"));
221 assert_eq!(cmds[0]["nowStamp"], json!(456));
222 }
223
224 #[test]
227 fn plan_claim_unknown_schema_errs() {
228 let r = registry();
229 let err = plan_claim(
230 "NoSuch",
231 &r,
232 None,
233 &json!({}),
234 &json!({ "lockedBy": "w1" }),
235 None,
236 1,
237 0,
238 )
239 .unwrap_err();
240 assert!(!err.is_empty());
241 }
242
243 #[test]
244 fn plan_claim_nonpositive_limit_errs() {
245 let r = registry();
246 for limit in [0i64, -3] {
247 let err = plan_claim(
248 "Outbox",
249 &r,
250 None,
251 &json!({}),
252 &json!({ "lockedBy": "w1" }),
253 None,
254 limit,
255 0,
256 )
257 .unwrap_err();
258 assert!(err.contains("limit 必须 > 0"), "{err}");
259 }
260 }
261
262 #[test]
263 fn plan_claim_empty_update_errs() {
264 let r = registry();
265 let err = plan_claim(
266 "Outbox",
267 &r,
268 None,
269 &json!({}),
270 &json!({}),
271 None,
272 1,
273 0,
274 )
275 .unwrap_err();
276 assert!(err.contains("非空对象"), "{err}");
277 }
278
279 #[test]
280 fn plan_claim_undeclared_update_field_errs() {
281 let r = registry();
282 let err = plan_claim(
283 "Outbox",
284 &r,
285 None,
286 &json!({}),
287 &json!({ "noSuchField": 1 }),
288 None,
289 1,
290 0,
291 )
292 .unwrap_err();
293 assert!(err.contains("未在 schema 声明"), "{err}");
294 }
295
296 #[test]
297 fn plan_cas_undeclared_update_field_errs() {
298 let r = registry();
299 let err = plan_compare_and_set(
300 "Outbox",
301 &r,
302 None,
303 &json!({}),
304 &json!({ "noSuchField": 1 }),
305 0,
306 )
307 .unwrap_err();
308 assert!(err.contains("未在 schema 声明"), "{err}");
309 }
310}