mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 08:18:52 +00:00
fix(mysql): preserve nested routine blocks
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -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)"]);
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user