diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 21a441eefc..7510fbfe3c 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -210,6 +210,31 @@ WHEN NOT MATCHED THEN INSERT (user_id, restyp_code, res_id, appcde) VALUES (b.userid, b.restyp_code, b.res_id, b.appcde);`; +// Issue #8979: a GaussDB/openGauss instance in Oracle (A) compatibility mode reached through a +// PostgreSQL/openGauss connection. The routine body is Oracle style and the definition is closed +// by a standalone `/` line, so splitting at the body semicolons sent a truncated procedure and the +// server answered `ERROR: subprogram body is not ended correctly at end of input`. +const postgresFamilyIssue8979ProcedureScript = `CREATE OR REPLACE PROCEDURE sync_yxdyurl_probe() AS +DECLARE +BEGIN + update test_xm_20260913 set a = '23' where a = '1'; + COMMIT; +EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + RAISE; +END; +/ +SELECT 1 AS after_procedure;`; + +const postgresFamilyDollarQuotedFunctionScript = `CREATE OR REPLACE FUNCTION dbx_issue_8979_pg() RETURNS int AS $$ +BEGIN + RETURN 1; +END; +$$ LANGUAGE plpgsql; +/ +SELECT dbx_issue_8979_pg();`; + const xuguProgrammableObjectFixtures = [ `CREATE OR REPLACE PROCEDURE dbx_xugu_procedure AS v_value INTEGER; @@ -694,6 +719,46 @@ END pkg_utils_without_replace;`; expect(rangeSqlTexts(splitSqlStatementRanges(`${packageSpecWithoutReplace}\nSELECT 1;`, "xugu"))).toEqual([packageSpecWithoutReplace, "SELECT 1"]); }); + it("keeps an Oracle-style procedure body together for PostgreSQL-family connections", () => { + const procedureBody = postgresFamilyIssue8979ProcedureScript.slice(0, postgresFamilyIssue8979ProcedureScript.indexOf("\n/")); + const cases = [ + { databaseType: "postgres" as const, options: undefined }, + { databaseType: "opengauss" as const, options: { compatibilityMode: "PG" } }, + ]; + + for (const { databaseType, options } of cases) { + expect(rangeSqlTexts(splitSqlStatementRanges(postgresFamilyIssue8979ProcedureScript, databaseType, options))).toEqual([procedureBody, "SELECT 1 AS after_procedure"]); + expect(statementRangeAtCursor(postgresFamilyIssue8979ProcedureScript, indexOf(postgresFamilyIssue8979ProcedureScript, "ROLLBACK"), databaseType, options)?.sql.trim()).toBe(procedureBody); + expect(statementRangeAtCursor(postgresFamilyIssue8979ProcedureScript, postgresFamilyIssue8979ProcedureScript.length - 1, databaseType, options)?.sql.trim()).toBe("SELECT 1 AS after_procedure"); + } + }); + + it("does not treat PostgreSQL dollar-quoted routines as Oracle-style bodies", () => { + const functionSql = `CREATE OR REPLACE FUNCTION dbx_issue_8979_pg() RETURNS int AS $$ +BEGIN + RETURN 1; +END; +$$ LANGUAGE plpgsql`; + + for (const databaseType of ["postgres", "opengauss"] as const) { + expect(rangeSqlTexts(splitSqlStatementRanges(postgresFamilyDollarQuotedFunctionScript, databaseType))).toEqual([functionSql, "SELECT dbx_issue_8979_pg()"]); + expect(statementRangeAtCursor(postgresFamilyDollarQuotedFunctionScript, indexOf(postgresFamilyDollarQuotedFunctionScript, "RETURN 1"), databaseType)?.sql.trim()).toBe(functionSql); + } + }); + + it("still splits ordinary PostgreSQL statements around a bare slash line", () => { + const script = `BEGIN; +SELECT 1; +COMMIT; +/ +CREATE FUNCTION dbx_issue_8979_sql() RETURNS int AS 'SELECT 1' LANGUAGE sql; +CREATE TRIGGER dbx_issue_8979_trg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f(); + +SELECT 2;`; + + expect(rangeSqlTexts(splitSqlStatementRanges(script, "postgres"))).toEqual(["BEGIN", "SELECT 1", "COMMIT", "CREATE FUNCTION dbx_issue_8979_sql() RETURNS int AS 'SELECT 1' LANGUAGE sql", "CREATE TRIGGER dbx_issue_8979_trg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()", "SELECT 2"]); + }); + it("keeps openGauss packages together while compatibility metadata is unknown", () => { const packageSpec = `CREATE OR REPLACE PACKAGE pkg_utils AS FUNCTION get_version RETURN VARCHAR2; diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts index a44aad2582..11d8f41a6b 100644 --- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts +++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts @@ -1,6 +1,6 @@ import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { getSqlCompletionContext, isOracleSystemValueName } from "@/lib/sql/sqlCompletion"; -import { executableStatementRanges, isOraclePlSqlStatement, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; +import { executableStatementRanges, keepsOracleStyleBlockTogether, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { DBX_TDENGINE_TBNAME_COLUMN, isTdengineStableTableType } from "@/lib/table/tableEditing"; import type { DatabaseType, SqlColumnReference, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database"; @@ -31,7 +31,7 @@ export function sqlSemanticDiagnosticRangesForViewport(sql: string, visibleRange const selected: SqlTextRange[] = []; const seen = new Set(); for (const statement of statements) { - if (isOraclePlSqlStatement(statement.sql, databaseType, parameterOptions)) continue; + if (keepsOracleStyleBlockTogether(statement.sql, databaseType, parameterOptions)) continue; if (!visibleRanges.some((visibleRange) => rangesIntersect(statement, visibleRange))) continue; const key = `${statement.from}:${statement.to}`; if (seen.has(key)) continue; diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 0c3692a113..a1a2f3d335 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -295,6 +295,11 @@ const SET_OPERATION_MODIFIER_KEYWORDS = new Set(["ALL", "DISTINCT"]); // ranges must stay whole instead of splitting at every body semicolon. const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle", "xugu", "argo"]); const MYSQL_ROUTINE_BLOCK_DATABASES: ReadonlySet = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]); +// PostgreSQL/openGauss are also the connection types users pick for GaussDB/openGauss instances +// running in Oracle (A) compatibility mode, where a routine body is written in Oracle style +// (`CREATE PROCEDURE p AS DECLARE ... BEGIN ... END;`) and closed by a standalone `/` line. +// Mirrors SqlDialectProfile::postgres_family in dbx-sql — keep both in sync. +const POSTGRES_FAMILY_DATABASES: ReadonlySet = new Set(["postgres", "opengauss"]); // Backslash escaping inside '...'/"..." strings is a MySQL-family extension; in standard SQL '\' // is a complete one-char string and quotes are escaped by doubling (''). Treating backslash as an // escape unconditionally makes ESCAPE '\' swallow its closing quote and the following statement @@ -517,7 +522,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType continue; } } - if (isOracleLikeDatabase(databaseType, parameterOptions) && isAtLineStart(sql, i) && isSlashLine(sql, i)) { + if ((isOracleLikeDatabase(databaseType, parameterOptions) || isPostgresFamilyDatabase(databaseType)) && isAtLineStart(sql, i) && isSlashLine(sql, i)) { const lineEnd = findLineEnd(sql, i); flush(i); i = nextLineStart(sql, lineEnd); @@ -627,7 +632,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType // Internal semicolons remain part of the routine body. flush(); } else { - if (oraclePlSqlStatementEnd === undefined && isOracleLikeDatabase(databaseType, parameterOptions) && !postgresDollarQuotedRoutine && statementStart !== -1) { + if (oraclePlSqlStatementEnd === undefined && !postgresDollarQuotedRoutine && statementStart !== -1 && keepsOracleStyleBlockTogether(sql.slice(statementStart, i), databaseType, parameterOptions)) { const statementSoFar = sql.slice(statementStart, i); oraclePlSqlStatementEnd = startsWithOraclePlSqlBlock(statementSoFar) ? statementStart + (oraclePlSqlBlockEnd(sql.slice(statementStart)) ?? sql.length - statementStart) : null; } @@ -766,7 +771,7 @@ function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: nu } function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] { - if (isOraclePlSqlStatement(statement.sql, databaseType, parameterOptions)) return [statement]; + if (keepsOracleStyleBlockTogether(statement.sql, databaseType, parameterOptions)) return [statement]; if (isSapHanaScriptBlockStatement(statement.sql, databaseType)) return [statement]; // Routine bodies contain top-level-looking SET/INSERT/SELECT lines that are not independent statements. if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql, parameterOptions)) return [statement]; @@ -1647,6 +1652,20 @@ export function isOracleLikeDatabase(databaseType?: DatabaseType, options?: SqlP return !!databaseType && (ORACLE_LIKE_PL_SQL_DATABASES.has(databaseType) || isOpenGaussOracleCompatibility(databaseType, options)); } +function isPostgresFamilyDatabase(databaseType?: DatabaseType): boolean { + return !!databaseType && POSTGRES_FAMILY_DATABASES.has(databaseType); +} + +/** + * Whether a statement must stay a single statement instead of being cut at its inner semicolons: + * Oracle PL/SQL blocks on Oracle-like dialects, and the Oracle-style routine bodies that a + * PostgreSQL-family connection reaches on a GaussDB/openGauss Oracle-compatibility server. + */ +export function keepsOracleStyleBlockTogether(sql: string, databaseType?: DatabaseType, options?: SqlParameterOptions): boolean { + if (isOraclePlSqlStatement(sql, databaseType, options)) return true; + return isPostgresFamilyDatabase(databaseType) && startsWithOracleStyleRoutineBody(sql); +} + export function isOraclePlSqlStatement(sql: string, databaseType?: DatabaseType, options?: SqlParameterOptions): boolean { return isOracleLikeDatabase(databaseType, options) && startsWithOraclePlSqlBlock(sql); } @@ -1916,6 +1935,65 @@ function startsWithOraclePlSqlBlockWords(words: readonly string[]): boolean { return ORACLE_PL_SQL_CREATE_OBJECT_TYPES.has(words[index] ?? ""); } +/** + * Whether the statement is a routine whose body is written in Oracle PL/SQL syntax + * (`CREATE [OR REPLACE] PROCEDURE|FUNCTION ... { AS | IS } { DECLARE | BEGIN }`, plus the + * Oracle-only `PACKAGE BODY` / `TYPE BODY` forms). + * + * This is deliberately narrower than `startsWithOraclePlSqlBlock`: a PostgreSQL connection only + * sees this shape when the server is a GaussDB/openGauss instance in Oracle (A) compatibility + * mode, because PostgreSQL itself requires a dollar-quoted or string body (`AS $$ ... $$`, + * `AS 'body'`) and has no `PACKAGE`/`TYPE BODY`. + */ +function startsWithOracleStyleRoutineBody(sql: string): boolean { + const words = oraclePlSqlWords(sql); + if (words[0] !== "CREATE") return false; + + const index = skipOraclePlSqlCreateModifiers(words, 1); + if ((words[index] === "PACKAGE" || words[index] === "TYPE") && words[index + 1] === "BODY") return true; + if (words[index] !== "FUNCTION" && words[index] !== "PROCEDURE") return false; + return routineBodyStartsAtPlsqlKeyword(sql); +} + +/** + * The body introducer of an Oracle-style routine has to be the DECLARE/BEGIN keyword itself. + * Declarations may follow `AS`/`IS` with or without the optional `DECLARE`, while a PostgreSQL + * body opens with `$$`, `$tag$` or a quoted string there, so only a bare identifier matches. + */ +function routineBodyStartsAtPlsqlKeyword(sql: string): boolean { + const tokens = oraclePlSqlTokens(sql); + for (const token of tokens) { + if (token.kind !== "word" || (token.value !== "AS" && token.value !== "IS")) continue; + return nextWordAtPlsqlBodyKeyword(sql, token.to); + } + return false; +} + +function nextWordAtPlsqlBodyKeyword(sql: string, from: number): boolean { + let i = from; + while (i < sql.length) { + const ch = sql[i]; + if (isSqlWhitespace(ch)) { + i += 1; + continue; + } + if (ch === "-" && sql[i + 1] === "-") { + const newline = sql.indexOf("\n", i); + i = newline === -1 ? sql.length : newline + 1; + continue; + } + if (ch === "/" && sql[i + 1] === "*") { + const close = sql.indexOf("*/", i + 2); + i = close === -1 ? sql.length : close + 2; + continue; + } + break; + } + const word = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(sql.slice(i, i + 16))?.[0]; + const upper = word?.toUpperCase(); + return upper === "DECLARE" || upper === "BEGIN"; +} + function startsWithPostgresDollarQuotedRoutinePrefix(sql: string): boolean { const words = oraclePlSqlWords(sql); if (words[0] !== "CREATE") return false; diff --git a/crates/dbx-sql/src/sql.rs b/crates/dbx-sql/src/sql.rs index d57e07196b..1d4ce389a5 100644 --- a/crates/dbx-sql/src/sql.rs +++ b/crates/dbx-sql/src/sql.rs @@ -132,6 +132,7 @@ pub struct SqlFileImportStatement { struct SqlDialectProfile { supports_hash_line_comments: bool, supports_oracle_plsql_blocks: bool, + supports_oracle_style_routine_bodies: bool, supports_slash_line_block_delimiter: bool, supports_custom_delimiter_commands: bool, supports_mysql_routine_blocks: bool, @@ -150,6 +151,7 @@ impl Default for SqlDialectProfile { Self { supports_hash_line_comments: false, supports_oracle_plsql_blocks: false, + supports_oracle_style_routine_bodies: false, supports_slash_line_block_delimiter: false, supports_custom_delimiter_commands: true, supports_mysql_routine_blocks: false, @@ -180,6 +182,10 @@ impl SqlDialectProfile { return Self::gaussdb(); } + if matches!(db_type, DatabaseType::Postgres | DatabaseType::OpenGauss) { + return Self::postgres_family(); + } + if Self::is_oracle_like_database(db_type) { return Self::oracle_like(); } @@ -212,6 +218,20 @@ impl SqlDialectProfile { Self { supports_oracle_plsql_blocks: true, supports_slash_line_block_delimiter: true, ..Self::default() } } + /// PostgreSQL and openGauss, which are also the connection types users pick for a + /// GaussDB/openGauss instance running in Oracle (A) compatibility mode. GaussDB PL/SQL + /// bodies are Oracle-style (`CREATE PROCEDURE p AS DECLARE ... BEGIN ... END;`) and are + /// terminated by a standalone `/` line, while the surrounding script syntax is still + /// PostgreSQL, so the profile keeps those constructs whole without enabling the full + /// Oracle PL/SQL dialect. + fn postgres_family() -> Self { + Self { + supports_oracle_style_routine_bodies: true, + supports_slash_line_block_delimiter: true, + ..Self::default() + } + } + fn gaussdb() -> Self { Self { supports_postgres_dollar_quoted_routines: true, @@ -556,9 +576,8 @@ impl SqlStatementSplitter { } else { 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) + } else if !self.postgres_dollar_quoted_routine + && keeps_oracle_style_block_together(self.options, &self.buffer) { self.buffer.push(ch); if oracle_plsql_block_is_complete(&self.buffer) { @@ -884,7 +903,7 @@ fn split_statement_range_at_blank_lines( statement: &SqlStatementRange, options: SqlParsingOptions, ) -> Vec { - if options.profile.supports_oracle_plsql_blocks && starts_with_oracle_plsql_block(&statement.text) { + if keeps_oracle_style_block_together(options, &statement.text) { return vec![statement.clone()]; } if options.profile.supports_hana_do_blocks && starts_with_hana_do_block(&statement.text) { @@ -1100,9 +1119,8 @@ 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 - && !postgres_dollar_quoted_routine - && starts_with_oracle_plsql_block(&sql[start..i]); + let is_oracle_plsql = + !postgres_dollar_quoted_routine && keeps_oracle_style_block_together(options, &sql[start..i]); if is_oracle_plsql { if !oracle_plsql_block_is_complete(&sql[start..i + ch.len_utf8()]) { i += ch.len_utf8(); @@ -2412,6 +2430,20 @@ fn starts_with_oracle_plsql_block(sql: &str) -> bool { OraclePlSqlBlock::parse(sql).starts_block() } +/// Whether a statement must stay a single statement instead of being cut at its inner +/// semicolons: Oracle PL/SQL blocks for Oracle-like dialects, and the Oracle-style routine bodies +/// that PostgreSQL-family connections reach on a GaussDB/openGauss Oracle-compatibility server. +fn keeps_oracle_style_block_together(options: SqlParsingOptions, statement_head: &str) -> bool { + if options.profile.supports_oracle_plsql_blocks { + return starts_with_oracle_plsql_block(statement_head); + } + options.profile.supports_oracle_style_routine_bodies && starts_with_oracle_style_routine_body(statement_head) +} + +fn starts_with_oracle_style_routine_body(sql: &str) -> bool { + OraclePlSqlBlock::parse(sql).starts_oracle_style_routine_body() +} + fn starts_with_postgres_dollar_quoted_routine_prefix(sql: &str) -> bool { let block = OraclePlSqlBlock::parse(sql); let Some(first) = block.tokens.first() else { @@ -2515,6 +2547,48 @@ impl OraclePlSqlBlock { } } + /// Whether the statement is a routine whose body is written in Oracle PL/SQL syntax + /// (`CREATE [OR REPLACE] PROCEDURE|FUNCTION ... { AS | IS } { DECLARE | BEGIN }`, plus the + /// Oracle-only `PACKAGE BODY` / `TYPE BODY` forms). + /// + /// This is deliberately narrower than [`Self::starts_block`]. A PostgreSQL connection only + /// sees this shape when the server is a GaussDB/openGauss instance in Oracle (A) compatibility + /// mode, because PostgreSQL itself requires a dollar-quoted or string body + /// (`AS $$ ... $$`, `AS 'body'`) and has no `PACKAGE`/`TYPE BODY`. Keeping the narrow test at + /// the entry point lets [`Self::is_complete`] end such a body at its outer `END;` while + /// ordinary PostgreSQL routines, `CREATE TRIGGER ... EXECUTE FUNCTION` statements and + /// transaction `BEGIN;` blocks keep splitting at their own statement terminator. + fn starts_oracle_style_routine_body(&self) -> bool { + let [first, rest @ ..] = self.tokens.as_slice() else { + return false; + }; + if !first.is_word("CREATE") { + return false; + } + let rest = Self::skip_create_modifiers(rest); + match rest { + [object, body, ..] if object.is_any_word(&["PACKAGE", "TYPE"]) && body.is_word("BODY") => true, + [object, ..] if object.is_any_word(&["FUNCTION", "PROCEDURE"]) => { + Self::routine_body_starts_with_plsql_keyword(rest) + } + _ => false, + } + } + + /// The body introducer of an Oracle-style routine. Declarations may follow `AS`/`IS` either + /// directly or after the optional `DECLARE` keyword, so only the `DECLARE`/`BEGIN` forms are + /// recognized; a dollar-quoted or string body is never a word token, so PostgreSQL routines + /// cannot match here. + fn routine_body_starts_with_plsql_keyword(tokens: &[OraclePlSqlToken]) -> bool { + for (index, token) in tokens.iter().enumerate() { + if !token.is_any_word(&["AS", "IS"]) { + continue; + } + return tokens.get(index + 1).is_some_and(|next| next.is_any_word(&["DECLARE", "BEGIN"])); + } + false + } + fn is_complete(&self) -> bool { if !self.starts_block() { return false; @@ -2924,8 +2998,8 @@ mod tests { find_statement_at_cursor_for_database, fuzzy_filter_enabled, fuzzy_like_pattern_with_escape, fuzzy_subsequence_match, optimize_sql_file_import_statements, prepare_sql_file_statement, split_sql_script, split_sql_statement_ranges_with_options, split_sql_statements_for_database, starts_with_executable_sql_keyword, - starts_with_executable_sql_keyword_for_database, SqlDialectProfile, SqlFileStatementAction, SqlParsingOptions, - SqlStatementSplitter, + starts_with_executable_sql_keyword_for_database, starts_with_oracle_style_routine_body, SqlDialectProfile, + SqlFileStatementAction, SqlParsingOptions, SqlStatementSplitter, }; #[test] @@ -3772,19 +3846,27 @@ SELECT 2;"; #[test] fn sql_dialect_profiles_map_database_types_to_parser_capabilities() { - let default = SqlDialectProfile::for_database_type(DatabaseType::Postgres); - assert_eq!(default, SqlDialectProfile::default()); + let default = SqlDialectProfile::default(); assert!(default.supports_custom_delimiter_commands); assert!(default.supports_dollar_quoted_strings); assert!(!default.supports_hash_line_comments); assert!(!default.supports_mysql_routine_blocks); assert!(!default.supports_oracle_plsql_blocks); + assert!(!default.supports_oracle_style_routine_bodies); 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); assert!(!default.preserves_tdsql_leading_directives); + for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss] { + let profile = SqlDialectProfile::for_database_type(db_type); + assert_eq!(profile, SqlDialectProfile::postgres_family()); + assert!(!profile.supports_oracle_plsql_blocks); + assert!(profile.supports_oracle_style_routine_bodies); + assert!(profile.supports_slash_line_block_delimiter); + } + let mysql = SqlDialectProfile::for_database_type(DatabaseType::Mysql); assert!(mysql.supports_hash_line_comments); assert!(mysql.supports_mysql_routine_blocks); @@ -4220,6 +4302,183 @@ SELECT 2 AS final_statement;"; assert_eq!(statements[2], "SELECT 2 AS final_statement"); } + const POSTGRES_FAMILY_PROCEDURE_SCRIPT: &str = "\ +CREATE OR REPLACE PROCEDURE sync_yxdyurl_and_flag() +AS DECLARE +BEGIN + + update test_xm_20260913 set a = '23' where a = '1'; + + COMMIT; +EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + RAISE; +END; +/ +SELECT 1;"; + + const POSTGRES_FAMILY_PROCEDURE_BODY: &str = "\ +CREATE OR REPLACE PROCEDURE sync_yxdyurl_and_flag() +AS DECLARE +BEGIN + + update test_xm_20260913 set a = '23' where a = '1'; + + COMMIT; +EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + RAISE; +END;"; + + /// Issue #8979: a GaussDB/openGauss Oracle-compatibility server reached through the plain + /// PostgreSQL connection type must not split the PL/SQL body at its inner semicolons, or the + /// server only receives an incomplete routine and answers with + /// `subprogram body is not ended correctly at end of input`. + #[test] + fn postgres_family_keeps_oracle_style_procedure_body_in_one_statement() { + for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss] { + assert_eq!( + split_sql_statements_for_database(POSTGRES_FAMILY_PROCEDURE_SCRIPT, db_type), + vec![POSTGRES_FAMILY_PROCEDURE_BODY.to_string(), "SELECT 1".to_string()], + "{db_type:?} must keep the procedure body whole and drop the slash terminator" + ); + } + } + + #[test] + fn postgres_family_current_statement_keeps_oracle_style_procedure_body_together() { + let sql = POSTGRES_FAMILY_PROCEDURE_SCRIPT; + let body_cursors = + [sql.find("update test_xm").unwrap(), sql.find("COMMIT").unwrap(), sql.find("RAISE").unwrap()]; + + for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss] { + for cursor in body_cursors { + assert_eq!( + find_statement_at_cursor_for_database(sql, cursor, db_type), + POSTGRES_FAMILY_PROCEDURE_BODY, + "{db_type:?} must resolve the cursor inside the body to the whole procedure" + ); + } + assert_eq!(find_statement_at_cursor_for_database(sql, sql.len(), db_type), "SELECT 1"); + } + } + + /// The reported scripts end with a bare `/` on the last line without a trailing newline, so the + /// terminator has to be recognized at end of input too. + #[test] + fn postgres_family_drops_trailing_slash_line_without_trailing_newline() { + for suffix in ["", "\n"] { + let sql = format!("{POSTGRES_FAMILY_PROCEDURE_BODY}\n/{suffix}"); + for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss] { + assert_eq!( + split_sql_statements_for_database(&sql, db_type), + vec![POSTGRES_FAMILY_PROCEDURE_BODY.to_string()], + "{db_type:?} must drop the trailing slash line (suffix {suffix:?})" + ); + assert_eq!( + find_statement_at_cursor_for_database(&sql, sql.len(), db_type), + POSTGRES_FAMILY_PROCEDURE_BODY + ); + } + } + } + + #[test] + fn postgres_family_keeps_slash_terminated_routine_body_whole() { + let sql = "\ +CREATE OR REPLACE FUNCTION f_pg() RETURNS int AS $$ +BEGIN + RETURN 1; +END; +$$ LANGUAGE plpgsql; +/ +SELECT f_pg();"; + + for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss] { + assert_eq!( + split_sql_statements_for_database(sql, db_type), + vec![ + "CREATE OR REPLACE FUNCTION f_pg() RETURNS int AS $$ +BEGIN + RETURN 1; +END; +$$ LANGUAGE plpgsql", + "SELECT f_pg()", + ], + "{db_type:?} must treat the standalone slash line as a statement terminator" + ); + } + } + + #[test] + fn postgres_family_current_statement_keeps_dollar_quoted_routine_whole() { + let sql = "\ +CREATE OR REPLACE FUNCTION f_pg() RETURNS int AS $$ +BEGIN + RETURN 1; +END; +$$ LANGUAGE plpgsql; +/ +SELECT f_pg();"; + + for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss] { + let statement = find_statement_at_cursor_for_database(sql, sql.find("RETURN 1").unwrap(), db_type); + assert!(statement.contains("LANGUAGE plpgsql"), "{db_type:?} resolved {statement:?}"); + assert!(!statement.contains("SELECT f_pg()"), "{db_type:?} resolved {statement:?}"); + } + } + + #[test] + fn postgres_family_still_splits_ordinary_postgres_statements() { + let sql = "\ +CREATE OR REPLACE FUNCTION f_pg() RETURNS int AS $$ +BEGIN + RETURN 1; +END; +$$ LANGUAGE plpgsql; +CREATE FUNCTION f_sql() RETURNS int AS 'SELECT 1' LANGUAGE sql; +CREATE TRIGGER t_pg AFTER INSERT ON t_pg FOR EACH ROW EXECUTE FUNCTION f_pg(); +BEGIN; +SELECT f_pg(); +COMMIT;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Postgres), + vec![ + "CREATE OR REPLACE FUNCTION f_pg() RETURNS int AS $$ +BEGIN + RETURN 1; +END; +$$ LANGUAGE plpgsql", + "CREATE FUNCTION f_sql() RETURNS int AS 'SELECT 1' LANGUAGE sql", + "CREATE TRIGGER t_pg AFTER INSERT ON t_pg FOR EACH ROW EXECUTE FUNCTION f_pg()", + "BEGIN", + "SELECT f_pg()", + "COMMIT", + ] + ); + } + + #[test] + fn postgres_family_does_not_enable_full_oracle_plsql_blocks() { + assert!(!starts_with_oracle_style_routine_body("CREATE PROCEDURE p() LANGUAGE sql AS 'SELECT 1';")); + assert!(!starts_with_oracle_style_routine_body( + "CREATE TRIGGER t AFTER INSERT ON x FOR EACH ROW EXECUTE FUNCTION f();" + )); + assert!(!starts_with_oracle_style_routine_body("DECLARE c CURSOR FOR SELECT 1;")); + assert!(!starts_with_oracle_style_routine_body( + "CREATE FUNCTION f_pg() RETURNS int AS $$\nBEGIN\n RETURN 1;\nEND;\n$$ LANGUAGE plpgsql;" + )); + assert!(!starts_with_oracle_style_routine_body( + "CREATE OR REPLACE FUNCTION f_tag() RETURNS int AS $body$ BEGIN RETURN 1; END; $body$ LANGUAGE plpgsql" + )); + assert!(starts_with_oracle_style_routine_body("CREATE OR REPLACE PROCEDURE p()\nAS DECLARE\nBEGIN")); + assert!(starts_with_oracle_style_routine_body("CREATE PROCEDURE p() IS BEGIN")); + assert!(starts_with_oracle_style_routine_body("CREATE OR REPLACE PACKAGE BODY pkg AS")); + } + #[test] fn oracle_like_split_keeps_issue_2405_anonymous_plsql_block_together() { let sql = "\ diff --git a/docs/screenshots/issue-8979-gaussdb-connection-current-statement-after.png b/docs/screenshots/issue-8979-gaussdb-connection-current-statement-after.png new file mode 100644 index 0000000000..6357681461 Binary files /dev/null and b/docs/screenshots/issue-8979-gaussdb-connection-current-statement-after.png differ diff --git a/docs/screenshots/issue-8979-opengauss-connection-current-statement-after.png b/docs/screenshots/issue-8979-opengauss-connection-current-statement-after.png new file mode 100644 index 0000000000..5485c03e48 Binary files /dev/null and b/docs/screenshots/issue-8979-opengauss-connection-current-statement-after.png differ diff --git a/docs/screenshots/issue-8979-pg-connection-current-statement-after.png b/docs/screenshots/issue-8979-pg-connection-current-statement-after.png new file mode 100644 index 0000000000..67905f8e13 Binary files /dev/null and b/docs/screenshots/issue-8979-pg-connection-current-statement-after.png differ diff --git a/docs/screenshots/issue-8979-pg-connection-select-all-after.png b/docs/screenshots/issue-8979-pg-connection-select-all-after.png new file mode 100644 index 0000000000..1d6edc3b16 Binary files /dev/null and b/docs/screenshots/issue-8979-pg-connection-select-all-after.png differ diff --git a/docs/screenshots/issue-8979-postgres-dollar-quoted-slash-after.png b/docs/screenshots/issue-8979-postgres-dollar-quoted-slash-after.png new file mode 100644 index 0000000000..86835dc564 Binary files /dev/null and b/docs/screenshots/issue-8979-postgres-dollar-quoted-slash-after.png differ