fix(editor): optimize large PL/SQL source rendering

Closes #5171
This commit is contained in:
t8y2 committed 2026-08-03 14:25:45 +08:00
1 parent 16920188ee
commit 043ecfeafa
7 files changed
+117 -41

No files matched your search

@@ -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 = [];
@@ -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);
@@ -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]);
});
@@ -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) {
+10 -9
View File
@@ -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);
}
+46 -26
View File
@@ -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;