mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-21 05:53:46 +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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user