diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 70a0d506d..6956b6755 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -3363,7 +3363,74 @@ func errorResponse(id json.RawMessage, err error) response { } func trimStatementSQL(sqlText string) string { - return strings.TrimRight(strings.TrimSpace(sqlText), "; \t\r\n") + trimmed := strings.TrimSpace(sqlText) + if isXuguProgrammableObjectDDL(trimmed) { + // Xugu's compiler requires the terminator after END. The desktop + // statement splitter already removes only client-side delimiters, while + // retaining this one for Oracle-style procedural objects. + return trimmed + } + return strings.TrimRight(trimmed, "; \t\r\n") +} + +func isXuguProgrammableObjectDDL(sqlText string) bool { + fields := strings.Fields(strings.ToUpper(stripLeadingSQLComments(sqlText))) + if len(fields) < 2 || fields[0] != "CREATE" { + return false + } + + // Skip CREATE modifiers used by Xugu/Oracle-style programmable DDL: + // OR REPLACE, FORCE/NOFORCE, and EDITIONABLE/NONEDITIONABLE (any order). + index := 1 + for index < len(fields) { + if index+1 < len(fields) && fields[index] == "OR" && fields[index+1] == "REPLACE" { + index += 2 + continue + } + switch fields[index] { + case "FORCE", "NOFORCE", "EDITIONABLE", "NONEDITIONABLE": + index++ + continue + } + break + } + if index >= len(fields) { + return false + } + + switch fields[index] { + case "PROCEDURE", "FUNCTION", "TRIGGER", "PACKAGE": + // PACKAGE also covers PACKAGE BODY (next token is BODY). + return true + case "TYPE": + // Only TYPE BODY needs the trailing END; terminator. + // Plain CREATE TYPE ... AS OBJECT (...); is ordinary SQL. + return index+1 < len(fields) && fields[index+1] == "BODY" + default: + return false + } +} + +func stripLeadingSQLComments(sqlText string) string { + remaining := strings.TrimLeft(sqlText, " \t\r\n") + for { + switch { + case strings.HasPrefix(remaining, "--"): + lineEnd := strings.IndexByte(remaining, '\n') + if lineEnd < 0 { + return "" + } + remaining = strings.TrimLeft(remaining[lineEnd+1:], " \t\r\n") + case strings.HasPrefix(remaining, "/*"): + commentEnd := strings.Index(remaining[2:], "*/") + if commentEnd < 0 { + return "" + } + remaining = strings.TrimLeft(remaining[commentEnd+4:], " \t\r\n") + default: + return remaining + } + } } func isQuerySQL(sqlText string) bool { diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index ddcb3d797..113417b9d 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -1037,6 +1037,69 @@ func TestNormalizeValuePreservesDriverNumericTypes(t *testing.T) { } } +func TestTrimStatementSQLKeepsXuguProgrammableObjectTerminators(t *testing.T) { + cases := []struct { + name string + sql string + }{ + {"procedure", "CREATE OR REPLACE PROCEDURE p AS BEGIN NULL; END;"}, + {"procedure without or replace", "CREATE PROCEDURE p AS BEGIN NULL; END;"}, + {"function", "CREATE OR REPLACE FUNCTION f RETURN INTEGER AS BEGIN RETURN 1; END;"}, + {"function without or replace", "CREATE FUNCTION f RETURN INTEGER AS BEGIN RETURN 1; END;"}, + {"trigger", "CREATE OR REPLACE TRIGGER t BEFORE INSERT ON events FOR EACH ROW BEGIN NULL; END;"}, + {"trigger without or replace", "CREATE TRIGGER t BEFORE INSERT ON events FOR EACH ROW BEGIN NULL; END;"}, + {"package", "CREATE OR REPLACE PACKAGE pkg AS PROCEDURE ping; END pkg;"}, + {"package without or replace", "CREATE PACKAGE pkg AS PROCEDURE ping; END pkg;"}, + {"package body", "CREATE OR REPLACE PACKAGE BODY pkg AS PROCEDURE ping AS BEGIN NULL; END ping; END pkg;"}, + {"force package", "CREATE OR REPLACE FORCE PACKAGE pkg AS PROCEDURE ping; END pkg;"}, + {"noforce package", "CREATE OR REPLACE NOFORCE PACKAGE pkg AS PROCEDURE ping; END pkg;"}, + {"force package body", "CREATE OR REPLACE FORCE PACKAGE BODY pkg AS PROCEDURE ping AS BEGIN NULL; END ping; END pkg;"}, + {"noforce package body", "CREATE OR REPLACE NOFORCE PACKAGE BODY pkg AS PROCEDURE ping AS BEGIN NULL; END ping; END pkg;"}, + {"type body", "CREATE OR REPLACE TYPE BODY obj AS MEMBER PROCEDURE ping IS BEGIN NULL; END; END;"}, + {"type body without or replace", "CREATE TYPE BODY obj AS MEMBER PROCEDURE ping IS BEGIN NULL; END; END;"}, + {"force type body", "CREATE OR REPLACE FORCE TYPE BODY obj AS MEMBER PROCEDURE ping IS BEGIN NULL; END; END;"}, + {"leading comments", "-- generated source\n/* object DDL */\nCREATE OR REPLACE PROCEDURE p AS BEGIN NULL; END;"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := trimStatementSQL(tc.sql); got != tc.sql { + t.Fatalf("trimStatementSQL() = %q, want %q", got, tc.sql) + } + }) + } + + if got := trimStatementSQL("CREATE TABLE items (id INTEGER);"); got != "CREATE TABLE items (id INTEGER)" { + t.Fatalf("regular SQL terminator should be removed, got %q", got) + } + // Plain CREATE TYPE ends with ");" and is ordinary SQL — strip the client terminator. + if got := trimStatementSQL("CREATE OR REPLACE TYPE address_t AS OBJECT (id INT);"); got != "CREATE OR REPLACE TYPE address_t AS OBJECT (id INT)" { + t.Fatalf("plain TYPE should strip trailing semicolon, got %q", got) + } + if got := trimStatementSQL("CREATE TYPE address_t AS OBJECT (id INT);"); got != "CREATE TYPE address_t AS OBJECT (id INT)" { + t.Fatalf("plain TYPE without OR REPLACE should strip trailing semicolon, got %q", got) + } +} + +func TestExecuteQueryPreservesXuguTypeBodyTerminator(t *testing.T) { + resetXuguRecordingDriver() + db, err := sql.Open("xugu-test-recording", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := newServer() + s.db = db + sqlText := "CREATE OR REPLACE TYPE BODY obj_t AS MEMBER PROCEDURE ping IS BEGIN NULL; END; END;" + if _, err := s.executeQuery(queryOptions{SQL: sqlText}); err != nil { + t.Fatalf("executeQuery() error: %v", err) + } + if got := recordedXuguSQL(); got != sqlText { + t.Fatalf("Agent executed %q, want %q", got, sqlText) + } +} + func contains(values []string, target string) bool { for _, value := range values { if value == target { @@ -1051,10 +1114,48 @@ func contains(values []string, target string) bool { func init() { sql.Register("xugu-test-blocking", &xuguBlockingDriver{}) sql.Register("xugu-test-fast", &xuguFastDriver{}) + sql.Register("xugu-test-recording", &xuguRecordingDriver{}) sql.Register("xugu-test-legacy-columns", &xuguLegacyColumnsDriver{}) sql.Register("xugu-test-table-objects", &xuguTableObjectsDriver{}) } +type xuguRecordingDriver struct{} + +var xuguRecordingState struct { + sync.Mutex + sql string +} + +func resetXuguRecordingDriver() { + xuguRecordingState.Lock() + xuguRecordingState.sql = "" + xuguRecordingState.Unlock() +} + +func recordedXuguSQL() string { + xuguRecordingState.Lock() + defer xuguRecordingState.Unlock() + return xuguRecordingState.sql +} + +func (d *xuguRecordingDriver) Open(name string) (driver.Conn, error) { + return &xuguRecordingConn{}, nil +} + +type xuguRecordingConn struct{} + +func (c *xuguRecordingConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguRecordingConn) Close() error { return nil } +func (c *xuguRecordingConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") } +func (c *xuguRecordingConn) ExecContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) { + xuguRecordingState.Lock() + xuguRecordingState.sql = query + xuguRecordingState.Unlock() + return driver.ResultNoRows, nil +} + type xuguTableObjectsDriver struct{} func (d *xuguTableObjectsDriver) Open(name string) (driver.Conn, error) { diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 3891ad67b..4af28be66 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -80,6 +80,75 @@ BEGIN NULL; END;`; +const xuguProgrammableObjectFixtures = [ + `CREATE OR REPLACE PROCEDURE dbx_xugu_procedure AS + v_value INTEGER; +BEGIN + v_value := 1; +END;`, + `CREATE PROCEDURE dbx_xugu_procedure_without_replace AS + v_value INTEGER; +BEGIN + v_value := 1; +END;`, + `CREATE OR REPLACE FUNCTION dbx_xugu_function RETURN INTEGER AS +BEGIN + RETURN 1; +END;`, + `CREATE FUNCTION dbx_xugu_function_without_replace RETURN INTEGER AS +BEGIN + RETURN 1; +END;`, + `CREATE OR REPLACE TRIGGER dbx_xugu_trigger +BEFORE INSERT ON dbx_xugu_events +FOR EACH ROW +BEGIN + NULL; +END;`, + `CREATE TRIGGER dbx_xugu_trigger_without_replace +BEFORE INSERT ON dbx_xugu_events +FOR EACH ROW +BEGIN + NULL; +END;`, + `CREATE OR REPLACE PACKAGE BODY dbx_xugu_package AS + PROCEDURE ping AS + BEGIN + NULL; + END ping; +END dbx_xugu_package;`, + `CREATE PACKAGE BODY dbx_xugu_package_without_replace AS + PROCEDURE ping AS + BEGIN + NULL; + END ping; +END dbx_xugu_package_without_replace;`, + `CREATE OR REPLACE FORCE PACKAGE BODY dbx_xugu_force_package AS + PROCEDURE ping AS + BEGIN + NULL; + END ping; +END dbx_xugu_force_package;`, + `CREATE OR REPLACE NOFORCE PACKAGE BODY dbx_xugu_noforce_package AS + PROCEDURE ping AS + BEGIN + NULL; + END ping; +END dbx_xugu_noforce_package;`, + `CREATE OR REPLACE TYPE BODY dbx_xugu_type AS + MEMBER PROCEDURE ping IS + BEGIN + NULL; + END; +END;`, + `CREATE TYPE BODY dbx_xugu_type_without_replace AS + MEMBER PROCEDURE ping IS + BEGIN + NULL; + END; +END;`, +]; + const mysqlRoutineFixture = `CREATE PROCEDURE p() BEGIN SELECT 1; @@ -223,6 +292,54 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(gaussDbNestedProcedure, "gaussdb"))).toEqual([gaussDbNestedProcedure]); }); + it("keeps Xugu programmable object DDL together and retains its terminator", () => { + for (const sql of xuguProgrammableObjectFixtures) { + const ranges = splitSqlStatementRanges(`${sql}\nSELECT 1;`, "xugu"); + expect(rangeSqlTexts(ranges)).toEqual([sql, "SELECT 1"]); + expect(ranges[0].sql.trimEnd()).toMatch(/END(?:\s+\w+)?;$/); + } + }); + + it("splits Xugu package specification without a slash before following SQL", () => { + const packageSpec = `CREATE OR REPLACE PACKAGE pkg_utils AS + FUNCTION get_version RETURN VARCHAR2; + PROCEDURE log_message(msg VARCHAR2); +END pkg_utils;`; + const forcePackageSpec = `CREATE OR REPLACE FORCE PACKAGE pkg_utils AS + PROCEDURE ping; +END pkg_utils;`; + const packageSpecWithoutReplace = `CREATE PACKAGE pkg_utils_without_replace AS + PROCEDURE ping; +END pkg_utils_without_replace;`; + + expect(rangeSqlTexts(splitSqlStatementRanges(`${packageSpec}\nSELECT 1;`, "xugu"))).toEqual([packageSpec, "SELECT 1"]); + expect(rangeSqlTexts(splitSqlStatementRanges(`${packageSpec}\n/\nSELECT 1;`, "xugu"))).toEqual([packageSpec, "SELECT 1"]); + expect(rangeSqlTexts(splitSqlStatementRanges(`${forcePackageSpec}\nSELECT 1;`, "xugu"))).toEqual([forcePackageSpec, "SELECT 1"]); + expect(rangeSqlTexts(splitSqlStatementRanges(`${packageSpecWithoutReplace}\nSELECT 1;`, "xugu"))).toEqual([packageSpecWithoutReplace, "SELECT 1"]); + }); + + it("splits plain CREATE TYPE AS OBJECT on semicolon without waiting for END", () => { + const sql = "CREATE OR REPLACE TYPE address_t AS OBJECT (id INT);\nSELECT 1;"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "xugu"))).toEqual(["CREATE OR REPLACE TYPE address_t AS OBJECT (id INT)", "SELECT 1"]); + expect(rangeSqlTexts(splitSqlStatementRanges("CREATE TYPE address_t_without_replace AS OBJECT (id INT);\nSELECT 1;", "xugu"))).toEqual(["CREATE TYPE address_t_without_replace AS OBJECT (id INT)", "SELECT 1"]); + }); + + it("keeps Oracle-style CASE expressions inside Xugu and Oracle routines", () => { + const routine = `CREATE OR REPLACE FUNCTION dbx_case_expr RETURN NUMBER AS +BEGIN + RETURN CASE WHEN 1 = 1 THEN CASE WHEN 2 = 2 THEN 1 ELSE 2 END ELSE 0 END; +END;`; + const caseStatementRoutine = `CREATE OR REPLACE PROCEDURE dbx_case_statement AS +BEGIN + CASE WHEN 1 = 1 THEN NULL; ELSE NULL; END CASE; +END;`; + + for (const database of ["xugu", "oracle"] as const) { + expect(rangeSqlTexts(splitSqlStatementRanges(`${routine}\nSELECT 1;`, database))).toEqual([routine, "SELECT 1"]); + expect(rangeSqlTexts(splitSqlStatementRanges(`${caseStatementRoutine}\nSELECT 1;`, database))).toEqual([caseStatementRoutine, "SELECT 1"]); + } + }); + it("keeps SAP HANA DO blocks together", () => { const ranges = splitSqlStatementRanges(sapHanaDoBlockFixture, "saphana"); diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 0a95e9701..b2a3dd547 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -249,14 +249,16 @@ const ALTER_BODY_KEYWORDS = new Set(["ADD", "ALTER", "COMMENT", "DROP", "MODIFY" const CLICKHOUSE_ALTER_TABLE_HEADER = /^ALTER\s+TABLE\s+(?:(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")\s*\.\s*)?(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")(?:\s+ON\s+CLUSTER\s+(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+"|'(?:''|[^'])+'))?\s*$/i; const SET_OPERATION_KEYWORDS = new Set(["UNION", "INTERSECT", "EXCEPT", "MINUS"]); const SET_OPERATION_MODIFIER_KEYWORDS = new Set(["ALL", "DISTINCT"]); -const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle"]); +const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle", "xugu"]); const MYSQL_ROUTINE_BLOCK_DATABASES: ReadonlySet = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]); const MYSQL_CREATE_TABLE_OPTION_DATABASES: ReadonlySet = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb", "gbase"]); const MYSQL_ROUTINE_OBJECT_TYPES = new Set(["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]); const MYSQL_NON_ROUTINE_CREATE_TYPES = new Set(["DATABASE", "INDEX", "LOGFILE", "ROLE", "SCHEMA", "SERVER", "SPATIAL", "TABLE", "TEMPORARY", "UNIQUE", "USER", "VIEW"]); const MYSQL_CONTROL_BLOCK_SUFFIXES = new Set(["IF", "LOOP", "CASE", "REPEAT", "WHILE"]); 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"]); +// Plain CREATE TYPE ... AS OBJECT (...); ends with ");" and is not a PL/SQL block. +// Only PACKAGE (spec), PACKAGE/TYPE BODY, and routine/trigger objects are PL/SQL blocks. +const ORACLE_PL_SQL_CREATE_OBJECT_TYPES = new Set(["FUNCTION", "PROCEDURE", "TRIGGER", "PACKAGE"]); const ORACLE_PL_SQL_TERMINATORS = new Set(["IF", "LOOP", "CASE"]); const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]); @@ -1614,15 +1616,32 @@ function startsWithOraclePlSqlBlock(sql: string): boolean { if (ORACLE_PL_SQL_BLOCK_STARTERS.has(first)) return first !== "BEGIN" || words[1] !== "TRANSACTION"; if (first !== "CREATE") return false; - let index = 1; - while (["OR", "REPLACE", "EDITIONABLE", "NONEDITIONABLE"].includes(words[index] ?? "")) { - index += 1; - } + const index = skipOraclePlSqlCreateModifiers(words, 1); + // PACKAGE BODY / TYPE BODY are programmable blocks with an outer END. if (words[index] === "PACKAGE" && words[index + 1] === "BODY") return true; if (words[index] === "TYPE" && words[index + 1] === "BODY") return true; + // Plain CREATE TYPE ... AS OBJECT (...); is ordinary SQL terminated by ';'. + if (words[index] === "TYPE") return false; return ORACLE_PL_SQL_CREATE_OBJECT_TYPES.has(words[index] ?? ""); } +/** Skip OR REPLACE / FORCE / NOFORCE / EDITIONABLE modifiers after CREATE. */ +function skipOraclePlSqlCreateModifiers(words: readonly string[], startIndex: number): number { + let index = startIndex; + while (index < words.length) { + if (words[index] === "OR" && words[index + 1] === "REPLACE") { + index += 2; + continue; + } + if (["FORCE", "NOFORCE", "EDITIONABLE", "NONEDITIONABLE"].includes(words[index] ?? "")) { + index += 1; + continue; + } + break; + } + return index; +} + function startsWithSapHanaScriptBlock(sql: string): boolean { return oraclePlSqlWords(sql)[0] === "DO"; } @@ -1663,7 +1682,11 @@ function oraclePlSqlBlockIsComplete(sql: string): boolean { const tokens = oraclePlSqlTokens(sql); if (!startsWithOraclePlSqlBlock(sql)) return false; - const stack: string[] = []; + // Package/type specifications have no BEGIN — only declarations closed by + // END [name];. Bodies also own an outer END beyond nested routine END pairs. + const objectKind = oraclePlSqlCreateObjectKind(sql); + const stack: string[] = objectKind === "body" ? ["OBJECT_BODY"] : objectKind === "spec" ? ["OBJECT_SPEC"] : []; + let sawBegin = false; for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; if (token.kind !== "word") continue; @@ -1676,6 +1699,7 @@ function oraclePlSqlBlockIsComplete(sql: string): boolean { if (tokens[index - 1]?.kind === "word" && tokens[index - 1]?.value === "TRANSACTION") continue; const previous = previousWordToken(tokens, index); if (previous === "END") continue; + sawBegin = true; if (stack[stack.length - 1] === "DECLARATION") stack[stack.length - 1] = "BLOCK"; else stack.push("BLOCK"); continue; @@ -1690,19 +1714,53 @@ function oraclePlSqlBlockIsComplete(sql: string): boolean { continue; } if (token.value === "CASE") { + // Both CASE statements and CASE expressions own an END. The CASE token + // following END CASE is ignored below, so it cannot start a new scope. if (previousWordToken(tokens, index) !== "END") stack.push("CASE"); continue; } if (token.value === "END") { - const next = nextWordToken(tokens, index); - const target = ORACLE_PL_SQL_TERMINATORS.has(next ?? "") ? next : "BLOCK"; const top = stack[stack.length - 1]; + // CASE expressions close as END; while CASE statements close as END CASE;. + // In either form, this END belongs to CASE rather than the surrounding block. + if (top === "CASE") { + stack.pop(); + continue; + } + const next = nextWordToken(tokens, index); + const target = ORACLE_PL_SQL_TERMINATORS.has(next ?? "") ? next : top === "OBJECT_BODY" || top === "OBJECT_SPEC" ? top : "BLOCK"; if (top === target || (target === "BLOCK" && top === "BLOCK")) stack.pop(); continue; } } - return stack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon"; + const endsWithSemicolon = tokens[tokens.length - 1]?.kind === "semicolon"; + if (objectKind === "spec") { + // Specs complete on outer END [name]; without requiring a BEGIN block. + return stack.length === 0 && endsWithSemicolon; + } + return sawBegin && stack.length === 0 && endsWithSemicolon; +} + +/** + * Classify CREATE programmable objects: + * - body: PACKAGE BODY / TYPE BODY (outer END beyond nested routines) + * - spec: PACKAGE specification only (declarations + END, no BEGIN) + * - null: ordinary SQL / other objects (including plain CREATE TYPE ... AS OBJECT) + */ +function oraclePlSqlCreateObjectKind(sql: string): "body" | "spec" | null { + const words = oraclePlSqlWords(sql); + if (words[0] !== "CREATE") return null; + + const index = skipOraclePlSqlCreateModifiers(words, 1); + if ((words[index] === "PACKAGE" || words[index] === "TYPE") && words[index + 1] === "BODY") { + return "body"; + } + // Only PACKAGE specs lack BEGIN; plain TYPE objects end with ");". + if (words[index] === "PACKAGE") { + return "spec"; + } + return null; } function oraclePlSqlWords(sql: string): string[] { diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index 155da5c79..c24e65029 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -191,6 +191,7 @@ impl SqlDialectProfile { | DatabaseType::Yashandb | DatabaseType::Oscar | DatabaseType::OceanbaseOracle + | DatabaseType::Xugu ) } } @@ -2084,51 +2085,99 @@ impl OraclePlSqlBlock { return false; } - let mut depth = 0usize; + // Package/type specifications have declarations and an outer END with + // no BEGIN. Bodies also own an outer END beyond nested routine END + // pairs so an inner END cannot finish the object. + let object_kind = self.create_object_kind(); + let mut scopes = match object_kind { + Some(OraclePlSqlCreateObjectKind::Body | OraclePlSqlCreateObjectKind::Spec) => { + vec![OraclePlSqlScope::Object] + } + None => Vec::new(), + }; let mut saw_begin = false; let mut complete = false; - let mut pending_end: Option = None; - for token in &self.tokens { + for (index, token) in self.tokens.iter().enumerate() { if token.is_semicolon() { - if let Some(is_block_end) = pending_end.take() { - if is_block_end && depth > 0 { - depth -= 1; - complete = depth == 0; - } - } - continue; - } - - if let Some(is_block_end) = pending_end.as_mut() { - if token.is_any_word(&["IF", "LOOP", "CASE"]) { - *is_block_end = false; - } continue; } if token.is_word("BEGIN") { - depth += 1; + scopes.push(OraclePlSqlScope::Block); saw_begin = true; complete = false; + } else if token.is_word("CASE") { + // Both CASE expressions and CASE statements own an END. + // The CASE token that follows END CASE is a suffix, not a scope start. + if previous_word_token(&self.tokens, index) != Some("END") { + scopes.push(OraclePlSqlScope::Case); + } } else if token.is_word("END") { - pending_end = Some(true); + let next = self.tokens.get(index + 1).and_then(OraclePlSqlToken::as_word); + if matches!(next, Some("IF" | "LOOP")) { + continue; + } + if matches!(next, Some("CASE")) && !matches!(scopes.last(), Some(OraclePlSqlScope::Case)) { + continue; + } + if scopes.pop().is_some() { + complete = scopes.is_empty(); + } } } - saw_begin && complete + match object_kind { + // Specs complete on outer END [name]; without requiring BEGIN. + Some(OraclePlSqlCreateObjectKind::Spec) => complete, + _ => saw_begin && complete, + } } fn starts_create_plsql_object(tokens: &[OraclePlSqlToken]) -> bool { - let tokens = Self::skip_or_replace(tokens); - tokens.first().is_some_and(|token| token.is_any_word(&["FUNCTION", "PROCEDURE", "TRIGGER", "PACKAGE", "TYPE"])) + let tokens = Self::skip_create_modifiers(tokens); + match tokens { + // PACKAGE BODY / TYPE BODY are programmable blocks with an outer END. + [object, body, ..] if object.is_any_word(&["PACKAGE", "TYPE"]) && body.is_word("BODY") => true, + // Plain CREATE TYPE ... AS OBJECT (...); ends with ");" — not a PL/SQL block. + [object, ..] if object.is_word("TYPE") => false, + [object, ..] if object.is_any_word(&["FUNCTION", "PROCEDURE", "TRIGGER", "PACKAGE"]) => true, + _ => false, + } } - fn skip_or_replace(tokens: &[OraclePlSqlToken]) -> &[OraclePlSqlToken] { - match tokens { - [or, replace, rest @ ..] if or.is_word("OR") && replace.is_word("REPLACE") => rest, - _ => tokens, + fn create_object_kind(&self) -> Option { + if self.tokens.first().is_none_or(|token| !token.is_word("CREATE")) { + return None; } + let tokens = Self::skip_create_modifiers(&self.tokens[1..]); + match tokens { + [object, body, ..] if object.is_any_word(&["PACKAGE", "TYPE"]) && body.is_word("BODY") => { + Some(OraclePlSqlCreateObjectKind::Body) + } + // Only PACKAGE specs lack BEGIN; plain TYPE objects are ordinary SQL. + [object, ..] if object.is_word("PACKAGE") => Some(OraclePlSqlCreateObjectKind::Spec), + _ => None, + } + } + + /// Skip OR REPLACE / FORCE / NOFORCE / EDITIONABLE modifiers after CREATE. + fn skip_create_modifiers(tokens: &[OraclePlSqlToken]) -> &[OraclePlSqlToken] { + let mut rest = tokens; + loop { + match rest { + [or, replace, tail @ ..] if or.is_word("OR") && replace.is_word("REPLACE") => { + rest = tail; + } + [modifier, tail @ ..] + if modifier.is_any_word(&["FORCE", "NOFORCE", "EDITIONABLE", "NONEDITIONABLE"]) => + { + rest = tail; + } + _ => break, + } + } + rest } fn is_transaction_begin_tail(token: &OraclePlSqlToken) -> bool { @@ -2136,6 +2185,19 @@ impl OraclePlSqlBlock { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OraclePlSqlCreateObjectKind { + Spec, + Body, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OraclePlSqlScope { + Object, + Block, + Case, +} + impl OraclePlSqlToken { fn word(value: String) -> Self { Self::Word(value) @@ -3083,6 +3145,7 @@ END;"; assert_eq!(split_sql_statements_for_database(sql, DatabaseType::Oracle), vec![sql.to_string()]); assert_eq!(split_sql_statements_for_database(sql, DatabaseType::Dameng), vec![sql.to_string()]); assert_eq!(split_sql_statements_for_database(sql, DatabaseType::Gaussdb), vec![sql.to_string()]); + assert_eq!(split_sql_statements_for_database(sql, DatabaseType::Xugu), vec![sql.to_string()]); } #[test] @@ -3229,6 +3292,10 @@ SELECT 1;"; split_sql_statements_for_database(sql, DatabaseType::Oracle), vec!["CREATE OR REPLACE FUNCTION number_tochar(nums VARCHAR(20))\nRETURN VARCHAR(20)\nAS\n res VARCHAR(20);\nBEGIN\n RETURN '一';\nEND;", "SELECT 1"] ); + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec!["CREATE OR REPLACE FUNCTION number_tochar(nums VARCHAR(20))\nRETURN VARCHAR(20)\nAS\n res VARCHAR(20);\nBEGIN\n RETURN '一';\nEND;", "SELECT 1"] + ); assert_eq!( split_sql_statements_for_database(sql, DatabaseType::Dameng), vec!["CREATE OR REPLACE FUNCTION number_tochar(nums VARCHAR(20))\nRETURN VARCHAR(20)\nAS\n res VARCHAR(20);\nBEGIN\n RETURN '一';\nEND;", "SELECT 1"] @@ -3254,6 +3321,13 @@ SELECT 1;"; "SELECT 1" ] ); + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec![ + "CREATE OR REPLACE PROCEDURE update_salary(p_id NUMBER, p_amount NUMBER)\nAS\nBEGIN\n UPDATE employees SET salary = salary + p_amount WHERE id = p_id;\n COMMIT;\nEND;", + "SELECT 1" + ] + ); } #[test] @@ -3275,6 +3349,13 @@ SELECT 1;"; "SELECT 1" ] ); + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec![ + "CREATE TRIGGER trg_audit\nBEFORE INSERT ON employees\nFOR EACH ROW\nBEGIN\n INSERT INTO audit_log VALUES (:NEW.id, 'INSERT');\nEND;", + "SELECT 1" + ] + ); } #[test] @@ -3317,6 +3398,206 @@ SELECT 1;"; "SELECT 1" ] ); + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec![ + "CREATE OR REPLACE PACKAGE pkg_utils AS\n FUNCTION get_version RETURN VARCHAR2;\n PROCEDURE log_message(msg VARCHAR2);\nEND pkg_utils;", + "SELECT 1" + ] + ); + } + + #[test] + fn xugu_split_keeps_create_package_body_together() { + let sql = "\ +CREATE OR REPLACE PACKAGE BODY dbx_pkg AS + PROCEDURE ping AS + BEGIN + NULL; + END ping; +END dbx_pkg; +/ +SELECT 1;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec![ + "CREATE OR REPLACE PACKAGE BODY dbx_pkg AS\n PROCEDURE ping AS\n BEGIN\n NULL;\n END ping;\nEND dbx_pkg;", + "SELECT 1" + ] + ); + } + + #[test] + fn xugu_split_keeps_routines_without_or_replace_together() { + let cases = [ + "CREATE PROCEDURE dbx_proc_without_replace AS BEGIN NULL; END;", + "CREATE FUNCTION dbx_func_without_replace RETURN INTEGER AS BEGIN RETURN 1; END;", + "CREATE TRIGGER dbx_trigger_without_replace BEFORE INSERT ON dbx_events FOR EACH ROW BEGIN NULL; END;", + ]; + + for statement in cases { + assert_eq!( + split_sql_statements_for_database(&format!("{statement}\nSELECT 1;"), DatabaseType::Xugu), + vec![statement.to_owned(), "SELECT 1".to_owned()], + "failed for {statement}" + ); + } + } + + #[test] + fn xugu_split_keeps_force_package_body_together() { + let sql = "\ +CREATE OR REPLACE FORCE PACKAGE BODY dbx_pkg AS + PROCEDURE ping AS + BEGIN + NULL; + END ping; +END dbx_pkg; +SELECT 1;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec![ + "CREATE OR REPLACE FORCE PACKAGE BODY dbx_pkg AS\n PROCEDURE ping AS\n BEGIN\n NULL;\n END ping;\nEND dbx_pkg;", + "SELECT 1" + ] + ); + assert_eq!( + split_sql_statements_for_database( + "CREATE OR REPLACE NOFORCE PACKAGE BODY dbx_pkg AS\n PROCEDURE ping AS\n BEGIN\n NULL;\n END ping;\nEND dbx_pkg;\nSELECT 1;", + DatabaseType::Xugu + ), + vec![ + "CREATE OR REPLACE NOFORCE PACKAGE BODY dbx_pkg AS\n PROCEDURE ping AS\n BEGIN\n NULL;\n END ping;\nEND dbx_pkg;", + "SELECT 1" + ] + ); + assert_eq!( + split_sql_statements_for_database( + "CREATE PACKAGE BODY dbx_pkg_without_replace AS\n PROCEDURE ping AS\n BEGIN\n NULL;\n END ping;\nEND dbx_pkg_without_replace;\nSELECT 1;", + DatabaseType::Xugu + ), + vec![ + "CREATE PACKAGE BODY dbx_pkg_without_replace AS\n PROCEDURE ping AS\n BEGIN\n NULL;\n END ping;\nEND dbx_pkg_without_replace;", + "SELECT 1" + ] + ); + } + + #[test] + fn xugu_split_package_spec_without_slash_does_not_consume_following_sql() { + let sql = "\ +CREATE OR REPLACE PACKAGE pkg_utils AS + FUNCTION get_version RETURN VARCHAR2; + PROCEDURE log_message(msg VARCHAR2); +END pkg_utils; +SELECT 1;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec![ + "CREATE OR REPLACE PACKAGE pkg_utils AS\n FUNCTION get_version RETURN VARCHAR2;\n PROCEDURE log_message(msg VARCHAR2);\nEND pkg_utils;", + "SELECT 1" + ] + ); + assert_eq!( + split_sql_statements_for_database( + "CREATE OR REPLACE PACKAGE pkg_utils AS\n FUNCTION get_version RETURN VARCHAR2;\n PROCEDURE log_message(msg VARCHAR2);\nEND pkg_utils;\n/\nSELECT 1;", + DatabaseType::Xugu + ), + vec![ + "CREATE OR REPLACE PACKAGE pkg_utils AS\n FUNCTION get_version RETURN VARCHAR2;\n PROCEDURE log_message(msg VARCHAR2);\nEND pkg_utils;", + "SELECT 1" + ] + ); + assert_eq!( + split_sql_statements_for_database( + "CREATE OR REPLACE FORCE PACKAGE pkg_utils AS\n PROCEDURE ping;\nEND pkg_utils;\nSELECT 1;", + DatabaseType::Xugu + ), + vec!["CREATE OR REPLACE FORCE PACKAGE pkg_utils AS\n PROCEDURE ping;\nEND pkg_utils;", "SELECT 1"] + ); + assert_eq!( + split_sql_statements_for_database( + "CREATE PACKAGE pkg_utils_without_replace AS\n PROCEDURE ping;\nEND pkg_utils_without_replace;\nSELECT 1;", + DatabaseType::Xugu + ), + vec![ + "CREATE PACKAGE pkg_utils_without_replace AS\n PROCEDURE ping;\nEND pkg_utils_without_replace;", + "SELECT 1" + ] + ); + } + + #[test] + fn xugu_split_keeps_create_type_body_together() { + let sql = "\ +CREATE OR REPLACE TYPE BODY obj_t AS + MEMBER PROCEDURE ping IS + BEGIN + NULL; + END; +END; +SELECT 1;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec![ + "CREATE OR REPLACE TYPE BODY obj_t AS\n MEMBER PROCEDURE ping IS\n BEGIN\n NULL;\n END;\nEND;", + "SELECT 1" + ] + ); + assert_eq!( + split_sql_statements_for_database( + "CREATE TYPE BODY obj_t_without_replace AS\n MEMBER PROCEDURE ping IS\n BEGIN\n NULL;\n END;\nEND;\nSELECT 1;", + DatabaseType::Xugu + ), + vec![ + "CREATE TYPE BODY obj_t_without_replace AS\n MEMBER PROCEDURE ping IS\n BEGIN\n NULL;\n END;\nEND;", + "SELECT 1" + ] + ); + } + + #[test] + fn xugu_split_plain_create_type_object_on_semicolon() { + // Plain CREATE TYPE ends with ");" and must not wait for a nonexistent outer END. + let sql = "CREATE OR REPLACE TYPE address_t AS OBJECT (id INT);\nSELECT 1;"; + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Xugu), + vec!["CREATE OR REPLACE TYPE address_t AS OBJECT (id INT)", "SELECT 1"] + ); + assert_eq!( + split_sql_statements_for_database( + "CREATE TYPE address_t_without_replace AS OBJECT (id INT);\nSELECT 1;", + DatabaseType::Xugu + ), + vec!["CREATE TYPE address_t_without_replace AS OBJECT (id INT)", "SELECT 1"] + ); + } + + #[test] + fn oracle_like_split_keeps_case_expressions_inside_routines() { + let function = "CREATE OR REPLACE FUNCTION dbx_case_expr RETURN NUMBER AS\nBEGIN\n RETURN CASE WHEN 1 = 1 THEN CASE WHEN 2 = 2 THEN 1 ELSE 2 END ELSE 0 END;\nEND;\nSELECT 1;"; + let procedure = "CREATE OR REPLACE PROCEDURE dbx_case_statement AS\nBEGIN\n CASE WHEN 1 = 1 THEN NULL; ELSE NULL; END CASE;\nEND;\nSELECT 1;"; + + for database in [DatabaseType::Xugu, DatabaseType::Oracle] { + assert_eq!( + split_sql_statements_for_database(function, database), + vec![ + "CREATE OR REPLACE FUNCTION dbx_case_expr RETURN NUMBER AS\nBEGIN\n RETURN CASE WHEN 1 = 1 THEN CASE WHEN 2 = 2 THEN 1 ELSE 2 END ELSE 0 END;\nEND;", + "SELECT 1" + ] + ); + assert_eq!( + split_sql_statements_for_database(procedure, database), + vec![ + "CREATE OR REPLACE PROCEDURE dbx_case_statement AS\nBEGIN\n CASE WHEN 1 = 1 THEN NULL; ELSE NULL; END CASE;\nEND;", + "SELECT 1" + ] + ); + } } #[test]