diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index e9716c80..6a44101c 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -3013,6 +3013,28 @@ describe('QueryEditor external SQL save', () => { tableName: 'dbo.orders', schemaName: 'dbo', }); + // MySQL 跨库手写 db.table:库不在可见列表时,只要元数据已加载也应可跳转 + expect(resolveQueryEditorNavigationTarget( + 'select * from front_end_sys_new.fs_mkefu_regist_record', + 'select * from front_end_sys_new.fs_mkefu_regist_record'.length, + 'mkefu_test_new', + ['mkefu_test_new'], + [ + { dbName: 'mkefu_test_new', tableName: 'uk_back_corp' }, + { dbName: 'front_end_sys_new', tableName: 'fs_mkefu_regist_record' }, + ], + [], + [], + [], + [], + [], + [], + )).toEqual({ + type: 'table', + dbName: 'front_end_sys_new', + tableName: 'fs_mkefu_regist_record', + schemaName: undefined, + }); expect(resolveQueryEditorNavigationTarget('use analytics', 6, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines, sequences, packages)).toEqual({ type: 'database', dbName: 'analytics', @@ -8143,7 +8165,8 @@ describe('QueryEditor external SQL save', () => { readOnly: false, }); expect(dataGridState.latestProps?.readOnly).toBe(false); - expect(dataGridState.latestProps?.showRowNumberColumn).toBe(true); + // 行号改由 appearance.showDataTableRowNumber 控制,不再按数据源硬编码写入结果集 + expect(dataGridState.latestProps?.showRowNumberColumn).toBeUndefined(); expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({ sql: 'SELECT * FROM EDC_LOG', status: 'success', diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index a143e94d..27a12ee7 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -10,7 +10,7 @@ import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiInTransaction, DB import { GONAVI_ROW_KEY } from './DataGrid'; import { EventsOn } from '../../wailsjs/runtime'; import { findConnectionMutatingStatements } from '../utils/connectionReadOnly'; -import { getDataSourceCapabilities, shouldShowOceanBaseRowNumberColumn } from '../utils/dataSourceCapabilities'; +import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, normalizeShortcutCombo, resolveShortcutBinding } from "../utils/shortcuts"; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; @@ -1209,6 +1209,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense const metadataFetchKeyRef = useRef(''); const metadataContextKeyRef = useRef(''); + /** SQL 中引用到的库集合变化时触发跨库元数据补拉(供超链接/补全) */ + const [sqlReferencedMetadataKey, setSqlReferencedMetadataKey] = useState(''); + const sqlReferencedMetadataTimerRef = useRef(null); + const lastSqlReferencedMetadataKeyRef = useRef(''); const connections = useStore(state => state.connections); const queryCapableConnections = useMemo( @@ -2656,6 +2660,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } let cancelled = false; + // 仅在本次 effect 成功完成后写入;中途 cancel 不得留下 key,否则同 key 永远不再拉取 → 超链接全灭 + let activeFetchKey = ''; const fetchMetadata = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; @@ -2683,10 +2689,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ); const metadataFetchKey = [ currentConnectionId, - ...metadataDbNames.map((dbName) => String(dbName || '').toLowerCase()), + ...metadataDbNames.map((dbName) => String(dbName || '').toLowerCase()).sort(), ].join('\u0000'); - if (metadataFetchKeyRef.current === metadataFetchKey) return; - metadataFetchKeyRef.current = metadataFetchKey; + const hasCurrentDbTables = tablesRef.current.some( + (table) => String(table.dbName || '').trim().toLowerCase() === metadataDbName.toLowerCase(), + ); + if (metadataFetchKeyRef.current === metadataFetchKey && hasCurrentDbTables) { + // 已成功拉过同一批库且当前库表仍在:只刷新装饰 + refreshObjectDecorations(); + return; + } + // key 相同但表为空(中途 cancel / 异常):允许重拉 + activeFetchKey = metadataFetchKey; const allTables: CompletionTableMeta[] = []; const allColumns: CompletionColumnMeta[] = []; @@ -2935,13 +2949,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } if (!syncMetadataSnapshot()) return; + // 成功完成后才固化 key,避免 cancel 后同 key 被误判为「已完成」 + metadataFetchKeyRef.current = activeFetchKey; + lastSqlReferencedMetadataKeyRef.current = activeFetchKey; refreshObjectDecorations(); }; void fetchMetadata(); return () => { cancelled = true; }; - }, [autoFetchVisible, currentConnectionId, currentDb, connections, isActive, isObjectEditQueryTab, refreshObjectDecorations]); + }, [ + autoFetchVisible, + currentConnectionId, + currentDb, + connections, + isActive, + isObjectEditQueryTab, + refreshObjectDecorations, + sqlReferencedMetadataKey, + ]); // Query ID management helpers const setQueryId = (id: string) => { @@ -4180,6 +4206,33 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (hasSlashCommandMarker) { refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH); } + // SQL 文本变更后,按引用库集合防抖触发跨库元数据拉取(db.table / schema.table / db.schema.table) + if (sqlReferencedMetadataTimerRef.current !== null) { + window.clearTimeout(sqlReferencedMetadataTimerRef.current); + } + sqlReferencedMetadataTimerRef.current = window.setTimeout(() => { + sqlReferencedMetadataTimerRef.current = null; + if (editorRef.current !== editor) { + return; + } + const modelText = String(editor.getModel?.()?.getValue?.() || ''); + const referencedDbs = collectQueryEditorReferencedDatabaseNames( + modelText, + currentDbRef.current || '', + visibleDbsRef.current, + ); + const nextKey = [ + String(currentConnectionIdRef.current || '').trim(), + ...referencedDbs.map((dbName) => String(dbName || '').toLowerCase()).sort(), + ].join('\u0000'); + if (nextKey === lastSqlReferencedMetadataKeyRef.current) { + // 库集合未变:仍刷新装饰(可能表列表已异步到位) + refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH); + return; + } + lastSqlReferencedMetadataKeyRef.current = nextKey; + setSqlReferencedMetadataKey(nextKey); + }, 450); const acceptedCurrentAiGhost = !aiInlineGhostAcceptingRef.current && didModelContentAcceptCurrentAiInlineGhost(event); if (acceptedCurrentAiGhost) { @@ -6186,7 +6239,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc .length || sourceStatements.length; const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; - const showRowNumberColumn = shouldShowOceanBaseRowNumberColumn(config); const defaultOracleSchema = isOracleLikeDialect(normalizedDbType) ? resolveOracleLikeDefaultSchemaName(config) : ''; @@ -6500,7 +6552,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc pkColumns: plan?.pkColumns || [], editLocator, readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly, - showRowNumberColumn, truncated, page, }); diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts index 7634a44e..a0ddd196 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + collectQueryEditorReferencedDatabaseNames, resolveOracleLikeDefaultSchemaName, resolveOracleLikeExecutionSchemaName, resolveOracleLikeLookupSchemaCandidates, + resolveQueryEditorNavigationTarget, } from './QueryEditorHelpers'; describe('QueryEditorHelpers Oracle-like execution schema', () => { @@ -31,3 +33,125 @@ describe('QueryEditorHelpers Oracle-like execution schema', () => { expect(resolveOracleLikeLookupSchemaCandidates(config, 'APP_OWNER')).toEqual(['APP_OWNER']); }); }); + +describe('QueryEditorHelpers qualified navigation (MySQL db.table + PG schema.table)', () => { + it('collects cross-db names from SQL without requiring an empty visible list', () => { + const sql = ` +SELECT * FROM uk_back_corp; +SELECT * FROM front_end_sys_new.fs_mkefu_regist_record WHERE mobile = '1'; +DELETE FROM front_end_sys_new.fs_mkefu_regist_record WHERE mobile = '1'; +SELECT * FROM public.users; +SELECT * FROM analytics.public.events; +`; + const names = collectQueryEditorReferencedDatabaseNames( + sql, + 'mkefu_test_new', + ['mkefu_test_new', 'front_end_sys_new', 'analytics'], + ); + expect(names).toEqual(expect.arrayContaining([ + 'mkefu_test_new', + 'front_end_sys_new', + 'analytics', + ])); + // public 是常见 schema,两段时不应当成库去拉取 + expect(names.map((name) => name.toLowerCase())).not.toContain('public'); + }); + + it('infers MySQL-style db.table even when the db is not yet in visibleDbs', () => { + const names = collectQueryEditorReferencedDatabaseNames( + "SELECT * FROM front_end_sys_new.fs_mkefu_regist_record", + 'mkefu_test_new', + ['mkefu_test_new'], + ); + expect(names).toEqual(expect.arrayContaining(['mkefu_test_new', 'front_end_sys_new'])); + }); + + it('resolves MySQL db.table when the database is visible', () => { + const tables = [ + { dbName: 'mkefu_test_new', tableName: 'uk_back_corp' }, + { dbName: 'front_end_sys_new', tableName: 'fs_mkefu_regist_record' }, + ]; + const sql = 'SELECT * FROM front_end_sys_new.fs_mkefu_regist_record'; + expect(resolveQueryEditorNavigationTarget( + sql, + sql.length, + 'mkefu_test_new', + ['mkefu_test_new', 'front_end_sys_new'], + tables, + )).toEqual({ + type: 'table', + dbName: 'front_end_sys_new', + tableName: 'fs_mkefu_regist_record', + schemaName: undefined, + }); + }); + + it('resolves PostgreSQL schema.table under the current database', () => { + const tables = [ + { dbName: 'appdb', tableName: 'public.users' }, + { dbName: 'appdb', tableName: 'billing.orders' }, + ]; + expect(resolveQueryEditorNavigationTarget( + 'select * from public.users', + 'select * from public.users'.length, + 'appdb', + ['appdb', 'otherdb'], + tables, + )).toEqual({ + type: 'table', + dbName: 'appdb', + tableName: 'public.users', + schemaName: 'public', + }); + expect(resolveQueryEditorNavigationTarget( + 'select * from billing.orders', + 'select * from billing.orders'.length, + 'appdb', + ['appdb'], + tables, + )).toEqual({ + type: 'table', + dbName: 'appdb', + tableName: 'billing.orders', + schemaName: 'billing', + }); + }); + + it('resolves database.schema.table three-part names for PostgreSQL-style metadata', () => { + const tables = [ + { dbName: 'analytics', tableName: 'public.events' }, + { dbName: 'analytics', tableName: 'events' }, + ]; + expect(resolveQueryEditorNavigationTarget( + 'select * from analytics.public.events', + 'select * from analytics.public.events'.length, + 'appdb', + ['appdb', 'analytics'], + tables, + )).toEqual({ + type: 'table', + dbName: 'analytics', + tableName: 'public.events', + schemaName: 'public', + }); + }); + + it('still resolves schema-qualified objects when the first segment is also a visible database name but no table exists there', () => { + // 边界:可见库列表里碰巧有 "billing" 这个库,但当前库下才有 billing.orders + const tables = [ + { dbName: 'main', tableName: 'billing.orders' }, + ]; + expect(resolveQueryEditorNavigationTarget( + 'select * from billing.orders', + 'select * from billing.orders'.length, + 'main', + ['main', 'billing'], + tables, + )).toEqual({ + type: 'table', + dbName: 'main', + tableName: 'billing.orders', + schemaName: 'billing', + }); + }); +}); diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index 1e52453e..20a75f6d 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -1605,6 +1605,23 @@ export const buildQueryEditorAliasMap = ( return aliasMap; }; +/** + * 常见「schema」名:两段限定时优先当作 schema.table(PG/SQL Server), + * 不把它们推断成需要跨库拉取的 database。 + */ +export const QUERY_EDITOR_COMMON_SCHEMA_NAME_SET = new Set([ + 'public', + 'dbo', + 'sys', + 'information_schema', + 'pg_catalog', + 'pg_toast', + 'mysql', + 'performance_schema', + 'sysdb', + 'guest', +]); + export const collectQueryEditorReferencedDatabaseNames = ( fullText: string, currentDb: string, @@ -1629,24 +1646,43 @@ export const collectQueryEditorReferencedDatabaseNames = ( .filter(Boolean) .map((db) => [db.toLowerCase(), db] as const), ); - const commonSchemaNames = new Set(['public', 'dbo', 'sys', 'information_schema', 'pg_catalog', 'mysql', 'performance_schema']); + const currentDbKey = String(currentDb || '').trim().toLowerCase(); const tableRegex = QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX; tableRegex.lastIndex = 0; let match: RegExpExecArray | null; while ((match = tableRegex.exec(String(fullText || ''))) !== null) { const tableIdent = normalizeCompletionQualifiedName(match[1] || ''); if (!tableIdent) continue; - const parts = tableIdent.split('.'); + const parts = tableIdent.split('.').map((part) => String(part || '').trim()).filter(Boolean); if (parts.length < 2) continue; - const candidate = visibleDbByLower.get(String(parts[0] || '').toLowerCase()); - if (candidate) { - addDb(candidate); - } else if (visibleDbByLower.size === 0) { - const inferredDb = String(parts[0] || '').trim(); - const inferredKey = inferredDb.toLowerCase(); - if (inferredDb && inferredKey !== String(currentDb || '').trim().toLowerCase() && !commonSchemaNames.has(inferredKey)) { - addDb(inferredDb); + + const firstPart = parts[0]; + const firstKey = firstPart.toLowerCase(); + const asVisibleDb = visibleDbByLower.get(firstKey); + if (asVisibleDb) { + // MySQL: db.table;PG 三段 db.schema.table 的首段也是库 + addDb(asVisibleDb); + continue; + } + + // 三段及以上:首段通常是 database(PG/SQL Server 跨库限定) + if (parts.length >= 3) { + if (firstKey && firstKey !== currentDbKey && !QUERY_EDITOR_COMMON_SCHEMA_NAME_SET.has(firstKey)) { + addDb(firstPart); } + continue; + } + + // 两段:可能是 MySQL 的 db.table,也可能是 PG 的 schema.table。 + // - 常见 schema 名不当库拉(避免 public/dbo 误请求) + // - 其它未知前缀:若在可见库中已处理;若不在可见库但仍不像 schema, + // 也作为候选库名拉取(覆盖 includeDatabases 过滤后手写跨库、或列表尚未刷新) + if ( + firstKey + && firstKey !== currentDbKey + && !QUERY_EDITOR_COMMON_SCHEMA_NAME_SET.has(firstKey) + ) { + addDb(firstPart); } } return result; @@ -1897,14 +1933,54 @@ export const resolveQueryEditorNavigationTarget = ( if (parts.length === 2) { const [firstPart, secondPart] = parts; - if (visibleDbSet.has(firstPart.toLowerCase())) { - return findObjectInPriorityOrder(firstPart, secondPart); + const firstKey = firstPart.toLowerCase(); + const firstIsVisibleDb = visibleDbSet.has(firstKey); + const firstLooksLikeSchema = QUERY_EDITOR_COMMON_SCHEMA_NAME_SET.has(firstKey); + + // 1) 首段是可见库 → MySQL/ClickHouse 风格 db.table(或跨库) + if (firstIsVisibleDb) { + const asDatabaseObject = findObjectInPriorityOrder(firstPart, secondPart); + if (asDatabaseObject) { + return asDatabaseObject; + } } - return findObjectInPriorityOrder(currentDbName, secondPart, firstPart); + + // 2) 当前库下的 schema.table(PostgreSQL / SQL Server / Oracle owner) + // 元数据里 tableName 可能是 "public.users" 或裸名 + schemaName + const asSchemaObject = findObjectInPriorityOrder(currentDbName, secondPart, firstPart); + if (asSchemaObject) { + return asSchemaObject; + } + const asRawQualifiedUnderCurrent = findObjectInPriorityOrder( + currentDbName, + `${firstPart}.${secondPart}`, + ); + if (asRawQualifiedUnderCurrent) { + return asRawQualifiedUnderCurrent; + } + + // 3) 首段不在可见库列表,但元数据里已有该库(或拉取中的跨库结果) + // 跳过明显 schema 名,避免 public.xxx 误当成库 + if (!firstIsVisibleDb && !firstLooksLikeSchema) { + const asInferredDatabaseObject = findObjectInPriorityOrder(firstPart, secondPart); + if (asInferredDatabaseObject) { + return asInferredDatabaseObject; + } + } + + return null; } + // 三段:database.schema.object(PG/SQL Server 跨库限定) const [dbName, schemaName, tableName] = parts; - if (!visibleDbSet.has(dbName.toLowerCase())) { + const dbKey = dbName.toLowerCase(); + const dbIsKnown = visibleDbSet.has(dbKey) + || tableMetas.some((meta) => meta.normalizedDbName === dbKey) + || viewMetas.some((meta) => meta.normalizedDbName === dbKey) + || materializedViewMetas.some((meta) => meta.normalizedDbName === dbKey); + + if (!dbIsKnown) { + // Oracle 风格:schema.package.member / schema.sequence.nextval(库仍是 currentDb) const schemaQualifiedSequence = findSequence(currentDbName, schemaName, dbName); if (schemaQualifiedSequence && ['nextval', 'currval'].includes(tableName.toLowerCase())) { return schemaQualifiedSequence; @@ -1913,9 +1989,12 @@ export const resolveQueryEditorNavigationTarget = ( if (schemaQualifiedPackage) { return schemaQualifiedPackage; } - return null; + // 仍尝试把首段当库解析(元数据可能已按 SQL 引用拉取) + return findObjectInPriorityOrder(dbName, tableName, schemaName) + || findObjectInPriorityOrder(dbName, `${schemaName}.${tableName}`); } - return findObjectInPriorityOrder(dbName, tableName, schemaName); + return findObjectInPriorityOrder(dbName, tableName, schemaName) + || findObjectInPriorityOrder(dbName, `${schemaName}.${tableName}`); }; export const resolveQueryEditorHoverTarget = (