diff --git a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts index 1627f5ced8..0ea6b1458e 100644 --- a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts @@ -103,6 +103,40 @@ describe("executableStatementRangeCacheForDoc", () => { expect(executableStatementRangeAtCursor(cache, delimiterCursor + 1)?.sql).toBe("SELECT *\nFROM users"); }); + it("resolves a trailing-whitespace cursor on a multi-line statement tail to that statement", () => { + const sql = "WITH x AS (SELECT 1)\nSELECT 2 \nSELECT 3;"; + const doc = Text.of(sql.split("\n")); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + + expect(executableStatementRangeAtCursor(cache, contentEnd)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + expect(executableStatementRangeAtCursor(cache, contentEnd + 1)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + expect(executableStatementRangeAtCursor(cache, contentEnd + 2)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + }); + + it("keeps the simple single-line trailing-whitespace case on the current statement", () => { + const sql = "SELECT 1;\nSELECT 2 \nSELECT 3;"; + const doc = Text.of(sql.split("\n")); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + + expect(executableStatementRangeAtCursor(cache, contentEnd)?.sql).toBe("SELECT 2"); + expect(executableStatementRangeAtCursor(cache, contentEnd + 1)?.sql).toBe("SELECT 2"); + }); + + it("keeps a trailing-whitespace cursor on a non-final line of a multi-line statement in the frame cache", () => { + const sql = "SELECT * FROM `profiles`;\nSELECT * FROM `users` AS uu \nWHERE id = 2\nSELECT * FROM `orders`;"; + const doc = Text.of(sql.split("\n")); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + const markerEnd = sql.indexOf("uu") + 2; + const lineEnd = sql.indexOf("\n", markerEnd); + const expected = "SELECT * FROM `users` AS uu \nWHERE id = 2"; + + for (let pos = markerEnd; pos < lineEnd; pos += 1) { + expect(executableStatementRangeAtCursor(cache, pos)?.sql).toBe(expected); + } + }); + it("does not attach a semicolon after a blank line to the previous statement", () => { const sql = "SELECT 1\n\n;"; const doc = Text.of(sql.split("\n")); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 8c80424d90..ddb51131ae 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -29,6 +29,23 @@ function candidateSummaries(candidates: Array<{ kind: string; sql: string }>): s return candidates.map((candidate) => `${candidate.kind}:${candidate.sql.trim()}`); } +function trailingSpacePositionsAfter(sql: string, marker: string): number[] { + const markerEnd = sql.indexOf(marker) + marker.length; + const lineEnd = sql.indexOf("\n", markerEnd); + const end = lineEnd === -1 ? sql.length : lineEnd; + const positions: number[] = []; + for (let p = markerEnd; p < end; p += 1) { + if (sql[p] === " " || sql[p] === "\t") positions.push(p); + } + return positions; +} + +// Reported repro: trailing spaces sit after `AS uu` (a NON-final line of the +// middle statement) with `WHERE id = 2` continuing on the next line. The cursor +// is inside the statement body, so the middle statement must be kept. +const screenshotSqlNoSemi = "SELECT * FROM `profiles`\nWHERE active = 1\nSELECT * FROM `users` AS uu \nWHERE id = 2\nSELECT * FROM `orders`\nWHERE status = 'paid'"; +const screenshotSqlWithSemi = "SELECT * FROM `profiles`;\nSELECT * FROM `users` AS uu \nWHERE id = 2\nSELECT * FROM `orders`;"; + const oraclePlSqlFixture = `DECLARE v_order_count NUMBER; BEGIN @@ -582,6 +599,70 @@ GET /_cat/indices`; expect(statementRangeAtCursor(sql, indexOf(sql, "tbB"))?.sql.trim()).toBe(expected); }); + it("keeps a trailing-whitespace cursor on a multi-line WITH statement tail", () => { + const sql = "WITH x AS (SELECT 1)\nSELECT 2 \nSELECT 3;"; + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + + for (const pos of [contentEnd, contentEnd + 1, contentEnd + 2]) { + expect(statementRangeAtCursor(sql, pos)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + } + }); + + it("keeps a trailing-whitespace cursor on a UNION operand tail", () => { + const sql = "SELECT 1\nUNION\nSELECT 2 \nSELECT 3;"; + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + + expect(statementRangeAtCursor(sql, contentEnd + 1)?.sql).toBe("SELECT 1\nUNION\nSELECT 2"); + expect(statementRangeAtCursor(sql, contentEnd + 2)?.sql).toBe("SELECT 1\nUNION\nSELECT 2"); + }); + + it("keeps a trailing-whitespace cursor on an EXPLAIN target tail", () => { + const sql = "EXPLAIN\nSELECT 2 \nSELECT 3;"; + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + + expect(statementRangeAtCursor(sql, contentEnd + 1)?.sql).toBe("EXPLAIN\nSELECT 2"); + }); + + it("does not swallow the following statement when a trailing-whitespace cursor sits on a semicolon-terminated multi-line statement tail", () => { + const sql = "SELECT 0;\nWITH x AS (SELECT 1)\nSELECT 2 \nSELECT 3;"; + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + + expect(statementRangeAtCursor(sql, contentEnd + 1)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + expect(statementRangeAtCursor(sql, contentEnd + 2)?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + }); + + it("keeps a trailing-whitespace cursor on a non-final line of a multi-line middle statement (no semicolons before)", () => { + // Reported repro: spaces after `AS uu`, `WHERE id = 2` follows on the next line. + const expected = "SELECT * FROM `users` AS uu \nWHERE id = 2"; + const positions = trailingSpacePositionsAfter(screenshotSqlNoSemi, "uu"); + + expect(positions.length).toBeGreaterThan(0); + for (const pos of positions) { + expect(statementRangeAtCursor(screenshotSqlNoSemi, pos, "mysql")?.sql).toBe(expected); + } + }); + + it("keeps a trailing-whitespace cursor on a non-final line of a multi-line middle statement (semicolons before)", () => { + const expected = "SELECT * FROM `users` AS uu \nWHERE id = 2"; + const positions = trailingSpacePositionsAfter(screenshotSqlWithSemi, "uu"); + + expect(positions.length).toBeGreaterThan(0); + for (const pos of positions) { + expect(statementRangeAtCursor(screenshotSqlWithSemi, pos, "mysql")?.sql).toBe(expected); + } + }); + + it("keeps a trailing-whitespace cursor on the last line of a multi-line middle statement", () => { + const sql = "SELECT 1;\nSELECT * FROM `users` AS uu\nWHERE id = 2 \nSELECT * FROM `orders`;"; + const expected = "SELECT * FROM `users` AS uu\nWHERE id = 2"; + const positions = trailingSpacePositionsAfter(sql, "= 2"); + + expect(positions.length).toBeGreaterThan(0); + for (const pos of positions) { + expect(statementRangeAtCursor(sql, pos, "mysql")?.sql).toBe(expected); + } + }); + it("keeps newline set-operation operands with ALL modifiers together", () => { const sql = "select * from tbA\nunion all\nselect * from tbB\nSELECT * FROM logs;"; const range = statementRangeAtCursor(sql, indexOf(sql, "tbA")); @@ -1175,6 +1256,51 @@ WHERE t2.product_name = '12345' expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 2", "all:SELECT 1\nSELECT 2"]); }); + it("uses the multi-line cursor statement at a trailing-whitespace tail instead of falling back to the whole script", () => { + const sql = "WITH x AS (SELECT 1)\nSELECT 2 \nSELECT 3;"; + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + const candidates = buildExecutionCandidates(sql, contentEnd + 1, "mysql"); + + expect(candidateKinds(candidates)).toEqual(["cursor", "all"]); + expect(candidates[0].sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + expect(executionCandidateForMode(candidates, "current")?.sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + }); + + it("uses the cursor statement at a semicolon-terminated multi-line tail instead of swallowing the following statement", () => { + const sql = "SELECT 0;\nWITH x AS (SELECT 1)\nSELECT 2 \nSELECT 3;"; + const contentEnd = sql.indexOf("SELECT 2") + "SELECT 2".length; + const candidates = buildExecutionCandidates(sql, contentEnd + 2, "mysql"); + + expect(candidates[0].kind).toBe("cursor"); + expect(candidates[0].sql).toBe("WITH x AS (SELECT 1)\nSELECT 2"); + }); + + it("submits only the middle statement for a trailing-whitespace cursor on its non-final line (no semicolons before)", () => { + const expected = "SELECT * FROM `users` AS uu \nWHERE id = 2"; + const pos = trailingSpacePositionsAfter(screenshotSqlNoSemi, "uu")[0]; + const candidates = buildExecutionCandidates(screenshotSqlNoSemi, pos, "mysql"); + + expect(executionCandidateForMode(candidates, "current")?.sql).toBe(expected); + expect(executionCandidateForMode(candidates, "all")?.sql).toBe(screenshotSqlNoSemi); + }); + + it("submits only the middle statement for a trailing-whitespace cursor on its non-final line (semicolons before)", () => { + const expected = "SELECT * FROM `users` AS uu \nWHERE id = 2"; + const pos = trailingSpacePositionsAfter(screenshotSqlWithSemi, "uu")[0]; + const candidates = buildExecutionCandidates(screenshotSqlWithSemi, pos, "mysql"); + + expect(executionCandidateForMode(candidates, "current")?.sql).toBe(expected); + }); + + it("submits only the middle statement, not the merged script, for a trailing-whitespace cursor on its last line", () => { + const sql = "SELECT 1;\nSELECT * FROM `users` AS uu\nWHERE id = 2 \nSELECT * FROM `orders`;"; + const expected = "SELECT * FROM `users` AS uu\nWHERE id = 2"; + const pos = trailingSpacePositionsAfter(sql, "= 2")[0]; + const candidates = buildExecutionCandidates(sql, pos, "mysql"); + + expect(executionCandidateForMode(candidates, "current")?.sql).toBe(expected); + }); + it("uses the final soft statement when the cursor is on a standalone trailing semicolon", () => { const sql = "SELECT * FROM `t_0001`\nSELECT * FROM `t_0001` LIMIT 1\n;"; const candidates = buildExecutionCandidates(sql, sql.lastIndexOf(";"), "mysql"); diff --git a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts index 6cbac76518..f8d99b1ef5 100644 --- a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts +++ b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts @@ -62,8 +62,9 @@ export function executableStatementRangeAtCursor(cache: ExecutableStatementRange } const next = cache.ranges[index + 1]; - if (pos > range.to && (!next || pos < next.from) && cursorBelongsToTrailingStatementDelimiter(cache.doc, range.to, pos)) { - return range; + if (pos > range.to && (!next || pos < next.from)) { + if (cursorBelongsToTrailingStatementDelimiter(cache.doc, range.to, pos)) return range; + if (isCursorOnRangeEndLine(cache.doc, pos, range.to)) return range; } } @@ -81,3 +82,8 @@ function isCursorOnLeadingBlockComment(lineText: string, lineOffset: number): bo return lineOffset <= commentEnd + 2; } + +function isCursorOnRangeEndLine(doc: Text, pos: number, rangeTo: number): boolean { + const line = doc.lineAt(pos); + return rangeTo >= line.from && rangeTo <= line.to; +} diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index c53e3c6976..7bf61f707e 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -597,7 +597,7 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT if (pos > statement.to && (!next || pos < next.hitFrom)) { const softRange = rangeForCursorInSoftRanges(sql, softRanges, pos); if (softRange) return softRange; - if (isCursorOnStatementLine(sql, pos, statement)) return rangeFor(statement, sql); + if (isCursorOnRangeEndLine(sql, pos, statement)) return rangeFor(statement, sql); } } @@ -639,7 +639,7 @@ function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: nu } const next = ranges[index + 1]; - if (pos > range.to && (!next || pos < next.hitFrom) && isCursorOnStatementLine(sql, pos, range)) { + if (pos > range.to && (!next || pos < next.hitFrom) && isCursorOnRangeEndLine(sql, pos, range)) { return rangeFor(range, sql); } } @@ -1992,6 +1992,13 @@ function isCursorOnStatementLine(sql: string, pos: number, statement: Pick= lineStart && statement.from <= lineEnd; } +function isCursorOnRangeEndLine(sql: string, pos: number, range: Pick): boolean { + const lineStart = sql.lastIndexOf("\n", pos - 1) + 1; + let lineEnd = sql.indexOf("\n", pos); + if (lineEnd === -1) lineEnd = sql.length; + return range.to >= lineStart && range.to <= lineEnd; +} + /** * Returns the full document as a range, or `null` when it is empty/whitespace. */