diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index efe949b0d..d6e97ce9c 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -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"]); }); diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 96384ccc0..0419e4dd4 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -80,6 +80,7 @@ const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial 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, +} + +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 = 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, } @@ -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 { 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]