diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 90d6c8388f..be5021142f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -14,7 +14,7 @@ import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard"; import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget"; import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; -import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameRangeTo, shouldRebuildCurrentStatementFrame, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints"; import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns"; import { formatSqlText, compressSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; @@ -3932,12 +3932,20 @@ onMounted(async () => { const currentStatementFrameHighlighter = ViewPlugin.fromClass( class { decorations: import("@codemirror/view").DecorationSet; + private configuration: string; constructor(view: import("@codemirror/view").EditorView) { + this.configuration = this.currentConfiguration(); this.decorations = this.getDeco(view); } update(update: import("@codemirror/view").ViewUpdate) { + const configuration = this.currentConfiguration(); + if (!shouldRebuildCurrentStatementFrame({ docChanged: update.docChanged, selectionSet: update.selectionSet, configurationChanged: configuration !== this.configuration })) return; + this.configuration = configuration; this.decorations = this.getDeco(update.view); } + currentConfiguration() { + return `${settingsStore.editorSettings.showCurrentStatementFrame}:${settingsStore.editorSettings.showInsertValueHints}:${props.databaseType ?? ""}`; + } getDeco(view: import("@codemirror/view").EditorView) { if (!settingsStore.editorSettings.showCurrentStatementFrame) return Decoration.none; if (view.state.selection.ranges.some((range) => !range.empty)) return Decoration.none; @@ -3950,10 +3958,13 @@ onMounted(async () => { let insertValueHints: Array<{ from: number; column: string }> = []; try { if (settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch") { - insertValueHints = parseInsertValueHints(view.state.doc.sliceString(range.from, range.to), { resolveTableColumns: getInsertValueHintTableColumns }).map((hint) => ({ - ...hint, - from: hint.from + range.from, - })); + const statementSql = view.state.doc.sliceString(range.from, range.to); + if (/\binsert\b/i.test(statementSql)) { + insertValueHints = parseInsertValueHints(statementSql, { resolveTableColumns: getInsertValueHintTableColumns }).map((hint) => ({ + ...hint, + from: hint.from + range.from, + })); + } } } catch { insertValueHints = []; diff --git a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts index 314e262d74..a292fc82d3 100644 --- a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { currentStatementFrameRangeTo, estimateInlineHintVisualColumns, isWideSqlChar, visualSqlColumns, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameRangeTo, estimateInlineHintVisualColumns, isWideSqlChar, shouldRebuildCurrentStatementFrame, visualSqlColumns, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges"; describe("currentStatementFrameRangeTo", () => { @@ -14,6 +14,18 @@ describe("currentStatementFrameRangeTo", () => { }); }); +describe("shouldRebuildCurrentStatementFrame", () => { + it("reuses frame decorations for pure viewport updates", () => { + expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: false, configurationChanged: false })).toBe(false); + }); + + it("rebuilds after document, selection, or configuration updates", () => { + expect(shouldRebuildCurrentStatementFrame({ docChanged: true, selectionSet: false, configurationChanged: false })).toBe(true); + expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: true, configurationChanged: false })).toBe(true); + expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: false, configurationChanged: true })).toBe(true); + }); +}); + describe("visualSqlColumns", () => { it("counts ASCII as one column, tabs as four, and CJK/fullwidth characters as two", () => { expect(visualSqlColumns("A\t中B")).toBe(1 + 4 + 2 + 2); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index c3c11627a0..24b31611ae 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -346,6 +346,24 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(oracleIssue2405PlSql, "oracle"))).toEqual([oracleIssue2405PlSql]); }); + it("splits large Dameng package bodies without repeated prefix parsing", () => { + const body = Array.from({ length: 3000 }, (_, index) => ` v_value := v_value + ${index % 10};`).join("\n"); + const packageBody = `CREATE OR REPLACE PACKAGE BODY app.big_pkg AS + PROCEDURE run IS + v_value NUMBER := 0; + BEGIN +${body} + END run; +END big_pkg;`; + const sql = `${packageBody}\n/\nSELECT 1;`; + const startedAt = performance.now(); + const ranges = splitSqlStatementRanges(sql, "dameng"); + const elapsedMs = performance.now() - startedAt; + + expect(rangeSqlTexts(ranges)).toEqual([packageBody, "SELECT 1"]); + expect(elapsedMs).toBeLessThan(1000); + }); + it("keeps nested GaussDB procedure blocks together", () => { expect(rangeSqlTexts(splitSqlStatementRanges(gaussDbNestedProcedure, "gaussdb"))).toEqual([gaussDbNestedProcedure]); }); diff --git a/apps/desktop/src/lib/sql/currentStatementFrame.ts b/apps/desktop/src/lib/sql/currentStatementFrame.ts index ae23e3b8ae..a1ea5021a9 100644 --- a/apps/desktop/src/lib/sql/currentStatementFrame.ts +++ b/apps/desktop/src/lib/sql/currentStatementFrame.ts @@ -4,6 +4,10 @@ export function currentStatementFrameRangeTo(nextChar: string, range: SqlTextRan return nextChar === ";" ? range.to + 1 : range.to; } +export function shouldRebuildCurrentStatementFrame(update: { docChanged: boolean; selectionSet: boolean; configurationChanged: boolean }): boolean { + return update.docChanged || update.selectionSet || update.configurationChanged; +} + export function visualSqlColumns(text: string): number { let columns = 0; for (const ch of text) { diff --git a/apps/desktop/src/lib/sql/insertValueHints.ts b/apps/desktop/src/lib/sql/insertValueHints.ts index 39e75af63c..9e4ffd3b23 100644 --- a/apps/desktop/src/lib/sql/insertValueHints.ts +++ b/apps/desktop/src/lib/sql/insertValueHints.ts @@ -33,18 +33,21 @@ function significantTokens(tokens: readonly SqlSemanticToken[]): SqlSemanticToke return tokens.filter((item) => item.kind !== "comment"); } -function statementSpans(sql: string, tokens: readonly SqlSemanticToken[]): SqlSemanticSpan[] { - const spans: SqlSemanticSpan[] = []; +function statementTokenGroups(sql: string, tokens: readonly SqlSemanticToken[]): Array<{ span: SqlSemanticSpan; tokens: SqlSemanticToken[] }> { + const groups: Array<{ span: SqlSemanticSpan; tokens: SqlSemanticToken[] }> = []; let start = 0; - for (const item of tokens) { + let tokenStart = 0; + for (let index = 0; index < tokens.length; index += 1) { + const item = tokens[index]!; if (item.kind !== "punctuation" || item.text !== ";" || item.depth !== 0) continue; const span = trimSpan(sql, start, item.span.start); - if (span.end > span.start) spans.push(span); + if (span.end > span.start) groups.push({ span, tokens: significantTokens(tokens.slice(tokenStart, index)) }); start = item.span.end; + tokenStart = index + 1; } const last = trimSpan(sql, start, sql.length); - if (last.end > last.start) spans.push(last); - return spans; + if (last.end > last.start) groups.push({ span: last, tokens: significantTokens(tokens.slice(tokenStart)) }); + return groups; } function trimSpan(sql: string, start: number, end: number): SqlSemanticSpan { @@ -444,10 +447,8 @@ export function parseInsertValuesClausesInRanges(sql: string, ranges: readonly T export function parseInsertValuesClauses(sql: string): InsertValuesClause[] { if (!sql.trim()) return []; const allTokens = tokenizeSqlSemantic(sql); - const spans = statementSpans(sql, allTokens); const clauses: InsertValuesClause[] = []; - for (const span of spans) { - const tokens = significantTokens(tokensInSpan(allTokens, span)); + for (const { span, tokens } of statementTokenGroups(sql, allTokens)) { const clause = parseInsertClause(tokens, span); if (clause) clauses.push(clause); } diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 0642129d7a..2ac14e70ac 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -300,6 +300,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType let state: QuoteState = "none"; let dollarTag = ""; let postgresDollarQuotedRoutine = false; + let oraclePlSqlStatementEnd: number | null | undefined; let i = 0; const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; @@ -317,6 +318,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType statementEnd = -1; pendingHintStart = -1; postgresDollarQuotedRoutine = false; + oraclePlSqlStatementEnd = undefined; return; } const trimmedTo = trimRangeEnd(sql, statementStart, to); @@ -327,6 +329,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType statementEnd = -1; pendingHintStart = -1; postgresDollarQuotedRoutine = false; + oraclePlSqlStatementEnd = undefined; }; while (i < len) { @@ -512,12 +515,16 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType // Internal semicolons remain part of the routine body. flush(); } else { - const statementSoFar = statementStart === -1 ? "" : sql.slice(statementStart, i); - const isOraclePlSql = isOracleLikeDatabase(databaseType) && !postgresDollarQuotedRoutine && statementStart !== -1 && startsWithOraclePlSqlBlock(statementSoFar); - const isSapHanaScriptBlock = isSapHanaScriptBlockDatabase(databaseType) && statementStart !== -1 && startsWithSapHanaScriptBlock(statementSoFar); + if (oraclePlSqlStatementEnd === undefined && isOracleLikeDatabase(databaseType) && !postgresDollarQuotedRoutine && statementStart !== -1) { + const statementSoFar = sql.slice(statementStart, i); + oraclePlSqlStatementEnd = startsWithOraclePlSqlBlock(statementSoFar) ? statementStart + (oraclePlSqlBlockEnd(sql.slice(statementStart)) ?? sql.length - statementStart) : null; + } + const resolvedOraclePlSqlStatementEnd = oraclePlSqlStatementEnd; + const isOraclePlSql = typeof resolvedOraclePlSqlStatementEnd === "number"; + const isSapHanaScriptBlock = isSapHanaScriptBlockDatabase(databaseType) && statementStart !== -1 && startsWithSapHanaScriptBlock(sql.slice(statementStart, i)); if (isOraclePlSql || isSapHanaScriptBlock) { markContent(i); - if (isOraclePlSql && !oraclePlSqlBlockIsComplete(sql.slice(statementStart, i + 1))) { + if (isOraclePlSql && i + 1 < resolvedOraclePlSqlStatementEnd) { i += 1; continue; } @@ -1638,7 +1645,10 @@ function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions) } function startsWithOraclePlSqlBlock(sql: string): boolean { - const words = oraclePlSqlWords(sql); + return startsWithOraclePlSqlBlockWords(oraclePlSqlWords(sql)); +} + +function startsWithOraclePlSqlBlockWords(words: readonly string[]): boolean { const first = words[0]; if (!first) return false; if (ORACLE_PL_SQL_BLOCK_STARTERS.has(first)) return first !== "BEGIN" || words[1] !== "TRANSACTION"; @@ -1713,17 +1723,23 @@ function sapHanaScriptBlockIsComplete(sql: string): boolean { return sawBegin && stack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon"; } -function oraclePlSqlBlockIsComplete(sql: string): boolean { +function oraclePlSqlBlockEnd(sql: string): number | null { const tokens = oraclePlSqlTokens(sql); - if (!startsWithOraclePlSqlBlock(sql)) return false; + const words = oraclePlSqlWordValues(tokens); + if (!startsWithOraclePlSqlBlockWords(words)) return null; // 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 objectKind = oraclePlSqlCreateObjectKind(words); 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 === "semicolon") { + const complete = objectKind === "spec" ? stack.length === 0 : sawBegin && stack.length === 0; + if (complete) return token.to; + continue; + } if (token.kind !== "word") continue; if (token.value === "DECLARE") { @@ -1769,12 +1785,7 @@ function oraclePlSqlBlockIsComplete(sql: string): boolean { } } - 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; + return null; } /** @@ -1783,8 +1794,7 @@ function oraclePlSqlBlockIsComplete(sql: string): boolean { * - 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); +function oraclePlSqlCreateObjectKind(words: readonly string[]): "body" | "spec" | null { if (words[0] !== "CREATE") return null; const index = skipOraclePlSqlCreateModifiers(words, 1); @@ -1799,13 +1809,22 @@ function oraclePlSqlCreateObjectKind(sql: string): "body" | "spec" | null { } function oraclePlSqlWords(sql: string): string[] { - return oraclePlSqlTokens(sql) - .filter((token): token is { kind: "word"; value: string } => token.kind === "word") - .map((token) => token.value); + return oraclePlSqlWordValues(oraclePlSqlTokens(sql)); } -function oraclePlSqlTokens(sql: string): Array<{ kind: "word" | "semicolon"; value: string }> { - const tokens: Array<{ kind: "word" | "semicolon"; value: string }> = []; +interface OraclePlSqlToken { + kind: "word" | "semicolon"; + value: string; + from: number; + to: number; +} + +function oraclePlSqlWordValues(tokens: readonly OraclePlSqlToken[]): string[] { + return tokens.filter((token) => token.kind === "word").map((token) => token.value); +} + +function oraclePlSqlTokens(sql: string): OraclePlSqlToken[] { + const tokens: OraclePlSqlToken[] = []; let state: QuoteState | "lineComment" | "blockComment" = "none"; let i = 0; @@ -1867,15 +1886,16 @@ function oraclePlSqlTokens(sql: string): Array<{ kind: "word" | "semicolon"; val continue; } if (ch === ";") { - tokens.push({ kind: "semicolon", value: ";" }); + tokens.push({ kind: "semicolon", value: ";", from: i, to: i + 1 }); 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; + if (/[A-Za-z_]/.test(ch)) { + const from = i; + i += 1; + while (i < sql.length && /[A-Za-z0-9_$]/.test(sql[i] ?? "")) i += 1; + tokens.push({ kind: "word", value: sql.slice(from, i).toUpperCase(), from, to: i }); continue; } i += 1; diff --git a/packages/app-tests/insertValueHints.test.ts b/packages/app-tests/insertValueHints.test.ts index 982bd348d5..f9825ca31c 100644 --- a/packages/app-tests/insertValueHints.test.ts +++ b/packages/app-tests/insertValueHints.test.ts @@ -219,6 +219,16 @@ test("ignores statements that are not INSERT VALUES", () => { assert.deepEqual(parseInsertValueHints(sql), []); }); +test("scans large procedural sources without repeatedly filtering all tokens", () => { + const sql = Array.from({ length: 6000 }, (_, index) => `v_value := v_value + ${index % 10};`).join("\n"); + const startedAt = performance.now(); + const hints = parseInsertValueHints(sql); + const elapsedMs = performance.now() - startedAt; + + assert.deepEqual(hints, []); + assert.ok(elapsedMs < 1000, `insert hint scan took ${elapsedMs.toFixed(1)}ms`); +}); + test("buildInsertValueHints skips unresolved tables without metadata", () => { const clauses = parseInsertValuesClauses("INSERT INTO mystery VALUES (1, 2)"); assert.deepEqual(buildInsertValueHints(clauses), []);