mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
🐛 fix(sql-editor): 修复大段INSERT脚本导致全局卡顿
- 对 SQL 字符串字面量和注释做屏蔽,避免对象高亮/hover 扫描 values 中的大量测试数据 - 复用候选 token 收集结果刷新对象装饰,减少无效对象解析 - 补充 INSERT 脚本候选收集和字符串内 hover 的回归测试
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user