pub fn split_sql_statements(script: &str) -> Vec { let bytes = script.as_bytes(); let mut statements = Vec::new(); let mut start = 0; let mut index = 0; let mut in_single = false; let mut in_double = false; let mut in_escape_single = false; while index < bytes.len() { match bytes[index] { b'\\' if in_single && in_escape_single => { index = (index + 2).min(bytes.len()); continue; } b'\'' if !in_double => { if in_single && index + 1 < bytes.len() && bytes[index + 1] == b'\'' { index += 2; continue; } if !in_single { in_escape_single = is_escape_string_quote(bytes, index); } in_single = !in_single; if !in_single { in_escape_single = false; } } b'"' if !in_single => { if in_double && index + 1 < bytes.len() && bytes[index + 1] == b'"' { index += 2; continue; } in_double = !in_double; } b'-' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'-') => { while index < bytes.len() && bytes[index] != b'\n' { index += 1; } continue; } b'/' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'*') => { index += 2; while index < bytes.len() && !(bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/')) { index += 1; } index = (index + 2).min(bytes.len()); continue; } b'$' if !in_single && !in_double => { if let Some(end) = dollar_quote_end(bytes, index) { index = end; continue; } } b';' if !in_single && !in_double => { push_statement(&mut statements, &script[start..index]); start = index + 1; } _ => {} } index += 1; } push_statement(&mut statements, &script[start..]); statements } fn is_escape_string_quote(bytes: &[u8], quote_index: usize) -> bool { if quote_index == 0 || !matches!(bytes[quote_index - 1], b'E' | b'e') { return false; } quote_index == 1 || !(bytes[quote_index - 2].is_ascii_alphanumeric() || bytes[quote_index - 2] == b'_') } fn push_statement(statements: &mut Vec, fragment: &str) { let trimmed = fragment.trim(); if !trimmed.is_empty() { statements.push(trimmed.to_string()); } } fn dollar_quote_end(bytes: &[u8], start: usize) -> Option { let mut tag_end = start + 1; while tag_end < bytes.len() && (bytes[tag_end] == b'_' || bytes[tag_end].is_ascii_alphanumeric()) { tag_end += 1; } if tag_end >= bytes.len() || bytes[tag_end] != b'$' { return None; } if tag_end > start + 1 && bytes[start + 1].is_ascii_digit() { return None; } let tag = &bytes[start..=tag_end]; let mut index = tag_end + 1; while index + tag.len() <= bytes.len() { if &bytes[index..index + tag.len()] == tag { return Some(index + tag.len()); } index += 1; } Some(bytes.len()) } pub fn attached_name_from_attach_sql(sql: &str) -> Option { let trimmed = strip_leading_comments(sql); let first_word = trimmed.split(|character: char| character.is_whitespace() || character == ';').next()?; if !first_word.eq_ignore_ascii_case("ATTACH") { return None; } let as_index = find_as_keyword_outside_quotes(trimmed)?; parse_identifier_after_as(&trimmed[as_index + 2..]) } pub fn strip_leading_comments(sql: &str) -> &str { let mut rest = sql.trim_start(); loop { if let Some(after) = rest.strip_prefix("--") { rest = after.split_once('\n').map(|(_, tail)| tail).unwrap_or("").trim_start(); } else if let Some(after) = rest.strip_prefix("/*") { rest = after.split_once("*/").map(|(_, tail)| tail).unwrap_or("").trim_start(); } else { return rest; } } } fn find_as_keyword_outside_quotes(sql: &str) -> Option { let bytes = sql.as_bytes(); let mut index = 0; let mut in_single = false; let mut in_double = false; while index < bytes.len() { match bytes[index] { b'\'' if !in_double => { if in_single && index + 1 < bytes.len() && bytes[index + 1] == b'\'' { index += 2; continue; } in_single = !in_single; } b'"' if !in_single => { if in_double && index + 1 < bytes.len() && bytes[index + 1] == b'"' { index += 2; continue; } in_double = !in_double; } b'a' | b'A' if !in_single && !in_double && index + 1 < bytes.len() && matches!(bytes[index + 1], b's' | b'S') && is_sql_word_boundary(bytes.get(index.wrapping_sub(1)).copied()) && is_sql_word_boundary(bytes.get(index + 2).copied()) => { return Some(index); } _ => {} } index += 1; } None } fn is_sql_word_boundary(byte: Option) -> bool { !matches!(byte, Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) } fn parse_identifier_after_as(input: &str) -> Option { let input = input.trim_start(); if input.is_empty() { return None; } if let Some(rest) = input.strip_prefix('"') { let mut name = String::new(); let mut characters = rest.chars().peekable(); while let Some(character) = characters.next() { if character == '"' { if characters.peek() == Some(&'"') { name.push('"'); characters.next(); continue; } return (!name.trim().is_empty()).then_some(name); } name.push(character); } return None; } let name = input.split(|character: char| character.is_whitespace() || character == ';').next().unwrap_or_default().trim(); (!name.is_empty()).then(|| name.to_string()) } /// Works around a DuckDB binder bug: `CURRENT_DATE - 4` (a bare numeric literal directly /// against the `CURRENT_DATE` keyword node) fails with "No function matches ... (DATE, /// INTEGER_LITERAL)", while the functionally identical `current_date() - 4` binds fine. /// Rewrites only that narrow pattern (`CURRENT_DATE` immediately followed by `+`/`-` and a raw /// numeric literal) to the function-call form, so plain `CURRENT_DATE`, qualified references /// (`t.current_date`), aliases (`AS current_date`), and already-cast literals are left untouched. pub fn rewrite_duckdb_current_date_literal_arithmetic(sql: &str) -> std::borrow::Cow<'_, str> { const KEYWORD: &str = "CURRENT_DATE"; let bytes = sql.as_bytes(); let mut rewritten: Option = None; let mut copied_to = 0usize; let mut index = 0usize; let mut in_single = false; let mut in_double = false; while index < bytes.len() { match bytes[index] { b'\'' if !in_double => { if in_single && bytes.get(index + 1) == Some(&b'\'') { index += 2; continue; } in_single = !in_single; index += 1; continue; } b'"' if !in_single => { if in_double && bytes.get(index + 1) == Some(&b'"') { index += 2; continue; } in_double = !in_double; index += 1; continue; } b'-' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'-') => { index += 2; while index < bytes.len() && bytes[index] != b'\n' { index += 1; } continue; } b'/' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'*') => { index += 2; while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') { index += 1; } index = (index + 2).min(bytes.len()); continue; } b'$' if !in_single && !in_double => { if let Some(end) = dollar_quote_end(bytes, index) { index = end; continue; } index += 1; } _ if !in_single && !in_double && matches_keyword_at(bytes, index, KEYWORD) => { let word_end = index + KEYWORD.len(); let preceded_by_dot = index > 0 && bytes[index - 1] == b'.'; let preceded_by_ident = index > 0 && is_ident_byte(bytes[index - 1]); let followed_by_ident = bytes.get(word_end).copied().is_some_and(is_ident_byte); if !preceded_by_dot && !preceded_by_ident && !followed_by_ident && has_raw_numeric_arithmetic_after(bytes, word_end) { let buf = rewritten.get_or_insert_with(String::new); buf.push_str(&sql[copied_to..index]); buf.push_str("current_date()"); copied_to = word_end; } index = word_end; continue; } _ => { index += 1; } } } match rewritten { Some(mut buf) => { buf.push_str(&sql[copied_to..]); std::borrow::Cow::Owned(buf) } None => std::borrow::Cow::Borrowed(sql), } } fn matches_keyword_at(bytes: &[u8], index: usize, keyword: &str) -> bool { let keyword_bytes = keyword.as_bytes(); bytes.len() >= index + keyword_bytes.len() && bytes[index..index + keyword_bytes.len()].eq_ignore_ascii_case(keyword_bytes) } fn is_ident_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || byte == b'_' } /// After the `CURRENT_DATE` token: skip whitespace, require a `+`/`-` that isn't the start of a /// `--` comment, skip whitespace again, and require a raw numeric literal (not `CAST(`/`::`). fn has_raw_numeric_arithmetic_after(bytes: &[u8], from: usize) -> bool { let mut index = from; while bytes.get(index).copied().is_some_and(|b| b.is_ascii_whitespace()) { index += 1; } let Some(&op) = bytes.get(index) else { return false }; if op != b'+' && op != b'-' { return false; } if op == b'-' && bytes.get(index + 1) == Some(&b'-') { return false; } index += 1; while bytes.get(index).copied().is_some_and(|b| b.is_ascii_whitespace()) { index += 1; } bytes.get(index).copied().is_some_and(|b| b.is_ascii_digit()) } pub fn starts_with_duckdb_result_sql_keyword(sql: &str) -> bool { let Some(token) = first_executable_sql_token(sql) else { return false; }; ["SELECT", "SHOW", "DESCRIBE", "EXPLAIN", "WITH", "PRAGMA", "FROM", "SUMMARIZE", "SUMMARISE", "PIVOT", "UNPIVOT"] .iter() .any(|keyword| { token.eq_ignore_ascii_case(keyword) || (*keyword == "DESCRIBE" && token.eq_ignore_ascii_case("DESC")) }) || matches!(token.to_ascii_uppercase().as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE") && contains_unquoted_sql_keyword(sql, "RETURNING") } fn contains_unquoted_sql_keyword(sql: &str, keyword: &str) -> bool { let bytes = sql.as_bytes(); let mut index = 0; let mut in_single = false; let mut in_double = false; while index < bytes.len() { match bytes[index] { b'\'' if !in_double => { if in_single && bytes.get(index + 1) == Some(&b'\'') { index += 2; continue; } in_single = !in_single; index += 1; } b'"' if !in_single => { if in_double && bytes.get(index + 1) == Some(&b'"') { index += 2; continue; } in_double = !in_double; index += 1; } b'-' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'-') => { index += 2; while index < bytes.len() && bytes[index] != b'\n' { index += 1; } } b'/' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'*') => { index += 2; while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') { index += 1; } index = (index + 2).min(bytes.len()); } byte if !in_single && !in_double && (byte.is_ascii_alphabetic() || byte == b'_') => { let start = index; index += 1; while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') { index += 1; } if sql[start..index].eq_ignore_ascii_case(keyword) { return true; } } _ => index += 1, } } false } fn first_executable_sql_token(sql: &str) -> Option<&str> { let bytes = sql.as_bytes(); let mut index = 0; while index < bytes.len() { while index < bytes.len() && (bytes[index].is_ascii_whitespace() || bytes[index] == b'(') { index += 1; } if index >= bytes.len() { break; } if index + 1 < bytes.len() && bytes[index] == b'-' && bytes[index + 1] == b'-' { index += 2; while index < bytes.len() && bytes[index] != b'\n' { index += 1; } continue; } if index + 1 < bytes.len() && bytes[index] == b'/' && bytes[index + 1] == b'*' { index += 2; while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') { index += 1; } index = (index + 2).min(bytes.len()); continue; } break; } let start = index; while index < bytes.len() && (bytes[index].is_ascii_alphabetic() || bytes[index] == b'_') { index += 1; } (index > start).then_some(&sql[start..index]) } #[cfg(test)] mod tests { use super::*; #[test] fn splits_quoted_and_commented_statements() { let script = "SET s3_endpoint='host;port'; -- comment;\nATTACH 'db;x.duckdb' AS a; SELECT $$a;b$$"; assert_eq!( split_sql_statements(script), vec![ "SET s3_endpoint='host;port'".to_string(), "-- comment;\nATTACH 'db;x.duckdb' AS a".to_string(), "SELECT $$a;b$$".to_string(), ] ); } #[test] fn detects_attach_alias_after_comments() { assert_eq!( attached_name_from_attach_sql("-- note\nATTACH 'sales.duckdb' AS \"sales db\";"), Some("sales db".to_string()) ); } #[test] fn detects_result_statements_after_comments() { assert!(starts_with_duckdb_result_sql_keyword("/* note */ WITH rows AS (SELECT 1) SELECT * FROM rows")); assert!(starts_with_duckdb_result_sql_keyword("DESC SELECT 1")); assert!(starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1) RETURNING id")); assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items(note) VALUES ('RETURNING')")); assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1)")); } #[test] fn rewrites_current_date_literal_arithmetic() { assert_eq!( rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4"), "SELECT current_date() - 4" ); assert_eq!(rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE-4"), "SELECT current_date()-4"); assert_eq!( rewrite_duckdb_current_date_literal_arithmetic("SELECT current_date + 1.5 AS d"), "SELECT current_date() + 1.5 AS d" ); assert_eq!( rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4, CURRENT_DATE - 1"), "SELECT current_date() - 4, current_date() - 1" ); // Already-cast literals still match the narrow "digit right after the operator" check; // rewriting them too is harmless since current_date() binds fine against any numeric form. assert_eq!( rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4::INTEGER"), "SELECT current_date() - 4::INTEGER" ); } #[test] fn leaves_non_literal_current_date_usage_untouched() { for sql in [ "SELECT CURRENT_DATE", "SELECT CURRENT_DATE::VARCHAR", "SELECT CURRENT_DATE - CAST(4 AS INTEGER)", "SELECT t.current_date - 4 FROM t", "SELECT 1 AS current_date", "CREATE TABLE cd_test (current_date INTEGER)", "SELECT 4 - CURRENT_DATE", "SELECT current_date() - 4", "-- CURRENT_DATE - 4\nSELECT 1", "SELECT '-- CURRENT_DATE - 4'", ] { assert_eq!(rewrite_duckdb_current_date_literal_arithmetic(sql), sql, "should not rewrite: {sql}"); } } #[test] fn leaves_dollar_quoted_current_date_arithmetic_untouched() { assert_eq!( rewrite_duckdb_current_date_literal_arithmetic( "SELECT $$CURRENT_DATE - 4$$ AS source, CURRENT_DATE - 1 AS actual" ), "SELECT $$CURRENT_DATE - 4$$ AS source, current_date() - 1 AS actual" ); assert_eq!( rewrite_duckdb_current_date_literal_arithmetic( "SELECT $body_1$CURRENT_DATE + 2$body_1$ AS source, CURRENT_DATE + 3 AS actual" ), "SELECT $body_1$CURRENT_DATE + 2$body_1$ AS source, current_date() + 3 AS actual" ); assert_eq!( rewrite_duckdb_current_date_literal_arithmetic( "SELECT $outer$CURRENT_DATE - 4 $inner$ CURRENT_DATE + 2 $inner$$outer$ AS source" ), "SELECT $outer$CURRENT_DATE - 4 $inner$ CURRENT_DATE + 2 $inner$$outer$ AS source" ); assert_eq!( rewrite_duckdb_current_date_literal_arithmetic("SELECT $body$CURRENT_DATE - 4"), "SELECT $body$CURRENT_DATE - 4" ); } }