🐛 fix(query-editor): 修复限定名超链接并支持跨库/schema 解析

- 两段名兼容 MySQL 库.表与 PG/SQL Server 的 schema.表
- 三段名支持 database.schema.object,常见 schema 不当成库拉取
- SQL 变更时按引用库防抖补拉元数据并刷新装饰
- 修复元数据 key 提前占死导致取消后不再拉取、超链接全灭
- 补充限定名解析与收集逻辑单测
This commit is contained in:
Syngnat
2026-07-09 14:23:09 +08:00
parent b1bd4bb5ec
commit 947277bdec
4 changed files with 301 additions and 24 deletions

View File

@@ -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',

View File

@@ -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<string[]>([]); // Store visible databases for cross-db intellisense
const metadataFetchKeyRef = useRef<string>('');
const metadataContextKeyRef = useRef<string>('');
/** SQL 中引用到的库集合变化时触发跨库元数据补拉(供超链接/补全) */
const [sqlReferencedMetadataKey, setSqlReferencedMetadataKey] = useState('');
const sqlReferencedMetadataTimerRef = useRef<number | null>(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,
});

View File

@@ -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',
});
});
});

View File

@@ -1605,6 +1605,23 @@ export const buildQueryEditorAliasMap = (
return aliasMap;
};
/**
* 常见「schema」名两段限定时优先当作 schema.tablePG/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.tablePG 三段 db.schema.table 的首段也是库
addDb(asVisibleDb);
continue;
}
// 三段及以上:首段通常是 databasePG/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.tablePostgreSQL / 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.objectPG/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 = (