feat: extract dbx-core crate for web deployment support
Refactor all database drivers, query execution, schema operations, and business logic into a shared `dbx-core` crate. The Tauri desktop app becomes a thin shell delegating to dbx-core, enabling future web and CLI frontends to reuse the same core.
This commit is contained in:
1 parent
7bee4bca55
commit
cf4f25fd52
48 files changed
+15354
-4759
No files matched your search
@@ -3,7 +3,6 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -12,6 +11,11 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::commands::connection::AppState;
|
||||
use crate::commands::query::execute_sql_statement;
|
||||
|
||||
pub use dbx_core::sql::{
|
||||
statement_summary, SqlFilePreview, SqlFileProgress, SqlFileRequest,
|
||||
SqlFileStatus, SqlStatementSplitter,
|
||||
};
|
||||
|
||||
static SQL_FILE_EXECUTIONS: std::sync::LazyLock<RwLock<HashMap<String, CancellationToken>>> =
|
||||
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
@@ -22,51 +26,6 @@ struct StatementErrorDecision {
|
||||
result: Result<bool, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlFileRequest {
|
||||
pub execution_id: String,
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub continue_on_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlFilePreview {
|
||||
pub file_name: String,
|
||||
pub file_path: String,
|
||||
pub size_bytes: u64,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SqlFileStatus {
|
||||
Started,
|
||||
Running,
|
||||
StatementDone,
|
||||
StatementFailed,
|
||||
Done,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlFileProgress {
|
||||
pub execution_id: String,
|
||||
pub status: SqlFileStatus,
|
||||
pub statement_index: usize,
|
||||
pub success_count: usize,
|
||||
pub failure_count: usize,
|
||||
pub affected_rows: u64,
|
||||
pub elapsed_ms: u128,
|
||||
pub statement_summary: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SqlFileSummary {
|
||||
@@ -76,255 +35,6 @@ struct SqlFileSummary {
|
||||
failed_statement_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SqlStatementSplitter {
|
||||
buffer: String,
|
||||
in_single_quote: bool,
|
||||
in_double_quote: bool,
|
||||
in_backtick: bool,
|
||||
in_line_comment: bool,
|
||||
in_block_comment: bool,
|
||||
dollar_quote_tag: Option<String>,
|
||||
previous: Option<char>,
|
||||
}
|
||||
|
||||
impl SqlStatementSplitter {
|
||||
fn push_chunk(&mut self, chunk: &str) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
let chars = chunk.chars().collect::<Vec<_>>();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
if let Some(tag) = &self.dollar_quote_tag {
|
||||
let tag_chars = tag.chars().collect::<Vec<_>>();
|
||||
if starts_with_chars(&chars, i, &tag_chars) {
|
||||
for tag_ch in &tag_chars {
|
||||
self.buffer.push(*tag_ch);
|
||||
self.previous = Some(*tag_ch);
|
||||
}
|
||||
i += tag_chars.len();
|
||||
self.dollar_quote_tag = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let ch = chars[i];
|
||||
self.buffer.push(ch);
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let ch = chars[i];
|
||||
let next = chars.get(i + 1).copied();
|
||||
|
||||
if self.in_line_comment {
|
||||
self.buffer.push(ch);
|
||||
if ch == '\n' {
|
||||
self.in_line_comment = false;
|
||||
}
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.in_block_comment {
|
||||
self.buffer.push(ch);
|
||||
if self.previous == Some('*') && ch == '/' {
|
||||
self.in_block_comment = false;
|
||||
}
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.in_single_quote && !self.in_double_quote && !self.in_backtick {
|
||||
if self.previous == Some('-') && ch == '-' {
|
||||
self.in_line_comment = true;
|
||||
self.buffer.push(ch);
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if self.previous == Some('/') && ch == '*' {
|
||||
self.in_block_comment = true;
|
||||
self.buffer.push(ch);
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ch == '-' && next == Some('-') {
|
||||
self.in_line_comment = true;
|
||||
self.buffer.push(ch);
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && next == Some('*') {
|
||||
self.in_block_comment = true;
|
||||
self.buffer.push(ch);
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(tag) = dollar_quote_tag_at(&chars, i) {
|
||||
for tag_ch in tag.chars() {
|
||||
self.buffer.push(tag_ch);
|
||||
self.previous = Some(tag_ch);
|
||||
}
|
||||
i += tag.chars().count();
|
||||
self.dollar_quote_tag = Some(tag);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\'' if !self.in_double_quote
|
||||
&& !self.in_backtick
|
||||
&& self.previous != Some('\\') =>
|
||||
{
|
||||
self.in_single_quote = !self.in_single_quote;
|
||||
self.buffer.push(ch);
|
||||
}
|
||||
'"' if !self.in_single_quote
|
||||
&& !self.in_backtick
|
||||
&& self.previous != Some('\\') =>
|
||||
{
|
||||
self.in_double_quote = !self.in_double_quote;
|
||||
self.buffer.push(ch);
|
||||
}
|
||||
'`' if !self.in_single_quote && !self.in_double_quote => {
|
||||
self.in_backtick = !self.in_backtick;
|
||||
self.buffer.push(ch);
|
||||
}
|
||||
';' if !self.in_single_quote && !self.in_double_quote && !self.in_backtick => {
|
||||
self.push_current_statement(&mut statements);
|
||||
}
|
||||
_ => self.buffer.push(ch),
|
||||
}
|
||||
|
||||
self.previous = Some(ch);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
self.push_current_statement(&mut statements);
|
||||
statements
|
||||
}
|
||||
|
||||
fn push_current_statement(&mut self, statements: &mut Vec<String>) {
|
||||
let statement = self.buffer.trim();
|
||||
if has_executable_sql(statement) {
|
||||
statements.push(statement.to_string());
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.previous = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_sql_statements(sql: &str) -> Vec<String> {
|
||||
let mut splitter = SqlStatementSplitter::default();
|
||||
let mut statements = splitter.push_chunk(sql);
|
||||
statements.extend(splitter.finish());
|
||||
statements
|
||||
}
|
||||
|
||||
fn starts_with_chars(chars: &[char], start: usize, needle: &[char]) -> bool {
|
||||
start + needle.len() <= chars.len() && chars[start..start + needle.len()] == *needle
|
||||
}
|
||||
|
||||
fn dollar_quote_tag_at(chars: &[char], start: usize) -> Option<String> {
|
||||
if chars.get(start) != Some(&'$') {
|
||||
return None;
|
||||
}
|
||||
|
||||
match chars.get(start + 1) {
|
||||
Some('$') => return Some("$$".to_string()),
|
||||
Some(ch) if ch.is_ascii_alphabetic() || *ch == '_' => {}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
let mut end = start + 2;
|
||||
while let Some(ch) = chars.get(end) {
|
||||
if *ch == '$' {
|
||||
return Some(chars[start..=end].iter().collect());
|
||||
}
|
||||
if !ch.is_ascii_alphanumeric() && *ch != '_' {
|
||||
return None;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn has_executable_sql(statement: &str) -> bool {
|
||||
let chars = statement.chars().collect::<Vec<_>>();
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
let mut previous = None;
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
let next = chars.get(i + 1).copied();
|
||||
|
||||
if in_line_comment {
|
||||
if ch == '\n' {
|
||||
in_line_comment = false;
|
||||
}
|
||||
previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_block_comment {
|
||||
if previous == Some('*') && ch == '/' {
|
||||
in_block_comment = false;
|
||||
}
|
||||
previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '-' && next == Some('-') {
|
||||
in_line_comment = true;
|
||||
previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '/' && next == Some('*') {
|
||||
if is_mysql_executable_comment_start(&chars, i) {
|
||||
return true;
|
||||
}
|
||||
in_block_comment = true;
|
||||
previous = Some(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !ch.is_whitespace() {
|
||||
return true;
|
||||
}
|
||||
|
||||
previous = Some(ch);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_mysql_executable_comment_start(chars: &[char], start: usize) -> bool {
|
||||
chars.get(start) == Some(&'/')
|
||||
&& chars.get(start + 1) == Some(&'*')
|
||||
&& (chars.get(start + 2) == Some(&'!')
|
||||
|| (chars.get(start + 2) == Some(&'M') && chars.get(start + 3) == Some(&'!')))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn preview_sql_file(file_path: String) -> Result<SqlFilePreview, String> {
|
||||
let path = PathBuf::from(&file_path);
|
||||
@@ -808,17 +518,6 @@ fn sql_file_progress(
|
||||
}
|
||||
}
|
||||
|
||||
fn statement_summary(statement: &str) -> String {
|
||||
const MAX_LEN: usize = 120;
|
||||
|
||||
let collapsed = statement.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if collapsed.chars().count() <= MAX_LEN {
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
collapsed.chars().take(MAX_LEN).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn run_statements_for_test(
|
||||
statements: Vec<String>,
|
||||
@@ -871,118 +570,6 @@ async fn run_statements_for_test(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn split_sql_script(sql: &str) -> Result<Vec<String>, String> {
|
||||
let mut splitter = SqlStatementSplitter::default();
|
||||
let mut statements = splitter.push_chunk(sql);
|
||||
statements.extend(splitter.finish());
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{split_sql_script, SqlStatementSplitter};
|
||||
|
||||
#[test]
|
||||
fn splits_semicolon_delimited_statements() {
|
||||
assert_eq!(
|
||||
split_sql_script("CREATE TABLE a(id int); INSERT INTO a VALUES (1);").unwrap(),
|
||||
vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_semicolons_inside_quotes_and_comments() {
|
||||
let sql = "\
|
||||
INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`);\n\
|
||||
-- comment ; ignored\n\
|
||||
/* block ; ignored */\n\
|
||||
SELECT 1;";
|
||||
assert_eq!(
|
||||
split_sql_script(sql).unwrap(),
|
||||
vec![
|
||||
"INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`)",
|
||||
"-- comment ; ignored\n/* block ; ignored */\nSELECT 1",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_trailing_statement_without_semicolon() {
|
||||
assert_eq!(
|
||||
split_sql_script("CREATE TABLE a(id int);\nINSERT INTO a VALUES (1)").unwrap(),
|
||||
vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_comment_openers_can_span_chunks() {
|
||||
let mut splitter = SqlStatementSplitter::default();
|
||||
|
||||
assert_eq!(splitter.push_chunk("SELECT 1; -"), vec!["SELECT 1"]);
|
||||
assert_eq!(
|
||||
splitter.push_chunk("- comment ; ignored\nSELECT 2;"),
|
||||
vec!["-- comment ; ignored\nSELECT 2"]
|
||||
);
|
||||
assert_eq!(splitter.finish(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_comment_openers_can_span_chunks() {
|
||||
let mut splitter = SqlStatementSplitter::default();
|
||||
|
||||
assert_eq!(splitter.push_chunk("SELECT 1; /"), vec!["SELECT 1"]);
|
||||
assert_eq!(
|
||||
splitter.push_chunk("* comment ; ignored */\nSELECT 2;"),
|
||||
vec!["/* comment ; ignored */\nSELECT 2"]
|
||||
);
|
||||
assert_eq!(splitter.finish(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comment_only_tail_after_statement() {
|
||||
assert_eq!(
|
||||
split_sql_script("CREATE TABLE a(id int); -- done\n/* no more sql */").unwrap(),
|
||||
vec!["CREATE TABLE a(id int)"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_postgres_dollar_quoted_function_body_together() {
|
||||
let sql = "\
|
||||
CREATE FUNCTION bump_counter()\n\
|
||||
RETURNS trigger AS $$\n\
|
||||
BEGIN\n\
|
||||
PERFORM 1;\n\
|
||||
RETURN NEW;\n\
|
||||
END;\n\
|
||||
$$ LANGUAGE plpgsql;\n\
|
||||
SELECT 1;";
|
||||
|
||||
assert_eq!(
|
||||
split_sql_script(sql).unwrap(),
|
||||
vec![
|
||||
"CREATE FUNCTION bump_counter()\nRETURNS trigger AS $$\nBEGIN\nPERFORM 1;\nRETURN NEW;\nEND;\n$$ LANGUAGE plpgsql",
|
||||
"SELECT 1",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_mysql_executable_comments_as_statements() {
|
||||
assert_eq!(
|
||||
split_sql_script(
|
||||
"/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\nSELECT 1;",
|
||||
)
|
||||
.unwrap(),
|
||||
vec![
|
||||
"/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */",
|
||||
"SELECT 1",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod execution_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in new issue
Block a user