use serde_json::{Map, Value};
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub struct CompileDiag {
pub line: i64,
pub message: String,
}
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct StatementOut {
pub index: usize,
pub source: String,
pub group: String,
pub sink: String,
pub pushdown: Vec<String>,
pub select_sql: String,
pub start_offset: Option<String>,
}
pub fn substitute_db_refs(sql: &str, ds_mapping: &Map<String, Value>) -> String {
let chars: Vec<char> = sql.chars().collect();
let mut out = String::with_capacity(sql.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\'' || c == '"' {
let quote = c;
out.push(c);
i += 1;
while i < chars.len() {
out.push(chars[i]);
if chars[i] == quote {
if quote == '\'' && i + 1 < chars.len() && chars[i + 1] == '\'' {
out.push(chars[i + 1]);
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
continue;
}
if is_ident_start(c) {
let start = i;
while i < chars.len() && is_ident_part(chars[i]) {
i += 1;
}
let first: String = chars[start..i].iter().collect();
if i < chars.len()
&& chars[i] == '.'
&& i + 1 < chars.len()
&& is_ident_start(chars[i + 1])
{
let mut j = i + 1;
while j < chars.len() && is_ident_part(chars[j]) {
j += 1;
}
let second: String = chars[i + 1..j].iter().collect();
let followed_by_dot = j < chars.len() && chars[j] == '.';
if !followed_by_dot {
if let Some(ds_id) = ds_mapping.get(&first).and_then(Value::as_i64) {
out.push_str(&format!("${{ds:{ds_id}.{first}.{second}}}"));
i = j;
continue;
}
}
out.push_str(&first);
out.push('.');
out.push_str(&second);
i = j;
continue;
}
out.push_str(&first);
continue;
}
out.push(c);
i += 1;
}
out
}
fn is_ident_start(c: char) -> bool {
c.is_alphabetic() || c == '_'
}
fn is_ident_part(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
pub fn compile_sql(sql: &str, pipeline_id: u64) -> (Vec<StatementOut>, Vec<CompileDiag>) {
match tailrace_parser::compile(sql, pipeline_id) {
Ok((compiled, diags)) => {
let statements = compiled
.statements
.iter()
.enumerate()
.map(|(index, s)| StatementOut {
index,
source: s.source.clone(),
group: s.group.clone(),
sink: s.sink.clone(),
pushdown: s.pushdown.clone(),
select_sql: s.select_sql.clone(),
start_offset: s.start_offset.clone(),
})
.collect();
let diags = diags
.iter()
.map(|d| CompileDiag {
line: d.statement.map(|s| s as i64 + 1).unwrap_or(1),
message: d.message.clone(),
})
.collect();
(statements, diags)
}
Err(e) => (
Vec::new(),
vec![CompileDiag {
line: 1,
message: format!("SQL 解析失败: {e:#}"),
}],
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn mapping() -> Map<String, Value> {
let mut m = Map::new();
m.insert("shop".into(), json!(3));
m.insert("ods".into(), json!(4));
m
}
#[test]
fn rewrites_only_mapped_two_part_refs() {
let sql = "INSERT INTO ods.orders\nSELECT o.id, 1.5 AS score FROM shop.orders o WHERE op_type <> 'D'";
let out = substitute_db_refs(sql, &mapping());
assert_eq!(
out,
"INSERT INTO ${ds:4.ods.orders}\nSELECT o.id, 1.5 AS score FROM ${ds:3.shop.orders} o WHERE op_type <> 'D'",
"aliases (o.id), numeric literals (1.5) and literals stay untouched"
);
}
#[test]
fn skips_strings_and_three_part_chains() {
let sql = "SELECT 'shop.orders' AS x, t.shop.orders FROM shop.orders";
let out = substitute_db_refs(sql, &mapping());
assert!(out.contains("'shop.orders'"), "literal untouched: {out}");
assert!(
out.contains("t.shop.orders"),
"three-part chain untouched: {out}"
);
assert!(
out.contains("${ds:3.shop.orders}"),
"from ref rewritten: {out}"
);
}
#[test]
fn compile_via_local_parser() {
let sql = "INSERT INTO ${ds:4.ods.orders} SELECT id, payload FROM ${ds:3.shop.orders} WHERE op_type IN ('I','U')";
let (stmts, diags) = compile_sql(sql, 42);
assert!(diags.is_empty(), "{diags:?}");
assert_eq!(stmts.len(), 1);
assert_eq!(stmts[0].source, "3.shop.orders");
assert_eq!(stmts[0].sink, "4.ods.orders");
assert_eq!(stmts[0].group, "tr.p42.s0");
assert_eq!(stmts[0].pushdown, vec!["I", "U"]);
assert!(stmts[0].select_sql.contains("FROM src"));
let (stmts, diags) = compile_sql("SELECT 1", 42);
assert!(stmts.is_empty());
assert!(!diags.is_empty());
assert!(diags[0].message.contains("INSERT"), "{diags:?}");
let raw = substitute_db_refs(
"INSERT INTO nosuch.orders SELECT id FROM shop.orders",
&mapping(),
);
let (_, diags) = compile_sql(&raw, 42);
assert!(
!diags.is_empty(),
"unmapped target must diagnose: {diags:?}"
);
}
}