fix(saphana): keep DO blocks together

This commit is contained in:
t8y2 2026-07-07 13:23:27 +08:00
parent f41c7dd6a0
commit 6e7f0578b6
3 changed files with 223 additions and 3 deletions

View File

@ -85,6 +85,12 @@ BEGIN
END;
SELECT 2;`;
const sapHanaDoBlockFixture = `DO
BEGIN
SELECT 1 AS "Result" FROM DUMMY;
END;
SELECT 2 FROM DUMMY;`;
describe("splitSqlStatementRanges", () => {
it("splits multiple top-level statements", () => {
const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;";
@ -177,6 +183,14 @@ describe("splitSqlStatementRanges", () => {
expect(ranges[0].sql).toContain("END;");
expect(ranges[0].sql).not.toContain("\n/");
});
it("keeps SAP HANA DO blocks together", () => {
const ranges = splitSqlStatementRanges(sapHanaDoBlockFixture, "saphana");
expect(rangeSqlTexts(ranges)).toEqual([sapHanaDoBlockFixture.slice(0, sapHanaDoBlockFixture.indexOf("\nSELECT 2")), "SELECT 2 FROM DUMMY"]);
expect(ranges[0].sql).toContain('SELECT 1 AS "Result" FROM DUMMY;');
expect(ranges[0].sql).toContain("END;");
});
});
describe("statementRangeAtCursor", () => {
@ -423,6 +437,12 @@ WHERE request_json LIKE '%"paperFlag":null%';`;
const range = statementRangeAtCursor(oraclePlSqlFixture, indexOf(oraclePlSqlFixture, "ORDERS_10K", 2), "oracle");
expect(range?.sql.trim()).toBe(oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")));
});
it("returns the full SAP HANA DO block for cursors inside nested statements", () => {
const range = statementRangeAtCursor(sapHanaDoBlockFixture, indexOf(sapHanaDoBlockFixture, "Result"), "saphana");
expect(range?.sql.trim()).toBe(sapHanaDoBlockFixture.slice(0, sapHanaDoBlockFixture.indexOf("\nSELECT 2")));
});
});
describe("executableStatementRanges", () => {
@ -465,6 +485,10 @@ describe("executableStatementRanges", () => {
expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineWithLoopsFixture, "mysql"))).toEqual([mysqlRoutineWithLoopsFixture.slice(0, mysqlRoutineWithLoopsFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]);
});
it("does not split executable SAP HANA DO ranges at inner statements", () => {
expect(rangeSqlTexts(executableStatementRanges(sapHanaDoBlockFixture, "saphana"))).toEqual([sapHanaDoBlockFixture.slice(0, sapHanaDoBlockFixture.indexOf("\nSELECT 2")), "SELECT 2 FROM DUMMY"]);
});
it("returns executable SQL Server batches without GO delimiter lines", () => {
expect(rangeSqlTexts(executableStatementRanges("SELECT 1\nGO\nSELECT 2;", "sqlserver"))).toEqual(["SELECT 1", "SELECT 2"]);
});

View File

