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);