feat(editor): 完善 SQL 编辑与数据编辑交互

- 结果区状态按 SQL Tab 独立保存,快捷键可恢复手动隐藏面板

- 对象设计保留完整字段类型和可空信息,完善兼容驱动 DDL 元数据

- 数据编辑新增手动/自动提交设置和自动提交倒计时

- 修复 schema 视图定位时找不到左侧树节点的问题
This commit is contained in:
Syngnat
2026-06-10 14:27:40 +08:00
parent 8ddd8a726d
commit c4153202ba
17 changed files with 890 additions and 126 deletions

View File

@@ -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<typeof storeState.dataEditTransactionOptions>) => {
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(
<DataGrid
data={[
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' },
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
pkColumns={['id']}
/>,
);
});
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({

View File

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

View File

@@ -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<DataGridProps> = ({
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<DataGridProps> = ({
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<number | null>(null);
const autoCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoCommitCountdownRef = useRef<ReturnType<typeof setInterval> | 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<DataGridProps> = ({
}, [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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
), [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<DataGridProps> = ({
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<DataGridProps> = ({
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}

View File

@@ -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<DataGridToolbarFrameProps> = ({
copiedCellPatchColumnCount,
hasChanges,
pendingChangeCount,
dataEditCommitMode,
dataEditAutoCommitDelayMs,
dataEditAutoCommitDelayOptions,
autoCommitRemainingSeconds,
canImport,
canExport,
isQueryResultExport,
@@ -186,6 +196,8 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
queryResultCopyMenu,
dbType,
onResetPendingChanges,
onDataEditCommitModeChange,
onDataEditAutoCommitDelayChange,
onRefresh,
onToggleFilterClick,
onAddRow,
@@ -360,6 +372,32 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
</Dropdown>
)}
{hasChanges && <Button icon={<UndoOutlined />} onClick={onResetPendingChanges}></Button>}
<Tooltip title="控制表数据编辑后的提交方式。手动提交更安全;自动提交会在最后一次修改后按所选时间提交。">
<Select
size="small"
value={dataEditCommitMode}
onChange={onDataEditCommitModeChange}
style={{ width: 118, flex: '0 0 auto' }}
options={[
{ value: 'manual', label: '手动提交' },
{ value: 'auto', label: '自动提交' },
]}
/>
</Tooltip>
{dataEditCommitMode === 'auto' && (
<Select
size="small"
value={dataEditAutoCommitDelayMs}
onChange={onDataEditAutoCommitDelayChange}
style={{ width: 82, flex: '0 0 auto' }}
options={dataEditAutoCommitDelayOptions}
/>
)}
{dataEditCommitMode === 'auto' && hasChanges && autoCommitRemainingSeconds !== null && (
<span style={{ fontSize: 12, color: '#888', whiteSpace: 'nowrap' }}>
{autoCommitRemainingSeconds}s
</span>
)}
</>
)}

View File

@@ -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<string, ((event?: any) => 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(<QueryEditor tab={createTab()} />);
});
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(<QueryEditor tab={createTab({ id: 'tab-1', resultPanelVisible: false })} />);
});
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
await act(async () => {
renderer.update(<QueryEditor tab={createTab({ id: 'tab-2', resultPanelVisible: true })} />);
});
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(<QueryEditor tab={createTab()} />);
renderer = create(<QueryEditor tab={createTab({ resultPanelVisible: true })} />);
});
const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度');

View File

@@ -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(() => {

View File

@@ -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<string>();
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<string>();
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: <EyeOutlined />,
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: <EyeOutlined />,
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: <ThunderboltOutlined />,
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: <ThunderboltOutlined />,
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()));

View File

@@ -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<Pick<TabData, "query" | "connectionId" | "dbName" | "title">>,
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<GlobalProxyConfig>) => void;
setSqlFormatOptions: (options: { keywordCase: "upper" | "lower" }) => void;
setQueryOptions: (options: Partial<QueryOptions>) => void;
setDataEditTransactionOptions: (
options: Partial<DataEditTransactionOptions>,
) => void;
updateShortcut: (
action: ShortcutAction,
binding: Partial<ShortcutPlatformBinding>,
@@ -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<string, unknown>)
: {};
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<string, number> => {
const raw =
value && typeof value === "object"
@@ -1981,6 +2017,10 @@ export const useStore = create<AppState>()(
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<AppState>()(
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<AppState>()(
}
}
// 语义去重:对 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<AppState>()(
};
}
}
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<AppState>()(
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<AppState>()(
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<AppState>()(
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<AppState>()(
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<AppState>()(
: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
dataEditTransactionOptions: state.dataEditTransactionOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
sqlLogs: sanitizeSqlLogs(state.sqlLogs),
sqlSnippets: state.sqlSnippets,

View File

@@ -412,6 +412,7 @@ export interface TabData {
dbName?: string;
tableName?: string;
query?: string;
resultPanelVisible?: boolean;
queryMode?: "standard" | "object-edit";
filePath?: string;
initialTab?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
}

View File

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