mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-16 11:39:38 +08:00
✨ feat(editor): 完善 SQL 编辑与数据编辑交互
- 结果区状态按 SQL Tab 独立保存,快捷键可恢复手动隐藏面板 - 对象设计保留完整字段类型和可空信息,完善兼容驱动 DDL 元数据 - 数据编辑新增手动/自动提交设置和自动提交倒计时 - 修复 schema 视图定位时找不到左侧树节点的问题
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 洞察');
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 === '拖动调整高度');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -412,6 +412,7 @@ export interface TabData {
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
query?: string;
|
||||
resultPanelVisible?: boolean;
|
||||
queryMode?: "standard" | "object-edit";
|
||||
filePath?: string;
|
||||
initialTab?: string;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user