fix: handle oracle plsql statement ranges
Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
parent
b5f40b0fd7
commit
a224a6747c
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<DatabaseType> = 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");
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue