🐛 fix(sql-editor): 修复大段INSERT脚本导致全局卡顿

- 对 SQL 字符串字面量和注释做屏蔽,避免对象高亮/hover 扫描 values 中的大量测试数据

- 复用候选 token 收集结果刷新对象装饰,减少无效对象解析

- 补充 INSERT 脚本候选收集和字符串内 hover 的回归测试
This commit is contained in:
Syngnat
2026-06-04 15:10:26 +08:00
parent 5b602bff75
commit 4cef232271
2 changed files with 217 additions and 53 deletions

View File

@@ -6,7 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
import QueryEditor, { resolveQueryEditorNavigationTarget } from './QueryEditor';
import QueryEditor, {
collectQueryEditorObjectDecorationCandidates,
resolveQueryEditorNavigationTarget,
} from './QueryEditor';
const storeState = vi.hoisted(() => ({
connections: [
@@ -1411,6 +1414,57 @@ describe('QueryEditor external SQL save', () => {
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
});
it('skips SQL literals when collecting object decoration candidates for insert scripts', () => {
const insertValues = Array.from({ length: 120 }, (_, index) => {
const suffix = String(index + 1).padStart(3, '0');
return `('legacy-seed-L${suffix}', '旧版企业-L${suffix}', '深圳市南山区 ${suffix} 号', 'legacy${suffix}@demo.test')`;
}).join(',\n');
const sql = [
'-- 字符串里的 fs_org_auth_file 不应参与对象装饰扫描',
'INSERT INTO mkefu_location_dev_local.uk_corp (id, corp_name, address, email) VALUES',
`${insertValues};`,
'SELECT uk_corp.id FROM uk_corp;',
].join('\n');
const candidates = collectQueryEditorObjectDecorationCandidates(sql, 1000);
const candidateTexts = candidates.map((candidate) => candidate.lineContent.slice(candidate.positionColumn - 1, candidate.positionColumn + 30));
expect(candidateTexts.some((text) => text.includes('legacy-seed'))).toBe(false);
expect(candidateTexts.some((text) => text.includes('旧版企业'))).toBe(false);
expect(candidateTexts.some((text) => text.includes('demo.test'))).toBe(false);
expect(candidateTexts.some((text) => text.includes('mkefu_location_dev_local'))).toBe(true);
expect(candidateTexts.some((text) => text.includes('uk_corp'))).toBe(true);
});
it('does not provide metadata hover inside SQL string literals', async () => {
editorState.value = "insert into users(name) values ('users.id should stay plain');";
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }],
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const hoverProvider = editorState.hoverProviders[0];
expect(hoverProvider).toBeTruthy();
const literalColumn = editorState.value.indexOf('users.id should') + 3;
const hover = hoverProvider.provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: literalColumn },
);
expect(hover).toBeNull();
});
it('registers Ctrl/Cmd+S to quick-save the active query', async () => {
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {

View File

@@ -1095,20 +1095,144 @@ const getQueryEditorObjectResolveText = (
maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH,
): string => getQueryEditorModelTextIfWithinLimit(model, maxTextLength) ?? lineContent;
const maskQueryEditorSqlLiteralsAndComments = (source: string): string => {
const text = String(source || '').replace(/\r\n/g, '\n');
if (!text) return '';
const chars = text.split('');
let inSingle = false;
let inLineComment = false;
let inBlockComment = false;
let escaped = false;
const maskAt = (index: number) => {
if (chars[index] !== '\n') {
chars[index] = ' ';
}
};
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
const next = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : '';
if (inLineComment) {
if (ch === '\n') {
inLineComment = false;
} else {
maskAt(i);
}
continue;
}
if (inBlockComment) {
maskAt(i);
if (ch === '*' && next === '/') {
maskAt(i + 1);
i += 1;
inBlockComment = false;
}
continue;
}
if (inSingle) {
maskAt(i);
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '\'' && next === '\'') {
maskAt(i + 1);
i += 1;
continue;
}
if (ch === '\'') {
inSingle = false;
}
continue;
}
if (ch === '/' && next === '*') {
maskAt(i);
maskAt(i + 1);
i += 1;
inBlockComment = true;
continue;
}
if (ch === '#') {
maskAt(i);
inLineComment = true;
continue;
}
if (ch === '-' && next === '-' && (i === 0 || /\s/.test(prev))) {
maskAt(i);
maskAt(i + 1);
i += 1;
inLineComment = true;
continue;
}
if (ch === '\'') {
maskAt(i);
inSingle = true;
}
}
return chars.join('');
};
export const collectQueryEditorObjectDecorationCandidates = (
source: string,
maxIdentifiers = QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS,
): Array<{ lineNumber: number; lineContent: string; positionColumn: number }> => {
const text = String(source || '').replace(/\r\n/g, '\n');
if (!text) return [];
const maskedText = maskQueryEditorSqlLiteralsAndComments(text);
const lines = text.split('\n');
const maskedLines = maskedText.split('\n');
const candidates: Array<{ lineNumber: number; lineContent: string; positionColumn: number }> = [];
const identifierRegex = /[`"\[]?[A-Za-z_][A-Za-z0-9_$]*(?:[`"\]]?\s*\.\s*[`"\[]?[A-Za-z_][A-Za-z0-9_$]*){0,2}[`"\]]?/g;
for (const [lineIndex, maskedLine] of maskedLines.entries()) {
let match: RegExpExecArray | null;
identifierRegex.lastIndex = 0;
while ((match = identifierRegex.exec(maskedLine)) !== null) {
candidates.push({
lineNumber: lineIndex + 1,
lineContent: lines[lineIndex] || '',
positionColumn: match.index + 2,
});
if (candidates.length >= maxIdentifiers) {
return candidates;
}
}
}
return candidates;
};
const findIdentifierWindowAtOffset = (
lineContent: string,
rawOffset: number,
): { start: number; end: number } | null => {
const text = String(lineContent || '');
if (!text) return null;
const searchableText = maskQueryEditorSqlLiteralsAndComments(text);
const maxIndex = text.length - 1;
if (maxIndex < 0) return null;
let offset = Math.max(0, Math.min(maxIndex, Number.isFinite(rawOffset) ? rawOffset : 0));
if (!QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset] || '')) {
if (offset > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset - 1] || '')) {
if (!QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(searchableText[offset] || '')) {
if (offset > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(searchableText[offset - 1] || '')) {
offset -= 1;
} else if (offset < maxIndex && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset + 1] || '')) {
} else if (offset < maxIndex && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(searchableText[offset + 1] || '')) {
offset += 1;
} else {
return null;
@@ -1116,12 +1240,12 @@ const findIdentifierWindowAtOffset = (
}
let start = offset;
while (start > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[start - 1] || '')) {
while (start > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(searchableText[start - 1] || '')) {
start -= 1;
}
let end = offset + 1;
while (end < text.length && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[end] || '')) {
while (end < text.length && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(searchableText[end] || '')) {
end += 1;
}
@@ -2042,55 +2166,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const decorations: any[] = [];
const seen = new Set<string>();
let scannedIdentifiers = 0;
const identifierRegex = /[`"\[]?[A-Za-z_][A-Za-z0-9_$]*(?:[`"\]]?\s*\.\s*[`"\[]?[A-Za-z_][A-Za-z0-9_$]*){0,2}[`"\]]?/g;
const lines = text.replace(/\r\n/g, '\n').split('\n');
const candidates = collectQueryEditorObjectDecorationCandidates(text);
for (const [lineIndex, lineContent] of lines.entries()) {
let match: RegExpExecArray | null;
identifierRegex.lastIndex = 0;
while ((match = identifierRegex.exec(lineContent)) !== null) {
scannedIdentifiers += 1;
if (scannedIdentifiers > QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS) {
break;
}
const positionColumn = match.index + 2;
const hoverTarget = resolveQueryEditorHoverTarget(
text,
lineContent,
positionColumn,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
allColumnsRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
);
if (!hoverTarget) continue;
for (const candidate of candidates) {
const hoverTarget = resolveQueryEditorHoverTarget(
text,
candidate.lineContent,
candidate.positionColumn,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
allColumnsRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
);
if (!hoverTarget) continue;
const inlineClassName = hoverTarget.kind === 'column'
? 'gonavi-query-editor-column-token'
: hoverTarget.kind === 'database'
? 'gonavi-query-editor-db-token'
: 'gonavi-query-editor-object-token';
const key = `${lineIndex + 1}:${hoverTarget.range.startColumn}:${hoverTarget.range.endColumn}:${inlineClassName}`;
if (seen.has(key)) continue;
seen.add(key);
decorations.push({
range: new monaco.Range(
lineIndex + 1,
hoverTarget.range.startColumn,
lineIndex + 1,
hoverTarget.range.endColumn,
),
options: { inlineClassName },
});
}
if (scannedIdentifiers > QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS) {
break;
}
const inlineClassName = hoverTarget.kind === 'column'
? 'gonavi-query-editor-column-token'
: hoverTarget.kind === 'database'
? 'gonavi-query-editor-db-token'
: 'gonavi-query-editor-object-token';
const key = `${candidate.lineNumber}:${hoverTarget.range.startColumn}:${hoverTarget.range.endColumn}:${inlineClassName}`;
if (seen.has(key)) continue;
seen.add(key);
decorations.push({
range: new monaco.Range(
candidate.lineNumber,
hoverTarget.range.startColumn,
candidate.lineNumber,
hoverTarget.range.endColumn,
),
options: { inlineClassName },
});
}
objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, decorations);