diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 4af28be66e..eccdda1be2 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -80,6 +80,18 @@ BEGIN NULL; END;`; +const gaussDbDollarQuotedFunctionScript = `DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_md5_uuid; + +CREATE OR REPLACE FUNCTION dbx_issue_4572_tmp_md5_uuid (v_str IN TEXT) RETURNS varchar(36) LANGUAGE PLPGSQL IMMUTABLE AS $function$ +DECLARE + str1 TEXT; +BEGIN + str1 := md5(v_str); + RETURN CAST(str1 AS varchar(36)); +END$function$; + +DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_missing;`; + const xuguProgrammableObjectFixtures = [ `CREATE OR REPLACE PROCEDURE dbx_xugu_procedure AS v_value INTEGER; @@ -292,6 +304,15 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(gaussDbNestedProcedure, "gaussdb"))).toEqual([gaussDbNestedProcedure]); }); + it("separates GaussDB dollar-quoted functions from surrounding statements", () => { + const ranges = splitSqlStatementRanges(gaussDbDollarQuotedFunctionScript, "gaussdb"); + expect(rangeSqlTexts(ranges)).toEqual([ + "DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_md5_uuid", + gaussDbDollarQuotedFunctionScript.slice(gaussDbDollarQuotedFunctionScript.indexOf("CREATE"), gaussDbDollarQuotedFunctionScript.lastIndexOf(";\n\nDROP")), + "DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_missing", + ]); + }); + it("keeps Xugu programmable object DDL together and retains its terminator", () => { for (const sql of xuguProgrammableObjectFixtures) { const ranges = splitSqlStatementRanges(`${sql}\nSELECT 1;`, "xugu"); diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index b2a3dd5477..3e3042fbcb 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -289,6 +289,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType let customDelimiter: string | null = null; let state: QuoteState = "none"; let dollarTag = ""; + let postgresDollarQuotedRoutine = false; let i = 0; const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; @@ -305,6 +306,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType if (statementStart === -1) { statementEnd = -1; pendingHintStart = -1; + postgresDollarQuotedRoutine = false; return; } const trimmedTo = trimRangeEnd(sql, statementStart, to); @@ -314,6 +316,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType statementStart = -1; statementEnd = -1; pendingHintStart = -1; + postgresDollarQuotedRoutine = false; }; while (i < len) { @@ -470,6 +473,9 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i)); if (tagMatch) { markContent(i); + if (databaseType === "gaussdb" && statementStart !== -1 && startsWithPostgresDollarQuotedRoutinePrefix(sql.slice(statementStart, i))) { + postgresDollarQuotedRoutine = true; + } dollarTag = tagMatch[0].slice(1, -1); i += tagMatch[0].length; state = "dollar"; @@ -497,7 +503,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType flush(); } else { const statementSoFar = statementStart === -1 ? "" : sql.slice(statementStart, i); - const isOraclePlSql = isOracleLikeDatabase(databaseType) && statementStart !== -1 && startsWithOraclePlSqlBlock(statementSoFar); + const isOraclePlSql = isOracleLikeDatabase(databaseType) && !postgresDollarQuotedRoutine && statementStart !== -1 && startsWithOraclePlSqlBlock(statementSoFar); const isSapHanaScriptBlock = isSapHanaScriptBlockDatabase(databaseType) && statementStart !== -1 && startsWithSapHanaScriptBlock(statementSoFar); if (isOraclePlSql || isSapHanaScriptBlock) { markContent(i); @@ -1625,6 +1631,13 @@ function startsWithOraclePlSqlBlock(sql: string): boolean { return ORACLE_PL_SQL_CREATE_OBJECT_TYPES.has(words[index] ?? ""); } +function startsWithPostgresDollarQuotedRoutinePrefix(sql: string): boolean { + const words = oraclePlSqlWords(sql); + if (words[0] !== "CREATE") return false; + const objectIndex = skipOraclePlSqlCreateModifiers(words, 1); + return (words[objectIndex] === "FUNCTION" || words[objectIndex] === "PROCEDURE") && words[words.length - 1] === "AS"; +} + /** Skip OR REPLACE / FORCE / NOFORCE / EDITIONABLE modifiers after CREATE. */ function skipOraclePlSqlCreateModifiers(words: readonly string[], startIndex: number): number { let index = startIndex; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 612b669d45..57cb7d4c7c 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -93,6 +93,7 @@ describe("connectionStore metadata loading", () => { vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), listDatabases, loadSchemaCache: vi.fn().mockResolvedValue(null), saveSchemaCache: vi.fn().mockResolvedValue(undefined), diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index c24e65029c..1ba258cc26 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -113,6 +113,7 @@ struct SqlDialectProfile { supports_custom_delimiter_commands: bool, supports_mysql_routine_blocks: bool, supports_dollar_quoted_strings: bool, + supports_postgres_dollar_quoted_routines: bool, supports_hana_do_blocks: bool, supports_go_batch_separator: bool, keeps_sqlserver_module_batch_at_cursor: bool, @@ -127,6 +128,7 @@ impl Default for SqlDialectProfile { supports_custom_delimiter_commands: true, supports_mysql_routine_blocks: false, supports_dollar_quoted_strings: true, + supports_postgres_dollar_quoted_routines: false, supports_hana_do_blocks: false, supports_go_batch_separator: false, keeps_sqlserver_module_batch_at_cursor: false, @@ -136,6 +138,10 @@ impl Default for SqlDialectProfile { impl SqlDialectProfile { fn for_database_type(db_type: DatabaseType) -> Self { + if matches!(db_type, DatabaseType::Gaussdb) { + return Self::gaussdb(); + } + if Self::is_oracle_like_database(db_type) { return Self::oracle_like(); } @@ -163,6 +169,10 @@ impl SqlDialectProfile { Self { supports_oracle_plsql_blocks: true, supports_slash_line_block_delimiter: true, ..Self::default() } } + fn gaussdb() -> Self { + Self { supports_postgres_dollar_quoted_routines: true, ..Self::oracle_like() } + } + fn sql_server() -> Self { Self { supports_go_batch_separator: true, keeps_sqlserver_module_batch_at_cursor: true, ..Self::default() } } @@ -271,6 +281,7 @@ pub struct SqlStatementSplitter { in_line_comment: bool, in_block_comment: bool, dollar_quote_tag: Option, + postgres_dollar_quoted_routine: bool, previous: Option, custom_delimiter: Option, options: SqlParsingOptions, @@ -373,6 +384,11 @@ impl SqlStatementSplitter { .flatten() { if self.custom_delimiter.is_none() && !self.on_delimiter_line() { + if self.options.profile.supports_postgres_dollar_quoted_routines + && starts_with_postgres_dollar_quoted_routine_prefix(&self.buffer) + { + self.postgres_dollar_quoted_routine = true; + } for tag_ch in tag.chars() { self.buffer.push(tag_ch); self.previous = Some(tag_ch); @@ -416,6 +432,7 @@ impl SqlStatementSplitter { self.buffer.push(ch); } } else if self.options.profile.supports_oracle_plsql_blocks + && !self.postgres_dollar_quoted_routine && starts_with_oracle_plsql_block(&self.buffer) { self.buffer.push(ch); @@ -445,6 +462,7 @@ impl SqlStatementSplitter { statements.push(before.to_string()); } self.buffer.clear(); + self.postgres_dollar_quoted_routine = false; self.previous = None; i += 1; continue; @@ -464,6 +482,7 @@ impl SqlStatementSplitter { } } self.buffer.clear(); + self.postgres_dollar_quoted_routine = false; self.previous = None; i += 1; continue; @@ -515,6 +534,7 @@ impl SqlStatementSplitter { statements.push(statement.to_string()); } self.buffer.clear(); + self.postgres_dollar_quoted_routine = false; self.previous = None; } @@ -726,6 +746,7 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions let mut in_block_comment = false; let mut dollar_quote_tag: Option = None; let mut custom_delimiter: Option = None; + let mut postgres_dollar_quoted_routine = false; while i < sql.len() { if let Some(tag) = &dollar_quote_tag { @@ -779,6 +800,11 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions options.profile.supports_dollar_quoted_strings.then(|| dollar_quote_tag_at_str(sql, i)).flatten() { if custom_delimiter.is_none() && !is_on_delimiter_line(sql, start, i) { + if options.profile.supports_postgres_dollar_quoted_routines + && starts_with_postgres_dollar_quoted_routine_prefix(&sql[start..i]) + { + postgres_dollar_quoted_routine = true; + } i += tag.len(); dollar_quote_tag = Some(tag); continue; @@ -790,6 +816,7 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions if options.profile.supports_slash_line_block_delimiter && line == "/" { push_statement_range(&mut ranges, sql, start, line_start, options); start = i + ch.len_utf8(); + postgres_dollar_quoted_routine = false; i = start; continue; } @@ -802,6 +829,7 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions } custom_delimiter = if new_delimiter == ";" { None } else { Some(new_delimiter.to_string()) }; start = i + ch.len_utf8(); + postgres_dollar_quoted_routine = false; i = start; continue; } @@ -836,8 +864,9 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions } 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]); + let is_oracle_plsql = options.profile.supports_oracle_plsql_blocks + && !postgres_dollar_quoted_routine + && 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(); @@ -856,6 +885,7 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions } i += ch.len_utf8(); start = i; + postgres_dollar_quoted_routine = false; } _ => { i += ch.len_utf8(); @@ -865,6 +895,7 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions let end = i - delimiter.len(); push_statement_range(&mut ranges, sql, start, end, options); start = i; + postgres_dollar_quoted_routine = false; } } } @@ -1990,6 +2021,19 @@ fn starts_with_oracle_plsql_block(sql: &str) -> bool { OraclePlSqlBlock::parse(sql).starts_block() } +fn starts_with_postgres_dollar_quoted_routine_prefix(sql: &str) -> bool { + let block = OraclePlSqlBlock::parse(sql); + let Some(first) = block.tokens.first() else { + return false; + }; + if !first.is_word("CREATE") { + return false; + } + let tokens = OraclePlSqlBlock::skip_create_modifiers(&block.tokens[1..]); + tokens.first().is_some_and(|token| token.is_any_word(&["FUNCTION", "PROCEDURE"])) + && block.tokens.iter().rev().find_map(OraclePlSqlToken::as_word) == Some("AS") +} + fn oracle_plsql_block_is_complete(sql: &str) -> bool { OraclePlSqlBlock::parse(sql).is_complete() } @@ -3072,7 +3116,6 @@ SELECT 2;"; for db_type in [ DatabaseType::Oracle, DatabaseType::Dameng, - DatabaseType::Gaussdb, DatabaseType::Yashandb, DatabaseType::Oscar, DatabaseType::OceanbaseOracle, @@ -3081,8 +3124,15 @@ SELECT 2;"; assert_eq!(profile, SqlDialectProfile::oracle_like()); assert!(profile.supports_oracle_plsql_blocks); assert!(profile.supports_slash_line_block_delimiter); + assert!(!profile.supports_postgres_dollar_quoted_routines); } + let gaussdb = SqlDialectProfile::for_database_type(DatabaseType::Gaussdb); + assert_eq!(gaussdb, SqlDialectProfile::gaussdb()); + assert!(gaussdb.supports_oracle_plsql_blocks); + assert!(gaussdb.supports_slash_line_block_delimiter); + assert!(gaussdb.supports_postgres_dollar_quoted_routines); + let sql_server = SqlDialectProfile::for_database_type(DatabaseType::SqlServer); assert_eq!(sql_server, SqlDialectProfile::sql_server()); assert!(sql_server.supports_go_batch_separator); @@ -3148,6 +3198,29 @@ END;"; assert_eq!(split_sql_statements_for_database(sql, DatabaseType::Xugu), vec![sql.to_string()]); } + #[test] + fn gaussdb_split_separates_dollar_quoted_function_from_following_statements() { + let sql = "\ +DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_md5_uuid; + +CREATE OR REPLACE FUNCTION dbx_issue_4572_tmp_md5_uuid (v_str IN TEXT) RETURNS varchar(36) LANGUAGE PLPGSQL IMMUTABLE AS $function$ +DECLARE + str1 TEXT; +BEGIN + str1 := md5(v_str); + RETURN CAST(str1 AS varchar(36)); +END$function$; + +DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_missing;"; + + let statements = split_sql_statements_for_database(sql, DatabaseType::Gaussdb); + assert_eq!(statements.len(), 3); + assert_eq!(statements[0], "DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_md5_uuid"); + assert!(statements[1].starts_with("CREATE OR REPLACE FUNCTION dbx_issue_4572_tmp_md5_uuid")); + assert!(statements[1].ends_with("END$function$")); + assert_eq!(statements[2], "DROP FUNCTION IF EXISTS dbx_issue_4572_tmp_missing"); + } + #[test] fn oracle_like_split_keeps_issue_2405_anonymous_plsql_block_together() { let sql = "\