From 4cdf18d6529ad5d8289876b77ee3eb385f25f4e6 Mon Sep 17 00:00:00 2001 From: dienaso <89768641@qq.com> Date: Fri, 18 Sep 2026 01:35:53 +0800 Subject: [PATCH] fix(sql): stop completion at the next top-level statement --- .../sql/sqlCompletion.context.spec.ts | 59 ++++++++++++++ apps/desktop/src/lib/sql/sqlCompletion.ts | 81 ++++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index d4e90b2ffd..06ce223ffa 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -1484,3 +1484,62 @@ describe("select alias visibility", () => { expect(untyped.selectAliases).toContain("cnt"); }); }); + +describe("line block statement boundary", () => { + it.each(["-- explanatory comment", "/* explanatory comment */", "/*\n explanatory comment\n*/"])("keeps column sources after %s", (comment) => { + const sql = `select na\n${comment}\nfrom users`; + const cursor = "select na".length; + const context = getSqlCompletionContext(sql, cursor, { databaseType: "iris" }); + + expect(context.referencedTables.map((table) => table.name)).toEqual(["users"]); + const items = buildSqlCompletionItems(sql, cursor, { + databaseType: "iris", + tables: [{ name: "users", type: "table" }], + columnsByTable: new Map([["users", [{ name: "name", table: "users", key: "name" } as never]]]), + }); + expect(items.some((item) => item.label === "name")).toBe(true); + }); + + it("still ends the block at a real blank line", () => { + const sql = "select na\n\nfrom users"; + const context = getSqlCompletionContext(sql, "select na".length, { databaseType: "iris" }); + expect(context.referencedTables).toEqual([]); + }); + + it("stops the active block at a top-level statement line without a semicolon", () => { + // t8y2/dbx#9370: two semicolon-free SELECT lines — completion at the first + // statement's WHERE must not pull the second statement's table in. + const sql = "select * from CT_Nation where\nselect * from CT_CityArea"; + const cursor = sql.indexOf("where") + "where".length; + const context = getSqlCompletionContext(sql, cursor, { databaseType: "iris" }); + + expect(context.referencedTables.map((table) => table.name)).toEqual(["CT_Nation"]); + }); + + it("keeps column completion unqualified once the second statement is excluded", () => { + const sql = "select * from CT_Nation where c\nselect * from CT_CityArea"; + const cursor = sql.indexOf("where") + "where c".length; + const items = buildSqlCompletionItems(sql, cursor, { + databaseType: "iris", + tables: [ + { name: "CT_Nation", type: "table" }, + { name: "CT_CityArea", type: "table" }, + ], + columnsByTable: new Map([ + ["CT_Nation", [{ name: "CTNAT_Code", table: "CT_Nation", key: "k1" } as never]], + ["CT_CityArea", [{ name: "CITAREA_Code", table: "CT_CityArea", key: "k2" } as never]], + ]), + }); + + expect(items.some((item) => item.label === "CT_CityArea.CITAREA_Code")).toBe(false); + expect(items.some((item) => item.label === "CTNAT_Code")).toBe(true); + }); + + it("does not end the block at a SELECT line inside parentheses", () => { + const sql = "select *\nfrom (\n select id from users\n) x\nwhere |"; + const cursor = sql.length; + const context = getSqlCompletionContext(sql, cursor, { databaseType: "mysql" }); + + expect(context.referencedTables.map((table) => table.name)).toEqual(expect.arrayContaining(["users"])); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 1acc492d8b..5f205c230f 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -2134,18 +2134,93 @@ function currentSqlLikeLineBlockSpan(sql: string, cursor: number, activeStatemen return { start, end: blockEnd == null ? activeStatementSpan.end : Math.min(activeStatementSpan.end, blockEnd) }; } +// Equal-length masking of string/comment characters, so parenthesis depth and +// line-start keywords can be scanned on the masked copy while offsets stay +// valid against the original text. +function maskSqlForStructureScan(sql: string): string { + let out = ""; + let i = 0; + while (i < sql.length) { + const ch = sql[i]!; + if (ch === "'" || ch === '"' || ch === "`") { + const quote = ch; + out += ch; + i += 1; + while (i < sql.length) { + if (sql[i] === "\\" && i + 1 < sql.length) { + out += " "; + i += 2; + continue; + } + const same = sql[i] === quote; + out += same ? quote : " "; + i += 1; + if (same) break; + } + continue; + } + if (ch === "-" && sql[i + 1] === "-") { + while (i < sql.length && sql[i] !== "\n") { + out += " "; + i += 1; + } + continue; + } + if (ch === "/" && sql[i + 1] === "*") { + out += " "; + i += 2; + while (i < sql.length) { + if (sql[i] === "*" && sql[i + 1] === "/") { + out += " "; + i += 2; + break; + } + out += sql[i] === "\n" ? "\n" : " "; + i += 1; + } + continue; + } + out += ch; + i += 1; + } + return out; +} + +// Statements users type without a trailing semicolon: a line starting one of +// these at top level begins a new statement block (t8y2/dbx#9370). +const STATEMENT_START_LINE_PATTERN = /^(?:select|with|insert|update|delete|create|drop|alter)\b/i; + function currentLineBlockEnd(sql: string, cursor: number, start: number): number | null { + const masked = maskSqlForStructureScan(sql); let lineStart = sql.lastIndexOf("\n", cursor - 1) + 1; + let depth = 0; + for (let i = start; i < lineStart; i += 1) { + const ch = masked[i]; + if (ch === "(") depth += 1; + else if (ch === ")") depth = Math.max(0, depth - 1); + } + let atCursorLine = true; while (lineStart < sql.length) { const lineEnd = sql.indexOf("\n", lineStart); const boundedLineEnd = lineEnd >= 0 ? lineEnd : sql.length; - const line = sql.slice(lineStart, boundedLineEnd); - const trimmed = line.trimStart(); - if (lineStart > start && (!trimmed || /^(get|post|put|delete|patch|head)\s+\//i.test(trimmed))) { + const originalTrimmed = sql.slice(lineStart, boundedLineEnd).trimStart(); + const trimmed = masked.slice(lineStart, boundedLineEnd).trimStart(); + if (lineStart > start && (!originalTrimmed || /^(get|post|put|delete|patch|head)\s+\//i.test(originalTrimmed))) { + return lineStart; + } + // The cursor's own line always belongs to the block; only a following + // top-level statement line ends it. + if (!atCursorLine && lineStart > start && depth === 0 && STATEMENT_START_LINE_PATTERN.test(trimmed)) { return lineStart; } if (lineEnd < 0) break; + for (let i = lineStart; i < boundedLineEnd; i += 1) { + const ch = masked[i]; + if (ch === "(") depth += 1; + else if (ch === ")") depth = Math.max(0, depth - 1); + } lineStart = lineEnd + 1; + atCursorLine = false; } return null; }