@ -80,6 +80,7 @@ const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial<Record<DatabaseType, readonly st
duckdb: ["ATTACH", "DETACH", "EXPORT", "IMPORT", "INSTALL", "LOAD"],
clickhouse: ["ATTACH", "CHECK", "DETACH", "EXCHANGE", "KILL", "OPTIMIZE", "SYSTEM"],
sqlserver: ["BACKUP", "DBCC", "DENY", "RESTORE"],
saphana: ["DO"],
oracle: ["FLASHBACK", "LOCK", "PURGE"],
dameng: ["FLASHBACK", "LOCK", "PURGE"],
gaussdb: ["DO", "LOCK"],
@ -111,6 +112,7 @@ const MYSQL_CONTROL_BLOCK_SUFFIXES = new Set(["IF", "LOOP", "CASE", "REPEAT", "W
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"]);
const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]);
/**
* Parse the SQL document into top-level statement ranges delimited by `;`.
@ -334,10 +336,16 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
// Internal semicolons remain part of the routine body.
flush();
} else {
const isOraclePlSql = isOracleLikeDatabase(databaseType) && statementStart !== -1 && startsWithOraclePlSqlBlock(sql.slice(statementStart, i));
if (isOraclePlSql) {
const statementSoFar = statementStart === -1 ? "" : sql.slice(statementStart, i);
const isOraclePlSql = isOracleLikeDatabase(databaseType) && statementStart !== -1 && startsWithOraclePlSqlBlock(statementSoFar);
const isSapHanaScriptBlock = isSapHanaScriptBlockDatabase(databaseType) && statementStart !== -1 && startsWithSapHanaScriptBlock(statementSoFar);
if (isOraclePlSql || isSapHanaScriptBlock) {
markContent(i);
if (!oraclePlSqlBlockIsComplete(sql.slice(statementStart, i + 1))) {
if (isOraclePlSql && !oraclePlSqlBlockIsComplete(sql.slice(statementStart, i + 1))) {
i += 1;
continue;
}
if (isSapHanaScriptBlock && !sapHanaScriptBlockIsComplete(sql.slice(statementStart, i + 1))) {
i += 1;
continue;
}
@ -459,6 +467,7 @@ function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: nu
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): RawStatement[] {
if (isOraclePlSqlStatement(statement.sql, databaseType)) return [statement];
if (isSapHanaScriptBlockStatement(statement.sql, databaseType)) return [statement];
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType);
if (lineStarts.length <= 1) return [statement];
@ -1082,6 +1091,14 @@ export function isOraclePlSqlStatement(sql: string, databaseType?: DatabaseType)
return isOracleLikeDatabase(databaseType) && startsWithOraclePlSqlBlock(sql);
}
function isSapHanaScriptBlockDatabase(databaseType?: DatabaseType): boolean {
return databaseType === "saphana";
}
function isSapHanaScriptBlockStatement(sql: string, databaseType?: DatabaseType): boolean {
return isSapHanaScriptBlockDatabase(databaseType) && startsWithSapHanaScriptBlock(sql);
}
function isMysqlRoutineBlockDatabase(databaseType?: DatabaseType): boolean {
return !!databaseType && MYSQL_ROUTINE_BLOCK_DATABASES.has(databaseType);
}
@ -1255,6 +1272,42 @@ function startsWithOraclePlSqlBlock(sql: string): boolean {
return ORACLE_PL_SQL_CREATE_OBJECT_TYPES.has(words[index] ?? "");
}
function startsWithSapHanaScriptBlock(sql: string): boolean {
return oraclePlSqlWords(sql)[0] === "DO";
}
function sapHanaScriptBlockIsComplete(sql: string): boolean {
if (!startsWithSapHanaScriptBlock(sql)) return false;
const tokens = oraclePlSqlTokens(sql);
const stack: string[] = [];
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;
stack.push("BLOCK");
sawBegin = true;
continue;
}
if (token.value === "IF" || token.value === "FOR" || token.value === "WHILE" || token.value === "CASE") {
if (previousWordToken(tokens, index) !== "END") stack.push(token.value);
continue;
}
if (token.value === "END") {
const next = nextWordToken(tokens, index);
const top = stack[stack.length - 1];
const target = SAP_HANA_SCRIPT_BLOCK_TERMINATORS.has(next ?? "") ? next : top === "CASE" ? "CASE" : "BLOCK";
if (top === target) stack.pop();
}
}
return sawBegin && stack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon";
}
function oraclePlSqlBlockIsComplete(sql: string): boolean {
const tokens = oraclePlSqlTokens(sql);
if (!startsWithOraclePlSqlBlock(sql)) return false;

View File

@ -111,6 +111,7 @@ struct SqlDialectProfile {
supports_custom_delimiter_commands: bool,
supports_mysql_routine_blocks: bool,
supports_dollar_quoted_strings: bool,
supports_hana_do_blocks: bool,
supports_go_batch_separator: bool,
keeps_sqlserver_module_batch_at_cursor: bool,
}
@ -124,6 +125,7 @@ impl Default for SqlDialectProfile {
supports_custom_delimiter_commands: true,
supports_mysql_routine_blocks: false,
supports_dollar_quoted_strings: true,
supports_hana_do_blocks: false,
supports_go_batch_separator: false,
keeps_sqlserver_module_batch_at_cursor: false,
}
@ -144,6 +146,10 @@ impl SqlDialectProfile {
return Self::mysql_compatible();
}
if matches!(db_type, DatabaseType::SapHana) {
return Self::sap_hana();
}
Self::default()
}
@ -159,6 +165,10 @@ impl SqlDialectProfile {
Self { supports_go_batch_separator: true, keeps_sqlserver_module_batch_at_cursor: true, ..Self::default() }
}
fn sap_hana() -> Self {
Self { supports_hana_do_blocks: true, ..Self::default() }
}
fn is_mysql_compatible_database(db_type: DatabaseType) -> bool {
matches!(
db_type,
@ -409,6 +419,11 @@ impl SqlStatementSplitter {
if oracle_plsql_block_is_complete(&self.buffer) {
self.push_current_statement(&mut statements);
}
} else if self.options.profile.supports_hana_do_blocks && starts_with_hana_do_block(&self.buffer) {
self.buffer.push(ch);
if hana_do_block_is_complete(&self.buffer) {
self.push_current_statement(&mut statements);
}
} else {
self.push_current_statement(&mut statements);
}
@ -623,6 +638,9 @@ fn split_statement_range_at_blank_lines(
if options.profile.supports_oracle_plsql_blocks && starts_with_oracle_plsql_block(&statement.text) {
return vec![statement.clone()];
}
if options.profile.supports_hana_do_blocks && starts_with_hana_do_block(&statement.text) {
return vec![statement.clone()];
}
let mut ranges = Vec::new();
let mut scanner = SqlScanner::with_profile(options.profile);
@ -670,6 +688,10 @@ fn split_statement_range_at_blank_lines(
}
fn starts_with_soft_statement_keyword(sql: &str, options: SqlParsingOptions) -> bool {
if options.profile.supports_hana_do_blocks && starts_with_executable_sql_keyword_with_options(sql, &["DO"], options)
{
return true;
}
starts_with_executable_sql_keyword_with_options(
sql,
&[
@ -815,6 +837,12 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions
continue;
}
push_statement_range(&mut ranges, sql, start, i + ch.len_utf8(), options);
} else if options.profile.supports_hana_do_blocks && starts_with_hana_do_block(&sql[start..i]) {
if !hana_do_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);
}
@ -1958,6 +1986,68 @@ fn oracle_plsql_block_is_complete(sql: &str) -> bool {
OraclePlSqlBlock::parse(sql).is_complete()
}
fn starts_with_hana_do_block(sql: &str) -> bool {
HanaDoBlock::parse(sql).starts_block()
}
fn hana_do_block_is_complete(sql: &str) -> bool {
HanaDoBlock::parse(sql).is_complete()
}
struct HanaDoBlock {
tokens: Vec<OraclePlSqlToken>,
}
impl HanaDoBlock {
fn parse(sql: &str) -> Self {
Self { tokens: oracle_plsql_tokens(sql) }
}
fn starts_block(&self) -> bool {
self.tokens.first().is_some_and(|token| token.is_word("DO"))
}
fn is_complete(&self) -> bool {
if !self.starts_block() {
return false;
}
let mut stack: Vec<String> = Vec::new();
let mut saw_begin = false;
for (index, token) in self.tokens.iter().enumerate() {
if token.is_word("BEGIN") {
if previous_word_token(&self.tokens, index).is_some_and(|previous| previous == "END") {
continue;
}
stack.push("BLOCK".to_string());
saw_begin = true;
continue;
}
if token.is_any_word(&["IF", "FOR", "WHILE", "CASE"]) {
if previous_word_token(&self.tokens, index).is_none_or(|previous| previous != "END") {
stack.push(token.as_word().unwrap_or("BLOCK").to_string());
}
continue;
}
if token.is_word("END") {
let next = next_word_token(&self.tokens, index);
let top = stack.last().map(|value| value.as_str());
let target = match next {
Some(keyword @ ("IF" | "FOR" | "WHILE")) => keyword,
_ if top == Some("CASE") => "CASE",
_ => "BLOCK",
};
if top == Some(target) {
stack.pop();
}
}
}
saw_begin && stack.is_empty() && self.tokens.last().is_some_and(OraclePlSqlToken::is_semicolon)
}
}
struct OraclePlSqlBlock {
tokens: Vec<OraclePlSqlToken>,
}
@ -2056,6 +2146,13 @@ impl OraclePlSqlToken {
matches!(self, Self::Word(value) if value == expected)
}
fn as_word(&self) -> Option<&str> {
match self {
Self::Word(value) => Some(value),
Self::Semicolon => None,
}
}
fn is_any_word(&self, expected: &[&str]) -> bool {
expected.iter().any(|word| self.is_word(word))
}
@ -2065,6 +2162,14 @@ impl OraclePlSqlToken {
}
}
fn previous_word_token(tokens: &[OraclePlSqlToken], index: usize) -> Option<&str> {
tokens[..index].iter().rev().find_map(OraclePlSqlToken::as_word)
}
fn next_word_token(tokens: &[OraclePlSqlToken], index: usize) -> Option<&str> {
tokens[index + 1..].iter().find_map(OraclePlSqlToken::as_word)
}
fn oracle_plsql_tokens(sql: &str) -> Vec<OraclePlSqlToken> {
let dialect = OracleDialect {};
if let Ok(tokens) = Tokenizer::new(&dialect, sql).tokenize() {
@ -2817,6 +2922,7 @@ SELECT 2;";
assert!(!default.supports_hash_line_comments);
assert!(!default.supports_mysql_routine_blocks);
assert!(!default.supports_oracle_plsql_blocks);
assert!(!default.supports_hana_do_blocks);
assert!(!default.supports_slash_line_block_delimiter);
assert!(!default.supports_go_batch_separator);
assert!(!default.keeps_sqlserver_module_batch_at_cursor);
@ -2848,6 +2954,43 @@ SELECT 2;";
assert_eq!(sql_server, SqlDialectProfile::sql_server());
assert!(sql_server.supports_go_batch_separator);
assert!(sql_server.keeps_sqlserver_module_batch_at_cursor);
let sap_hana = SqlDialectProfile::for_database_type(DatabaseType::SapHana);
assert_eq!(sap_hana, SqlDialectProfile::sap_hana());
assert!(sap_hana.supports_hana_do_blocks);
}
#[test]
fn sap_hana_split_keeps_do_block_together() {
let sql = "\
DO
BEGIN
SELECT 1 AS \"Result\" FROM DUMMY;
END;
SELECT 2 FROM DUMMY;";
assert_eq!(
split_sql_statements_for_database(sql, DatabaseType::SapHana),
vec!["DO\nBEGIN\n SELECT 1 AS \"Result\" FROM DUMMY;\nEND;", "SELECT 2 FROM DUMMY"]
);
}
#[test]
fn sap_hana_cursor_statement_keeps_nested_do_block_together() {
let sql = "\
DO
BEGIN
IF 1 = 1 THEN
SELECT CASE WHEN 1 = 1 THEN 1 ELSE 0 END AS \"Result\" FROM DUMMY;
END IF;
END;
SELECT 2 FROM DUMMY;";
let cursor = sql.find("Result").unwrap();
assert_eq!(
find_statement_at_cursor_for_database(sql, cursor, DatabaseType::SapHana),
"DO\nBEGIN\n IF 1 = 1 THEN\n SELECT CASE WHEN 1 = 1 THEN 1 ELSE 0 END AS \"Result\" FROM DUMMY;\n END IF;\nEND;"
);
}
#[test]