use vibesql_parser::{Keyword, Lexer, Span, Token};
pub fn rewrite_table_refs_in_trigger_sql(sql: &str, old_table: &str, new_table: &str) -> String {
let tokens = match Lexer::new(sql).tokenize_with_spans() {
Ok(t) => t,
Err(_) => return sql.to_string(),
};
let edits = collect_table_ref_spans(&tokens, old_table);
if edits.is_empty() {
return sql.to_string();
}
apply_edits(sql, &edits, new_table)
}
fn apply_edits(sql: &str, edits: &[Span], new_table: &str) -> String {
let replacement = format!("\"{}\"", new_table);
let mut out = String::with_capacity(sql.len() + edits.len() * (replacement.len() + 2));
let mut cursor = 0usize;
for span in edits {
if span.start < cursor {
continue;
}
out.push_str(&sql[cursor..span.start]);
out.push_str(&replacement);
cursor = span.end;
}
out.push_str(&sql[cursor..]);
out
}
fn collect_table_ref_spans(tokens: &[(Token, Span)], old_table: &str) -> Vec<Span> {
let mut spans = Vec::new();
let mut from_list_stack: Vec<bool> = vec![false];
let mut seen_header_on = false;
let mut expect_header_table = false;
let significant: Vec<usize> =
(0..tokens.len()).filter(|&i| !matches!(tokens[i].0, Token::Eof)).collect();
for (pos, &idx) in significant.iter().enumerate() {
let (tok, span) = &tokens[idx];
match tok {
Token::LParen => {
from_list_stack.push(false);
}
Token::RParen => {
if from_list_stack.len() > 1 {
from_list_stack.pop();
}
}
Token::Keyword { keyword, .. } => {
update_from_list_for_keyword(*keyword, &mut from_list_stack);
if *keyword == Keyword::On && !seen_header_on {
seen_header_on = true;
expect_header_table = true;
continue;
}
}
Token::Identifier(name) => {
let is_match = name.eq_ignore_ascii_case(old_table);
if expect_header_table {
expect_header_table = false;
if is_match {
spans.push(*span);
}
continue;
}
if is_match && is_table_position(tokens, &significant, pos, &from_list_stack) {
spans.push(*span);
}
}
_ => {
expect_header_table = false;
}
}
}
spans
}
fn update_from_list_for_keyword(keyword: Keyword, from_list_stack: &mut [bool]) {
let top = from_list_stack.last_mut().expect("stack always has top level");
match keyword {
Keyword::From | Keyword::Join | Keyword::Into | Keyword::Update => {
*top = true;
}
Keyword::Set
| Keyword::Where
| Keyword::Select
| Keyword::Group
| Keyword::Order
| Keyword::Having
| Keyword::On
| Keyword::Using
| Keyword::Values
| Keyword::Limit => {
*top = false;
}
_ => {}
}
}
fn is_table_position(
tokens: &[(Token, Span)],
significant: &[usize],
pos: usize,
from_list_stack: &[bool],
) -> bool {
if let Some(&next_idx) = significant.get(pos + 1) {
if matches!(tokens[next_idx].0, Token::Symbol('.')) {
return true;
}
}
if pos == 0 {
return false;
}
let prev_idx = significant[pos - 1];
match &tokens[prev_idx].0 {
Token::Keyword { keyword, .. } => matches!(
keyword,
Keyword::From | Keyword::Join | Keyword::Into | Keyword::Update | Keyword::Table
),
Token::Comma => *from_list_stack.last().unwrap_or(&false),
_ => false,
}
}
pub fn rewrite_column_refs_in_trigger_sql(
sql: &str,
renamed_table: &str,
old_column: &str,
new_column: &str,
table_has_column: &dyn Fn(&str, &str) -> bool,
) -> Result<String, String> {
let tokens = match Lexer::new(sql).tokenize_with_spans() {
Ok(t) => t,
Err(_) => return Ok(sql.to_string()),
};
let significant: Vec<usize> =
(0..tokens.len()).filter(|&i| !matches!(tokens[i].0, Token::Eof)).collect();
let scopes = collect_scope_tables(&tokens, &significant);
let mut edits: Vec<Span> = Vec::new();
for (pos, &idx) in significant.iter().enumerate() {
let Token::Identifier(name) = &tokens[idx].0 else { continue };
if pos >= 2 {
let dot_idx = significant[pos - 1];
let qual_idx = significant[pos - 2];
if matches!(tokens[dot_idx].0, Token::Symbol('.')) {
if let Token::Identifier(qualifier) = &tokens[qual_idx].0 {
if name.eq_ignore_ascii_case(old_column)
&& qualifier_is_renamed_table(
qualifier,
renamed_table,
paren_scope_at(&tokens, &significant, pos),
&scopes,
)
{
edits.push(tokens[idx].1);
}
continue;
}
}
}
if let Some(&next_idx) = significant.get(pos + 1) {
if matches!(tokens[next_idx].0, Token::Symbol('.')) {
continue;
}
}
if name.eq_ignore_ascii_case(old_column) && is_column_position(&tokens, &significant, pos) {
let scope_key = paren_scope_at(&tokens, &significant, pos);
match resolve_unqualified_column(
renamed_table,
old_column,
scope_key,
&scopes,
table_has_column,
) {
Resolution::Rewrite => edits.push(tokens[idx].1),
Resolution::Ambiguous => return Err(name.clone()),
Resolution::Skip => {}
}
}
}
if edits.is_empty() {
return Ok(sql.to_string());
}
Ok(apply_column_edits(sql, &edits, new_column))
}
enum Resolution {
Rewrite,
Skip,
Ambiguous,
}
#[derive(Clone)]
struct TableRef {
table: String,
alias: Option<String>,
}
fn apply_column_edits(sql: &str, edits: &[Span], new_column: &str) -> String {
let mut sorted = edits.to_vec();
sorted.sort_by_key(|s| s.start);
let mut out = String::with_capacity(sql.len() + sorted.len() * new_column.len());
let mut cursor = 0usize;
for span in sorted {
if span.start < cursor {
continue;
}
out.push_str(&sql[cursor..span.start]);
out.push_str(new_column);
cursor = span.end;
}
out.push_str(&sql[cursor..]);
out
}
fn paren_scope_at(tokens: &[(Token, Span)], significant: &[usize], pos: usize) -> Option<usize> {
let target = significant[pos];
let mut idx_stack: Vec<usize> = Vec::new();
for (i, (tok, _)) in tokens.iter().enumerate().take(target) {
match tok {
Token::LParen => idx_stack.push(i),
Token::RParen => {
idx_stack.pop();
}
_ => {}
}
}
idx_stack.last().copied()
}
fn collect_scope_tables(
tokens: &[(Token, Span)],
significant: &[usize],
) -> std::collections::HashMap<Option<usize>, Vec<TableRef>> {
use std::collections::HashMap;
let mut scopes: HashMap<Option<usize>, Vec<TableRef>> = HashMap::new();
let mut paren_stack: Vec<usize> = Vec::new();
let mut in_table_list: Vec<bool> = vec![false];
let mut pos = 0usize;
while pos < significant.len() {
let idx = significant[pos];
match &tokens[idx].0 {
Token::LParen => {
paren_stack.push(idx);
in_table_list.push(false);
}
Token::RParen => {
if paren_stack.pop().is_some() {
in_table_list.pop();
}
}
Token::Keyword { keyword, .. } => {
let top = in_table_list.last_mut().expect("stack has top");
match keyword {
Keyword::From | Keyword::Into | Keyword::Update | Keyword::Join => {
*top = true;
}
Keyword::Set
| Keyword::Where
| Keyword::Select
| Keyword::Group
| Keyword::Order
| Keyword::Having
| Keyword::On
| Keyword::Using
| Keyword::Values
| Keyword::Limit => {
*top = false;
}
_ => {}
}
}
Token::Identifier(name) if *in_table_list.last().unwrap_or(&false) => {
let preceded_by_dot =
pos > 0 && matches!(tokens[significant[pos - 1]].0, Token::Symbol('.'));
let followed_by_dot = significant
.get(pos + 1)
.is_some_and(|&n| matches!(tokens[n].0, Token::Symbol('.')));
if !preceded_by_dot && !followed_by_dot {
let table = name.clone();
let alias = parse_alias(tokens, significant, pos);
let scope_key = paren_stack.last().copied();
scopes.entry(scope_key).or_default().push(TableRef { table, alias });
}
}
_ => {}
}
pos += 1;
}
scopes
}
fn parse_alias(tokens: &[(Token, Span)], significant: &[usize], pos: usize) -> Option<String> {
let mut next = pos + 1;
if let Some(&nidx) = significant.get(next) {
if matches!(tokens[nidx].0, Token::Keyword { keyword: Keyword::As, .. }) {
next += 1;
}
}
if let Some(&nidx) = significant.get(next) {
if let Token::Identifier(alias) = &tokens[nidx].0 {
if next == pos + 1 || next == pos + 2 {
return Some(alias.clone());
}
}
}
None
}
fn qualifier_is_renamed_table(
qualifier: &str,
renamed_table: &str,
scope_key: Option<usize>,
scopes: &std::collections::HashMap<Option<usize>, Vec<TableRef>>,
) -> bool {
if qualifier.eq_ignore_ascii_case(renamed_table) {
return true;
}
for key in [scope_key, None] {
if let Some(refs) = scopes.get(&key) {
for r in refs {
if let Some(alias) = &r.alias {
if alias.eq_ignore_ascii_case(qualifier)
&& r.table.eq_ignore_ascii_case(renamed_table)
{
return true;
}
}
}
}
}
false
}
fn resolve_unqualified_column(
renamed_table: &str,
old_column: &str,
scope_key: Option<usize>,
scopes: &std::collections::HashMap<Option<usize>, Vec<TableRef>>,
table_has_column: &dyn Fn(&str, &str) -> bool,
) -> Resolution {
if !table_has_column(renamed_table, old_column) {
return Resolution::Skip;
}
let mut renamed_in_scope = false;
let mut other_owner = false;
for key in [scope_key, None] {
if let Some(refs) = scopes.get(&key) {
for r in refs {
if r.table.eq_ignore_ascii_case(renamed_table) {
renamed_in_scope = true;
} else if table_has_column(&r.table, old_column) {
other_owner = true;
}
}
}
if renamed_in_scope {
break;
}
}
match (renamed_in_scope, other_owner) {
(true, false) => Resolution::Rewrite,
(true, true) => Resolution::Ambiguous,
(false, _) => Resolution::Skip,
}
}
fn is_column_position(tokens: &[(Token, Span)], significant: &[usize], pos: usize) -> bool {
if pos == 0 {
return true;
}
let prev_idx = significant[pos - 1];
match &tokens[prev_idx].0 {
Token::Keyword { keyword, .. } => !matches!(
keyword,
Keyword::From
| Keyword::Join
| Keyword::Into
| Keyword::Update
| Keyword::Table
| Keyword::As
),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rewrite(sql: &str, old: &str, new: &str) -> String {
rewrite_table_refs_in_trigger_sql(sql, old, new)
}
fn altertrig_schema(table: &str, column: &str) -> bool {
let cols: &[&str] = match table.to_ascii_lowercase().as_str() {
"t1" => &["a", "b"],
"t2" => &["c", "d"],
"t3" | "t4" => &["e", "f"],
_ => &[],
};
cols.iter().any(|c| c.eq_ignore_ascii_case(column))
}
fn rewrite_col(sql: &str, table: &str, old: &str, new: &str) -> String {
rewrite_column_refs_in_trigger_sql(sql, table, old, new, &altertrig_schema)
.expect("rewrite should not be ambiguous in this fixture")
}
fn rewrite_col_result(sql: &str, table: &str, old: &str, new: &str) -> Result<String, String> {
rewrite_column_refs_in_trigger_sql(sql, table, old, new, &altertrig_schema)
}
#[test]
fn col_2_3_unqualified_in_nested_subquery() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a='xyz' FROM t3, (SELECT * FROM (SELECT e FROM t3));\n END";
let got = rewrite_col(sql, "t3", "e", "abc");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a='xyz' FROM t3, (SELECT * FROM (SELECT abc FROM t3));\n END"
);
}
#[test]
fn col_2_4_unqualified_in_where() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a='xyz' FROM t3, (SELECT 1 FROM t2 WHERE c);\n END";
let got = rewrite_col(sql, "t2", "c", "abc");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a='xyz' FROM t3, (SELECT 1 FROM t2 WHERE abc);\n END"
);
}
#[test]
fn col_2_5_qualified_before_from() {
let sql =
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=t2.c FROM t2;\n END";
let got = rewrite_col(sql, "t2", "c", "abc");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=t2.abc FROM t2;\n END"
);
}
#[test]
fn col_2_6_qualified_multi_from() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=t2.c FROM t2, t3;\n END";
let got = rewrite_col(sql, "t2", "c", "abc");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=t2.abc FROM t2, t3;\n END"
);
}
#[test]
fn col_2_7_qualified_natural_join() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=1 FROM t3 NATURAL JOIN t4 WHERE t4.e=a;\n END";
let got = rewrite_col(sql, "t4", "e", "abc");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=1 FROM t3 NATURAL JOIN t4 WHERE t4.abc=a;\n END"
);
}
#[test]
fn col_does_not_touch_qualifier_table_name() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=t2.c FROM t2; END";
let got = rewrite_col(sql, "t2", "c", "abc");
assert!(got.contains("t2.abc"));
assert!(got.contains("FROM t2;"));
}
#[test]
fn col_alias_qualifier_resolves() {
let sql =
"CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=1 FROM t3 AS x WHERE x.e=1; END";
let got = rewrite_col(sql, "t3", "e", "abc");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=1 FROM t3 AS x WHERE x.abc=1; END"
);
}
#[test]
fn col_no_match_returns_unchanged() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=1 WHERE b=1; END";
assert_eq!(rewrite_col(sql, "t2", "c", "abc"), sql);
}
#[test]
fn col_unqualified_other_table_not_in_scope_unchanged() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=1 FROM t3 WHERE e=1; END";
assert_eq!(rewrite_col(sql, "t2", "c", "abc"), sql);
}
#[test]
fn col_ambiguous_unqualified_errors() {
let sql = "CREATE TRIGGER r1 INSERT ON t3 BEGIN UPDATE t3 SET e=1 FROM t4 WHERE e=2; END";
let err = rewrite_col_result(sql, "t3", "e", "abc").expect_err("should be ambiguous");
assert_eq!(err, "e");
let err = rewrite_col_result(sql, "t4", "e", "abc").expect_err("should be ambiguous");
assert_eq!(err, "e");
}
#[test]
fn col_ambiguity_only_when_renamed_table_in_scope() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t3 SET e=1 FROM t4 WHERE e=2; END";
assert_eq!(rewrite_col_result(sql, "t2", "c", "abc").unwrap(), sql);
}
#[test]
fn col_qualified_ref_not_ambiguous() {
let sql =
"CREATE TRIGGER r1 INSERT ON t3 BEGIN UPDATE t3 SET a=1 FROM t4 WHERE t3.e=2; END";
assert_eq!(
rewrite_col_result(sql, "t3", "e", "abc").unwrap(),
"CREATE TRIGGER r1 INSERT ON t3 BEGIN UPDATE t3 SET a=1 FROM t4 WHERE t3.abc=2; END"
);
}
#[test]
fn rewrites_table_after_comma_in_from_list() {
let sql =
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET d='xyz' FROM t2, t3; \nEND";
let got = rewrite(sql, "t3", "t5");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET d='xyz' FROM t2, \"t5\"; \nEND"
);
}
#[test]
fn rewrites_table_in_subquery_from() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET d='xyz' FROM t2, (SELECT * FROM t5); \nEND";
let got = rewrite(sql, "t5", "t3");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET d='xyz' FROM t2, (SELECT * FROM \"t3\"); \nEND"
);
}
#[test]
fn rewrites_multiple_table_refs_nested() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a='xyz' FROM t3, (SELECT * FROM (SELECT e FROM t3)); \nEND";
let got = rewrite(sql, "t3", "t10");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a='xyz' FROM \"t10\", (SELECT * FROM (SELECT e FROM \"t10\")); \nEND"
);
}
#[test]
fn rewrites_qualifier_and_join_target() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=1 FROM t3 NATURAL JOIN t4 WHERE t4.e=a; \nEND";
let got = rewrite(sql, "t4", "abc");
assert_eq!(
got,
"CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=1 FROM t3 NATURAL JOIN \"abc\" WHERE \"abc\".e=a; \nEND"
);
}
#[test]
fn does_not_rewrite_unrelated_column_of_same_name() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN \n UPDATE t1 SET a=1 WHERE z=1; \nEND";
let got = rewrite(sql, "z", "zz");
assert_eq!(got, sql);
}
#[test]
fn rewrites_header_on_table() {
let sql = "CREATE TRIGGER r1 INSERT ON t3 BEGIN \n UPDATE t1 SET a=1; \nEND";
let got = rewrite(sql, "t3", "t5");
assert_eq!(got, "CREATE TRIGGER r1 INSERT ON \"t5\" BEGIN \n UPDATE t1 SET a=1; \nEND");
}
#[test]
fn no_match_returns_input_unchanged() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=1; END";
assert_eq!(rewrite(sql, "nosuch", "x"), sql);
}
#[test]
fn case_insensitive_match() {
let sql = "CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=1 FROM T3; END";
let got = rewrite(sql, "t3", "t5");
assert_eq!(got, "CREATE TRIGGER r1 INSERT ON t1 BEGIN UPDATE t1 SET a=1 FROM \"t5\"; END");
}
}