diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 5f8ea08..9a62bee 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -40,6 +40,11 @@ const storeState = vi.hoisted(() => ({ showColumnType: false, }, setQueryOptions: vi.fn(), + dataEditTransactionOptions: { + commitMode: 'manual' as 'manual' | 'auto', + autoCommitDelayMs: 5000, + }, + setDataEditTransactionOptions: vi.fn(), addTab: vi.fn(), setActiveContext: vi.fn(), tableColumnOrders: {}, @@ -608,6 +613,17 @@ describe('DataGrid DDL interactions', () => { backendApp.DBQuery.mockResolvedValue({ success: true, data: [] }); backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' }); storeState.appearance.uiVersion = 'legacy'; + storeState.dataEditTransactionOptions = { + commitMode: 'manual', + autoCommitDelayMs: 5000, + }; + storeState.setDataEditTransactionOptions.mockReset(); + storeState.setDataEditTransactionOptions.mockImplementation((options: Partial) => { + storeState.dataEditTransactionOptions = { + ...storeState.dataEditTransactionOptions, + ...options, + }; + }); storeState.addTab.mockReset(); storeState.setActiveContext.mockReset(); testRenderState.latestColumns = []; @@ -646,6 +662,7 @@ describe('DataGrid DDL interactions', () => { }); afterEach(() => { + vi.useRealTimers(); backendApp.ImportData.mockReset(); backendApp.ExportTable.mockReset(); backendApp.ExportData.mockReset(); @@ -1092,6 +1109,105 @@ describe('DataGrid DDL interactions', () => { renderer!.unmount(); }); + it('auto commits pending table edits after the configured delay', async () => { + vi.useFakeTimers(); + storeState.appearance.uiVersion = 'v2'; + storeState.dataEditTransactionOptions = { + commitMode: 'auto', + autoCommitDelayMs: 3000, + }; + backendApp.ApplyChanges.mockResolvedValue({ success: true, message: 'ok' }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name'); + const contextTarget = { + closest: (selector: string) => selector === '[data-row-key][data-col-name]' + ? { + getAttribute: (name: string) => { + if (name === 'data-row-key') return 'row-1'; + if (name === 'data-col-name') return 'name'; + return null; + }, + } + : null, + } as unknown as HTMLElement; + + const openMenu = async () => { + const cellProps = nameColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }); + await act(async () => { + cellProps.onContextMenu({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 160, + clientY: 120, + currentTarget: contextTarget, + target: contextTarget, + }); + }); + }; + + await openMenu(); + await act(async () => { + findButton(renderer!, '复制本行为新增行').props.onClick({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + }); + await openMenu(); + await act(async () => { + findButton(renderer!, '粘贴为新增行 (1)').props.onClick({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + }); + + expect(backendApp.ApplyChanges).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(2999); + await Promise.resolve(); + }); + expect(backendApp.ApplyChanges).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.ApplyChanges).toHaveBeenCalledTimes(1); + expect(backendApp.ApplyChanges.mock.calls[0][3]).toMatchObject({ + inserts: [ + expect.objectContaining({ + id: 1, + name: 'alpha', + }), + ], + updates: [], + deletes: [], + locatorStrategy: 'primary-key', + }); + expect(messageApi.success).toHaveBeenCalledWith('自动提交成功'); + renderer!.unmount(); + }); + it('switches the v2 footer object tab into the embedded designer view', async () => { storeState.appearance.uiVersion = 'v2'; backendApp.DBGetColumns.mockResolvedValueOnce({ diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 221522d..23ea1ee 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -31,6 +31,11 @@ vi.mock('../store', () => ({ showColumnType: false, }, setQueryOptions: vi.fn(), + dataEditTransactionOptions: { + commitMode: 'manual', + autoCommitDelayMs: 5000, + }, + setDataEditTransactionOptions: vi.fn(), addTab: vi.fn(), setActiveContext: vi.fn(), tableColumnOrders: {}, @@ -271,6 +276,7 @@ describe('DataGrid layout', () => { expect(markup).toContain('gn-v2-data-grid-table-wrap'); expect(markup).toContain('· main'); expect(markup).toContain('提交事务'); + expect(markup).toContain('手动提交'); expect(markup).toContain('AI 洞察'); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0c8bdfd..0e0e58a 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -189,6 +189,12 @@ const CELL_KEY_SEP = '\u0001'; const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; const DATE_TIME_CACHE_LIMIT = 2000; const TABLE_CELL_PREVIEW_MAX_CHARS = 240; +const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ + { value: 3000, label: '3 秒' }, + { value: 5000, label: '5 秒' }, + { value: 10000, label: '10 秒' }, + { value: 30000, label: '30 秒' }, +]; const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION'); const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION'); const DEFAULT_GRID_MONO_FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace'; @@ -1499,6 +1505,8 @@ const DataGrid: React.FC = ({ const uiScale = useStore(state => state.uiScale); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); + const dataEditTransactionOptions = useStore(state => state.dataEditTransactionOptions); + const setDataEditTransactionOptions = useStore(state => state.setDataEditTransactionOptions); const tableColumnOrders = useStore(state => state.tableColumnOrders); const enableColumnOrderMemory = useStore(state => state.enableColumnOrderMemory); const setTableColumnOrder = useStore(state => state.setTableColumnOrder); @@ -3962,6 +3970,26 @@ const DataGrid: React.FC = ({ const pendingChangeCount = addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size; const hasChanges = pendingChangeCount > 0; + const dataEditCommitMode = dataEditTransactionOptions?.commitMode === 'auto' ? 'auto' : 'manual'; + const dataEditAutoCommitDelayMs = DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS.some((item) => item.value === dataEditTransactionOptions?.autoCommitDelayMs) + ? Number(dataEditTransactionOptions?.autoCommitDelayMs) + : 5000; + const [autoCommitRemainingSeconds, setAutoCommitRemainingSeconds] = useState(null); + const autoCommitTimerRef = useRef | null>(null); + const autoCommitCountdownRef = useRef | null>(null); + const autoCommitChangeTokenRef = useRef(0); + const autoCommitFailedTokenRef = useRef(-1); + const clearAutoCommitTimer = useCallback(() => { + if (autoCommitTimerRef.current) { + clearTimeout(autoCommitTimerRef.current); + autoCommitTimerRef.current = null; + } + if (autoCommitCountdownRef.current) { + clearInterval(autoCommitCountdownRef.current); + autoCommitCountdownRef.current = null; + } + setAutoCommitRemainingSeconds(null); + }, []); const allSelectedAreDeleted = useMemo(() => { if (selectedRowKeys.length === 0) return false; @@ -3979,6 +4007,11 @@ const DataGrid: React.FC = ({ }, [addedRows, rowKeyStr]); const modifiedRowKeySet = useMemo(() => new Set(Object.keys(modifiedRows)), [modifiedRows]); + useEffect(() => { + autoCommitChangeTokenRef.current += 1; + autoCommitFailedTokenRef.current = -1; + }, [addedRows, modifiedRows, deletedRowKeys]); + const rowClassName = useCallback((record: Item) => { const k = record?.[GONAVI_ROW_KEY]; if (k === undefined || k === null) return ''; @@ -5255,7 +5288,8 @@ const DataGrid: React.FC = ({ visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn, connectionId, tableName, connections]); - const handleCommit = async () => { + const handleCommit = useCallback(async (source: 'manual' | 'auto' = 'manual') => { + clearAutoCommitTimer(); if (!connectionId || !tableName) return; const conn = connections.find(c => c.id === connectionId); if (!conn) return; @@ -5301,6 +5335,7 @@ const DataGrid: React.FC = ({ if (deletes.length > 0) logSql += `DELETE ${deletes.length} rows;\n`; if (res.success) { + autoCommitFailedTokenRef.current = -1; addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), @@ -5310,7 +5345,7 @@ const DataGrid: React.FC = ({ message: res.message, dbName }); - void message.success("事务提交成功"); + void message.success(source === 'auto' ? "自动提交成功" : "事务提交成功"); setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); @@ -5326,9 +5361,70 @@ const DataGrid: React.FC = ({ message: res.message, dbName }); - void message.error("提交失败: " + res.message); + if (source === 'auto') { + autoCommitFailedTokenRef.current = autoCommitChangeTokenRef.current; + } + void message.error((source === 'auto' ? "自动提交失败: " : "提交失败: ") + res.message); } - }; + }, [ + clearAutoCommitTimer, + connectionId, + tableName, + connections, + addedRows, + modifiedRows, + deletedRowKeys, + data, + effectiveEditLocator, + visibleColumnNames, + rowKeyStr, + normalizeCommitCellValue, + shouldCommitColumn, + dbName, + addSqlLog, + onReload, + ]); + + useEffect(() => { + if (!canModifyData || dataEditCommitMode !== 'auto' || !hasChanges) { + clearAutoCommitTimer(); + return; + } + if (autoCommitFailedTokenRef.current === autoCommitChangeTokenRef.current) { + clearAutoCommitTimer(); + return; + } + + const delayMs = dataEditAutoCommitDelayMs; + const dueAt = Date.now() + delayMs; + const updateRemaining = () => { + setAutoCommitRemainingSeconds(Math.max(1, Math.ceil((dueAt - Date.now()) / 1000))); + }; + clearAutoCommitTimer(); + updateRemaining(); + autoCommitCountdownRef.current = setInterval(updateRemaining, 250); + autoCommitTimerRef.current = setTimeout(() => { + autoCommitTimerRef.current = null; + if (autoCommitCountdownRef.current) { + clearInterval(autoCommitCountdownRef.current); + autoCommitCountdownRef.current = null; + } + setAutoCommitRemainingSeconds(null); + void handleCommit('auto'); + }, delayMs); + + return clearAutoCommitTimer; + }, [ + canModifyData, + dataEditCommitMode, + dataEditAutoCommitDelayMs, + hasChanges, + pendingChangeCount, + handleCommit, + clearAutoCommitTimer, + ]); + + useEffect(() => clearAutoCommitTimer, [clearAutoCommitTimer]); const copyToClipboard = useCallback((text: string) => { navigator.clipboard.writeText(text).catch(console.error); @@ -7350,12 +7446,23 @@ const DataGrid: React.FC = ({ ), [displayColumnNames, columnMetaMap, columnMetaMapByLowerName, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); const handleRefreshGrid = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); setSelectedRowKeys([]); if (onReload) onReload(); - }, [onReload]); + }, [clearAutoCommitTimer, onReload]); + + const handleResetPendingChanges = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + }, [clearAutoCommitTimer]); const handleToggleFilterWithDefault = useCallback(() => { if (!onToggleFilter) return; @@ -7425,6 +7532,10 @@ const DataGrid: React.FC = ({ copiedCellPatchColumnCount={copiedCellPatch ? Object.keys(copiedCellPatch.values).length : 0} hasChanges={hasChanges} pendingChangeCount={pendingChangeCount} + dataEditCommitMode={dataEditCommitMode} + dataEditAutoCommitDelayMs={dataEditAutoCommitDelayMs} + dataEditAutoCommitDelayOptions={DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS} + autoCommitRemainingSeconds={autoCommitRemainingSeconds} canImport={canImport} canExport={canExport} isQueryResultExport={isQueryResultExport} @@ -7451,12 +7562,9 @@ const DataGrid: React.FC = ({ exportMenu={exportMenu} queryResultCopyMenu={queryResultCopyMenu} dbType={dbType} - onResetPendingChanges={() => { - setAddedRows([]); - setModifiedRows({}); - setDeletedRowKeys(new Set()); - setModifiedColumns({}); - }} + onResetPendingChanges={handleResetPendingChanges} + onDataEditCommitModeChange={(mode) => setDataEditTransactionOptions({ commitMode: mode })} + onDataEditAutoCommitDelayChange={(delayMs) => setDataEditTransactionOptions({ autoCommitDelayMs: delayMs })} onRefresh={handleRefreshGrid} onToggleFilterClick={handleToggleFilterWithDefault} onAddRow={handleAddRow} diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx index 9cc70d0..ea1a69d 100644 --- a/frontend/src/components/DataGridToolbarFrame.tsx +++ b/frontend/src/components/DataGridToolbarFrame.tsx @@ -65,6 +65,10 @@ export interface DataGridToolbarFrameProps { copiedCellPatchColumnCount: number; hasChanges: boolean; pendingChangeCount: number; + dataEditCommitMode: 'manual' | 'auto'; + dataEditAutoCommitDelayMs: number; + dataEditAutoCommitDelayOptions: Array<{ value: number; label: string }>; + autoCommitRemainingSeconds: number | null; canImport: boolean; canExport: boolean; isQueryResultExport: boolean; @@ -92,6 +96,8 @@ export interface DataGridToolbarFrameProps { queryResultCopyMenu: MenuProps['items']; dbType: string; onResetPendingChanges: () => void; + onDataEditCommitModeChange: (mode: 'manual' | 'auto') => void; + onDataEditAutoCommitDelayChange: (delayMs: number) => void; onRefresh: () => void; onToggleFilterClick: () => void; onAddRow: () => void; @@ -159,6 +165,10 @@ const DataGridToolbarFrame: React.FC = ({ copiedCellPatchColumnCount, hasChanges, pendingChangeCount, + dataEditCommitMode, + dataEditAutoCommitDelayMs, + dataEditAutoCommitDelayOptions, + autoCommitRemainingSeconds, canImport, canExport, isQueryResultExport, @@ -186,6 +196,8 @@ const DataGridToolbarFrame: React.FC = ({ queryResultCopyMenu, dbType, onResetPendingChanges, + onDataEditCommitModeChange, + onDataEditAutoCommitDelayChange, onRefresh, onToggleFilterClick, onAddRow, @@ -360,6 +372,32 @@ const DataGridToolbarFrame: React.FC = ({ )} {hasChanges && } + + + )} + {dataEditCommitMode === 'auto' && hasChanges && autoCommitRemainingSeconds !== null && ( + + {autoCommitRemainingSeconds}s 后提交 + + )} )} diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index fe64c10..7eb84e8 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -516,7 +516,9 @@ describe('QueryEditor external SQL save', () => { editorState.editor.layout.mockClear(); storeState.updateQueryTabDraft.mockReset(); clearQueryTabDraft('tab-1'); + clearQueryTabDraft('tab-2'); clearSQLFileTabDraft('tab-1'); + clearSQLFileTabDraft('tab-2'); }); afterEach(() => { @@ -556,7 +558,9 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer.toJSON())).toContain('等待执行 SQL'); - expect(storeState.setQueryOptions).toHaveBeenCalledWith({ showQueryResultsPanel: true }); + expect(storeState.updateQueryTabDraft).toHaveBeenCalledWith('tab-1', { + resultPanelVisible: true, + }); }); it('hides the expanded empty query results panel from the inline hide action', async () => { @@ -577,7 +581,9 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL'); - expect(storeState.setQueryOptions).toHaveBeenLastCalledWith({ showQueryResultsPanel: false }); + expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', { + resultPanelVisible: false, + }); }); it('auto expands the query results panel after a successful execution returns rows', async () => { @@ -603,7 +609,9 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer.toJSON())).toContain('结果 1'); - expect(storeState.setQueryOptions).toHaveBeenCalledWith({ showQueryResultsPanel: true }); + expect(storeState.updateQueryTabDraft).toHaveBeenCalledWith('tab-1', { + resultPanelVisible: true, + }); }); it('keeps the inline hide action available after query results render rows', async () => { @@ -633,7 +641,9 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer.toJSON())).not.toContain('结果 1'); - expect(storeState.setQueryOptions).toHaveBeenLastCalledWith({ showQueryResultsPanel: false }); + expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', { + resultPanelVisible: false, + }); }); it('toggles the query results panel with Ctrl/Cmd+Shift+M', async () => { @@ -699,6 +709,81 @@ describe('QueryEditor external SQL save', () => { expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL'); }); + it('shows the query results panel with the shortcut after manually hiding it', async () => { + storeState.appearance.uiVersion = 'v2'; + + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + findButton(renderer, '结果').props.onClick(); + }); + await act(async () => { + findButton(renderer, '隐藏').props.onClick(); + }); + expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL'); + + const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`); + const toggleEvent = { + ctrlKey: !isMacRuntime, + metaKey: isMacRuntime, + altKey: false, + shiftKey: true, + key: 'm', + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener(toggleEvent)); + }); + + expect(toggleEvent.preventDefault).toHaveBeenCalled(); + expect(textContent(renderer.toJSON())).toContain('等待执行 SQL'); + expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', { + resultPanelVisible: true, + }); + + renderer.unmount(); + }); + + it('keeps query result panel visibility isolated per tab', async () => { + storeState.appearance.uiVersion = 'v2'; + storeState.queryOptions.showQueryResultsPanel = false; + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL'); + + await act(async () => { + renderer.update(); + }); + + expect(textContent(renderer.toJSON())).toContain('等待执行 SQL'); + + renderer.unmount(); + }); + it('keeps table name completion available after typing in a fresh query tab', async () => { let renderer!: ReactTestRenderer; autoFetchState.visible = true; @@ -3270,7 +3355,6 @@ describe('QueryEditor external SQL save', () => { const moveListeners: Array<(event: MouseEvent) => void> = []; const upListeners: Array<() => void> = []; const frameCallbacks: FrameRequestCallback[] = []; - storeState.queryOptions.showQueryResultsPanel = true; vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => { if (type === 'mousemove') moveListeners.push(listener); if (type === 'mouseup') upListeners.push(listener); @@ -3282,7 +3366,7 @@ describe('QueryEditor external SQL save', () => { let renderer!: ReactTestRenderer; await act(async () => { - renderer = create(); + renderer = create(); }); const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度'); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index ab1dfed..f8e06d6 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -2021,7 +2021,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); - const [isResultPanelVisible, setIsResultPanelVisible] = useState(Boolean(queryOptions?.showQueryResultsPanel)); + const [isResultPanelVisible, setIsResultPanelVisible] = useState( + () => tab.resultPanelVisible === true + ); const shortcutOptions = useStore(state => state.shortcutOptions); const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); const runQueryShortcutBinding = useMemo( @@ -2045,19 +2047,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc [activeShortcutPlatform], ); useEffect(() => { - setIsResultPanelVisible(Boolean(queryOptions?.showQueryResultsPanel)); - }, [queryOptions?.showQueryResultsPanel]); + setIsResultPanelVisible(tab.resultPanelVisible === true); + }, [tab.id, tab.resultPanelVisible]); const updateResultPanelVisibility = useCallback((visible: boolean) => { setIsResultPanelVisible(visible); - setQueryOptions({ showQueryResultsPanel: visible }); - }, [setQueryOptions]); + updateQueryTabDraft(tab.id, { resultPanelVisible: visible }); + }, [tab.id, updateQueryTabDraft]); const toggleResultPanelVisibility = useCallback(() => { setIsResultPanelVisible((previousVisible) => { const nextVisible = !previousVisible; - setQueryOptions({ showQueryResultsPanel: nextVisible }); + updateQueryTabDraft(tab.id, { resultPanelVisible: nextVisible }); return nextVisible; }); - }, [setQueryOptions]); + }, [tab.id, updateQueryTabDraft]); const autoFetchVisible = useAutoFetchVisibility(); const currentSavedQuery = useMemo(() => { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a554564..7b92684 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -63,7 +63,14 @@ import FindInDatabaseModal from './FindInDatabaseModal'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; -import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName, resolveSidebarMetadataDialect, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata'; +import { + buildMySQLCompatibleViewMetadataSqls, + isSidebarViewTableType, + normalizeSidebarViewMetadataEntry, + resolveSidebarMetadataDialect, + resolveSidebarRuntimeDatabase, + type SidebarViewMetadataEntry, +} from '../utils/sidebarMetadata'; import { splitQualifiedNameLast } from '../utils/qualifiedName'; import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql'; import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; @@ -1335,6 +1342,14 @@ const Sidebar: React.FC<{ return `${schema}.${name}`; }; + const buildSidebarObjectKeyName = (dbName: string, schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const name = String(objectName || '').trim(); + if (!schema || !name || name.includes('.')) return name; + if (schema.toLowerCase() === String(dbName || '').trim().toLowerCase()) return name; + return `${schema}.${name}`; + }; + const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { const parsed = splitQualifiedNameLast(qualifiedName); return { @@ -1586,13 +1601,13 @@ const Sidebar: React.FC<{ return { results, hasSuccessfulQuery }; }; - const loadViews = async (conn: any, dbName: string): Promise<{ views: string[]; supported: boolean }> => { + const loadViews = async (conn: any, dbName: string): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { const savedConn = conn as SavedConnection; const dialect = getMetadataDialect(savedConn); const querySpecs = buildViewsMetadataQuerySpecs(dialect, dbName); const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); const seen = new Set(); - const views: string[] = []; + const views: SidebarViewMetadataEntry[] = []; results.forEach((queryResult) => { queryResult.rows.forEach((row) => { @@ -1603,10 +1618,12 @@ const Sidebar: React.FC<{ getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) || getMySQLShowTablesName(row) || getFirstRowValue(row); - const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName); - if (!fullName || seen.has(fullName)) return; - seen.add(fullName); - views.push(fullName); + const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); + if (!entry) return; + const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + views.push(entry); }); }); return { views, supported: hasSuccessfulQuery }; @@ -1615,7 +1632,7 @@ const Sidebar: React.FC<{ const loadStarRocksMaterializedViews = async ( conn: any, dbName: string - ): Promise<{ views: string[]; supported: boolean }> => { + ): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { const dialect = getMetadataDialect(conn as SavedConnection); if (dialect !== 'starrocks') { return { views: [], supported: false }; @@ -1634,7 +1651,7 @@ const Sidebar: React.FC<{ ]); const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); const seen = new Set(); - const views: string[] = []; + const views: SidebarViewMetadataEntry[] = []; results.forEach((queryResult) => { queryResult.rows.forEach((row) => { @@ -1642,10 +1659,12 @@ const Sidebar: React.FC<{ const viewName = getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name']) || getFirstRowValue(row); - const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName); - if (!fullName || seen.has(fullName)) return; - seen.add(fullName); - views.push(fullName); + const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); + if (!entry) return; + const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + views.push(entry); }); }); @@ -2116,28 +2135,28 @@ const Sidebar: React.FC<{ loadFunctions(conn, conn.dbName), loadDatabaseEvents(conn, conn.dbName), ]); - const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : []; - const materializedViewRows: string[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : []; + const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewsResult.views) ? viewsResult.views : []; + const materializedViewRows: SidebarViewMetadataEntry[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : []; const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : []; const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : []; const eventRows: any[] = Array.isArray(eventsResult.events) ? eventsResult.events : []; const schemaRows: string[] = Array.isArray(schemasResult.schemas) ? schemasResult.schemas : []; - const viewEntries = viewRows.map((viewName: string) => { - const parsed = splitQualifiedName(viewName); + const viewEntries = viewRows.map((entry: SidebarViewMetadataEntry) => { + const parsed = splitQualifiedName(entry.viewName); return { - viewName, - schemaName: parsed.schemaName, - displayName: getSidebarTableDisplayName(conn, viewName), + viewName: entry.viewName, + schemaName: entry.schemaName || parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, entry.viewName), }; }); - const materializedViewEntries = materializedViewRows.map((viewName: string) => { - const parsed = splitQualifiedName(viewName); + const materializedViewEntries = materializedViewRows.map((entry: SidebarViewMetadataEntry) => { + const parsed = splitQualifiedName(entry.viewName); return { - viewName, - schemaName: parsed.schemaName, - displayName: getSidebarTableDisplayName(conn, viewName), + viewName: entry.viewName, + schemaName: entry.schemaName || parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, entry.viewName), }; }); @@ -2254,23 +2273,29 @@ const Sidebar: React.FC<{ }; }; - const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({ - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-view-${entry.viewName}`, - icon: , - type: 'view', - dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName }, - isLeaf: true, - }); + const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => { + const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName); + return { + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-view-${keyName}`, + icon: , + type: 'view', + dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName }, + isLeaf: true, + }; + }; - const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({ - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-materialized-view-${entry.viewName}`, - icon: , - type: 'materialized-view', - dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' }, - isLeaf: true, - }); + const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => { + const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName); + return { + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-materialized-view-${keyName}`, + icon: , + type: 'materialized-view', + dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' }, + isLeaf: true, + }; + }; const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({ title: entry.displayName, @@ -3125,8 +3150,16 @@ const Sidebar: React.FC<{ } const tableRows: any[] = Array.isArray(res.data) ? res.data : []; - const viewRows: string[] = Array.isArray(viewResult.views) ? viewResult.views : []; - const viewSet = new Set(viewRows.map((view: string) => view.toLowerCase())); + const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewResult.views) ? viewResult.views : []; + const viewSet = new Set( + viewRows.flatMap((view) => { + const names = [view.viewName.toLowerCase()]; + if (view.schemaName && !view.viewName.includes('.')) { + names.push(`${view.schemaName}.${view.viewName}`.toLowerCase()); + } + return names; + }) + ); const tableObjects: BatchObjectItem[] = tableRows .map((row: any) => Object.values(row)[0] as string) @@ -3139,13 +3172,16 @@ const Sidebar: React.FC<{ dataRef: { ...conn, tableName, dbName, objectType: 'table' }, })); - const viewObjects: BatchObjectItem[] = viewRows.map((viewName: string) => ({ - title: getSidebarTableDisplayName(conn, viewName), - key: `${conn.id}-${dbName}-view-${viewName}`, - objectName: viewName, - objectType: 'view' as const, - dataRef: { ...conn, tableName: viewName, dbName, objectType: 'view' }, - })); + const viewObjects: BatchObjectItem[] = viewRows.map((view) => { + const keyName = buildSidebarObjectKeyName(dbName, view.schemaName, view.viewName); + return { + title: getSidebarTableDisplayName(conn, view.viewName), + key: `${conn.id}-${dbName}-view-${keyName}`, + objectName: view.viewName, + objectType: 'view' as const, + dataRef: { ...conn, tableName: view.viewName, schemaName: view.schemaName, dbName, objectType: 'view' }, + }; + }); tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 514824a..38b3ac0 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1114,6 +1114,11 @@ export interface QueryOptions { showQueryResultsPanel: boolean; } +export interface DataEditTransactionOptions { + commitMode: "manual" | "auto"; + autoCommitDelayMs: number; +} + interface AppState { connections: SavedConnection[]; connectionTags: ConnectionTag[]; @@ -1131,6 +1136,7 @@ interface AppState { globalProxy: GlobalProxyConfig; sqlFormatOptions: { keywordCase: "upper" | "lower" }; queryOptions: QueryOptions; + dataEditTransactionOptions: DataEditTransactionOptions; shortcutOptions: ShortcutOptions; sqlSnippets: SqlSnippet[]; sqlLogs: SqlLog[]; @@ -1202,7 +1208,12 @@ interface AppState { addTab: (tab: TabData) => void; updateQueryTabDraft: ( id: string, - draft: Partial>, + draft: Partial< + Pick< + TabData, + "query" | "connectionId" | "dbName" | "title" | "resultPanelVisible" + > + >, ) => void; closeTab: (id: string) => void; closeOtherTabs: (id: string) => void; @@ -1231,6 +1242,9 @@ interface AppState { replaceGlobalProxy: (proxy: Partial) => void; setSqlFormatOptions: (options: { keywordCase: "upper" | "lower" }) => void; setQueryOptions: (options: Partial) => void; + setDataEditTransactionOptions: ( + options: Partial, + ) => void; updateShortcut: ( action: ShortcutAction, binding: Partial, @@ -1434,6 +1448,10 @@ const sanitizeQueryTabs = (value: unknown): TabData[] => { connectionId: toTrimmedString(raw.connectionId), dbName: toTrimmedString(raw.dbName), query, + resultPanelVisible: + typeof raw.resultPanelVisible === "boolean" + ? raw.resultPanelVisible + : undefined, filePath: filePath || undefined, savedQueryId: savedQueryId || undefined, readOnly: raw.readOnly === true, @@ -1595,6 +1613,24 @@ const sanitizeQueryOptions = (value: unknown): QueryOptions => { }; }; +const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = new Set([3000, 5000, 10000, 30000]); + +const sanitizeDataEditTransactionOptions = ( + value: unknown, +): DataEditTransactionOptions => { + const raw = + value && typeof value === "object" + ? (value as Record) + : {}; + const autoCommitDelayMs = Number(raw.autoCommitDelayMs); + return { + commitMode: raw.commitMode === "auto" ? "auto" : "manual", + autoCommitDelayMs: DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS.has(autoCommitDelayMs) + ? autoCommitDelayMs + : 5000, + }; +}; + const sanitizeTableAccessCount = (value: unknown): Record => { const raw = value && typeof value === "object" @@ -1981,6 +2017,10 @@ export const useStore = create()( showColumnType: true, showQueryResultsPanel: false, }, + dataEditTransactionOptions: { + commitMode: "manual", + autoCommitDelayMs: 5000, + }, shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS), sqlSnippets: DEFAULT_SQL_SNIPPETS, sqlLogs: [], @@ -2308,41 +2348,48 @@ export const useStore = create()( addTab: (tab) => set((state) => { - const index = state.tabs.findIndex((t) => t.id === tab.id); + const incomingTab = + tab.type === "query" && tab.resultPanelVisible === undefined + ? { + ...tab, + resultPanelVisible: state.queryOptions.showQueryResultsPanel, + } + : tab; + const index = state.tabs.findIndex((t) => t.id === incomingTab.id); if (index !== -1) { // Update existing tab with new data (e.g. switch initialTab) const newTabs = [...state.tabs]; - newTabs[index] = { ...newTabs[index], ...tab }; + newTabs[index] = { ...newTabs[index], ...incomingTab }; return { tabs: newTabs, - activeTabId: tab.id, + activeTabId: incomingTab.id, activeContext: resolveActiveContextForTabId( newTabs, - tab.id, + incomingTab.id, state.activeContext, ), }; } // 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab if ( - (tab.type === "table" || tab.type === "design") && - tab.tableName && - tab.connectionId && - tab.dbName + (incomingTab.type === "table" || incomingTab.type === "design") && + incomingTab.tableName && + incomingTab.connectionId && + incomingTab.dbName ) { const semanticIndex = state.tabs.findIndex( (t) => - t.type === tab.type && - t.connectionId === tab.connectionId && - t.dbName === tab.dbName && - t.tableName === tab.tableName, + t.type === incomingTab.type && + t.connectionId === incomingTab.connectionId && + t.dbName === incomingTab.dbName && + t.tableName === incomingTab.tableName, ); if (semanticIndex !== -1) { const existingTab = state.tabs[semanticIndex]; const newTabs = [...state.tabs]; newTabs[semanticIndex] = { ...existingTab, - ...tab, + ...incomingTab, id: existingTab.id, }; return { @@ -2357,19 +2404,19 @@ export const useStore = create()( } } // 语义去重:对 query 类型按 savedQueryId 匹配已有 Tab(避免保存后重复打开) - if (tab.type === "query" && tab.savedQueryId) { + if (incomingTab.type === "query" && incomingTab.savedQueryId) { const savedQueryIndex = state.tabs.findIndex( (t) => t.type === "query" && - (t.savedQueryId === tab.savedQueryId || - t.id === tab.savedQueryId), + (t.savedQueryId === incomingTab.savedQueryId || + t.id === incomingTab.savedQueryId), ); if (savedQueryIndex !== -1) { const existingTab = state.tabs[savedQueryIndex]; const newTabs = [...state.tabs]; newTabs[savedQueryIndex] = { ...existingTab, - ...tab, + ...incomingTab, id: existingTab.id, }; return { @@ -2383,13 +2430,13 @@ export const useStore = create()( }; } } - const nextTabs = [...state.tabs, tab]; + const nextTabs = [...state.tabs, incomingTab]; return { tabs: nextTabs, - activeTabId: tab.id, + activeTabId: incomingTab.id, activeContext: resolveActiveContextForTabId( nextTabs, - tab.id, + incomingTab.id, state.activeContext, ), }; @@ -2433,6 +2480,13 @@ export const useStore = create()( changed = true; } } + if (draft.resultPanelVisible !== undefined) { + const nextResultPanelVisible = draft.resultPanelVisible === true; + if (nextTab.resultPanelVisible !== nextResultPanelVisible) { + nextTab.resultPanelVisible = nextResultPanelVisible; + changed = true; + } + } return nextTab; }); @@ -2711,6 +2765,13 @@ export const useStore = create()( set((state) => ({ queryOptions: { ...state.queryOptions, ...options }, })), + setDataEditTransactionOptions: (options) => + set((state) => ({ + dataEditTransactionOptions: sanitizeDataEditTransactionOptions({ + ...state.dataEditTransactionOptions, + ...options, + }), + })), updateShortcut: (action, binding, platform) => { runWithExplicitShortcutPersistence(() => { const targetPlatform = platform ?? getShortcutPlatform(); @@ -3117,6 +3178,8 @@ export const useStore = create()( state.sqlFormatOptions, ); nextState.queryOptions = sanitizeQueryOptions(state.queryOptions); + nextState.dataEditTransactionOptions = + sanitizeDataEditTransactionOptions(state.dataEditTransactionOptions); nextState.shortcutOptions = sanitizeShortcutOptions( state.shortcutOptions, ); @@ -3219,6 +3282,9 @@ export const useStore = create()( sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), + dataEditTransactionOptions: sanitizeDataEditTransactionOptions( + state.dataEditTransactionOptions, + ), shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions), sqlLogs: sanitizeSqlLogs(state.sqlLogs), sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets), @@ -3249,6 +3315,7 @@ export const useStore = create()( : toPersistedGlobalProxy(state.globalProxy), sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions, + dataEditTransactionOptions: state.dataEditTransactionOptions, shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions), sqlLogs: sanitizeSqlLogs(state.sqlLogs), sqlSnippets: state.sqlSnippets, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 67abe18..8044a38 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -412,6 +412,7 @@ export interface TabData { dbName?: string; tableName?: string; query?: string; + resultPanelVisible?: boolean; queryMode?: "standard" | "object-edit"; filePath?: string; initialTab?: string; diff --git a/frontend/src/utils/columnDefinition.test.ts b/frontend/src/utils/columnDefinition.test.ts index cf8d6d6..e8d00fb 100644 --- a/frontend/src/utils/columnDefinition.test.ts +++ b/frontend/src/utils/columnDefinition.test.ts @@ -29,6 +29,37 @@ describe('columnDefinition metadata normalization', () => { }); }); + it('prefers complete column type aliases over base data type', () => { + const column = { + COLUMN_NAME: 'USER_NAME', + DATA_TYPE: 'varchar', + COLUMN_TYPE: 'varchar(64)', + IS_NULLABLE: 'NO', + }; + + expect(normalizeColumnDefinition(column)).toMatchObject({ + name: 'USER_NAME', + type: 'varchar(64)', + nullable: 'NO', + }); + }); + + it('builds display type from base type and length metadata', () => { + const column = { + column_name: 'amount', + data_type: 'decimal', + numeric_precision: 10, + numeric_scale: 2, + is_nullable: 'YES', + }; + + expect(normalizeColumnDefinition(column)).toMatchObject({ + name: 'amount', + type: 'decimal(10,2)', + nullable: 'YES', + }); + }); + it('maps boolean primary and unique metadata aliases to GoNavi keys', () => { expect(getColumnDefinitionKey({ column_name: 'id', isPrimary: true })).toBe('PRI'); expect(getColumnDefinitionKey({ column_name: 'id', primary_key: 't' })).toBe('PRI'); diff --git a/frontend/src/utils/columnDefinition.ts b/frontend/src/utils/columnDefinition.ts index ca03235..22d9c12 100644 --- a/frontend/src/utils/columnDefinition.ts +++ b/frontend/src/utils/columnDefinition.ts @@ -48,13 +48,72 @@ const readBooleanProperty = (value: unknown, keys: string[]): boolean => { return text === '1' || text === 't' || text === 'true' || text === 'y' || text === 'yes' || text === 'pri' || text === 'primary'; }; +const readNumberProperty = (value: unknown, keys: string[]): number => { + const raw = readProperty(value, keys); + if (raw === undefined || raw === null || raw === '') return 0; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0; +}; + export const getColumnDefinitionName = (column: unknown): string => ( readStringProperty(column, ['name', 'Name', 'COLUMN_NAME', 'column_name', 'field', 'Field']) ); -export const getColumnDefinitionType = (column: unknown): string => ( - readStringProperty(column, ['type', 'Type', 'DATA_TYPE', 'data_type']) -); +export const getColumnDefinitionType = (column: unknown): string => { + const fullType = readStringProperty(column, [ + 'COLUMN_TYPE', + 'column_type', + 'FULL_TYPE', + 'full_type', + 'FULL_DATA_TYPE', + 'full_data_type', + 'TYPE_NAME', + 'type_name', + 'Type', + 'type', + ]); + if (fullType) return fullType; + + const dataType = readStringProperty(column, ['DATA_TYPE', 'data_type']); + if (!dataType || /\(.+\)/.test(dataType)) return dataType; + + const upperType = dataType.toUpperCase(); + const charLength = readNumberProperty(column, [ + 'CHARACTER_MAXIMUM_LENGTH', + 'character_maximum_length', + 'CHARACTER_MAX_LENGTH', + 'character_max_length', + 'CHAR_LENGTH', + 'char_length', + 'LENGTH', + 'length', + ]); + if (charLength > 0 && /(CHAR|VARCHAR|BINARY|VARBINARY|NCHAR|NVARCHAR)/.test(upperType)) { + return `${dataType}(${charLength})`; + } + + const precision = readNumberProperty(column, [ + 'NUMERIC_PRECISION', + 'numeric_precision', + 'DATA_PRECISION', + 'data_precision', + 'PRECISION', + 'precision', + ]); + if (precision > 0 && /(DECIMAL|NUMERIC|NUMBER)/.test(upperType)) { + const scale = readNumberProperty(column, [ + 'NUMERIC_SCALE', + 'numeric_scale', + 'DATA_SCALE', + 'data_scale', + 'SCALE', + 'scale', + ]); + return scale > 0 ? `${dataType}(${precision},${scale})` : `${dataType}(${precision})`; + } + + return dataType; +}; export const getColumnDefinitionKey = (column: unknown): string => { const key = readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key']); @@ -89,7 +148,7 @@ export const normalizeColumnDefinition = (column: unknown): ColumnDefinition => ...source, name: getColumnDefinitionName(column), type: getColumnDefinitionType(column), - nullable: readStringProperty(column, ['nullable', 'Nullable', 'NULLABLE', 'is_nullable']), + nullable: readStringProperty(column, ['nullable', 'Nullable', 'NULLABLE', 'is_nullable', 'IS_NULLABLE', 'Null', 'null']), key: getColumnDefinitionKey(column), default: source.default, extra: getColumnDefinitionExtra(column), diff --git a/frontend/src/utils/sidebarLocate.test.ts b/frontend/src/utils/sidebarLocate.test.ts index df1cf35..515c074 100644 --- a/frontend/src/utils/sidebarLocate.test.ts +++ b/frontend/src/utils/sidebarLocate.test.ts @@ -1041,6 +1041,70 @@ describe('sidebarLocate', () => { ]); }); + it('prefers the current database schema when bare view nodes keep schema metadata separately', () => { + const target = resolveSidebarLocateTarget({ + tabId: 'conn-1-SYSDBA-view-V_ACCOUNT', + connectionId: 'conn-1', + dbName: 'SYSDBA', + tableName: 'V_ACCOUNT', + objectGroup: 'views', + }, { groupBySchema: true }); + + const tree = [ + { + key: 'conn-1', + children: [ + { + key: 'conn-1-SYSDBA', + dataRef: { id: 'conn-1', dbName: 'SYSDBA' }, + children: [ + { + key: 'conn-1-SYSDBA-schema-REPORT', + children: [ + { + key: 'conn-1-SYSDBA-schema-REPORT-views', + children: [ + { + key: 'conn-1-SYSDBA-view-REPORT.V_ACCOUNT', + title: 'V_ACCOUNT', + type: 'view', + dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'V_ACCOUNT', schemaName: 'REPORT' }, + }, + ], + }, + ], + }, + { + key: 'conn-1-SYSDBA-schema-SYSDBA', + children: [ + { + key: 'conn-1-SYSDBA-schema-SYSDBA-views', + children: [ + { + key: 'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT', + title: 'V_ACCOUNT', + type: 'view', + dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'V_ACCOUNT', schemaName: 'SYSDBA' }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]; + + expect(findSidebarNodePathForLocate(tree, target)).toEqual([ + 'conn-1', + 'conn-1-SYSDBA', + 'conn-1-SYSDBA-schema-SYSDBA', + 'conn-1-SYSDBA-schema-SYSDBA-views', + 'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT', + ]); + }); + it('does not guess a schema-qualified view when no current-schema preference resolves ambiguity', () => { const target = resolveSidebarLocateTarget({ tabId: 'conn-1-SYSDBA-view-V_ACCOUNT', diff --git a/frontend/src/utils/sidebarLocate.ts b/frontend/src/utils/sidebarLocate.ts index bf83885..c7661dd 100644 --- a/frontend/src/utils/sidebarLocate.ts +++ b/frontend/src/utils/sidebarLocate.ts @@ -272,8 +272,6 @@ const matchesLocateObjectName = ( const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName; const resolvedTargetSchema = toTrimmedString(target.schemaName) || targetParsed.schemaName; - if (normalizeLocateName(normalizedNodeName) === normalizeLocateName(target.tableName)) return true; - if ( resolvedTargetSchema && !resolvedNodeSchema @@ -542,8 +540,15 @@ export const findSidebarNodePathForLocate = ( } } + const relaxedPaths = collectSidebarNodePathsForLocateByObject( + nodes, + target, + { allowUnqualifiedSchemaMatch: true }, + ); + const relaxedPath = selectPreferredSidebarLocatePath(relaxedPaths, target); + if (relaxedPath) return relaxedPath; + if (hasLocateTargetSchema(target)) return null; - const relaxedPaths = collectSidebarNodePathsForLocateByObject(nodes, target, { allowUnqualifiedSchemaMatch: true }); - return selectPreferredSidebarLocatePath(relaxedPaths, target); + return null; }; diff --git a/frontend/src/utils/sidebarMetadata.test.ts b/frontend/src/utils/sidebarMetadata.test.ts index ecdb4e0..dc8ed00 100644 --- a/frontend/src/utils/sidebarMetadata.test.ts +++ b/frontend/src/utils/sidebarMetadata.test.ts @@ -1,12 +1,29 @@ import { describe, expect, it } from 'vitest'; -import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName, resolveSidebarMetadataDialect } from './sidebarMetadata'; +import { + buildMySQLCompatibleViewMetadataSqls, + isSidebarViewTableType, + normalizeSidebarViewMetadataEntry, + normalizeSidebarViewName, + resolveSidebarMetadataDialect, +} from './sidebarMetadata'; describe('sidebarMetadata', () => { it('normalizes MySQL-compatible view names without schema prefixes', () => { expect(normalizeSidebarViewName('mysql', 'SYSDBA', 'SYSDBA', 'SYSDBA.V_ACCOUNT')).toBe('V_ACCOUNT'); }); + it('keeps MySQL-compatible view schema metadata after display-name normalization', () => { + expect(normalizeSidebarViewMetadataEntry('mysql', 'SYSDBA', 'SYSDBA', 'SYSDBA.V_ACCOUNT')).toEqual({ + viewName: 'V_ACCOUNT', + schemaName: 'SYSDBA', + }); + expect(normalizeSidebarViewMetadataEntry('mysql', 'GDB_APP', 'SYSDBA', 'V_ACCOUNT')).toEqual({ + viewName: 'V_ACCOUNT', + schemaName: 'SYSDBA', + }); + }); + it('uses MySQL metadata queries for custom MySQL-compatible domestic drivers', () => { expect(resolveSidebarMetadataDialect('custom', 'gdb')).toBe('mysql'); expect(resolveSidebarMetadataDialect('custom', 'goldendb')).toBe('mysql'); diff --git a/frontend/src/utils/sidebarMetadata.ts b/frontend/src/utils/sidebarMetadata.ts index 2410716..0c17c1c 100644 --- a/frontend/src/utils/sidebarMetadata.ts +++ b/frontend/src/utils/sidebarMetadata.ts @@ -60,6 +60,28 @@ export const normalizeSidebarViewName = (dialect: string, dbName: string, schema return `${normalizedSchemaName}.${normalizedViewName}`; }; +export interface SidebarViewMetadataEntry { + viewName: string; + schemaName: string; +} + +export const normalizeSidebarViewMetadataEntry = ( + dialect: string, + dbName: string, + schemaName: string, + viewName: string, +): SidebarViewMetadataEntry | null => { + const normalizedViewName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName); + if (!normalizedViewName) return null; + + const parsedViewName = splitQualifiedNameLast(viewName); + const parsedNormalizedViewName = splitQualifiedNameLast(normalizedViewName); + return { + viewName: normalizedViewName, + schemaName: String(schemaName || parsedNormalizedViewName.parentPath || parsedViewName.parentPath || '').trim(), + }; +}; + export const isSidebarViewTableType = (tableType: unknown): boolean => { const normalizedType = String(tableType ?? '').trim().toUpperCase(); if (!normalizedType) return true; diff --git a/internal/db/custom_impl.go b/internal/db/custom_impl.go index 20b2eed..8a06122 100644 --- a/internal/db/custom_impl.go +++ b/internal/db/custom_impl.go @@ -251,8 +251,8 @@ func (c *CustomDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi schema = dbName } - query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns + query := fmt.Sprintf(`SELECT column_name, data_type, character_maximum_length, numeric_precision, numeric_scale, is_nullable, column_default + FROM information_schema.columns WHERE table_name = '%s'`, tableName) // Adjust for schema if likely supported @@ -272,30 +272,97 @@ func (c *CustomDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi var columns []connection.ColumnDefinition for _, row := range data { - col := connection.ColumnDefinition{} - // flexible mapping - for k, v := range row { - kl := strings.ToLower(k) - val := fmt.Sprintf("%v", v) - if strings.Contains(kl, "field") || strings.Contains(kl, "column_name") { - col.Name = val - } else if strings.Contains(kl, "type") { - col.Type = val - } else if strings.Contains(kl, "null") || strings.Contains(kl, "nullable") { - col.Nullable = val - } else if strings.Contains(kl, "default") { - col.Default = &val - } else if strings.Contains(kl, "key") { - col.Key = val - } else if strings.Contains(kl, "comment") { - col.Comment = val - } - } - columns = append(columns, col) + columns = append(columns, buildCustomColumnDefinition(row)) } return columns, nil } +func buildCustomColumnDefinition(row map[string]interface{}) connection.ColumnDefinition { + col := connection.ColumnDefinition{ + Name: customMetadataString(row, "Field", "field", "COLUMN_NAME", "column_name", "NAME", "name"), + Type: buildCustomColumnType(row), + Nullable: normalizeCustomNullable(customMetadataString(row, "Null", "null", "IS_NULLABLE", "is_nullable", "NULLABLE", "nullable")), + Key: customMetadataString(row, "Key", "key", "COLUMN_KEY", "column_key", "PRIMARY_KEY", "primary_key"), + Extra: customMetadataString(row, "Extra", "extra", "EXTRA"), + Comment: customMetadataString(row, "Comment", "comment", "COMMENTS", "comments", "COLUMN_COMMENT", "column_comment"), + } + if defaultValue, ok := customMetadataStringOK(row, "Default", "default", "COLUMN_DEFAULT", "column_default", "DATA_DEFAULT", "data_default"); ok { + col.Default = &defaultValue + } + return col +} + +func buildCustomColumnType(row map[string]interface{}) string { + rawType := customMetadataString( + row, + "COLUMN_TYPE", + "column_type", + "FULL_TYPE", + "full_type", + "FULL_DATA_TYPE", + "full_data_type", + "TYPE_NAME", + "type_name", + "Type", + "type", + "DATA_TYPE", + "data_type", + ) + if rawType == "" || strings.Contains(rawType, "(") { + return rawType + } + + upperType := strings.ToUpper(rawType) + charLength := customMetadataInt(row, "CHARACTER_MAXIMUM_LENGTH", "character_maximum_length", "CHARACTER_MAX_LENGTH", "character_max_length", "CHAR_LENGTH", "char_length", "LENGTH", "length") + if charLength > 0 && strings.Contains(upperType, "CHAR") { + return fmt.Sprintf("%s(%d)", rawType, charLength) + } + + precision := customMetadataInt(row, "NUMERIC_PRECISION", "numeric_precision", "DATA_PRECISION", "data_precision", "PRECISION", "precision") + if precision > 0 && (strings.Contains(upperType, "DECIMAL") || strings.Contains(upperType, "NUMERIC") || strings.Contains(upperType, "NUMBER")) { + scale := customMetadataInt(row, "NUMERIC_SCALE", "numeric_scale", "DATA_SCALE", "data_scale", "SCALE", "scale") + if scale > 0 { + return fmt.Sprintf("%s(%d,%d)", rawType, precision, scale) + } + return fmt.Sprintf("%s(%d)", rawType, precision) + } + + return rawType +} + +func customMetadataString(row map[string]interface{}, keys ...string) string { + value, _ := customMetadataStringOK(row, keys...) + return value +} + +func customMetadataStringOK(row map[string]interface{}, keys ...string) (string, bool) { + for _, key := range keys { + for rowKey, raw := range row { + if !strings.EqualFold(rowKey, key) || raw == nil { + continue + } + return strings.TrimSpace(fmt.Sprintf("%v", raw)), true + } + } + return "", false +} + +func customMetadataInt(row map[string]interface{}, keys ...string) int { + return parseMetadataInt(customMetadataString(row, keys...)) +} + +func normalizeCustomNullable(value string) string { + trimmed := strings.TrimSpace(value) + switch strings.ToLower(trimmed) { + case "n", "no", "false", "0": + return "NO" + case "y", "yes", "true", "1": + return "YES" + default: + return trimmed + } +} + func (c *CustomDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { return nil, fmt.Errorf("not implemented for custom") } diff --git a/internal/db/custom_impl_test.go b/internal/db/custom_impl_test.go index 4f983ed..a43d923 100644 --- a/internal/db/custom_impl_test.go +++ b/internal/db/custom_impl_test.go @@ -142,3 +142,44 @@ func TestCustomDBOnlyNormalizesBuiltInMySQLDriverDSN(t *testing.T) { t.Fatalf("non-mysql custom driver DSN should stay untouched, got %q", customMySQLDSNRecordingLastDSN) } } + +func TestBuildCustomColumnDefinitionPrefersCompleteColumnType(t *testing.T) { + col := buildCustomColumnDefinition(map[string]interface{}{ + "COLUMN_NAME": "USER_NAME", + "DATA_TYPE": "varchar", + "COLUMN_TYPE": "varchar(64)", + "IS_NULLABLE": "NO", + }) + + if col.Name != "USER_NAME" { + t.Fatalf("expected name USER_NAME, got %q", col.Name) + } + if col.Type != "varchar(64)" { + t.Fatalf("expected complete type varchar(64), got %q", col.Type) + } + if col.Nullable != "NO" { + t.Fatalf("expected nullable NO, got %q", col.Nullable) + } +} + +func TestBuildCustomColumnDefinitionBuildsTypeFromLengthAndPrecision(t *testing.T) { + nameCol := buildCustomColumnDefinition(map[string]interface{}{ + "column_name": "display_name", + "data_type": "varchar", + "character_maximum_length": int64(128), + "is_nullable": "YES", + }) + if nameCol.Type != "varchar(128)" { + t.Fatalf("expected varchar(128), got %q", nameCol.Type) + } + + amountCol := buildCustomColumnDefinition(map[string]interface{}{ + "column_name": "amount", + "data_type": "decimal", + "numeric_precision": float64(10), + "numeric_scale": float64(2), + }) + if amountCol.Type != "decimal(10,2)" { + t.Fatalf("expected decimal(10,2), got %q", amountCol.Type) + } +}