From 4cef23227178c6953ca66f42f74889735c99a052 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 4 Jun 2026 15:10:26 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sql-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=A7=E6=AE=B5INSERT=E8=84=9A=E6=9C=AC=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=85=A8=E5=B1=80=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对 SQL 字符串字面量和注释做屏蔽,避免对象高亮/hover 扫描 values 中的大量测试数据 - 复用候选 token 收集结果刷新对象装饰,减少无效对象解析 - 补充 INSERT 脚本候选收集和字符串内 hover 的回归测试 --- .../QueryEditor.external-sql-save.test.tsx | 56 ++++- frontend/src/components/QueryEditor.tsx | 214 +++++++++++++----- 2 files changed, 217 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 67b8022..27ac840 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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(); + }); + 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 void)[]> = {}; vi.stubGlobal('window', { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 320745d..8449fe5 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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(); - 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);