diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 8226896e44..7229cc9173 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -65,6 +65,15 @@ END; / SELECT 1;`; +const mysqlRoutineFixture = `CREATE PROCEDURE p() +BEGIN + SELECT 1; + IF 1 = 1 THEN + SELECT 'ok'; + END IF; +END; +SELECT 2;`; + describe("splitSqlStatementRanges", () => { it("splits multiple top-level statements", () => { const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;"; @@ -127,6 +136,19 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(sql, "mysql"))).toEqual(["select COUNT(1) FROM your_table", "select COUNT(1) FROM your_table;"]); }); + it("keeps MySQL routine blocks together without delimiter commands", () => { + const ranges = splitSqlStatementRanges(mysqlRoutineFixture, "mysql"); + expect(rangeSqlTexts(ranges)).toEqual([mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); + expect(ranges[0].sql).toContain("SELECT 1;"); + expect(ranges[0].sql).toContain("END IF;"); + expect(ranges[0].sql).not.toMatch(/END;$/); + }); + + it("does not merge regular MySQL transaction statements as routine blocks", () => { + const sql = "BEGIN; INSERT INTO t VALUES (1); COMMIT;"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "mysql"))).toEqual(["BEGIN", "INSERT INTO t VALUES (1)", "COMMIT"]); + }); + it("keeps Oracle PL/SQL blocks together and treats slash lines as delimiters", () => { const ranges = splitSqlStatementRanges(oraclePlSqlFixture, "oracle"); expect(rangeSqlTexts(ranges)).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]); @@ -359,6 +381,11 @@ WHERE request_json LIKE '%"paperFlag":null%';`; expect(statementRangeAtCursor(sql, indexOf(sql, "delimiter"), "mysql")).toBeNull(); }); + it("returns the full MySQL routine block for cursors inside nested statements", () => { + const range = statementRangeAtCursor(mysqlRoutineFixture, indexOf(mysqlRoutineFixture, "ok"), "mysql"); + expect(range?.sql.trim()).toBe(mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim()); + }); + it("returns the full Oracle PL/SQL block for cursors inside nested statements", () => { const range = statementRangeAtCursor(oraclePlSqlFixture, indexOf(oraclePlSqlFixture, "ORDERS_10K", 2), "oracle"); expect(range?.sql.trim()).toBe(oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/"))); @@ -396,6 +423,10 @@ describe("executableStatementRanges", () => { it("does not split executable Oracle PL/SQL ranges at inner statement starts", () => { expect(rangeSqlTexts(executableStatementRanges(oraclePlSqlFixture, "oracle"))).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]); }); + + it("does not split executable MySQL routine ranges at inner statements", () => { + expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineFixture, "mysql"))).toEqual([mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); + }); }); describe("fullSqlRange", () => { @@ -520,6 +551,10 @@ describe("hasMultipleExecutionTargets", () => { expect(hasMultipleExecutionTargets(sql, "mysql")).toBe(true); }); + it("counts MySQL routine blocks without delimiter by executable statements", () => { + expect(hasMultipleExecutionTargets(mysqlRoutineFixture, "mysql")).toBe(true); + }); + it("does not show multiple targets for MySQL DESC UPDATE joins", () => { const sql = "desc update test_orders a\njoin test_users b\non a.id=b.id \nset a.name = '张三'\nwhere b.id > 10;"; expect(hasMultipleExecutionTargets(sql, "mysql")).toBe(false); diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 7561f9da56..0be1579a8b 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -104,6 +104,10 @@ const ALTER_BODY_KEYWORDS = new Set(["ADD", "ALTER", "COMMENT", "DROP", "MODIFY" const SET_OPERATION_KEYWORDS = new Set(["UNION", "INTERSECT", "EXCEPT", "MINUS"]); const SET_OPERATION_MODIFIER_KEYWORDS = new Set(["ALL", "DISTINCT"]); const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle"]); +const MYSQL_ROUTINE_BLOCK_DATABASES: ReadonlySet = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]); +const MYSQL_ROUTINE_OBJECT_TYPES = new Set(["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]); +const MYSQL_NON_ROUTINE_CREATE_TYPES = new Set(["DATABASE", "INDEX", "LOGFILE", "ROLE", "SCHEMA", "SERVER", "SPATIAL", "TABLE", "TEMPORARY", "UNIQUE", "USER", "VIEW"]); +const MYSQL_CONTROL_BLOCK_SUFFIXES = new Set(["IF", "LOOP", "CASE", "REPEAT", "WHILE"]); const ORACLE_PL_SQL_BLOCK_STARTERS = new Set(["DECLARE", "BEGIN"]); const ORACLE_PL_SQL_CREATE_OBJECT_TYPES = new Set(["FUNCTION", "PROCEDURE", "TRIGGER", "PACKAGE", "PACKAGE BODY", "TYPE", "TYPE BODY"]); const ORACLE_PL_SQL_TERMINATORS = new Set(["IF", "LOOP", "CASE"]); @@ -311,16 +315,28 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType continue; } } else if (ch === ";") { - const isOraclePlSql = isOracleLikeDatabase(databaseType) && statementStart !== -1 && startsWithOraclePlSqlBlock(sql.slice(statementStart, i)); - if (isOraclePlSql) { - markContent(i); - if (!oraclePlSqlBlockIsComplete(sql.slice(statementStart, i + 1))) { + const isMysqlRoutineBlock = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 && startsWithMysqlRoutineBlock(sql.slice(statementStart, i)); + if (isMysqlRoutineBlock) { + if (!mysqlRoutineBlockIsComplete(sql.slice(statementStart, i + 1))) { + markContent(i); i += 1; continue; } - flush(i + 1); - } else { + // The final semicolon is the client-side statement delimiter. + // Internal semicolons remain part of the routine body. flush(); + } else { + const isOraclePlSql = isOracleLikeDatabase(databaseType) && statementStart !== -1 && startsWithOraclePlSqlBlock(sql.slice(statementStart, i)); + if (isOraclePlSql) { + markContent(i); + if (!oraclePlSqlBlockIsComplete(sql.slice(statementStart, i + 1))) { + i += 1; + continue; + } + flush(i + 1); + } else { + flush(); + } } statementHitStart = i + 1; i += 1; @@ -1038,6 +1054,163 @@ export function isOraclePlSqlStatement(sql: string, databaseType?: DatabaseType) return isOracleLikeDatabase(databaseType) && startsWithOraclePlSqlBlock(sql); } +function isMysqlRoutineBlockDatabase(databaseType?: DatabaseType): boolean { + return !!databaseType && MYSQL_ROUTINE_BLOCK_DATABASES.has(databaseType); +} + +function startsWithMysqlRoutineBlock(sql: string): boolean { + return isMysqlRoutineDdlStart(sql) && mysqlRoutineTokens(sql).some((token) => token.kind === "word" && token.value === "BEGIN"); +} + +function isMysqlRoutineDdlStart(sql: string): boolean { + const words = mysqlRoutineWords(sql).slice(0, 16); + if (words[0] !== "CREATE") return false; + + for (const word of words.slice(1)) { + if (MYSQL_ROUTINE_OBJECT_TYPES.has(word)) return true; + if (MYSQL_NON_ROUTINE_CREATE_TYPES.has(word)) return false; + } + return false; +} + +function mysqlRoutineBlockIsComplete(sql: string): boolean { + if (!startsWithMysqlRoutineBlock(sql)) return false; + + const tokens = mysqlRoutineTokens(sql); + let beginDepth = 0; + let sawBegin = false; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind !== "word") continue; + if (token.value === "BEGIN") { + if (previousWordToken(tokens, index) === "END") continue; + sawBegin = true; + beginDepth += 1; + continue; + } + if (token.value === "END" && sawBegin) { + if (MYSQL_CONTROL_BLOCK_SUFFIXES.has(nextWordToken(tokens, index) ?? "")) continue; + beginDepth = Math.max(0, beginDepth - 1); + } + } + + return sawBegin && beginDepth === 0 && tokens[tokens.length - 1]?.kind === "semicolon"; +} + +function mysqlRoutineWords(sql: string): string[] { + return mysqlRoutineTokens(sql) + .filter((token): token is { kind: "word"; value: string } => token.kind === "word") + .map((token) => token.value); +} + +function mysqlRoutineTokens(sql: string): Array<{ kind: "word" | "semicolon"; value: string }> { + const tokens: Array<{ kind: "word" | "semicolon"; value: string }> = []; + let state: QuoteState | "lineComment" | "blockComment" = "none"; + let i = 0; + + while (i < sql.length) { + const ch = sql[i]; + const next = sql[i + 1] ?? ""; + + if (state === "lineComment") { + if (ch === "\n") state = "none"; + i += 1; + continue; + } + if (state === "blockComment") { + if (ch === "*" && next === "/") { + state = "none"; + i += 2; + continue; + } + i += 1; + continue; + } + if (state === "single") { + if (ch === "\\" && next) { + i += 2; + continue; + } + if (ch === "'" && next === "'") { + i += 2; + continue; + } + if (ch === "'") state = "none"; + i += 1; + continue; + } + if (state === "double") { + if (ch === "\\" && next) { + i += 2; + continue; + } + if (ch === '"' && next === '"') { + i += 2; + continue; + } + if (ch === '"') state = "none"; + i += 1; + continue; + } + if (state === "backtick") { + if (ch === "`" && next === "`") { + i += 2; + continue; + } + if (ch === "`") state = "none"; + i += 1; + continue; + } + + if (ch === "-" && next === "-") { + state = "lineComment"; + i += 2; + continue; + } + if (ch === "#") { + state = "lineComment"; + i += 1; + continue; + } + if (ch === "/" && next === "*") { + state = "blockComment"; + i += 2; + continue; + } + if (ch === "'") { + state = "single"; + i += 1; + continue; + } + if (ch === '"') { + state = "double"; + i += 1; + continue; + } + if (ch === "`") { + state = "backtick"; + i += 1; + continue; + } + if (ch === ";") { + tokens.push({ kind: "semicolon", value: ";" }); + i += 1; + continue; + } + + const word = /^[A-Za-z_][\w$]*/.exec(sql.slice(i))?.[0]; + if (word) { + tokens.push({ kind: "word", value: word.toUpperCase() }); + i += word.length; + continue; + } + i += 1; + } + + return tokens; +} + function startsWithOraclePlSqlBlock(sql: string): boolean { const words = oraclePlSqlWords(sql); const first = words[0]; diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index ece8991b77..9508015845 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -109,6 +109,7 @@ struct SqlDialectProfile { supports_oracle_plsql_blocks: bool, supports_slash_line_block_delimiter: bool, supports_custom_delimiter_commands: bool, + supports_mysql_routine_blocks: bool, supports_dollar_quoted_strings: bool, supports_go_batch_separator: bool, keeps_sqlserver_module_batch_at_cursor: bool, @@ -121,6 +122,7 @@ impl Default for SqlDialectProfile { supports_oracle_plsql_blocks: false, supports_slash_line_block_delimiter: false, supports_custom_delimiter_commands: true, + supports_mysql_routine_blocks: false, supports_dollar_quoted_strings: true, supports_go_batch_separator: false, keeps_sqlserver_module_batch_at_cursor: false, @@ -146,7 +148,7 @@ impl SqlDialectProfile { } fn mysql_compatible() -> Self { - Self { supports_hash_line_comments: true, ..Self::default() } + Self { supports_hash_line_comments: true, supports_mysql_routine_blocks: true, ..Self::default() } } fn oracle_like() -> Self { @@ -387,6 +389,19 @@ impl SqlStatementSplitter { self.buffer.push(ch); } else if self.custom_delimiter.is_some() { self.buffer.push(ch); + } else if self.options.profile.supports_mysql_routine_blocks + && starts_with_mysql_routine_block(&self.buffer) + { + let mut candidate = self.buffer.clone(); + candidate.push(ch); + if mysql_routine_block_is_complete(&candidate) { + // The final semicolon is the client-side statement delimiter. + // Keep semicolons inside BEGIN...END, but do not send the + // delimiter after END to the MySQL server. + self.push_current_statement(&mut statements); + } else { + self.buffer.push(ch); + } } else if self.options.profile.supports_oracle_plsql_blocks && starts_with_oracle_plsql_block(&self.buffer) { @@ -783,16 +798,26 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions && custom_delimiter.is_none() && !(options.profile.supports_custom_delimiter_commands && is_on_delimiter_line(sql, start, i)) => { - let is_oracle_plsql = - options.profile.supports_oracle_plsql_blocks && starts_with_oracle_plsql_block(&sql[start..i]); - if is_oracle_plsql { - if !oracle_plsql_block_is_complete(&sql[start..i + ch.len_utf8()]) { + let is_mysql_routine = + options.profile.supports_mysql_routine_blocks && starts_with_mysql_routine_block(&sql[start..i]); + if is_mysql_routine { + if !mysql_routine_block_is_complete(&sql[start..i + ch.len_utf8()]) { i += ch.len_utf8(); continue; } - push_statement_range(&mut ranges, sql, start, i + ch.len_utf8(), options); - } else { push_statement_range(&mut ranges, sql, start, i, options); + } else { + let is_oracle_plsql = + options.profile.supports_oracle_plsql_blocks && starts_with_oracle_plsql_block(&sql[start..i]); + if is_oracle_plsql { + if !oracle_plsql_block_is_complete(&sql[start..i + ch.len_utf8()]) { + i += ch.len_utf8(); + continue; + } + push_statement_range(&mut ranges, sql, start, i + ch.len_utf8(), options); + } else { + push_statement_range(&mut ranges, sql, start, i, options); + } } i += ch.len_utf8(); start = i; @@ -1009,6 +1034,217 @@ fn is_sqlserver_module_keyword(token: &str) -> bool { ["FUNCTION", "PROC", "PROCEDURE", "TRIGGER", "VIEW"].iter().any(|keyword| token.eq_ignore_ascii_case(keyword)) } +fn starts_with_mysql_routine_block(sql: &str) -> bool { + is_mysql_routine_ddl_start(sql) && mysql_routine_tokens(sql).iter().any(|token| token.eq_ignore_ascii_case("BEGIN")) +} + +fn is_mysql_routine_ddl_start(sql: &str) -> bool { + let executable = leading_executable_sql_with_options(sql, SqlParsingOptions::mysql_compatible()); + let tokens = first_sql_tokens(executable, 16); + if tokens.first().is_none_or(|token| !token.eq_ignore_ascii_case("CREATE")) { + return false; + } + + for token in tokens.iter().skip(1) { + if ["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"].iter().any(|keyword| token.eq_ignore_ascii_case(keyword)) { + return true; + } + if [ + "DATABASE", + "INDEX", + "LOGFILE", + "ROLE", + "SCHEMA", + "SERVER", + "SPATIAL", + "TABLE", + "TEMPORARY", + "UNIQUE", + "USER", + "VIEW", + ] + .iter() + .any(|keyword| token.eq_ignore_ascii_case(keyword)) + { + return false; + } + } + + false +} + +fn mysql_routine_block_is_complete(sql: &str) -> bool { + if !starts_with_mysql_routine_block(sql) { + return false; + } + + let tokens = mysql_routine_tokens(sql); + let mut begin_depth = 0usize; + let mut saw_begin = false; + + for (index, token) in tokens.iter().enumerate() { + if token == ";" { + continue; + } + if token.eq_ignore_ascii_case("BEGIN") { + if previous_mysql_routine_word(&tokens, index).is_some_and(|previous| previous.eq_ignore_ascii_case("END")) + { + continue; + } + saw_begin = true; + begin_depth += 1; + continue; + } + if token.eq_ignore_ascii_case("END") && saw_begin { + if next_mysql_routine_word(&tokens, index).is_some_and(is_mysql_control_block_suffix) { + continue; + } + begin_depth = begin_depth.saturating_sub(1); + } + } + + saw_begin && begin_depth == 0 && tokens.last().is_some_and(|token| token == ";") +} + +fn is_mysql_control_block_suffix(token: &str) -> bool { + ["IF", "LOOP", "CASE", "REPEAT", "WHILE"].iter().any(|keyword| token.eq_ignore_ascii_case(keyword)) +} + +fn previous_mysql_routine_word(tokens: &[String], index: usize) -> Option<&str> { + tokens[..index].iter().rev().find(|token| token.as_str() != ";").map(String::as_str) +} + +fn next_mysql_routine_word(tokens: &[String], index: usize) -> Option<&str> { + tokens.get(index + 1..)?.iter().find(|token| token.as_str() != ";").map(String::as_str) +} + +fn mysql_routine_tokens(sql: &str) -> Vec { + let chars = sql.chars().collect::>(); + let mut tokens = Vec::new(); + let mut in_line_comment = false; + let mut in_block_comment = false; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut in_backtick = false; + 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; + } + i += 1; + continue; + } + + if in_block_comment { + if ch == '*' && next == Some('/') { + in_block_comment = false; + i += 2; + } else { + i += 1; + } + continue; + } + + if in_single_quote { + if ch == '\\' && next.is_some() { + i += 2; + continue; + } + if ch == '\'' { + if next == Some('\'') { + i += 2; + continue; + } + in_single_quote = false; + } + i += 1; + continue; + } + + if in_double_quote { + if ch == '\\' && next.is_some() { + i += 2; + continue; + } + if ch == '"' { + if next == Some('"') { + i += 2; + continue; + } + in_double_quote = false; + } + i += 1; + continue; + } + + if in_backtick { + if ch == '`' { + if next == Some('`') { + i += 2; + continue; + } + in_backtick = false; + } + i += 1; + continue; + } + + if ch == '-' && next == Some('-') { + in_line_comment = true; + i += 2; + continue; + } + if ch == '#' { + in_line_comment = true; + i += 1; + continue; + } + if ch == '/' && next == Some('*') { + in_block_comment = true; + i += 2; + continue; + } + if ch == '\'' { + in_single_quote = true; + i += 1; + continue; + } + if ch == '"' { + in_double_quote = true; + i += 1; + continue; + } + if ch == '`' { + in_backtick = true; + i += 1; + continue; + } + if ch == ';' { + tokens.push(";".to_string()); + i += 1; + continue; + } + if ch == '_' || ch.is_ascii_alphabetic() { + let start = i; + i += 1; + while i < chars.len() && is_sql_ident_char(chars[i]) { + i += 1; + } + tokens.push(chars[start..i].iter().collect::().to_ascii_uppercase()); + continue; + } + + i += 1; + } + + tokens +} + fn first_sql_tokens(sql: &str, limit: usize) -> Vec { let mut tokens = Vec::new(); for token in sql.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') { @@ -2511,6 +2747,45 @@ DELIMITER ;"; assert_eq!(super::split_sql_statements(sql), vec!["SELECT 1", "SELECT 2"]); } + #[test] + fn mysql_routine_without_delimiter_keeps_body_together() { + let sql = "\ +CREATE PROCEDURE p() +BEGIN + SELECT 1; + SELECT 2; +END; +SELECT 3;"; + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Mysql), + vec!["CREATE PROCEDURE p()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND", "SELECT 3"] + ); + } + + #[test] + fn mysql_routine_without_delimiter_handles_nested_end_suffixes() { + let sql = "\ +CREATE PROCEDURE p() +BEGIN + IF 1 = 1 THEN + SELECT 'ok'; + END IF; +END; +SELECT 2;"; + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Mysql), + vec!["CREATE PROCEDURE p()\nBEGIN\n IF 1 = 1 THEN\n SELECT 'ok';\n END IF;\nEND", "SELECT 2"] + ); + } + + #[test] + fn mysql_regular_begin_transaction_still_splits_without_delimiter() { + assert_eq!( + split_sql_statements_for_database("BEGIN; INSERT INTO t VALUES (1); COMMIT;", DatabaseType::Mysql), + vec!["BEGIN", "INSERT INTO t VALUES (1)", "COMMIT"] + ); + } + #[test] fn sql_dialect_profiles_map_database_types_to_parser_capabilities() { let default = SqlDialectProfile::for_database_type(DatabaseType::Postgres); @@ -2518,6 +2793,7 @@ DELIMITER ;"; assert!(default.supports_custom_delimiter_commands); assert!(default.supports_dollar_quoted_strings); assert!(!default.supports_hash_line_comments); + assert!(!default.supports_mysql_routine_blocks); assert!(!default.supports_oracle_plsql_blocks); assert!(!default.supports_slash_line_block_delimiter); assert!(!default.supports_go_batch_separator); @@ -2526,6 +2802,7 @@ DELIMITER ;"; let mysql = SqlDialectProfile::for_database_type(DatabaseType::Mysql); assert_eq!(mysql, SqlDialectProfile::mysql_compatible()); assert!(mysql.supports_hash_line_comments); + assert!(mysql.supports_mysql_routine_blocks); assert!(SqlDialectProfile::for_database_type(DatabaseType::Doris).supports_hash_line_comments); assert!(SqlDialectProfile::for_database_type(DatabaseType::StarRocks).supports_hash_line_comments); assert!(SqlDialectProfile::for_database_type(DatabaseType::ManticoreSearch).supports_hash_line_comments); diff --git a/crates/dbx-core/tests/live_mysql57.rs b/crates/dbx-core/tests/live_mysql57.rs index 5e796f2f4e..8fa91aa39f 100644 --- a/crates/dbx-core/tests/live_mysql57.rs +++ b/crates/dbx-core/tests/live_mysql57.rs @@ -1,7 +1,7 @@ use dbx_core::connection::AppState; use dbx_core::models::connection::{ConnectionConfig, DatabaseType}; use dbx_core::query::execute_sql_statement; -use dbx_core::sql::SqlFileRequest; +use dbx_core::sql::{split_sql_statements_for_database, SqlFileRequest}; use dbx_core::sql_file_import::execute_sql_file_content; use dbx_core::storage::Storage; use tokio_util::sync::CancellationToken; @@ -134,6 +134,37 @@ END assert_eq!(result.rows, vec![vec![serde_json::json!("1"), serde_json::json!("测试数据001")]]); } +#[tokio::test] +#[ignore = "requires a remote writable MySQL endpoint"] +async fn live_mysql_splitter_executes_routine_without_delimiter() { + let url = std::env::var("DBX_LIVE_MYSQL_PROCEDURE_URL").expect("DBX_LIVE_MYSQL_PROCEDURE_URL"); + let pool = dbx_core::db::mysql::connect(&url, std::time::Duration::from_secs(10)).await.unwrap(); + let procedure = "dbx_issue_2695_proc"; + + let sql = format!( + "\ +DROP PROCEDURE IF EXISTS {procedure}; +CREATE PROCEDURE {procedure}() +BEGIN + SET @dbx_issue_2695_value = 2695; + SELECT @dbx_issue_2695_value AS value; +END; +CALL {procedure}(); +DROP PROCEDURE IF EXISTS {procedure};" + ); + let statements = split_sql_statements_for_database(&sql, DatabaseType::Mysql); + assert_eq!(statements.len(), 4); + assert!(statements[1].contains("SET @dbx_issue_2695_value = 2695;")); + assert!(statements[1].contains("SELECT @dbx_issue_2695_value AS value;")); + assert!(statements[1].ends_with("END")); + + for statement in statements { + dbx_core::db::mysql::execute_query_with_max_rows(&pool, &statement, false, Some(10), Default::default()) + .await + .unwrap(); + } +} + #[tokio::test] #[ignore = "requires a remote OceanBase MySQL-compatible endpoint"] async fn live_oceanbase_mysql_setup_applies_query_timeout() {