diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index c32ca05..cf5fdc3 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -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'; diff --git a/frontend/src/components/DefinitionViewer.object-edit.test.tsx b/frontend/src/components/DefinitionViewer.object-edit.test.tsx index 031eb7f..a215792 100644 --- a/frontend/src/components/DefinitionViewer.object-edit.test.tsx +++ b/frontend/src/components/DefinitionViewer.object-edit.test.tsx @@ -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(); + 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(); + 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(); + await flushPromises(); + }); + + await act(async () => { + renderer.update(); + 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'); + }); }); diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index 703de86..9f72f2e 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -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 = ({ tab }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [definition, setDefinition] = useState(''); + 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 = ({ 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 = ({ 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 = ({ tab }) => { ); } - if (error) { + if (error && !hasDefinition) { return (
@@ -595,16 +644,21 @@ const DefinitionViewer: React.FC = ({ tab }) => { {tab.dbName && 数据库: {tab.dbName}} {tab.routineType && 类型: {tab.routineType}}
- + {error && hasDefinition && ( +
+ +
+ )}
{ 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(); + }); + + 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', diff --git a/frontend/src/components/TriggerViewer.object-edit.test.tsx b/frontend/src/components/TriggerViewer.object-edit.test.tsx index 6f64db9..28eb4b9 100644 --- a/frontend/src/components/TriggerViewer.object-edit.test.tsx +++ b/frontend/src/components/TriggerViewer.object-edit.test.tsx @@ -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(); + 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(); + 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(); + await flushPromises(); + }); + + await act(async () => { + renderer.update(); + 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'); + }); }); diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx index 21ace7b..ff5f984 100644 --- a/frontend/src/components/TriggerViewer.tsx +++ b/frontend/src/components/TriggerViewer.tsx @@ -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 = ({ tab }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [triggerDefinition, setTriggerDefinition] = useState(''); + 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 (
@@ -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`]; 触发器: {tab.triggerName} {tab.dbName && 数据库: {tab.dbName}}
-
+ {error && hasDefinition && ( +
+ +
+ )}
{ }); }); + 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(); diff --git a/frontend/src/utils/queryResultTable.ts b/frontend/src/utils/queryResultTable.ts index 98c1b4f..0d5548f 100644 --- a/frontend/src/utils/queryResultTable.ts +++ b/frontend/src/utils/queryResultTable.ts @@ -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, }; }; diff --git a/internal/app/methods_db_metadata_retry_test.go b/internal/app/methods_db_metadata_retry_test.go index fefbf3b..a571d18 100644 --- a/internal/app/methods_db_metadata_retry_test.go +++ b/internal/app/methods_db_metadata_retry_test.go @@ -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) + } +} diff --git a/internal/db/duckdb_metadata_integration_test.go b/internal/db/duckdb_metadata_integration_test.go index 153ef27..828b5fc 100644 --- a/internal/db/duckdb_metadata_integration_test.go +++ b/internal/db/duckdb_metadata_integration_test.go @@ -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 {