use serde_json::{json, Value};
use crate::dialect::filter::build_filter;
use crate::dialect::ir::{RowCol, RowShape, SqlStmt};
use crate::dialect::{field_column_ref, Backend, ColumnRef};
use crate::schema::Schema;
use super::update::translate_update_many;
use super::update::build_assignments;
use super::{col_map, returning_cols, returning_shape, tname, Binder};
pub(super) fn translate_claim(
backend: Backend,
schema: &Schema,
cmd: &Value,
) -> Result<Vec<SqlStmt>, String> {
let filter = cmd.get("filter").cloned().unwrap_or(json!({}));
let sort = cmd.get("sort").cloned().unwrap_or_else(|| json!([["_id", 1]]));
let limit = cmd
.get("limit")
.and_then(|v| v.as_i64())
.ok_or_else(|| "claim 命令缺少 limit(或非整数)".to_string())?;
let update = cmd.get("update").cloned().unwrap_or(json!({}));
if limit <= 0 {
return Err(format!(
"claim 的 limit 必须 > 0,实际 {limit}(禁静默取 1)"
));
}
let mut binder = Binder::new(backend);
let assigns = build_assignments(&mut binder, schema, &update, None)?;
if assigns.is_empty() {
return Err("claim 的 update 没有提供要更新的字段".to_string());
}
match backend {
Backend::Mysql | Backend::Postgres => {
let mut sel = Binder::new(backend);
let mut seq = sel.seq();
let wh = build_filter(&filter, backend, "t", &col_map(schema), &mut seq, None)?;
sel.params.extend(wh.params.clone());
let lim_ph = sel.bind(json!(limit));
let where_sql = if wh.text.is_empty() {
String::new()
} else {
format!(" WHERE {}", wh.text)
};
let text = format!(
"SELECT t.{} FROM {} t{}{} LIMIT {} FOR UPDATE SKIP LOCKED",
backend.pcol("_id"),
tname(backend, schema),
where_sql,
order_by_of(backend, schema, &sort)?,
lim_ph,
);
let shape = RowShape {
columns: vec![RowCol::scalar("_id", &["_id"])],
present_alias: None,
};
let stmt1 = SqlStmt::select(text, sel.params, shape);
let upd_text = format!(
"UPDATE {} AS t SET {} WHERE t.{} IN ({})",
tname(backend, schema),
assigns.join(", "),
backend.pcol("_id"),
crate::command::CLAIMED_IDS,
);
let stmt2 = SqlStmt::write(upd_text, binder.params);
Ok(vec![stmt1, stmt2])
}
Backend::Sqlite => {
let mut seq = 0usize;
let wh = build_filter(&filter, backend, "t", &col_map(schema), &mut seq, None)?;
let where_sql = if wh.text.is_empty() {
String::new()
} else {
format!(" WHERE {}", wh.text)
};
let order = order_by_of(backend, schema, &sort)?;
let subquery = format!(
"(SELECT t.{} FROM {} t{}{} LIMIT ?)",
backend.pcol("_id"),
tname(backend, schema),
where_sql,
order,
);
let cols = returning_cols(schema);
let select_list = cols
.iter()
.map(|c| backend.pcol(c))
.collect::<Vec<_>>()
.join(", ");
let upd_text = format!(
"UPDATE {} AS t SET {} WHERE t.{} IN {} RETURNING {}",
tname(backend, schema),
assigns.join(", "),
backend.pcol("_id"),
subquery,
select_list,
);
let mut upd_params = binder.params;
upd_params.extend(wh.params.iter().cloned());
upd_params.push(json!(limit));
Ok(vec![SqlStmt::write_returning(
upd_text,
upd_params,
returning_shape(schema, &cols),
)])
}
}
}
pub(super) fn translate_cas(
backend: Backend,
schema: &Schema,
cmd: &Value,
) -> Result<Vec<SqlStmt>, String> {
let filter = cmd.get("filter").cloned().unwrap_or(json!({}));
let update = cmd.get("update").cloned().unwrap_or(json!({}));
if backend == Backend::Mysql {
return translate_cas_mysql(schema, &filter, &update);
}
translate_update_many(backend, schema, &filter, &update)
}
fn translate_cas_mysql(
schema: &Schema,
filter: &Value,
update: &Value,
) -> Result<Vec<SqlStmt>, String> {
let mut binder = Binder::new(Backend::Mysql);
let assigns = build_assignments(&mut binder, schema, update, None)?;
if assigns.is_empty() {
return Err("compareAndSet 的 update 没有提供要更新的字段".to_string());
}
let mut sel = Binder::new(Backend::Mysql);
let mut seq = sel.seq();
let wh = build_filter(filter, Backend::Mysql, "t", &col_map(schema), &mut seq, None)?;
sel.params.extend(wh.params.clone());
let where_sql = if wh.text.is_empty() {
String::new()
} else {
format!(" WHERE {}", wh.text)
};
let text = format!(
"SELECT t.{} FROM {} t{} FOR UPDATE",
Backend::Mysql.pcol("_id"),
tname(Backend::Mysql, schema),
where_sql,
);
let shape = RowShape {
columns: vec![RowCol::scalar("_id", &["_id"])],
present_alias: None,
};
let stmt1 = SqlStmt::select(text, sel.params, shape);
let upd_text = format!(
"UPDATE {} AS t SET {} WHERE t.{} IN ({})",
tname(Backend::Mysql, schema),
assigns.join(", "),
Backend::Mysql.pcol("_id"),
crate::command::CLAIMED_IDS,
);
let stmt2 = SqlStmt::write(upd_text, binder.params);
Ok(vec![stmt1, stmt2])
}
fn order_by_of(backend: Backend, schema: &Schema, sort: &Value) -> Result<String, String> {
let pairs = sort
.as_array()
.ok_or_else(|| "claim 的 sort 必须为 [[field, dir], ...] 数组".to_string())?;
if pairs.is_empty() {
return Err("claim 的 sort 不能为空(领取顺序必须确定性,禁降级)".to_string());
}
let mut parts: Vec<String> = Vec::with_capacity(pairs.len());
for pair in pairs {
let arr = pair
.as_array()
.filter(|a| a.len() == 2)
.ok_or_else(|| "claim 的 sort 项必须为 [field, dir] 二元组".to_string())?;
let field = arr[0]
.as_str()
.ok_or_else(|| "claim 的 sort 字段名必须为字符串".to_string())?;
let dir = if arr[1].as_i64().unwrap_or(1) >= 0 {
"ASC"
} else {
"DESC"
};
match field_column_ref(schema, field) {
Some(ColumnRef::Scalar(c)) => {
parts.push(format!("t.{} {}", backend.pcol(&c), dir));
}
Some(ColumnRef::JsonPath(col, path)) => {
let base = format!("t.{}", backend.pcol(&col));
let segs: Vec<&str> = path.iter().map(String::as_str).collect();
let expr = backend.json_extract_scalar(&base, &segs)?;
parts.push(format!("{} {}", expr, dir));
}
_ => {
return Err(format!(
"claim 的 sort 字段 \"{field}\" 无法映射为可下推排序列(object/array 整值排序禁止;claim 排序必须确定性,禁降级)"
));
}
}
}
Ok(format!(" ORDER BY {}", parts.join(", ")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::Registry;
fn registry() -> Registry {
let mut r = Registry::new();
r.register(&json!({
"name": "Outbox", "collection": "outbox", "timestamps": false,
"fields": {
"status": { "type": "string" },
"lockedBy": { "type": "string" },
"lockedAt": { "type": "int" },
"payload": { "type": "object" }
},
"relations": {}
}))
.expect("schema 注册失败");
r
}
fn claim_cmd() -> Value {
json!({
"kind": "claim", "collection": "outbox",
"filter": { "status": "pending" },
"sort": [["_id", 1]],
"limit": 10,
"update": { "$set": { "lockedBy": "w1", "lockedAt": 123 } },
"nowStamp": 123
})
}
fn cas_cmd() -> Value {
json!({
"kind": "compareAndSet", "collection": "outbox",
"filter": { "_id": "l1", "lockedBy": "w1" },
"update": { "$set": { "lockedAt": 456 } },
"nowStamp": 456
})
}
fn stmts_of(backend: Backend, cmd: &Value) -> Vec<Value> {
let r = registry();
let out = crate::dialect::translate(backend, cmd, &r).expect("translate");
out["stmts"]
.as_array()
.expect("stmts 数组")
.clone()
}
#[test]
fn claim_mysql_two_step_snapshot() {
let stmts = stmts_of(Backend::Mysql, &claim_cmd());
assert_eq!(stmts.len(), 2, "MySQL 应产出两步 SQL");
let s1 = stmts[0]["text"].as_str().unwrap();
assert!(s1.contains("FOR UPDATE SKIP LOCKED"), "stmt1 须行锁: {s1}");
assert!(s1.contains("ORDER BY t.`_id` ASC"), "缺省排序 FIFO: {s1}");
assert!(s1.ends_with("LIMIT ? FOR UPDATE SKIP LOCKED"), "{s1}");
assert_eq!(stmts[0]["isWrite"], json!(false));
assert_eq!(stmts[0]["params"], json!(["pending", 10]));
let s2 = stmts[1]["text"].as_str().unwrap();
assert!(s2.starts_with("UPDATE `outbox` AS t SET"), "{s2}");
assert!(s2.contains("t.`_id` IN (___claimed_ids___)"), "token 占位: {s2}");
assert_eq!(stmts[1]["isWrite"], json!(true));
assert_eq!(stmts[1]["params"], json!([123, "w1"]));
}
#[test]
fn claim_pg_two_step_snapshot() {
let stmts = stmts_of(Backend::Postgres, &claim_cmd());
assert_eq!(stmts.len(), 2);
let s1 = stmts[0]["text"].as_str().unwrap();
assert!(s1.contains("FOR UPDATE SKIP LOCKED"), "{s1}");
assert!(s1.contains("LIMIT $2 FOR UPDATE SKIP LOCKED"), "{s1}");
let s2 = stmts[1]["text"].as_str().unwrap();
assert!(s2.contains("IN (___claimed_ids___)"), "{s2}");
assert_eq!(stmts[1]["params"], json!([123, "w1"]));
}
#[test]
fn claim_sqlite_returning_snapshot() {
let stmts = stmts_of(Backend::Sqlite, &claim_cmd());
assert_eq!(stmts.len(), 1, "SQLite 单条 RETURNING");
let s1 = stmts[0]["text"].as_str().unwrap();
assert!(s1.starts_with("UPDATE"), "SQLite 领取为 UPDATE: {s1}");
assert!(
s1.contains("WHERE t.\"_id\" IN (SELECT t.\"_id\" FROM \"outbox\" t WHERE"),
"子查询领取: {s1}"
);
assert!(s1.contains("ORDER BY t.\"_id\" ASC LIMIT ?"), "{s1}");
assert!(s1.contains("RETURNING"), "领取后取行: {s1}");
assert_eq!(stmts[0]["params"], json!([123, "w1", "pending", 10]));
assert_eq!(stmts[0]["isWrite"], json!(true));
assert_eq!(stmts[0]["rowShape"]["present"], json!("__present"));
}
#[test]
fn cas_pg_sqlite_single_update_snapshot() {
for backend in [Backend::Postgres, Backend::Sqlite] {
let stmts = stmts_of(backend, &cas_cmd());
assert_eq!(stmts.len(), 1, "{backend:?} CAS 单条 UPDATE");
let text = stmts[0]["text"].as_str().unwrap();
assert!(
text.starts_with("UPDATE ") && text.contains(" AS t SET "),
"{backend:?}: {text}"
);
assert!(text.contains("WHERE"), "{backend:?}: {text}");
assert_eq!(stmts[0]["isWrite"], json!(true));
}
}
#[test]
fn cas_mysql_two_step_snapshot() {
let stmts = stmts_of(Backend::Mysql, &cas_cmd());
assert_eq!(stmts.len(), 2, "MySQL CAS 两步");
let s1 = stmts[0]["text"].as_str().unwrap();
assert!(s1.starts_with("SELECT t.`_id` FROM `outbox` t WHERE"), "{s1}");
assert!(s1.ends_with("FOR UPDATE"), "CAS 等锁(无 SKIP LOCKED): {s1}");
assert_eq!(stmts[0]["params"], json!(["l1", "w1"]));
let s2 = stmts[1]["text"].as_str().unwrap();
assert!(s2.starts_with("UPDATE `outbox` AS t SET"), "{s2}");
assert!(s2.contains("t.`_id` IN (___claimed_ids___)"), "token 占位: {s2}");
assert_eq!(stmts[1]["isWrite"], json!(true));
assert_eq!(stmts[1]["params"], json!([456]));
}
#[test]
fn claim_rejects_unmappable_sort_field() {
let mut cmd = claim_cmd();
cmd["sort"] = json!([["payload", 1]]); let r = registry();
let err = crate::dialect::translate(Backend::Mysql, &cmd, &r).unwrap_err();
assert!(err.contains("无法映射为可下推排序列"), "{err}");
}
#[test]
fn claim_rejects_nonpositive_limit() {
let mut cmd = claim_cmd();
cmd["limit"] = json!(0);
let r = registry();
let err = crate::dialect::translate(Backend::Mysql, &cmd, &r).unwrap_err();
assert!(err.contains("limit 必须 > 0"), "{err}");
}
#[test]
fn claim_rejects_raw_operator_in_update() {
let mut cmd = claim_cmd();
cmd["update"] = json!({ "$push": { "tags": "x" } });
let r = registry();
let err = crate::dialect::translate(Backend::Mysql, &cmd, &r).unwrap_err();
assert!(err.contains("不支持的操作符"), "{err}");
}
#[test]
fn unknown_kind_returns_err_not_panic() {
let r = registry();
let err = crate::dialect::translate(
Backend::Mysql,
&json!({ "kind": "claimX", "collection": "outbox" }),
&r,
)
.unwrap_err();
assert!(err.contains("未支持的命令 kind"), "{err}");
}
}