diff --git a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts index 633745a332..9191aa0c65 100644 --- a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts @@ -37,6 +37,24 @@ describe("executableStatementRangeCacheForDoc", () => { expect(executableStatementRangeStartingAt(cache, secondStatementLine.from)?.sql).toBe("SELECT *\nFROM menus AS mn\nLIMIT 100"); }); + it("only exposes the routine start as executable when a MySQL procedure contains a CASE expression", () => { + const sql = [ + "CREATE PROCEDURE p_case()", + "BEGIN", + " INSERT INTO audit_log (status_text)", + " SELECT CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END;", + " CASE WHEN active = 1 THEN SET @status_code = 1; ELSE SET @status_code = 0; END CASE;", + " DELETE FROM stale_rows WHERE expires_at < NOW();", + "END;", + ].join("\n"); + const doc = Text.of(sql.split("\n")); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + + expect(executableStatementRangeStartingAt(cache, doc.line(1).from)?.sql).toBe(sql.slice(0, -1)); + expect(executableStatementRangeStartingAt(cache, doc.line(6).from)).toBeNull(); + expect(executableStatementRangeAtCursor(cache, doc.line(6).from + 4)?.sql).toBe(sql.slice(0, -1)); + }); + it("keeps MyBatis parameters in a Kingbase gutter execution range", () => { const sql = ["SELECT sum(nvl(a.medfee_sumamt, 0)) AS medfee_sumamt, a.insutype", "FROM yd_org_decla_detail a", "WHERE a.busin_type = '1' AND a.clr_ym = #{ym}", "GROUP BY a.clr_ym, a.insutype;"].join("\n"); const doc = Text.of(sql.split("\n")); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 1e82d1c47d..b236c2610e 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -230,6 +230,39 @@ BEGIN END; SELECT 2;`; +const mysqlRoutineWithCaseExpressionFixture = `CREATE PROCEDURE p_case() +BEGIN + INSERT INTO audit_log (status_text) + SELECT CASE + WHEN active = 1 THEN 'active' + ELSE 'inactive' + END; + + CASE + WHEN active = 1 THEN SET @status_code = 1; + ELSE SET @status_code = 0; + END CASE; + + DELETE FROM stale_rows + WHERE expires_at < NOW(); +END; +SELECT 2;`; + +const mysqlRoutineWithNestedCaseBlockFixture = `CREATE PROCEDURE p_nested_case() +BEGIN + CASE + WHEN active = 1 THEN + BEGIN + SET @status_code = 1; + END; + ELSE SET @status_code = 0; + END CASE; + + DELETE FROM stale_rows + WHERE expires_at < NOW(); +END; +SELECT 2;`; + const mysqlDelimitedRoutineFixture = `DELIMITER // CREATE PROCEDURE sp_insert_random_users(IN p_count INT) BEGIN @@ -342,6 +375,10 @@ describe("splitSqlStatementRanges", () => { expect(ranges[0].sql).not.toMatch(/END;$/); }); + it("closes nested MySQL CASE and BEGIN blocks in their opening order", () => { + expect(rangeSqlTexts(splitSqlStatementRanges(mysqlRoutineWithNestedCaseBlockFixture, "mysql"))).toEqual([mysqlRoutineWithNestedCaseBlockFixture.slice(0, mysqlRoutineWithNestedCaseBlockFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); + }); + 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"]); @@ -1105,6 +1142,14 @@ describe("executableStatementRanges", () => { expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineWithLoopsFixture, "mysql"))).toEqual([mysqlRoutineWithLoopsFixture.slice(0, mysqlRoutineWithLoopsFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); }); + it("does not treat a CASE expression ending as the end of a MySQL routine", () => { + expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineWithCaseExpressionFixture, "mysql"))).toEqual([mysqlRoutineWithCaseExpressionFixture.slice(0, mysqlRoutineWithCaseExpressionFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); + }); + + it("does not split MySQL routine ranges when a CASE branch contains a BEGIN block", () => { + expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineWithNestedCaseBlockFixture, "mysql"))).toEqual([mysqlRoutineWithNestedCaseBlockFixture.slice(0, mysqlRoutineWithNestedCaseBlockFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); + }); + it("does not expose run targets for statements inside a delimited MySQL routine", () => { expect(rangeSqlTexts(executableStatementRanges(mysqlDelimitedRoutineFixture, "mysql"))).toEqual([mysqlDelimitedRoutineFixture.slice(mysqlDelimitedRoutineFixture.indexOf("CREATE PROCEDURE"), mysqlDelimitedRoutineFixture.indexOf(" //\nDELIMITER")), "CALL sp_insert_random_users(100)"]); }); diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 5fef311c3d..03a0876b29 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -1520,7 +1520,7 @@ function mysqlRoutineBlockIsComplete(sql: string, parameterOptions?: SqlParamete if (!startsWithMysqlRoutineBlock(sql, parameterOptions)) return false; const tokens = mysqlRoutineTokens(sql, parameterOptions); - let beginDepth = 0; + const blockStack: Array<"BEGIN" | "CASE"> = []; let sawBegin = false; for (let index = 0; index < tokens.length; index += 1) { @@ -1529,16 +1529,26 @@ function mysqlRoutineBlockIsComplete(sql: string, parameterOptions?: SqlParamete if (token.value === "BEGIN") { if (previousWordToken(tokens, index) === "END") continue; sawBegin = true; - beginDepth += 1; + blockStack.push("BEGIN"); + continue; + } + if (token.value === "CASE") { + if (previousWordToken(tokens, index) === "END") continue; + blockStack.push("CASE"); continue; } if (token.value === "END" && sawBegin) { - if (MYSQL_CONTROL_BLOCK_SUFFIXES.has(nextWordToken(tokens, index) ?? "")) continue; - beginDepth = Math.max(0, beginDepth - 1); + const suffix = nextWordToken(tokens, index) ?? ""; + if (suffix === "CASE") { + if (blockStack[blockStack.length - 1] === "CASE") blockStack.pop(); + continue; + } + if (MYSQL_CONTROL_BLOCK_SUFFIXES.has(suffix)) continue; + blockStack.pop(); } } - return sawBegin && beginDepth === 0 && tokens[tokens.length - 1]?.kind === "semicolon"; + return sawBegin && blockStack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon"; } function mysqlRoutineWords(sql: string, parameterOptions?: SqlParameterOptions): string[] { diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index 30be134951..4b115b86ca 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -1164,13 +1164,19 @@ fn is_mysql_routine_ddl_start(sql: &str) -> bool { false } +#[derive(Clone, Copy, PartialEq, Eq)] +enum MysqlRoutineBlockType { + Begin, + Case, +} + 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 block_stack = Vec::new(); let mut saw_begin = false; for (index, token) in tokens.iter().enumerate() { @@ -1183,18 +1189,33 @@ fn mysql_routine_block_is_complete(sql: &str) -> bool { continue; } saw_begin = true; - begin_depth += 1; + block_stack.push(MysqlRoutineBlockType::Begin); + continue; + } + if token.eq_ignore_ascii_case("CASE") { + if previous_mysql_routine_word(&tokens, index).is_some_and(|previous| previous.eq_ignore_ascii_case("END")) + { + continue; + } + block_stack.push(MysqlRoutineBlockType::Case); 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) { + let suffix = next_mysql_routine_word(&tokens, index); + if suffix.is_some_and(|next| next.eq_ignore_ascii_case("CASE")) { + if block_stack.last() == Some(&MysqlRoutineBlockType::Case) { + block_stack.pop(); + } continue; } - begin_depth = begin_depth.saturating_sub(1); + if suffix.is_some_and(is_mysql_control_block_suffix) { + continue; + } + block_stack.pop(); } } - saw_begin && begin_depth == 0 && tokens.last().is_some_and(|token| token == ";") + saw_begin && block_stack.is_empty() && tokens.last().is_some_and(|token| token == ";") } fn is_mysql_control_block_suffix(token: &str) -> bool { @@ -3189,6 +3210,58 @@ SELECT 2;"; ); } + #[test] + fn mysql_routine_without_delimiter_handles_case_endings() { + let expression = "\ +CREATE PROCEDURE p_case() +BEGIN + INSERT INTO audit_log (status_text) + SELECT CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END; + DELETE FROM stale_rows WHERE expires_at < NOW(); +END; +SELECT 2;"; + assert_eq!( + split_sql_statements_for_database(expression, DatabaseType::Mysql), + vec![ + "CREATE PROCEDURE p_case()\nBEGIN\n INSERT INTO audit_log (status_text)\n SELECT CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END;\n DELETE FROM stale_rows WHERE expires_at < NOW();\nEND", + "SELECT 2", + ] + ); + + let statement = "\ +CREATE PROCEDURE p_case_statement() +BEGIN + CASE WHEN active = 1 THEN SELECT 1; ELSE SELECT 0; END CASE; + DELETE FROM stale_rows WHERE expires_at < NOW(); +END; +SELECT 2;"; + assert_eq!( + split_sql_statements_for_database(statement, DatabaseType::Mysql), + vec![ + "CREATE PROCEDURE p_case_statement()\nBEGIN\n CASE WHEN active = 1 THEN SELECT 1; ELSE SELECT 0; END CASE;\n DELETE FROM stale_rows WHERE expires_at < NOW();\nEND", + "SELECT 2", + ] + ); + } + + #[test] + fn mysql_routine_without_delimiter_closes_nested_case_begin_blocks_in_order() { + let sql = "\ +CREATE PROCEDURE p_nested_case() +BEGIN + CASE WHEN active = 1 THEN BEGIN SELECT 1; END; ELSE SELECT 0; END CASE; + DELETE FROM stale_rows WHERE expires_at < NOW(); +END; +SELECT 2;"; + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Mysql), + vec![ + "CREATE PROCEDURE p_nested_case()\nBEGIN\n CASE WHEN active = 1 THEN BEGIN SELECT 1; END; ELSE SELECT 0; END CASE;\n DELETE FROM stale_rows WHERE expires_at < NOW();\nEND", + "SELECT 2", + ] + ); + } + #[test] fn mysql_regular_begin_transaction_still_splits_without_delimiter() { assert_eq!( diff --git a/crates/dbx-core/tests/live_mysql57.rs b/crates/dbx-core/tests/live_mysql57.rs index dfd9dd6221..e86d41965c 100644 --- a/crates/dbx-core/tests/live_mysql57.rs +++ b/crates/dbx-core/tests/live_mysql57.rs @@ -630,30 +630,44 @@ END 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 procedure = format!("dbx_routine_range_{}", uuid::Uuid::new_v4().simple()); 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; + SET @dbx_routine_range_value = CASE WHEN 1 = 1 THEN 2695 ELSE 0 END; + SELECT @dbx_routine_range_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].contains("CASE WHEN 1 = 1 THEN 2695 ELSE 0 END;")); + assert!(statements[1].contains("SELECT @dbx_routine_range_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(); + let execution = async { + for statement in &statements[..3] { + dbx_core::db::mysql::execute_query_with_max_rows(&pool, statement, false, Some(10), Default::default()) + .await?; + } + Ok::<(), String>(()) } + .await; + let cleanup = dbx_core::db::mysql::execute_query_with_max_rows( + &pool, + statements.last().expect("cleanup statement"), + false, + Some(10), + Default::default(), + ) + .await; + + execution.unwrap(); + cleanup.unwrap(); } #[tokio::test]