mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-06 02:21:33 +08:00
🐛 fix(frontend): 修复 DuckDB 对象编辑与安全修改回归
- 修复 DuckDB qualified table 在查询结果页丢失 schema 导致无法识别主键的问题 - 打开对象修改前强制刷新最新定义,并避免切换对象失败时沿用旧定义 - 为 DuckDB 元数据链路补充前后端回归测试,并给 app 层真实 runtime 测试增加环境门槛
This commit is contained in:
@@ -317,6 +317,28 @@ describe('DataViewer safe editing locator', () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('keeps DuckDB table preview writable when primary key metadata arrives for a qualified table name', async () => {
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-pri', dbName: 'main', tableName: 'main.events', title: 'events' }));
|
||||
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual(['id']);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'primary-key',
|
||||
columns: ['id'],
|
||||
valueColumns: ['id'],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('invalidates a stale known total when table data grows after a manual refresh', async () => {
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('DefinitionViewer object edit entry', () => {
|
||||
storeState.setActiveContext.mockReset();
|
||||
storeState.theme = 'light';
|
||||
storeState.connections[0].config.type = 'postgres';
|
||||
backendApp.DBQuery.mockReset();
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ view_definition: 'SELECT id, name FROM users' }],
|
||||
@@ -213,4 +214,101 @@ describe('DefinitionViewer object edit entry', () => {
|
||||
expect(query).toContain('v_count PLS_INTEGER;');
|
||||
expect(query).toContain('SELECT COUNT(*) INTO v_count FROM dual;');
|
||||
});
|
||||
|
||||
it('reloads the latest object definition before opening object edit', async () => {
|
||||
backendApp.DBQuery
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ view_definition: 'SELECT id FROM users' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ view_definition: 'SELECT id, name, updated_at FROM users' }],
|
||||
});
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(<DefinitionViewer tab={createTab()} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0];
|
||||
|
||||
await act(async () => {
|
||||
await button.props.onClick();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(backendApp.DBQuery).toHaveBeenCalledTimes(2);
|
||||
const query = storeState.addTab.mock.calls[0][0].query;
|
||||
expect(query).toContain('SELECT id, name, updated_at FROM users;');
|
||||
expect(query).not.toContain('SELECT id FROM users;');
|
||||
|
||||
const editor = renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0];
|
||||
expect(String(editor.children.join(''))).toContain('SELECT id, name, updated_at FROM users');
|
||||
});
|
||||
|
||||
it('keeps the current definition visible when refresh for object edit fails', async () => {
|
||||
backendApp.DBQuery
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ view_definition: 'SELECT id, name FROM users' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: 'network down',
|
||||
data: [],
|
||||
});
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(<DefinitionViewer tab={createTab()} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0];
|
||||
|
||||
await act(async () => {
|
||||
await button.props.onClick();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(storeState.addTab).not.toHaveBeenCalled();
|
||||
expect(String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join(''))).toContain('SELECT id, name FROM users');
|
||||
expect(findButtonText(renderer.root)).toContain('刷新最新定义失败');
|
||||
expect(findButtonText(renderer.root)).toContain('network down');
|
||||
});
|
||||
|
||||
it('does not keep the previous object definition when switching objects and the new load fails', async () => {
|
||||
backendApp.DBQuery
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ view_definition: 'SELECT id, name FROM users' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: 'load failed',
|
||||
data: [],
|
||||
});
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(<DefinitionViewer tab={createTab()} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer.update(<DefinitionViewer tab={createTab({
|
||||
id: 'view-def-conn-1-main-reporting.archived_users',
|
||||
title: '视图: reporting.archived_users',
|
||||
viewName: 'reporting.archived_users',
|
||||
})} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(findButtonText(renderer.root)).toContain('加载失败');
|
||||
expect(findButtonText(renderer.root)).toContain('load failed');
|
||||
expect(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')).toHaveLength(0);
|
||||
expect(findButtonText(renderer.root)).not.toContain('SELECT id, name FROM users');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Editor from './MonacoEditor';
|
||||
import { Button, Spin, Alert } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
@@ -69,12 +69,25 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [definition, setDefinition] = useState<string>('');
|
||||
const [openingObjectEdit, setOpeningObjectEdit] = useState(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const loadedDefinitionKeyRef = useRef('');
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const darkMode = theme === 'dark';
|
||||
const objectIdentityKey = [
|
||||
tab.connectionId,
|
||||
tab.dbName,
|
||||
tab.type,
|
||||
tab.viewName,
|
||||
tab.viewKind,
|
||||
tab.eventName,
|
||||
tab.routineName,
|
||||
tab.routineType,
|
||||
].map((item) => String(item || '')).join('||');
|
||||
|
||||
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||
|
||||
@@ -446,107 +459,122 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefinition = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const loadDefinition = async (): Promise<{ success: boolean; definition?: string; error?: string }> => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
return { success: false, error: '未找到数据库连接' };
|
||||
}
|
||||
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
setError('未找到数据库连接');
|
||||
setLoading(false);
|
||||
return;
|
||||
const dbName = tab.dbName || '';
|
||||
const dialect = getMetadataDialect(conn);
|
||||
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
|
||||
|
||||
let queries: string[];
|
||||
let extractFn: (dialect: string, data: any[]) => string;
|
||||
let resolvedObjectLabel: string;
|
||||
|
||||
if (tab.type === 'view-def') {
|
||||
const viewName = tab.viewName || '';
|
||||
if (!viewName) {
|
||||
return { success: false, error: '视图名称为空' };
|
||||
}
|
||||
queries = buildShowViewQueries(dialect, viewName, dbName, tab.viewKind);
|
||||
extractFn = extractViewDefinition;
|
||||
resolvedObjectLabel = tab.viewKind === 'materialized' ? '物化视图' : '视图';
|
||||
} else if (tab.type === 'event-def') {
|
||||
const eventName = tab.eventName || '';
|
||||
if (!eventName) {
|
||||
return { success: false, error: '事件名称为空' };
|
||||
}
|
||||
queries = buildShowEventQueries(dialect, eventName, dbName);
|
||||
extractFn = extractEventDefinition;
|
||||
resolvedObjectLabel = '事件';
|
||||
} else {
|
||||
const routineName = tab.routineName || '';
|
||||
const routineType = tab.routineType || 'FUNCTION';
|
||||
if (!routineName) {
|
||||
return { success: false, error: '函数/存储过程名称为空' };
|
||||
}
|
||||
queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName);
|
||||
extractFn = extractRoutineDefinition;
|
||||
resolvedObjectLabel = '函数/存储过程';
|
||||
}
|
||||
|
||||
if (!queries.length || String(queries[0] || '').startsWith('--')) {
|
||||
return { success: true, definition: String(queries[0] || '-- 暂不支持该对象定义查看') };
|
||||
}
|
||||
|
||||
try {
|
||||
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 result = await runQueryCandidates(config, dbName, queries);
|
||||
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
return { success: true, definition: extractFn(dialect, result.data) };
|
||||
}
|
||||
|
||||
const dbName = tab.dbName || '';
|
||||
const dialect = getMetadataDialect(conn);
|
||||
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
|
||||
|
||||
let queries: string[];
|
||||
let extractFn: (dialect: string, data: any[]) => string;
|
||||
let objectLabel: string;
|
||||
|
||||
if (tab.type === 'view-def') {
|
||||
const viewName = tab.viewName || '';
|
||||
if (!viewName) {
|
||||
setError('视图名称为空');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
queries = buildShowViewQueries(dialect, viewName, dbName, tab.viewKind);
|
||||
extractFn = extractViewDefinition;
|
||||
objectLabel = tab.viewKind === 'materialized' ? '物化视图' : '视图';
|
||||
} else if (tab.type === 'event-def') {
|
||||
const eventName = tab.eventName || '';
|
||||
if (!eventName) {
|
||||
setError('事件名称为空');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
queries = buildShowEventQueries(dialect, eventName, dbName);
|
||||
extractFn = extractEventDefinition;
|
||||
objectLabel = '事件';
|
||||
} else {
|
||||
const routineName = tab.routineName || '';
|
||||
const routineType = tab.routineType || 'FUNCTION';
|
||||
if (!routineName) {
|
||||
setError('函数/存储过程名称为空');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName);
|
||||
extractFn = extractRoutineDefinition;
|
||||
objectLabel = '函数/存储过程';
|
||||
}
|
||||
|
||||
if (!queries.length || String(queries[0] || '').startsWith('--')) {
|
||||
setDefinition(String(queries[0] || '-- 暂不支持该对象定义查看'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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 result = await runQueryCandidates(config, dbName, queries);
|
||||
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
const def = extractFn(dialect, result.data);
|
||||
setDefinition(def);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
setDefinition(`-- 当前 Sphinx 实例${versionText}未返回${objectLabel}定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
|
||||
return;
|
||||
}
|
||||
setDefinition(`-- 未找到${objectLabel}定义`);
|
||||
} else if (sphinxLike) {
|
||||
if (result.success) {
|
||||
if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
setDefinition(`-- 当前 Sphinx 实例${versionText}不支持${objectLabel}定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
|
||||
} else {
|
||||
setError(result.message || '查询定义失败');
|
||||
return {
|
||||
success: true,
|
||||
definition: `-- 当前 Sphinx 实例${versionText}未返回${resolvedObjectLabel}定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('查询定义失败: ' + (e?.message || String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return { success: true, definition: `-- 未找到${resolvedObjectLabel}定义` };
|
||||
}
|
||||
|
||||
if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
return {
|
||||
success: true,
|
||||
definition: `-- 当前 Sphinx 实例${versionText}不支持${resolvedObjectLabel}定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: result.message || '查询定义失败' };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: '查询定义失败: ' + (e?.message || String(e)) };
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const syncDefinition = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await loadDefinition();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
loadedDefinitionKeyRef.current = objectIdentityKey;
|
||||
setDefinition(String(result.definition || ''));
|
||||
} else {
|
||||
setError(result.error || '查询定义失败');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadDefinition();
|
||||
}, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections]);
|
||||
syncDefinition();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections, objectIdentityKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
isMountedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const objectLabel = tab.type === 'view-def'
|
||||
? (tab.viewKind === 'materialized' ? '物化视图' : '视图')
|
||||
@@ -555,20 +583,41 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
? tab.viewName
|
||||
: (tab.type === 'event-def' ? tab.eventName : tab.routineName);
|
||||
const normalizedObjectName = String(objectName || '').trim();
|
||||
const displayedDefinition = loadedDefinitionKeyRef.current === objectIdentityKey ? definition : '';
|
||||
const hasDefinition = String(displayedDefinition || '').trim() !== '';
|
||||
|
||||
const openObjectEditQuery = () => {
|
||||
if (!normalizedObjectName) return;
|
||||
const openObjectEditQuery = async () => {
|
||||
if (!normalizedObjectName || openingObjectEdit) return;
|
||||
const dbName = String(tab.dbName || '').trim();
|
||||
const query = buildEditableDefinitionSql(tab, definition, objectLabel, normalizedObjectName);
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName });
|
||||
addTab({
|
||||
id: `query-edit-object-${tab.connectionId}-${dbName}-${Date.now()}`,
|
||||
title: `修改${objectLabel}: ${normalizedObjectName}`,
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName,
|
||||
query,
|
||||
});
|
||||
setOpeningObjectEdit(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await loadDefinition();
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!result.success) {
|
||||
setError(result.error || '查询定义失败');
|
||||
return;
|
||||
}
|
||||
const latestDefinition = String(result.definition || '');
|
||||
loadedDefinitionKeyRef.current = objectIdentityKey;
|
||||
setDefinition(latestDefinition);
|
||||
const query = buildEditableDefinitionSql(tab, latestDefinition, objectLabel, normalizedObjectName);
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName });
|
||||
addTab({
|
||||
id: `query-edit-object-${tab.connectionId}-${dbName}-${Date.now()}`,
|
||||
title: `修改${objectLabel}: ${normalizedObjectName}`,
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName,
|
||||
query,
|
||||
});
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setOpeningObjectEdit(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -579,7 +628,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error && !hasDefinition) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Alert type="error" message="加载失败" description={error} showIcon />
|
||||
@@ -595,16 +644,21 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>数据库: {tab.dbName}</span>}
|
||||
{tab.routineType && <span style={{ marginLeft: 16, color: '#888' }}>类型: {tab.routineType}</span>}
|
||||
</div>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openObjectEditQuery} disabled={!normalizedObjectName}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openObjectEditQuery} disabled={!normalizedObjectName} loading={openingObjectEdit}>
|
||||
对象修改
|
||||
</Button>
|
||||
</div>
|
||||
{error && hasDefinition && (
|
||||
<div style={{ padding: '8px 16px 0' }}>
|
||||
<Alert type="warning" message="刷新最新定义失败" description={error} showIcon />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={definition}
|
||||
value={displayedDefinition}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
|
||||
@@ -3320,6 +3320,49 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps DuckDB qualified table query results writable when primary key metadata arrives', async () => {
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['NAME', '__gonavi_locator_1_id'], rows: [{ NAME: 'launch', __gonavi_locator_1_id: 7 }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM main.events' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'main', 'main.events');
|
||||
expect(dataGridState.latestProps?.tableName).toBe('main.events');
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual(['id']);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'primary-key',
|
||||
columns: ['id'],
|
||||
valueColumns: ['__gonavi_locator_1_id'],
|
||||
hiddenColumns: ['__gonavi_locator_1_id'],
|
||||
writableColumns: {
|
||||
NAME: 'name',
|
||||
},
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"id" AS "__gonavi_locator_1_id"');
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('TriggerViewer object edit entry', () => {
|
||||
storeState.addTab.mockReset();
|
||||
storeState.setActiveContext.mockReset();
|
||||
storeState.connections[0].config.type = 'postgres';
|
||||
backendApp.DBQuery.mockReset();
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
|
||||
@@ -170,4 +171,102 @@ describe('TriggerViewer object edit entry', () => {
|
||||
expect(query).toContain(':NEW.updated_at := SYSDATE;');
|
||||
expect(query).not.toContain('请补全 CREATE TRIGGER 语句');
|
||||
});
|
||||
|
||||
it('reloads the latest trigger definition before opening object edit', async () => {
|
||||
backendApp.DBQuery
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT OR UPDATE ON audit.users EXECUTE FUNCTION audit.audit_users_v2();' }],
|
||||
});
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(<TriggerViewer tab={tab} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0];
|
||||
|
||||
await act(async () => {
|
||||
await button.props.onClick();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(backendApp.DBQuery).toHaveBeenCalledTimes(2);
|
||||
const query = storeState.addTab.mock.calls[0][0].query;
|
||||
expect(query).toContain('CREATE TRIGGER users_bi BEFORE INSERT OR UPDATE ON audit.users');
|
||||
expect(query).toContain('audit.audit_users_v2()');
|
||||
|
||||
const editor = renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0];
|
||||
expect(String(editor.children.join(''))).toContain('CREATE TRIGGER users_bi BEFORE INSERT OR UPDATE ON audit.users');
|
||||
});
|
||||
|
||||
it('keeps the current trigger definition visible when refresh for object edit fails', async () => {
|
||||
backendApp.DBQuery
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: 'refresh failed',
|
||||
data: [],
|
||||
});
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(<TriggerViewer tab={tab} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0];
|
||||
|
||||
await act(async () => {
|
||||
await button.props.onClick();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(storeState.addTab).not.toHaveBeenCalled();
|
||||
expect(String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join(''))).toContain('CREATE TRIGGER users_bi BEFORE INSERT ON audit.users');
|
||||
expect(findButtonText(renderer.root)).toContain('刷新最新定义失败');
|
||||
expect(findButtonText(renderer.root)).toContain('refresh failed');
|
||||
});
|
||||
|
||||
it('does not keep the previous trigger definition when switching objects and the new load fails', async () => {
|
||||
backendApp.DBQuery
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: 'load failed',
|
||||
data: [],
|
||||
});
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(<TriggerViewer tab={tab} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer.update(<TriggerViewer tab={{
|
||||
...tab,
|
||||
id: 'trigger-conn-1-main-audit.users_bu',
|
||||
title: '触发器: audit.users_bu',
|
||||
triggerName: 'audit.users_bu',
|
||||
}} />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(findButtonText(renderer.root)).toContain('加载失败');
|
||||
expect(findButtonText(renderer.root)).toContain('load failed');
|
||||
expect(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')).toHaveLength(0);
|
||||
expect(findButtonText(renderer.root)).not.toContain('CREATE TRIGGER users_bi BEFORE INSERT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Editor from './MonacoEditor';
|
||||
import { Button, Spin, Alert } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
@@ -42,12 +42,23 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [triggerDefinition, setTriggerDefinition] = useState<string>('');
|
||||
const [openingObjectEdit, setOpeningObjectEdit] = useState(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const loadedDefinitionKeyRef = useRef('');
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const darkMode = theme === 'dark';
|
||||
const objectIdentityKey = [
|
||||
tab.connectionId,
|
||||
tab.dbName,
|
||||
tab.type,
|
||||
tab.triggerName,
|
||||
tab.triggerTableName,
|
||||
tab.schemaName,
|
||||
].map((item) => String(item || '')).join('||');
|
||||
|
||||
// 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 stickyScroll 不透明背景)
|
||||
|
||||
@@ -242,79 +253,98 @@ LIMIT 1`];
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadTriggerDefinition = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const loadTriggerDefinition = async (): Promise<{ success: boolean; definition?: string; error?: string }> => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
return { success: false, error: '未找到数据库连接' };
|
||||
}
|
||||
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
setError('未找到数据库连接');
|
||||
setLoading(false);
|
||||
return;
|
||||
const triggerName = tab.triggerName || '';
|
||||
const dbName = tab.dbName || '';
|
||||
|
||||
if (!triggerName) {
|
||||
return { success: false, error: '触发器名称为空' };
|
||||
}
|
||||
|
||||
const dialect = getMetadataDialect(conn);
|
||||
const queries = buildShowTriggerQueries(dialect, triggerName, dbName);
|
||||
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
|
||||
|
||||
if (!queries.length || String(queries[0] || '').startsWith('--')) {
|
||||
return { success: true, definition: String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看') };
|
||||
}
|
||||
|
||||
try {
|
||||
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 result = await runQueryCandidates(config, dbName, queries);
|
||||
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
return { success: true, definition: extractTriggerDefinition(dialect, result.data) };
|
||||
}
|
||||
|
||||
const triggerName = tab.triggerName || '';
|
||||
const dbName = tab.dbName || '';
|
||||
|
||||
if (!triggerName) {
|
||||
setError('触发器名称为空');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialect = getMetadataDialect(conn);
|
||||
const queries = buildShowTriggerQueries(dialect, triggerName, dbName);
|
||||
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
|
||||
|
||||
if (!queries.length || String(queries[0] || '').startsWith('--')) {
|
||||
setTriggerDefinition(String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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 result = await runQueryCandidates(config, dbName, queries);
|
||||
|
||||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
const definition = extractTriggerDefinition(dialect, result.data);
|
||||
setTriggerDefinition(definition);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}未返回触发器定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
|
||||
return;
|
||||
}
|
||||
setTriggerDefinition('-- 未找到触发器定义');
|
||||
} else if (sphinxLike) {
|
||||
if (result.success) {
|
||||
if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}不支持触发器定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
|
||||
} else {
|
||||
setError(result.message || '查询触发器定义失败');
|
||||
return {
|
||||
success: true,
|
||||
definition: `-- 当前 Sphinx 实例${versionText}未返回触发器定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('查询触发器定义失败: ' + (e?.message || String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return { success: true, definition: '-- 未找到触发器定义' };
|
||||
}
|
||||
|
||||
if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
return {
|
||||
success: true,
|
||||
definition: `-- 当前 Sphinx 实例${versionText}不支持触发器定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: result.message || '查询触发器定义失败' };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: '查询触发器定义失败: ' + (e?.message || String(e)) };
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const syncTriggerDefinition = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await loadTriggerDefinition();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
loadedDefinitionKeyRef.current = objectIdentityKey;
|
||||
setTriggerDefinition(String(result.definition || ''));
|
||||
} else {
|
||||
setError(result.error || '查询触发器定义失败');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadTriggerDefinition();
|
||||
}, [tab.connectionId, tab.dbName, tab.triggerName, connections]);
|
||||
syncTriggerDefinition();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tab.connectionId, tab.dbName, tab.triggerName, connections, objectIdentityKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
isMountedRef.current = false;
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -324,7 +354,10 @@ LIMIT 1`];
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const displayedDefinition = loadedDefinitionKeyRef.current === objectIdentityKey ? triggerDefinition : '';
|
||||
const hasDefinition = String(displayedDefinition || '').trim() !== '';
|
||||
|
||||
if (error && !hasDefinition) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Alert type="error" message="加载失败" description={error} showIcon />
|
||||
@@ -334,17 +367,36 @@ LIMIT 1`];
|
||||
|
||||
const triggerName = String(tab.triggerName || '').trim();
|
||||
const dbName = String(tab.dbName || '').trim();
|
||||
const openObjectEditQuery = () => {
|
||||
if (!triggerName) return;
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName });
|
||||
addTab({
|
||||
id: `query-edit-trigger-${tab.connectionId}-${dbName}-${Date.now()}`,
|
||||
title: `修改触发器: ${triggerName}`,
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName,
|
||||
query: buildEditableTriggerSql(triggerName, triggerDefinition),
|
||||
});
|
||||
const openObjectEditQuery = async () => {
|
||||
if (!triggerName || openingObjectEdit) return;
|
||||
setOpeningObjectEdit(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await loadTriggerDefinition();
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!result.success) {
|
||||
setError(result.error || '查询触发器定义失败');
|
||||
return;
|
||||
}
|
||||
const latestDefinition = String(result.definition || '');
|
||||
loadedDefinitionKeyRef.current = objectIdentityKey;
|
||||
setTriggerDefinition(latestDefinition);
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName });
|
||||
addTab({
|
||||
id: `query-edit-trigger-${tab.connectionId}-${dbName}-${Date.now()}`,
|
||||
title: `修改触发器: ${triggerName}`,
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName,
|
||||
query: buildEditableTriggerSql(triggerName, latestDefinition),
|
||||
});
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setOpeningObjectEdit(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -354,16 +406,21 @@ LIMIT 1`];
|
||||
<strong>触发器: </strong>{tab.triggerName}
|
||||
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>数据库: {tab.dbName}</span>}
|
||||
</div>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openObjectEditQuery} disabled={!triggerName}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openObjectEditQuery} disabled={!triggerName} loading={openingObjectEdit}>
|
||||
对象修改
|
||||
</Button>
|
||||
</div>
|
||||
{error && hasDefinition && (
|
||||
<div style={{ padding: '8px 16px 0' }}>
|
||||
<Alert type="warning" message="刷新最新定义失败" description={error} showIcon />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={triggerDefinition}
|
||||
value={displayedDefinition}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
|
||||
@@ -48,6 +48,15 @@ describe('extractQueryResultTableRef', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps DuckDB schema-qualified table names for metadata lookups', () => {
|
||||
expect(extractQueryResultTableRef('SELECT * FROM main.events LIMIT 500', 'duckdb', 'main'))
|
||||
.toEqual({
|
||||
tableName: 'main.events',
|
||||
metadataDbName: 'main',
|
||||
metadataTableName: 'main.events',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mark join results as editable table refs', () => {
|
||||
expect(extractQueryResultTableRef('SELECT * FROM users u JOIN orders o ON u.id = o.user_id', 'oracle', 'APP'))
|
||||
.toBeUndefined();
|
||||
|
||||
@@ -21,6 +21,11 @@ const isOracleLikeDialect = (dialect: string): boolean => {
|
||||
return normalized === 'oracle' || normalized === 'dameng' || normalized === 'dm' || normalized === 'dm8';
|
||||
};
|
||||
|
||||
const keepsQualifiedTableNameForMetadata = (dialect: string): boolean => {
|
||||
const normalized = String(dialect || '').trim().toLowerCase();
|
||||
return normalized === 'duckdb';
|
||||
};
|
||||
|
||||
const isQuotedIdentifier = (part: string): boolean => {
|
||||
const text = String(part || '').trim();
|
||||
if (!text) return false;
|
||||
@@ -73,13 +78,16 @@ export const extractQueryResultTableRef = (
|
||||
|
||||
const owner = parts.length >= 2 ? parts[parts.length - 2] : '';
|
||||
const metadataDbName = owner || normalizeCurrentDbName(currentDb, dialect);
|
||||
const tableName = isOracleLikeDialect(dialect) && owner
|
||||
const tableName = (isOracleLikeDialect(dialect) || keepsQualifiedTableNameForMetadata(dialect)) && owner
|
||||
? `${owner}.${metadataTableName}`
|
||||
: metadataTableName;
|
||||
const resolvedMetadataTableName = keepsQualifiedTableNameForMetadata(dialect) && owner
|
||||
? `${owner}.${metadataTableName}`
|
||||
: metadataTableName;
|
||||
|
||||
return {
|
||||
tableName,
|
||||
metadataDbName,
|
||||
metadataTableName,
|
||||
metadataTableName: resolvedMetadataTableName,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user