From a224a6747ca794faa1fb3af57b038db2f1b93e38 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 1 Jul 2026 12:26:03 +0800 Subject: [PATCH] fix: handle oracle plsql statement ranges Co-authored-by: staff --- .../lib/__tests__/sqlStatementRanges.spec.ts | 54 +++++ .../desktop/src/lib/sqlSemanticDiagnostics.ts | 3 +- apps/desktop/src/lib/sqlStatementRanges.ts | 199 +++++++++++++++++- crates/dbx-core/src/sql.rs | 51 +++++ .../app-tests/sqlSemanticDiagnostics.test.ts | 20 ++ 5 files changed, 325 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts index b94393abe..6ea690a3d 100644 --- a/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts @@ -28,6 +28,43 @@ function candidateSummaries(candidates: Array<{ kind: string; sql: string }>): s return candidates.map((candidate) => `${candidate.kind}:${candidate.sql.trim()}`); } +const oraclePlSqlFixture = `DECLARE + v_order_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_order_count + FROM "DBX_TEST"."ORDERS_10K"; + + IF v_order_count = 0 THEN + INSERT INTO "DBX_TEST"."STORES" + ("ID", "STORE_CODE", "STORE_NAME", "CITY", "OPENED_AT") + SELECT 10001, 'TEST_STORE_001', '测试门店', '上海', SYSDATE + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM "DBX_TEST"."STORES" WHERE "ID" = 10001 + ); + + INSERT INTO "DBX_TEST"."PRODUCTS" + ("ID", "SKU", "PRODUCT_NAME", "CATEGORY", "PRICE") + SELECT 10001, 'TEST_SKU_001', '测试商品', '测试分类', 99.90 + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM "DBX_TEST"."PRODUCTS" WHERE "ID" = 10001 + ); + + INSERT INTO "DBX_TEST"."ORDERS_10K" + ("ID", "ORDER_NO", "STORE_ID", "PRODUCT_ID", "CUSTOMER_NAME", "QUANTITY", "AMOUNT", "ORDER_STATUS", "CREATED_AT") + SELECT 10001, 'TEST_ORDER_001', 10001, 10001, '测试客户', 2, 199.80, 'PAID', SYSDATE + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM "DBX_TEST"."ORDERS_10K" WHERE "ORDER_NO" = 'TEST_ORDER_001' + ); + + COMMIT; + END IF; +END; +/ +SELECT 1;`; + describe("splitSqlStatementRanges", () => { it("splits multiple top-level statements", () => { const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;"; @@ -89,6 +126,14 @@ describe("splitSqlStatementRanges", () => { const sql = "select COUNT(1) FROM your_table;\ndelimiter ;;\nselect COUNT(1) FROM your_table;\n\n;;\ndelimiter ;"; expect(rangeSqlTexts(splitSqlStatementRanges(sql, "mysql"))).toEqual(["select COUNT(1) FROM your_table", "select COUNT(1) FROM your_table;"]); }); + + it("keeps Oracle PL/SQL blocks together and treats slash lines as delimiters", () => { + const ranges = splitSqlStatementRanges(oraclePlSqlFixture, "oracle"); + expect(rangeSqlTexts(ranges)).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]); + expect(ranges[0].sql).toContain("v_order_count NUMBER;"); + expect(ranges[0].sql).toContain("END;"); + expect(ranges[0].sql).not.toContain("\n/"); + }); }); describe("statementRangeAtCursor", () => { @@ -250,6 +295,11 @@ describe("statementRangeAtCursor", () => { expect(statementRangeAtCursor(sql, indexOf(sql, "COUNT", 2), "mysql")?.sql.trim()).toBe("select COUNT(1) FROM your_table;"); expect(statementRangeAtCursor(sql, indexOf(sql, "delimiter"), "mysql")).toBeNull(); }); + + it("returns the full Oracle PL/SQL block for cursors inside nested statements", () => { + const range = statementRangeAtCursor(oraclePlSqlFixture, indexOf(oraclePlSqlFixture, "ORDERS_10K", 2), "oracle"); + expect(range?.sql.trim()).toBe(oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/"))); + }); }); describe("executableStatementRanges", () => { @@ -266,6 +316,10 @@ describe("executableStatementRanges", () => { expect(rangeSqlTexts(ranges)).toEqual(["GET user:1", "DEL user:2"]); expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("DEL")]); }); + + it("does not split executable Oracle PL/SQL ranges at inner statement starts", () => { + expect(rangeSqlTexts(executableStatementRanges(oraclePlSqlFixture, "oracle"))).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]); + }); }); describe("fullSqlRange", () => { diff --git a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts index 76e06da94..7377d0f76 100644 --- a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts +++ b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts @@ -1,6 +1,6 @@ import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion"; import { getSqlCompletionContext } from "@/lib/sqlCompletion"; -import { executableStatementRanges, type SqlTextRange } from "@/lib/sqlStatementRanges"; +import { executableStatementRanges, isOraclePlSqlStatement, type SqlTextRange } from "@/lib/sqlStatementRanges"; import type { DatabaseType, SqlColumnReference, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database"; export interface SqlSemanticDiagnostic { @@ -29,6 +29,7 @@ export function sqlSemanticDiagnosticRangesForViewport(sql: string, visibleRange const selected: SqlTextRange[] = []; const seen = new Set(); for (const statement of statements) { + if (isOraclePlSqlStatement(statement.sql, databaseType)) 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/sqlStatementRanges.ts b/apps/desktop/src/lib/sqlStatementRanges.ts index 00771daf8..29862b2eb 100644 --- a/apps/desktop/src/lib/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sqlStatementRanges.ts @@ -99,6 +99,10 @@ const WITH_MAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "INSERT", "UPDATE", "DEL const EXPLAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE", "ALTER", "DROP"]); const CREATE_BODY_KEYWORDS = new Set(["SELECT", "WITH", "BEGIN", "DECLARE"]); const INSERT_BODY_KEYWORDS = new Set(["SELECT", "WITH"]); +const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle"]); +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"]); /** * Parse the SQL document into top-level statement ranges delimited by `;`. @@ -233,6 +237,13 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType continue; } } + if (isOracleLikeDatabase(databaseType) && isAtLineStart(sql, i) && isSlashLine(sql, i)) { + const lineEnd = findLineEnd(sql, i); + flush(i); + i = nextLineStart(sql, lineEnd); + statementHitStart = i; + continue; + } // Line comments consume up to (and including) the newline. if (ch === "-" && next === "-") { @@ -296,7 +307,17 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType continue; } } else if (ch === ";") { - flush(); + const isOraclePlSql = isOracleLikeDatabase(databaseType) && statementStart !== -1 && startsWithOraclePlSqlBlock(sql.slice(statementStart, i)); + if (isOraclePlSql) { + markContent(i); + if (!oraclePlSqlBlockIsComplete(sql.slice(statementStart, i + 1))) { + i += 1; + continue; + } + flush(i + 1); + } else { + flush(); + } statementHitStart = i + 1; i += 1; continue; @@ -383,6 +404,8 @@ function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: nu } function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): RawStatement[] { + if (isOraclePlSqlStatement(statement.sql, databaseType)) return [statement]; + const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType); if (lineStarts.length <= 1) return [statement]; @@ -814,6 +837,175 @@ function isSqlWhitespace(ch: string): boolean { return ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; } +export function isOracleLikeDatabase(databaseType?: DatabaseType): boolean { + return !!databaseType && ORACLE_LIKE_PL_SQL_DATABASES.has(databaseType); +} + +export function isOraclePlSqlStatement(sql: string, databaseType?: DatabaseType): boolean { + return isOracleLikeDatabase(databaseType) && startsWithOraclePlSqlBlock(sql); +} + +function startsWithOraclePlSqlBlock(sql: string): boolean { + const words = oraclePlSqlWords(sql); + const first = words[0]; + if (!first) return false; + 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; + } + if (words[index] === "PACKAGE" && words[index + 1] === "BODY") return true; + if (words[index] === "TYPE" && words[index + 1] === "BODY") return true; + return ORACLE_PL_SQL_CREATE_OBJECT_TYPES.has(words[index] ?? ""); +} + +function oraclePlSqlBlockIsComplete(sql: string): boolean { + const tokens = oraclePlSqlTokens(sql); + if (!startsWithOraclePlSqlBlock(sql)) return false; + + const stack: string[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind !== "word") continue; + + if (token.value === "DECLARE") { + stack.push("BLOCK"); + continue; + } + if (token.value === "BEGIN") { + if (tokens[index - 1]?.kind === "word" && tokens[index - 1]?.value === "TRANSACTION") continue; + const previous = previousWordToken(tokens, index); + if (previous === "END") continue; + if (stack[stack.length - 1] !== "BLOCK") stack.push("BLOCK"); + continue; + } + if (token.value === "IF") { + const previous = previousWordToken(tokens, index); + if (previous !== "END" && previous !== "ELSIF") stack.push("IF"); + continue; + } + if (token.value === "LOOP") { + if (previousWordToken(tokens, index) !== "END") stack.push("LOOP"); + continue; + } + if (token.value === "CASE") { + 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]; + if (top === target || (target === "BLOCK" && top === "BLOCK")) stack.pop(); + continue; + } + } + + return stack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon"; +} + +function oraclePlSqlWords(sql: string): string[] { + return oraclePlSqlTokens(sql) + .filter((token): token is { kind: "word"; value: string } => token.kind === "word") + .map((token) => token.value); +} + +function oraclePlSqlTokens(sql: string): Array<{ kind: "word" | "semicolon"; value: string }> { + const tokens: Array<{ kind: "word" | "semicolon"; value: string }> = []; + let state: QuoteState | "lineComment" | "blockComment" = "none"; + let i = 0; + + while (i < sql.length) { + const ch = sql[i]; + const next = sql[i + 1] ?? ""; + + if (state === "lineComment") { + if (ch === "\n") state = "none"; + i += 1; + continue; + } + if (state === "blockComment") { + if (ch === "*" && next === "/") { + state = "none"; + i += 2; + continue; + } + i += 1; + continue; + } + if (state === "single") { + if (ch === "'" && next === "'") { + i += 2; + continue; + } + if (ch === "'") state = "none"; + i += 1; + continue; + } + if (state === "double") { + if (ch === '"' && next === '"') { + i += 2; + continue; + } + if (ch === '"') state = "none"; + i += 1; + continue; + } + + if (ch === "-" && next === "-") { + state = "lineComment"; + i += 2; + continue; + } + if (ch === "/" && next === "*") { + state = "blockComment"; + i += 2; + continue; + } + if (ch === "'") { + state = "single"; + i += 1; + continue; + } + if (ch === '"') { + state = "double"; + i += 1; + continue; + } + if (ch === ";") { + tokens.push({ kind: "semicolon", value: ";" }); + i += 1; + continue; + } + + const word = /^[A-Za-z_][\w$]*/.exec(sql.slice(i))?.[0]; + if (word) { + tokens.push({ kind: "word", value: word.toUpperCase() }); + i += word.length; + continue; + } + i += 1; + } + + return tokens; +} + +function previousWordToken(tokens: Array<{ kind: "word" | "semicolon"; value: string }>, index: number): string | null { + for (let i = index - 1; i >= 0; i -= 1) { + if (tokens[i].kind === "word") return tokens[i].value; + } + return null; +} + +function nextWordToken(tokens: Array<{ kind: "word" | "semicolon"; value: string }>, index: number): string | null { + for (let i = index + 1; i < tokens.length; i += 1) { + if (tokens[i].kind === "word") return tokens[i].value; + } + return null; +} + function isAtLineStart(sql: string, pos: number): boolean { for (let i = pos - 1; i >= 0; i -= 1) { const ch = sql[i]; @@ -823,6 +1015,11 @@ function isAtLineStart(sql: string, pos: number): boolean { return true; } +function isSlashLine(sql: string, pos: number): boolean { + const lineEnd = findLineEnd(sql, pos); + return sql.slice(pos, lineEnd).trim() === "/"; +} + function startsDelimiterCommand(sql: string, pos: number): boolean { const prefix = sql.slice(pos, pos + 9); return prefix.toLowerCase() === "delimiter" && (sql[pos + 9] === " " || sql[pos + 9] === "\t"); diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index ca3bba1a3..f5626a24f 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -2594,6 +2594,57 @@ SELECT 1;"; assert_eq!(find_statement_at_cursor_for_database(sql, next_cursor, DatabaseType::Oracle), "SELECT 1"); } + #[test] + fn oracle_like_current_statement_keeps_nested_dml_plsql_block_together() { + let sql = "\ +DECLARE + v_order_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_order_count + FROM \"DBX_TEST\".\"ORDERS_10K\"; + + IF v_order_count = 0 THEN + INSERT INTO \"DBX_TEST\".\"STORES\" + (\"ID\", \"STORE_CODE\", \"STORE_NAME\", \"CITY\", \"OPENED_AT\") + SELECT 10001, 'TEST_STORE_001', '测试门店', '上海', SYSDATE + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM \"DBX_TEST\".\"STORES\" WHERE \"ID\" = 10001 + ); + + INSERT INTO \"DBX_TEST\".\"PRODUCTS\" + (\"ID\", \"SKU\", \"PRODUCT_NAME\", \"CATEGORY\", \"PRICE\") + SELECT 10001, 'TEST_SKU_001', '测试商品', '测试分类', 99.90 + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM \"DBX_TEST\".\"PRODUCTS\" WHERE \"ID\" = 10001 + ); + + INSERT INTO \"DBX_TEST\".\"ORDERS_10K\" + (\"ID\", \"ORDER_NO\", \"STORE_ID\", \"PRODUCT_ID\", \"CUSTOMER_NAME\", \"QUANTITY\", \"AMOUNT\", \"ORDER_STATUS\", \"CREATED_AT\") + SELECT 10001, 'TEST_ORDER_001', 10001, 10001, '测试客户', 2, 199.80, 'PAID', SYSDATE + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM \"DBX_TEST\".\"ORDERS_10K\" WHERE \"ORDER_NO\" = 'TEST_ORDER_001' + ); + + COMMIT; + END IF; +END; +/ +SELECT 1;"; + let expected = sql.split("\n/").next().unwrap(); + let cursor = sql[..sql.find("ORDERS_10K").unwrap()].encode_utf16().count(); + let next_cursor = sql[..sql.find("SELECT 1;").unwrap()].encode_utf16().count(); + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Oracle), + vec![expected.to_string(), "SELECT 1".to_string()] + ); + assert_eq!(find_statement_at_cursor_for_database(sql, cursor, DatabaseType::Oracle), expected); + assert_eq!(find_statement_at_cursor_for_database(sql, next_cursor, DatabaseType::Oracle), "SELECT 1"); + } + #[test] fn oracle_like_split_keeps_transaction_begin_as_statement() { assert_eq!( diff --git a/packages/app-tests/sqlSemanticDiagnostics.test.ts b/packages/app-tests/sqlSemanticDiagnostics.test.ts index 975b02e29..c03556889 100644 --- a/packages/app-tests/sqlSemanticDiagnostics.test.ts +++ b/packages/app-tests/sqlSemanticDiagnostics.test.ts @@ -344,6 +344,26 @@ test("selects complete SQL statements intersecting the visible viewport for diag assert.equal(ranges[0]?.to, sql.indexOf(";\nSELECT * FROM third")); }); +test("skips Oracle PL/SQL blocks when selecting semantic diagnostic ranges", () => { + const sql = `DECLARE + v_order_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_order_count + FROM "DBX_TEST"."ORDERS_10K"; + + IF v_order_count = 0 THEN + COMMIT; + END IF; +END; +/ +SELECT * FROM "DBX_TEST"."ORDERS_10K";`; + + const ranges = sqlSemanticDiagnosticRangesForViewport(sql, [{ from: 0, to: sql.length }], "oracle"); + + assert.equal(ranges.length, 1); + assert.equal(ranges[0]?.sql, 'SELECT * FROM "DBX_TEST"."ORDERS_10K"'); +}); + test("keeps a long statement complete when only its middle is visible", () => { const sql = "SELECT id,\n name,\n missing_field\nFROM users\nWHERE id > 1;"; const visibleFrom = sql.indexOf("missing_field");