🐛 fix(query-editor): 修复对象超链接误定位左树

This commit is contained in:
Syngnat
2026-06-25 22:29:09 +08:00
parent b210d078d0
commit 22cdeb677b
2 changed files with 289 additions and 132 deletions

View File

@@ -1656,7 +1656,7 @@ describe('QueryEditor external SQL save', () => {
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'analytics' });
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith({
id: 'conn-1-analytics-table-events',
title: 'events',
@@ -1666,7 +1666,7 @@ describe('QueryEditor external SQL save', () => {
tableName: 'events',
objectType: 'table',
});
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
}));
expect(preventDefault).toHaveBeenCalled();
@@ -1703,7 +1703,7 @@ describe('QueryEditor external SQL save', () => {
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'mkefu_location_dev_local' });
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
@@ -1711,6 +1711,79 @@ describe('QueryEditor external SQL save', () => {
tableName: 'fs_mkefu_regist_record',
objectType: 'table',
}));
expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
}));
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
});
it('opens a routine object-edit tab on ctrl click without locating the sidebar tree', async () => {
storeState.connections[0].config.type = 'postgres';
editorState.value = 'call reporting.refresh_stats();';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => {
const text = String(sql || '');
if (text.includes('pg_get_functiondef')) {
return {
success: true,
data: [{
routine_definition: 'CREATE OR REPLACE PROCEDURE reporting.refresh_stats() LANGUAGE plpgsql AS $$ BEGIN NULL; END; $$;',
}],
};
}
if (text.includes('FROM pg_proc') || text.includes('information_schema.routines')) {
return {
success: true,
data: [{ schema_name: 'reporting', routine_name: 'refresh_stats', routine_type: 'PROCEDURE' }],
};
}
return { success: true, data: [] };
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
for (let i = 0; i < 12; i += 1) {
await Promise.resolve();
}
});
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 21 } },
event: {
browserEvent: { button: 0, buttons: 1 },
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault,
stopPropagation,
},
});
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
});
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
title: expect.stringContaining('refresh_stats'),
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
queryMode: 'object-edit',
query: expect.stringContaining('CREATE OR REPLACE PROCEDURE reporting.refresh_stats()'),
}));
expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
}));
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
});
@@ -3691,7 +3764,7 @@ describe('QueryEditor external SQL save', () => {
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' });
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith({
id: 'view-def-conn-1-main-active_users',
title: '视图active_users',
@@ -3703,13 +3776,8 @@ describe('QueryEditor external SQL save', () => {
schemaName: 'reporting',
sidebarLocateKey: 'conn-1-main-view-active_users',
});
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'conn-1-main-view-active_users',
schemaName: 'reporting',
objectGroup: 'views',
}),
}));
});
@@ -3775,34 +3843,17 @@ describe('QueryEditor external SQL save', () => {
schemaName: 'audit',
sidebarLocateKey: 'conn-1-main-trigger-audit.users_bi-audit.users',
});
expect(storeState.addTab).toHaveBeenCalledWith({
id: 'routine-def-conn-1-main-reporting.refresh_stats',
title: '存储过程reporting.refresh_stats',
type: 'routine-def',
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
id: expect.stringMatching(/^query-edit-routine-conn-1-main-reporting\.refresh_stats-\d+$/),
title: '编辑 存储过程reporting.refresh_stats',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
routineName: 'reporting.refresh_stats',
routineType: 'PROCEDURE',
schemaName: 'reporting',
sidebarLocateKey: 'conn-1-main-routine-reporting.refresh_stats',
});
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'conn-1-main-trigger-audit.users_bi-audit.users',
triggerName: 'audit.users_bi',
schemaName: 'audit',
objectGroup: 'triggers',
}),
queryMode: 'object-edit',
query: expect.stringContaining('CREATE OR REPLACE PROCEDURE reporting.refresh_stats()'),
}));
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'conn-1-main-routine-reporting.refresh_stats',
routineName: 'reporting.refresh_stats',
schemaName: 'reporting',
objectGroup: 'routines',
}),
}));
});
@@ -3879,23 +3930,8 @@ describe('QueryEditor external SQL save', () => {
schemaName: 'billing',
sidebarLocateKey: 'package-def-conn-1-main-billing.pkg_order',
});
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
expect((window as any).dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'sequence-def-conn-1-main-billing.order_seq',
sequenceName: 'billing.order_seq',
schemaName: 'billing',
objectGroup: 'sequences',
}),
}));
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'package-def-conn-1-main-billing.pkg_order',
packageName: 'billing.pkg_order',
schemaName: 'billing',
objectGroup: 'packages',
}),
}));
});
@@ -4003,9 +4039,10 @@ describe('QueryEditor external SQL save', () => {
type: 'trigger',
}));
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
id: 'routine-def-conn-1-main-reporting.refresh_stats',
title: 'Procedure: reporting.refresh_stats',
type: 'routine-def',
id: expect.stringMatching(/^query-edit-routine-conn-1-main-reporting\.refresh_stats-\d+$/),
title: 'Edit Procedure: reporting.refresh_stats',
type: 'query',
queryMode: 'object-edit',
}));
});
@@ -7743,7 +7780,7 @@ describe('QueryEditor external SQL save', () => {
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' });
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
@@ -7825,7 +7862,7 @@ describe('QueryEditor external SQL save', () => {
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' });
expect(storeState.setActiveContext).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',

View File

@@ -35,6 +35,7 @@ import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import { t as translate } from '../i18n';
import { buildSqlAnalysisWorkbenchTab } from '../utils/sqlAnalysisTab';
import { isLocalizedUntitledQueryTitle } from '../utils/queryTabTitle';
import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition';
import {
DUCKDB_ROWID_LOCATOR_COLUMN,
ORACLE_ROWID_LOCATOR_COLUMN,
@@ -63,6 +64,7 @@ import {
type CompletionTableMeta,
type CompletionTriggerMeta,
type CompletionViewMeta,
type QueryEditorNavigationTarget,
type QueryStatementPlan,
QUERY_EDITOR_HOVER_DELAY_MS,
QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH,
@@ -88,7 +90,6 @@ import {
clearQueryEditorObjectDecorations,
collectQueryEditorObjectDecorationCandidates,
collectQueryEditorReferencedDatabaseNames,
dispatchQueryEditorSidebarLocate,
getCaseInsensitiveValue,
getFirstRowValue,
getNormalizedPositionAtOffset,
@@ -135,6 +136,52 @@ const buildQueryEditorMonacoActionLabel = (key: string): string =>
const QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER = '{SQL}';
const escapeQueryEditorObjectEditSqlLiteral = (value: unknown): string => (
String(value || '').replace(/'/g, "''")
);
const getQueryEditorObjectEditRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
const keyMap = new Map<string, any>();
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
for (const key of candidateKeys) {
if (keyMap.has(key.toLowerCase())) {
const value = keyMap.get(key.toLowerCase());
if (value !== undefined && value !== null) return value;
}
}
return undefined;
};
const normalizeQueryEditorRoutineDefinitionForEdit = (
definition: string,
routineName: string,
routineType: string,
): string => {
const text = String(definition || '').trim();
if (!text) return '';
if (/^\s*create\b/i.test(text)) return text;
if (/^\s*(function|procedure)\b/i.test(text)) {
return `CREATE OR REPLACE ${text}`;
}
const normalizedType = String(routineType || 'FUNCTION').trim().toUpperCase().includes('PROC')
? 'PROCEDURE'
: 'FUNCTION';
return `CREATE OR REPLACE ${normalizedType} ${routineName}\n${text}`;
};
const buildQueryEditorRoutineEditFallbackSql = (
routineName: string,
routineType: string,
): string => {
const normalizedType = String(routineType || 'FUNCTION').trim().toUpperCase().includes('PROC')
? 'PROCEDURE'
: 'FUNCTION';
if (normalizedType === 'PROCEDURE') {
return `CREATE OR REPLACE PROCEDURE ${routineName}()\nBEGIN\n -- TODO: edit procedure body\nEND;`;
}
return `CREATE OR REPLACE FUNCTION ${routineName}()\nRETURNS INTEGER\nBEGIN\n -- TODO: edit function body\n RETURN 0;\nEND;`;
};
const buildQueryEditorAiContextPrompt = (connection: any, database: string): string => {
if (!connection) {
return '';
@@ -1390,6 +1437,154 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
}, [cancelEditorResizeFrame, handleMouseMove, handleMouseUp]);
const openRoutineObjectEditTab = useCallback(async (
navigationTarget: Extract<QueryEditorNavigationTarget, { type: 'routine' }>,
connectionId: string,
targetDbName: string,
) => {
const targetRoutineName = String(navigationTarget.routineName || '').trim();
if (!targetRoutineName) return;
const normalizedRoutineType = String(navigationTarget.routineType || 'FUNCTION').trim().toUpperCase().includes('PROC')
? 'PROCEDURE'
: 'FUNCTION';
const routineTypeLabel = normalizedRoutineType === 'PROCEDURE'
? translate('sidebar.object.procedure')
: translate('sidebar.object.function');
const sqlTemplateHeader = `-- ${translate('sidebar.sql_template.edit_routine', {
type: routineTypeLabel,
name: targetRoutineName,
})}`;
let editSql = `${sqlTemplateHeader}\n-- ${translate('sidebar.sql_template.modify_then_execute')}\n${buildQueryEditorRoutineEditFallbackSql(targetRoutineName, normalizedRoutineType)}`;
const conn = connectionsRef.current.find((item) => item.id === connectionId);
if (conn) {
const dialect = normalizeMetadataDialect(conn);
const parsedRoutine = splitSidebarQualifiedName(targetRoutineName);
const routineObjectName = parsedRoutine.objectName || targetRoutineName;
const routineSchemaName = String(navigationTarget.schemaName || parsedRoutine.schemaName || '').trim();
const safeName = escapeQueryEditorObjectEditSqlLiteral(routineObjectName);
const safeSchema = escapeQueryEditorObjectEditSqlLiteral(routineSchemaName);
const safeDbName = escapeQueryEditorObjectEditSqlLiteral(targetDbName);
const config = {
...conn.config,
port: Number(conn.config?.port),
password: conn.config?.password || '',
database: conn.config?.database || '',
useSSH: conn.config?.useSSH || false,
ssh: conn.config?.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const queries = (() => {
switch (dialect) {
case 'mysql':
case 'starrocks':
return [
`SHOW CREATE ${normalizedRoutineType} \`${routineObjectName.replace(/`/g, '``')}\``,
safeDbName
? `SELECT ROUTINE_DEFINITION AS routine_definition FROM information_schema.routines WHERE routine_schema = '${safeDbName}' AND routine_name = '${safeName}' AND UPPER(routine_type) = '${normalizedRoutineType}' LIMIT 1`
: '',
].filter(Boolean);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
case 'gaussdb': {
const schemaRef = safeSchema || 'public';
return [`SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${schemaRef}' AND p.proname = '${safeName}' LIMIT 1`];
}
case 'sqlserver':
return buildSqlServerObjectDefinitionQueries('routine', targetRoutineName, targetDbName, 'routine_definition');
case 'oracle':
case 'dm':
case 'dameng': {
const owner = safeSchema || safeDbName;
return [
owner
? `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner.toUpperCase()}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = '${normalizedRoutineType}' ORDER BY LINE`
: `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${normalizedRoutineType}' ORDER BY LINE`,
];
}
case 'duckdb': {
const schemaRef = safeSchema || 'main';
return [
`SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${schemaRef}' AND function_name = '${safeName}' LIMIT 1`,
];
}
default:
return [];
}
})();
for (const queryText of queries) {
try {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, targetDbName, queryText);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}
let definition = '';
if (dialect === 'oracle' || dialect === 'dm' || dialect === 'dameng') {
definition = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
} else if (dialect === 'duckdb') {
const row = result.data[0] as Record<string, any>;
const schemaName = String(getQueryEditorObjectEditRawValue(row, ['schema_name']) || routineSchemaName || '').trim();
const functionName = String(getQueryEditorObjectEditRawValue(row, ['function_name', 'routine_name', 'name']) || routineObjectName || '').trim();
const parametersRaw = getQueryEditorObjectEditRawValue(row, ['parameters']);
const macroDefinition = String(getQueryEditorObjectEditRawValue(row, ['macro_definition']) || '').trim();
const parameters = Array.isArray(parametersRaw)
? parametersRaw.map((item) => String(item ?? '').trim()).filter(Boolean).join(', ')
: String(parametersRaw ?? '').replace(/^\[|\]$/g, '').trim();
const qualifiedName = schemaName ? `${schemaName}.${functionName}` : functionName;
if (qualifiedName && macroDefinition) {
definition = macroDefinition.startsWith('(')
? `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`
: `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
}
} else if (dialect === 'sqlserver') {
definition = result.data
.map((row: any) => getQueryEditorObjectEditRawValue(row, ['routine_definition', 'definition', 'text', 'Text']) ?? '')
.map((value) => String(value))
.join('');
} else {
const row = result.data[0] as Record<string, any>;
const direct = getQueryEditorObjectEditRawValue(row, ['routine_definition', 'definition']);
if (direct !== undefined && direct !== null && String(direct).trim()) {
definition = String(direct);
} else {
const createKey = Object.keys(row).find((key) => /create\s+(function|procedure)/i.test(key));
definition = createKey ? String(row[createKey] || '') : '';
}
}
const normalizedDefinition = normalizeQueryEditorRoutineDefinitionForEdit(
definition,
targetRoutineName,
normalizedRoutineType,
);
if (normalizedDefinition) {
editSql = `${sqlTemplateHeader}\n${normalizedDefinition}`;
break;
}
} catch {
// 查询最新定义失败时保留可编辑模板。
}
}
}
addTab({
id: `query-edit-routine-${connectionId}-${targetDbName}-${targetRoutineName}-${Date.now()}`,
title: translate('sidebar.tab.edit_routine', {
type: routineTypeLabel,
name: targetRoutineName,
}),
type: 'query',
connectionId,
dbName: targetDbName,
query: editSql,
queryMode: 'object-edit',
});
}, [addTab]);
// Setup Autocomplete and Editor
const handleEditorDidMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
@@ -1716,9 +1911,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
setCurrentDb(targetDbName);
currentDbRef.current = targetDbName;
setActiveContext({ connectionId, dbName: targetDbName });
if (navigationTarget.type === 'table') {
const targetTableName = String(navigationTarget.tableName || '').trim();
if (!targetTableName) return;
@@ -1731,13 +1923,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
tableName: targetTableName,
objectType: 'table',
});
dispatchQueryEditorSidebarLocate({
connectionId,
dbName: targetDbName,
tableName: targetTableName,
schemaName: navigationTarget.schemaName,
objectGroup: 'tables',
});
return;
}
@@ -1762,15 +1947,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
schemaName: targetSchemaName || undefined,
sidebarLocateKey,
});
dispatchQueryEditorSidebarLocate({
tabId: sidebarLocateKey,
connectionId,
dbName: targetDbName,
viewName: targetViewName,
tableName: targetViewName,
schemaName: targetSchemaName,
objectGroup: navigationTarget.type === 'materialized-view' ? 'materializedViews' : 'views',
});
return;
}
@@ -1791,15 +1967,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
schemaName: targetSchemaName || undefined,
sidebarLocateKey,
});
dispatchQueryEditorSidebarLocate({
tabId: sidebarLocateKey,
connectionId,
dbName: targetDbName,
triggerName: targetTriggerName,
tableName: targetTriggerName,
schemaName: targetSchemaName,
objectGroup: 'triggers',
});
return;
}
@@ -1818,15 +1985,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
schemaName: targetSchemaName || undefined,
sidebarLocateKey,
});
dispatchQueryEditorSidebarLocate({
tabId: sidebarLocateKey,
connectionId,
dbName: targetDbName,
sequenceName: targetSequenceName,
tableName: targetSequenceName,
schemaName: targetSchemaName,
objectGroup: 'sequences',
});
return;
}
@@ -1845,48 +2003,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
schemaName: targetSchemaName || undefined,
sidebarLocateKey,
});
dispatchQueryEditorSidebarLocate({
tabId: sidebarLocateKey,
connectionId,
dbName: targetDbName,
packageName: targetPackageName,
tableName: targetPackageName,
schemaName: targetSchemaName,
objectGroup: 'packages',
});
return;
}
const targetRoutineName = String(navigationTarget.routineName || '').trim();
if (!targetRoutineName) return;
const routineTypeLabel = navigationTarget.routineType === 'PROCEDURE'
? translate('sidebar.object.procedure')
: translate('sidebar.object.function');
const targetSchemaName = String(navigationTarget.schemaName || '').trim();
const sidebarLocateKey = `${connectionId}-${targetDbName}-routine-${targetRoutineName}`;
addTab({
id: `routine-def-${connectionId}-${targetDbName}-${targetRoutineName}`,
title: translate('sidebar.tab.routine_definition', {
type: routineTypeLabel,
name: targetRoutineName,
}),
type: 'routine-def',
connectionId,
dbName: targetDbName,
routineName: targetRoutineName,
routineType: navigationTarget.routineType,
schemaName: targetSchemaName || undefined,
sidebarLocateKey,
});
dispatchQueryEditorSidebarLocate({
tabId: sidebarLocateKey,
connectionId,
dbName: targetDbName,
routineName: targetRoutineName,
tableName: targetRoutineName,
schemaName: targetSchemaName,
objectGroup: 'routines',
});
void openRoutineObjectEditTab(navigationTarget, connectionId, targetDbName);
});
editor.onDidDispose?.(() => {