🐛 fix(frontend): 修复 DuckDB 对象编辑与安全修改回归

- 修复 DuckDB qualified table 在查询结果页丢失 schema 导致无法识别主键的问题

- 打开对象修改前强制刷新最新定义,并避免切换对象失败时沿用旧定义

- 为 DuckDB 元数据链路补充前后端回归测试,并给 app 层真实 runtime 测试增加环境门槛
This commit is contained in:
Syngnat
2026-06-04 22:00:55 +08:00
parent a664f1a869
commit 274c32ebdd
10 changed files with 802 additions and 189 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package app
import (
"errors"
"path/filepath"
"testing"
"GoNavi-Wails/internal/connection"
@@ -9,6 +10,17 @@ import (
"GoNavi-Wails/internal/secretstore"
)
func requireDuckDBOptionalDriverRuntime(t *testing.T) {
t.Helper()
if !db.IsOptionalGoDriverBuildIncluded("duckdb") {
t.Skip("当前构建未包含 DuckDB 可选驱动")
}
if ready, reason := db.DriverRuntimeSupportStatus("duckdb"); !ready {
t.Skipf("DuckDB runtime 未就绪,跳过集成测试: %s", reason)
}
}
type fakeMetadataRetryDB struct {
columns []connection.ColumnDefinition
indexes []connection.IndexDefinition
@@ -224,6 +236,38 @@ func TestDBGetColumnsKeepsDatabaseForMySQLMetadata(t *testing.T) {
}
}
func TestDBGetColumnsKeepsDuckDBQualifiedTableMetadata(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
dbInst := &fakeMetadataRetryDB{
columns: []connection.ColumnDefinition{{Name: "id", Key: "PRI"}},
}
newDatabaseFunc = func(dbType string) (db.Database, error) {
return dbInst, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
result := app.DBGetColumns(connection.ConnectionConfig{
Type: "duckdb",
Host: "D:/tmp/demo.duckdb",
}, "main", "main.events")
if !result.Success {
t.Fatalf("expected DBGetColumns success, got failure: %s", result.Message)
}
if dbInst.columnSchema != "main" || dbInst.columnTable != "main.events" {
t.Fatalf("expected duckdb metadata to preserve main/main.events, got %q.%q", dbInst.columnSchema, dbInst.columnTable)
}
}
func TestDBGetIndexesRetriesAfterCachedConnectionRefresh(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
@@ -276,3 +320,106 @@ func TestDBGetIndexesRetriesAfterCachedConnectionRefresh(t *testing.T) {
t.Fatalf("unexpected indexes after retry: %#v", indexes)
}
}
func TestDBGetIndexesKeepsDuckDBQualifiedTableMetadata(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
dbInst := &fakeMetadataRetryDB{
indexes: []connection.IndexDefinition{{Name: "events_id_pkey", ColumnName: "id", NonUnique: 0}},
}
newDatabaseFunc = func(dbType string) (db.Database, error) {
return dbInst, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
result := app.DBGetIndexes(connection.ConnectionConfig{
Type: "duckdb",
Host: "D:/tmp/demo.duckdb",
}, "main", "main.events")
if !result.Success {
t.Fatalf("expected DBGetIndexes success, got failure: %s", result.Message)
}
if dbInst.indexSchema != "main" || dbInst.indexTable != "main.events" {
t.Fatalf("expected duckdb index metadata to preserve main/main.events, got %q.%q", dbInst.indexSchema, dbInst.indexTable)
}
}
func TestDuckDBMetadataEndpointsReturnPrimaryKeyForQualifiedTableName(t *testing.T) {
t.Parallel()
requireDuckDBOptionalDriverRuntime(t)
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
t.Cleanup(func() {
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
dbPath := filepath.Join(t.TempDir(), "duckdb-primary-key.duckdb")
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
config := connection.ConnectionConfig{
Type: "duckdb",
Host: dbPath,
}
t.Cleanup(func() {
app.invalidateCachedDatabase(config, nil)
})
createResult := app.DBQuery(config, "main", `
CREATE TABLE main.events (
id BIGINT PRIMARY KEY,
name VARCHAR
);
CREATE UNIQUE INDEX idx_events_name ON main.events(name);
`)
if !createResult.Success {
t.Fatalf("expected DuckDB setup success, got failure: %s", createResult.Message)
}
columnResult := app.DBGetColumns(config, "main", "main.events")
if !columnResult.Success {
t.Fatalf("expected DBGetColumns success, got failure: %s", columnResult.Message)
}
columns, ok := columnResult.Data.([]connection.ColumnDefinition)
if !ok {
t.Fatalf("expected []connection.ColumnDefinition, got %T", columnResult.Data)
}
if len(columns) == 0 {
t.Fatalf("expected DuckDB columns, got %#v", columns)
}
if columns[0].Name != "id" || columns[0].Key != "PRI" {
t.Fatalf("expected primary key metadata on first column, got %#v", columns)
}
indexResult := app.DBGetIndexes(config, "main", "main.events")
if !indexResult.Success {
t.Fatalf("expected DBGetIndexes success, got failure: %s", indexResult.Message)
}
indexes, ok := indexResult.Data.([]connection.IndexDefinition)
if !ok {
t.Fatalf("expected []connection.IndexDefinition, got %T", indexResult.Data)
}
if len(indexes) == 0 {
t.Fatalf("expected DuckDB indexes, got %#v", indexes)
}
foundPrimary := false
for _, index := range indexes {
if index.ColumnName == "id" && index.NonUnique == 0 {
foundPrimary = true
break
}
}
if !foundPrimary {
t.Fatalf("expected DuckDB primary key index metadata, got %#v", indexes)
}
}

View File

@@ -4,6 +4,7 @@ package db
import (
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -66,6 +67,81 @@ CREATE UNIQUE INDEX idx_events_name ON events(name);
}
}
func TestDuckDBDefinitionReloadReflectsLatestDDL(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "definition-reload.duckdb")
client := &DuckDB{}
if err := client.Connect(connection.ConnectionConfig{Type: "duckdb", Host: dbPath}); err != nil {
t.Fatalf("Connect failed: %v", err)
}
t.Cleanup(func() {
_ = client.Close()
})
if _, err := client.Exec(`
CREATE VIEW active_users AS
SELECT id FROM (VALUES (1), (2)) AS users(id);
CREATE OR REPLACE MACRO add_one(x) AS x + 1;
`); err != nil {
t.Fatalf("create initial objects failed: %v", err)
}
viewDefinitionBefore, _, err := client.Query(`SELECT view_definition FROM information_schema.views WHERE table_schema = 'main' AND table_name = 'active_users' LIMIT 1`)
if err != nil {
t.Fatalf("query initial view definition failed: %v", err)
}
if len(viewDefinitionBefore) != 1 {
t.Fatalf("expected one initial view definition row, got %+v", viewDefinitionBefore)
}
if got := duckDBRowString(viewDefinitionBefore[0], "view_definition"); !strings.Contains(got, "SELECT id FROM") || !strings.Contains(got, "VALUES (1), (2)") {
t.Fatalf("unexpected initial view definition: %q", got)
}
routineDefinitionBefore, _, err := client.Query(`SELECT macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = 'main' AND function_name = 'add_one' LIMIT 1`)
if err != nil {
t.Fatalf("query initial macro definition failed: %v", err)
}
if len(routineDefinitionBefore) != 1 {
t.Fatalf("expected one initial macro definition row, got %+v", routineDefinitionBefore)
}
if got := duckDBRowString(routineDefinitionBefore[0], "macro_definition"); !strings.Contains(got, "x + 1") {
t.Fatalf("unexpected initial macro definition: %q", got)
}
if _, err := client.Exec(`
CREATE OR REPLACE VIEW active_users AS
SELECT id, id * 10 AS score FROM (VALUES (1), (2)) AS users(id);
CREATE OR REPLACE MACRO add_one(x) AS x + 2;
`); err != nil {
t.Fatalf("replace latest objects failed: %v", err)
}
viewDefinitionAfter, _, err := client.Query(`SELECT view_definition FROM information_schema.views WHERE table_schema = 'main' AND table_name = 'active_users' LIMIT 1`)
if err != nil {
t.Fatalf("query latest view definition failed: %v", err)
}
if len(viewDefinitionAfter) != 1 {
t.Fatalf("expected one latest view definition row, got %+v", viewDefinitionAfter)
}
if got := duckDBRowString(viewDefinitionAfter[0], "view_definition"); !strings.Contains(got, "SELECT id") || !strings.Contains(got, "score") || !strings.Contains(got, "10") {
t.Fatalf("expected latest view definition, got %q", got)
}
routineDefinitionAfter, _, err := client.Query(`SELECT macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = 'main' AND function_name = 'add_one' LIMIT 1`)
if err != nil {
t.Fatalf("query latest macro definition failed: %v", err)
}
if len(routineDefinitionAfter) != 1 {
t.Fatalf("expected one latest macro definition row, got %+v", routineDefinitionAfter)
}
if got := duckDBRowString(routineDefinitionAfter[0], "macro_definition"); !strings.Contains(got, "x + 2") {
t.Fatalf("expected latest macro definition, got %q", got)
}
}
func duckDBTestHasUniqueIndexColumn(indexes []connection.IndexDefinition, columnName string) bool {
for _, index := range indexes {
if index.ColumnName == columnName && index.NonUnique == 0 {