0.8.0: MariaDB + PlanetScale, TimescaleDB, multi-result sets, streaming export, schema diff #27
@@ -1,5 +1,6 @@
|
|||||||
pub mod introspection;
|
pub mod introspection;
|
||||||
pub mod mysql;
|
pub mod mysql;
|
||||||
|
pub mod sql_split;
|
||||||
pub mod object_crud;
|
pub mod object_crud;
|
||||||
pub mod object_ddl;
|
pub mod object_ddl;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//! Pure SQL statement splitter shared by multi-result execution and the
|
||||||
|
//! streaming export (single-statement validation).
|
||||||
|
//!
|
||||||
|
//! A hand-rolled state machine (no new dependencies). It understands:
|
||||||
|
//! single/double/backtick-quoted strings with doubled-quote escapes,
|
||||||
|
//! `--` line comments, `/* */` block comments, and PostgreSQL dollar-quoted
|
||||||
|
//! bodies (`$$…$$`, `$tag$…$tag$`). Conservative by design: anything it
|
||||||
|
//! cannot confidently close is kept inside the current statement rather
|
||||||
|
//! than split — a naive semicolon split that could execute fragmentary SQL
|
||||||
|
//! is unacceptable (spec §2).
|
||||||
|
//!
|
||||||
|
//! Statement classification (SELECT vs DML vs DDL) happens at execution
|
||||||
|
//! time, not here.
|
||||||
|
|
||||||
|
/// Split `sql` into ordered statement texts. Comment-only and empty
|
||||||
|
/// fragments are dropped. Statement text keeps its comments verbatim.
|
||||||
|
pub fn split_statements(sql: &str) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = sql.chars().collect();
|
||||||
|
let mut stmts: Vec<String> = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut i = 0usize;
|
||||||
|
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
match c {
|
||||||
|
'\'' | '"' | '`' => {
|
||||||
|
copy_quoted(&chars, &mut i, &mut current, c);
|
||||||
|
}
|
||||||
|
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||||
|
copy_until(&chars, &mut i, &mut current, |ch| ch == '\n');
|
||||||
|
}
|
||||||
|
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||||
|
copy_block_comment(&chars, &mut i, &mut current);
|
||||||
|
}
|
||||||
|
'$' => {
|
||||||
|
if let Some(end) = dollar_quote_end(&chars, i) {
|
||||||
|
for _ in i..end {
|
||||||
|
if i < chars.len() {
|
||||||
|
current.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
';' => {
|
||||||
|
if has_executable_text(¤t) {
|
||||||
|
stmts.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
current.clear();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
current.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_executable_text(¤t) {
|
||||||
|
stmts.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
stmts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(chars: &[char], idx: usize) -> Option<char> {
|
||||||
|
chars.get(idx).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a quoted region starting at the opening `quote` char. Doubled quotes
|
||||||
|
/// are escapes and stay inside the region. Unterminated quotes consume the
|
||||||
|
/// rest of the input (conservative).
|
||||||
|
fn copy_quoted(chars: &[char], i: &mut usize, out: &mut String, quote: char) {
|
||||||
|
out.push(chars[*i]);
|
||||||
|
*i += 1;
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
out.push(c);
|
||||||
|
*i += 1;
|
||||||
|
if c == quote {
|
||||||
|
if peek(chars, *i) == Some(quote) {
|
||||||
|
// doubled quote — escaped, keep it and continue
|
||||||
|
out.push(quote);
|
||||||
|
*i += 1;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy characters until `stop` matches (inclusive for `\n` handled by the
|
||||||
|
/// caller's predicate) or input ends.
|
||||||
|
fn copy_until(chars: &[char], i: &mut usize, out: &mut String, stop: impl Fn(char) -> bool) {
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
out.push(c);
|
||||||
|
*i += 1;
|
||||||
|
if stop(c) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a `/* … */` block comment (no nesting per the SQL standard).
|
||||||
|
fn copy_block_comment(chars: &[char], i: &mut usize, out: &mut String) {
|
||||||
|
out.push(chars[*i]); // '/'
|
||||||
|
*i += 1;
|
||||||
|
if *i < chars.len() {
|
||||||
|
out.push(chars[*i]); // '*'
|
||||||
|
*i += 1;
|
||||||
|
}
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
out.push(c);
|
||||||
|
*i += 1;
|
||||||
|
if c == '*' && peek(chars, *i) == Some('/') {
|
||||||
|
out.push('/');
|
||||||
|
*i += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At `chars[start] == '$'`, try to match `$[A-Za-z_][A-Za-z0-9_]*$` and find
|
||||||
|
/// the matching closing delimiter. Returns the index one past the closing
|
||||||
|
/// delimiter, or None if this is not a well-formed (and closable) dollar
|
||||||
|
/// quote — in which case the `$` is ordinary text.
|
||||||
|
fn dollar_quote_end(chars: &[char], start: usize) -> Option<usize> {
|
||||||
|
let mut j = start + 1;
|
||||||
|
if j < chars.len() && chars[j] == '$' {
|
||||||
|
// bare $$ body
|
||||||
|
let tag = String::new();
|
||||||
|
return find_dollar_close(chars, j + 1, &tag);
|
||||||
|
}
|
||||||
|
let mut tag = String::new();
|
||||||
|
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
|
||||||
|
tag.push(chars[j]);
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if tag.is_empty() || j >= chars.len() || chars[j] != '$' {
|
||||||
|
return None; // not $tag$
|
||||||
|
}
|
||||||
|
find_dollar_close(chars, j + 1, &tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_dollar_close(chars: &[char], from: usize, tag: &str) -> Option<usize> {
|
||||||
|
let closing: Vec<char> = format!("${tag}$").chars().collect();
|
||||||
|
let mut j = from;
|
||||||
|
while j + closing.len() <= chars.len() {
|
||||||
|
if chars[j..j + closing.len()] == closing[..] {
|
||||||
|
return Some(j + closing.len());
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the fragment contains anything other than whitespace and comments
|
||||||
|
/// (comments are preserved in statement text, but a fragment that is ONLY
|
||||||
|
/// comments/whitespace is not a statement).
|
||||||
|
pub fn has_executable_text(stmt: &str) -> bool {
|
||||||
|
let chars: Vec<char> = stmt.chars().collect();
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
match c {
|
||||||
|
'\'' | '"' | '`' => {
|
||||||
|
let mut probe = String::new();
|
||||||
|
let mut j = i;
|
||||||
|
copy_quoted(&chars, &mut j, &mut probe, c);
|
||||||
|
return true; // a quoted region IS executable text
|
||||||
|
}
|
||||||
|
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||||
|
skip_until(&chars, &mut i, |ch| ch == '\n');
|
||||||
|
}
|
||||||
|
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||||
|
skip_block_comment(&chars, &mut i);
|
||||||
|
}
|
||||||
|
c if c.is_whitespace() => i += 1,
|
||||||
|
_ => return true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_until(chars: &[char], i: &mut usize, stop: impl Fn(char) -> bool) {
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
*i += 1;
|
||||||
|
if stop(c) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_block_comment(chars: &[char], i: &mut usize) {
|
||||||
|
*i += 2; // "/*"
|
||||||
|
while *i < chars.len() {
|
||||||
|
if chars[*i] == '*' && peek(chars, *i + 1) == Some('/') {
|
||||||
|
*i += 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "sql_split.test.rs"]
|
||||||
|
mod sql_split_tests;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splits_two_statements() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 1; SELECT 2"),
|
||||||
|
vec!["SELECT 1", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_trailing_semicolon_is_fine() {
|
||||||
|
assert_eq!(split_statements("SELECT 1"), vec!["SELECT 1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semicolon_inside_string_is_not_a_boundary() {
|
||||||
|
assert_eq!(split_statements("SELECT 'a;b'"), vec!["SELECT 'a;b'"]);
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 'a;b'; SELECT 2"),
|
||||||
|
vec!["SELECT 'a;b'", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doubled_quote_escapes() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 'it''s'; SELECT 2"),
|
||||||
|
vec!["SELECT 'it''s'", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_quoted_and_backtick_identifiers() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT \"a;b\"; SELECT `x;y`"),
|
||||||
|
vec!["SELECT \"a;b\"", "SELECT `x;y`"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT `a``b`; SELECT 2"),
|
||||||
|
vec!["SELECT `a``b`", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_comments_do_not_break_statements() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 1 -- ; not a boundary\n; SELECT 2"),
|
||||||
|
vec!["SELECT 1 -- ; not a boundary", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_comments_do_not_break_statements() {
|
||||||
|
assert_eq!(split_statements("SELECT /* ; */ 1"), vec!["SELECT /* ; */ 1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollar_quoted_bodies() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT $$a;b$$; SELECT 2"),
|
||||||
|
vec!["SELECT $$a;b$$", "SELECT 2"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT $fn$x;y$fn$; SELECT 2"),
|
||||||
|
vec!["SELECT $fn$x;y$fn$", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unclosed_dollar_is_literal() {
|
||||||
|
assert_eq!(split_statements("SELECT a$b"), vec!["SELECT a$b"]);
|
||||||
|
assert_eq!(split_statements("SELECT 1$"), vec!["SELECT 1$"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_only_statements_are_skipped() {
|
||||||
|
assert_eq!(split_statements("-- nothing\n; SELECT 1"), vec!["SELECT 1"]);
|
||||||
|
assert_eq!(split_statements("/* c */"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_whitespace_input() {
|
||||||
|
assert_eq!(split_statements(""), Vec::<String>::new());
|
||||||
|
assert_eq!(split_statements(" ; ; "), Vec::<String>::new());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user