Merge pull request #46 from Syngnat/feature/table-and-database-export-20260203-ygf

- 支持分号多语句拆分(含引号/注释/PG dollar-quote),多结果集 Tab 展示;
- 支持选中运行;结果 Tab 支持关闭
- 修复结果区高度自动收缩/最后一行裁剪;切换结果更顺滑(关闭 ink-bar 动画、修复隐藏面板叠加显示)
- 补齐 PostgreSQL/SQLite 设计表元数据接口;
- 修复 Kingbase schema/标识符引用导致打开表失败
- 标签页右键支持关闭其他/关闭左侧/关闭右侧/关闭所有
This commit is contained in:
Syngnat
2026-02-03 22:46:25 +08:00
committed by GitHub
18 changed files with 1709 additions and 153 deletions

View File

@@ -0,0 +1,58 @@
name: 问题反馈
description: 软件问题反馈
title: "[Bug] "
labels: ["bug"]
body:
- type: checkboxes
id: searched
attributes:
label: 已经搜索过 Issues未发现重复问题*
options:
- label: 我已经搜索过 Issues没有发现重复问题
validations:
required: true
- type: input
id: system
attributes:
label: 操作系统及版本
placeholder: Windows 10 22H2 / macOS Mojave / Linux
validations:
required: true
- type: input
id: version
attributes:
label: 软件安装版本
placeholder: v0.2.3
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题简述及复现流程
description: 请详细描述你遇到的问题,并提供复现步骤
placeholder: |
1. 打开软件
2. 点击 xxx
3. 预期结果是 ...
4. 实际结果是 ...
5. 截图 ...
validations:
required: true
- type: textarea
id: extra
attributes:
label: 其他补充
description: 如果你有额外信息,请在此填写
placeholder: 可选
- type: checkboxes
id: pr
attributes:
label: 是否愿意提交 PR 修复当前 Issue
options:
- label: 我愿意尝试提交 PR

View File

@@ -0,0 +1,37 @@
name: 功能建议
description: 添加全新功能或改进现有功能
title: "[Enhancement] "
labels: ["enhancement"]
body:
- type: checkboxes
id: searched
attributes:
label: 已经搜索过 Issues未发现重复问题*
options:
- label: 我已经搜索过 Issues没有发现重复问题
validations:
required: true
- type: textarea
id: feature
attributes:
label: 功能描述
description: 请详细描述你希望添加或改进的功能
placeholder: 请描述你想要的功能
validations:
required: true
- type: textarea
id: extra
attributes:
label: 其他补充
description: 如果你有额外信息,请在此填写
placeholder: 可选
- type: checkboxes
id: pr
attributes:
label: 是否愿意提交 PR 实现当前 Issue
options:
- label: 我愿意尝试提交 PR

30
.github/ISSUE_TEMPLATE/03-generic.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: 其他反馈
description: 其他类型反馈、建议或讨论
title: "[Question] "
labels: ["question"]
body:
- type: checkboxes
id: searched
attributes:
label: 已经搜索过 Issues未发现重复问题*
options:
- label: 我已经搜索过 Issues没有发现重复问题
validations:
required: true
- type: textarea
id: content
attributes:
label: 内容
description: 请填写你的反馈、建议或讨论内容
placeholder: 请描述你的问题或想法
validations:
required: true
- type: textarea
id: extra
attributes:
label: 其他补充
description: 如果你有额外信息,请在此填写
placeholder: 可选

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -285,12 +285,12 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={() => setIsLogPanelOpen(false)}
onResizeStart={handleLogResizeStart}
@@ -343,4 +343,4 @@ function App() {
);
}
export default App;
export default App;

View File

@@ -243,26 +243,38 @@ const DataGrid: React.FC<DataGridProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
let rafId: number;
const el = containerRef.current;
if (!el) return;
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(entries => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
for (let entry of entries) {
// Use boundingClientRect for more accurate render size (including padding if any)
const height = entry.contentRect.height;
if (height < 50) return;
// Subtract header (~42px) and a buffer
const h = Math.max(100, height - 42);
setTableHeight(h);
}
const target = (entries[0]?.target as HTMLElement | undefined) || containerRef.current;
if (!target) return;
const height = target.getBoundingClientRect().height;
if (!Number.isFinite(height) || height < 50) return;
const headerEl =
(target.querySelector('.ant-table-header') as HTMLElement | null) ||
(target.querySelector('.ant-table-thead') as HTMLElement | null);
const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN;
const headerHeight =
Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42;
// 留一点余量,避免底部(边框/滚动条)遮挡最后一行
const extraBottom = 16;
const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom));
setTableHeight(nextHeight);
});
});
resizeObserver.observe(containerRef.current);
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, []);
@@ -727,12 +739,12 @@ const DataGrid: React.FC<DataGridProps> = ({
const enableVirtual = mergedDisplayData.length >= 200;
return (
<div className={gridId} style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
setAddedRows([]);
setModifiedRows({});
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
onReload();
@@ -842,14 +854,13 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
)}
<style>{`
.${gridId} .row-added td { background-color: #f6ffed !important; }
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
.${gridId} .ant-table-body {
height: ${tableHeight}px !important;
max-height: ${tableHeight}px !important;
}
`}</style>
<style>{`
.${gridId} .row-added td { background-color: #f6ffed !important; }
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
.${gridId} .ant-table-body {
max-height: ${tableHeight}px !important;
}
`}</style>
{/* Ghost Resize Line for Columns */}
<div

View File

@@ -46,15 +46,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const normalizeIdentPart = (ident: string) => {
let raw = (ident || '').trim();
if (!raw) return raw;
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
raw = raw.slice(1, -1).trim();
}
// 防御:如果传入已包含引号(例如 `"schema"."table"` 的拆分结果),移除残留引号再重新安全转义。
raw = raw.replace(/["`]/g, '').trim();
return raw;
};
const quoteIdentPart = (ident: string) => {
if (!ident) return ident;
if (config.type === 'mysql') return `\`${ident.replace(/`/g, '``')}\``;
return `"${ident.replace(/"/g, '""')}"`;
const raw = normalizeIdentPart(ident);
if (!raw) return raw;
if (config.type === 'mysql') return `\`${raw.replace(/`/g, '``')}\``;
return `"${raw.replace(/"/g, '""')}"`;
};
const quoteQualifiedIdent = (ident: string) => {
const raw = (ident || '').trim();
if (!raw) return raw;
const parts = raw.split('.').filter(Boolean);
const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean);
if (parts.length <= 1) return quoteIdentPart(raw);
return parts.map(quoteIdentPart).join('.');
};
@@ -227,7 +241,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return (
<div style={{ height: '100%', width: '100%', overflow: 'hidden' }}>
<div style={{ flex: '1 1 auto', minHeight: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={data}
columnNames={columnNames}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
@@ -11,11 +11,19 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
// DataGrid State
const [results, setResults] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [targetTableName, setTargetTableName] = useState<string | undefined>(undefined);
type ResultSet = {
key: string;
sql: string;
rows: any[];
columns: string[];
tableName?: string;
pkColumns: string[];
readOnly: boolean;
};
// Result Sets
const [resultSets, setResultSets] = useState<ResultSet[]>([]);
const [activeResultKey, setActiveResultKey] = useState<string>('');
const [loading, setLoading] = useState(false);
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
@@ -210,6 +218,144 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
},
];
const splitSQLStatements = (sql: string): string[] => {
const text = (sql || '').replace(/\r\n/g, '\n');
const statements: string[] = [];
let cur = '';
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
let dollarTag: string | null = null; // postgres/kingbase: $$...$$ or $tag$...$tag$
const push = () => {
const s = cur.trim();
if (s) statements.push(s);
cur = '';
};
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : '';
const next2 = i + 2 < text.length ? text[i + 2] : '';
if (!inSingle && !inDouble && !inBacktick) {
if (inLineComment) {
cur += ch;
if (ch === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
cur += ch;
if (ch === '*' && next === '/') {
cur += next;
i++;
inBlockComment = false;
}
continue;
}
// Start comments
if (ch === '/' && next === '*') {
cur += ch + next;
i++;
inBlockComment = true;
continue;
}
if (ch === '#') {
cur += ch;
inLineComment = true;
continue;
}
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
cur += ch + next;
i++;
inLineComment = true;
continue;
}
// Dollar-quoted strings (PG/Kingbase)
if (dollarTag) {
if (text.startsWith(dollarTag, i)) {
cur += dollarTag;
i += dollarTag.length - 1;
dollarTag = null;
} else {
cur += ch;
}
continue;
}
if (ch === '$') {
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
if (m && m[0]) {
dollarTag = m[0];
cur += dollarTag;
i += dollarTag.length - 1;
continue;
}
}
}
if (escaped) {
cur += ch;
escaped = false;
continue;
}
if ((inSingle || inDouble) && ch === '\\') {
cur += ch;
escaped = true;
continue;
}
if (!inDouble && !inBacktick && ch === '\'') {
inSingle = !inSingle;
cur += ch;
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
cur += ch;
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
cur += ch;
continue;
}
if (!inSingle && !inDouble && !inBacktick && !dollarTag && (ch === ';' || ch === '')) {
push();
continue;
}
cur += ch;
}
push();
return statements;
};
const getSelectedSQL = (): string => {
const editor = editorRef.current;
if (!editor) return '';
const model = editor.getModel?.();
const selection = editor.getSelection?.();
if (!model || !selection) return '';
const selected = model.getValueInRange?.(selection) || '';
if (typeof selected !== 'string') return '';
if (!selected.trim()) return '';
return selected;
};
const handleRun = async () => {
if (!query.trim()) return;
if (!currentDb) {
@@ -217,6 +363,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return;
}
setLoading(true);
const runStartTime = Date.now();
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) {
message.error("Connection not found");
@@ -233,76 +380,114 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
// Detect Simple Table Query
let simpleTableName: string | undefined = undefined;
let primaryKeys: string[] = [];
// Naive regex to detect SELECT * FROM table
const tableMatch = query.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
// Fetch PKs for editing
const resCols = await DBGetColumns(config as any, currentDb, simpleTableName);
if (resCols.success) {
primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
}
}
setTargetTableName(simpleTableName);
setPkColumns(primaryKeys);
const startTime = Date.now();
try {
const res = await DBQuery(config as any, currentDb, query);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query`,
timestamp: Date.now(),
sql: query,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
const rawSQL = getSelectedSQL() || query;
const statements = splitSQLStatements(rawSQL);
if (statements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
return;
}
const nextResultSets: ResultSet[] = [];
for (let idx = 0; idx < statements.length; idx++) {
const sql = statements[idx];
const startTime = Date.now();
const res = await DBQuery(config as any, currentDb, sql);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query-${idx + 1}`,
timestamp: Date.now(),
sql,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
if (!res.success) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + res.message);
setResultSets([]);
setActiveResultKey('');
return;
}
if (Array.isArray(res.data)) {
const rows = (res.data as any[]) || [];
const cols = (res.fields && res.fields.length > 0)
? (res.fields as string[])
: (rows.length > 0 ? Object.keys(rows[0]) : []);
if (res.success) {
if (Array.isArray(res.data)) {
if (res.data.length > 0) {
const cols = Object.keys(res.data[0]);
setColumnNames(cols);
const rows = res.data as any[];
rows.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
setResults(rows);
let simpleTableName: string | undefined = undefined;
let primaryKeys: string[] = [];
const tableMatch = sql.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
const resCols = await DBGetColumns(config as any, currentDb, simpleTableName);
if (resCols.success) {
primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
}
}
nextResultSets.push({
key: `result-${idx + 1}`,
sql,
rows,
columns: cols,
tableName: simpleTableName,
pkColumns: primaryKeys,
readOnly: !simpleTableName
});
} else {
message.info('查询执行成功,但没有返回结果。');
setResults([]);
setColumnNames([]);
const affected = Number((res.data as any)?.affectedRows);
if (Number.isFinite(affected)) {
const row = { affectedRows: affected };
(row as any)[GONAVI_ROW_KEY] = 0;
nextResultSets.push({
key: `result-${idx + 1}`,
sql,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
readOnly: true
});
}
}
} else {
const affected = (res.data as any).affectedRows;
message.success(`受影响行数: ${affected}`);
setResults([]);
setColumnNames([]);
}
} else {
message.error(res.message);
}
setResultSets(nextResultSets);
setActiveResultKey(nextResultSets[0]?.key || '');
if (statements.length > 1) {
message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
message.success('执行成功。');
}
} catch (e: any) {
message.error("Error executing query: " + e.message);
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: query,
sql: getSelectedSQL() || query,
status: 'error',
duration: Date.now() - startTime,
duration: Date.now() - runStartTime,
message: e.message,
dbName: currentDb
});
setResultSets([]);
setActiveResultKey('');
} finally {
setLoading(false);
}
setLoading(false);
};
const handleSave = async () => {
@@ -322,8 +507,66 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
};
const handleCloseResult = (key: string) => {
setResultSets(prev => {
const idx = prev.findIndex(r => r.key === key);
if (idx < 0) return prev;
const next = prev.filter(r => r.key !== key);
setActiveResultKey(prevActive => {
if (prevActive && prevActive !== key) return prevActive;
const nextKey = next[idx]?.key || next[idx - 1]?.key || next[0]?.key || '';
return nextKey;
});
return next;
});
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<style>{`
.query-result-tabs {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.query-result-tabs .ant-tabs-nav {
flex: 0 0 auto;
}
.query-result-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-result-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-result-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.query-result-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
}
.query-result-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.query-result-tabs .ant-tabs-ink-bar {
transition: none !important;
}
`}</style>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
@@ -393,18 +636,55 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
title="拖动调整高度"
/>
<div style={{ flex: 1, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={results}
columnNames={columnNames}
loading={loading}
tableName={targetTableName} // Pass table name only if detection succeeded
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={pkColumns}
onReload={handleRun}
readOnly={!targetTableName} // Read-only if not a simple table query
/>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{resultSets.length > 0 ? (
<Tabs
className="query-result-tabs"
activeKey={activeResultKey || resultSets[0]?.key}
onChange={setActiveResultKey}
animated={false}
style={{ flex: 1, minHeight: 0 }}
items={resultSets.map((rs, idx) => ({
key: rs.key,
label: (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Tooltip title={rs.sql}>
<span>{`结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length})` : ''}`}</span>
</Tooltip>
<Tooltip title="关闭结果">
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCloseResult(rs.key);
}}
style={{ display: 'inline-flex', alignItems: 'center', color: '#999', cursor: 'pointer' }}
>
<CloseOutlined style={{ fontSize: 12 }} />
</span>
</Tooltip>
</div>
),
children: (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={rs.rows}
columnNames={rs.columns}
loading={loading}
tableName={rs.tableName}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
onReload={handleRun}
readOnly={rs.readOnly}
/>
</div>
)
}))}
/>
) : (
<div style={{ flex: 1, minHeight: 0 }} />
)}
</div>
<Modal

View File

@@ -47,6 +47,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [selectedNodes, setSelectedNodes] = useState<any[]>([]);
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
@@ -283,10 +285,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onSelect = (keys: React.Key[], info: any) => {
if (!info.node.selected) {
setSelectedKeys(keys);
setSelectedNodes(info.selectedNodes || []);
if (keys.length === 0) {
setActiveContext(null);
return;
}
if (!info.selected) return;
const { type, dataRef, key, title } = info.node;
@@ -313,15 +319,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
const key = node.key;
const isExpanded = expandedKeys.includes(key);
const newExpandedKeys = isExpanded
? expandedKeys.filter(k => k !== key)
: [...expandedKeys, key];
setExpandedKeys(newExpandedKeys);
if (!isExpanded) setAutoExpandParent(false);
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
addTab({
@@ -332,6 +329,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
dbName,
tableName,
});
return;
} else if (node.type === 'saved-query') {
const q = node.dataRef;
addTab({
@@ -342,7 +340,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
dbName: q.dbName,
query: q.sql
});
return;
}
const key = node.key;
const isExpanded = expandedKeys.includes(key);
const newExpandedKeys = isExpanded
? expandedKeys.filter(k => k !== key)
: [...expandedKeys, key];
setExpandedKeys(newExpandedKeys);
if (!isExpanded) setAutoExpandParent(false);
};
const handleCopyStructure = async (node: any) => {
@@ -382,6 +390,60 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
};
const normalizeConnConfig = (raw: any) => ({
...raw,
port: Number(raw.port),
password: raw.password || "",
database: raw.database || "",
useSSH: raw.useSSH || false,
ssh: raw.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
});
const handleExportDatabaseSQL = async (node: any, includeData: boolean) => {
const conn = node.dataRef;
const dbName = conn.dbName || node.title;
const hide = message.loading(includeData ? `正在备份数据库 ${dbName} (结构+数据)...` : `正在导出数据库 ${dbName} 表结构...`, 0);
try {
const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(conn.config), dbName, includeData);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
hide();
message.error('导出失败: ' + (e?.message || String(e)));
}
};
const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => {
if (!nodes || nodes.length === 0) return;
const first = nodes[0].dataRef;
const dbName = first.dbName;
const connId = first.id;
const allSame = nodes.every(n => n?.dataRef?.id === connId && n?.dataRef?.dbName === dbName);
if (!allSame) {
message.error('请在同一连接、同一数据库下选择多张表进行导出');
return;
}
const tableNames = nodes.map(n => n.dataRef.tableName).filter(Boolean);
const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0);
try {
const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(first.config), dbName, tableNames, includeData);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
hide();
message.error('导出失败: ' + (e?.message || String(e)));
}
};
const handleRunSQLFile = async (node: any) => {
const res = await (window as any).go.app.App.OpenSQLFile();
if (res.success) {
@@ -550,6 +612,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
icon: <ReloadOutlined />,
onClick: () => loadTables(node)
},
{
key: 'export-db-schema',
label: '导出全部表结构 (SQL)',
icon: <ExportOutlined />,
onClick: () => handleExportDatabaseSQL(node, false)
},
{
key: 'backup-db-sql',
label: '备份全部表 (结构+数据 SQL)',
icon: <SaveOutlined />,
onClick: () => handleExportDatabaseSQL(node, true)
},
{ type: 'divider' },
{
key: 'disconnect-db',
@@ -588,7 +662,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
];
} else if (node.type === 'table') {
const sameContextSelectedTables = (selectedNodes || []).filter((n: any) => n?.type === 'table' && n?.dataRef?.id === node?.dataRef?.id && n?.dataRef?.dbName === node?.dataRef?.dbName);
const selectedForAction = sameContextSelectedTables.some((n: any) => n?.key === node.key) ? sameContextSelectedTables : [node];
return [
...(selectedForAction.length > 1 ? ([
{
key: 'export-selected-schema',
label: `导出选中表结构 (${selectedForAction.length}) (SQL)`,
icon: <ExportOutlined />,
onClick: () => handleExportTablesSQL(selectedForAction, false)
},
{
key: 'backup-selected-sql',
label: `备份选中表 (${selectedForAction.length}) (结构+数据 SQL)`,
icon: <SaveOutlined />,
onClick: () => handleExportTablesSQL(selectedForAction, true)
},
{ type: 'divider' as const }
]) : []),
{
key: 'new-query',
label: '新建查询',
@@ -684,6 +776,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
loadedKeys={loadedKeys}
onLoad={setLoadedKeys}
autoExpandParent={autoExpandParent}
multiple
selectedKeys={selectedKeys}
blockNode
height={treeHeight}
onRightClick={onRightClick}

View File

@@ -1,12 +1,13 @@
import React, { useMemo } from 'react';
import { Tabs, Button } from 'antd';
import { Tabs, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
const TabManager: React.FC = () => {
const { tabs, activeTabId, setActiveTab, closeTab } = useStore();
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs } = useStore();
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
@@ -18,7 +19,7 @@ const TabManager: React.FC = () => {
}
};
const items = useMemo(() => tabs.map(tab => {
const items = useMemo(() => tabs.map((tab, index) => {
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
@@ -27,27 +28,95 @@ const TabManager: React.FC = () => {
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
}
const menuItems: MenuProps['items'] = [
{
key: 'close-other',
label: '关闭其他页',
disabled: tabs.length <= 1,
onClick: () => closeOtherTabs(tab.id),
},
{
key: 'close-left',
label: '关闭左侧',
disabled: index === 0,
onClick: () => closeTabsToLeft(tab.id),
},
{
key: 'close-right',
label: '关闭右侧',
disabled: index === tabs.length - 1,
onClick: () => closeTabsToRight(tab.id),
},
{ type: 'divider' },
{
key: 'close-all',
label: '关闭所有',
disabled: tabs.length === 0,
onClick: () => closeAllTabs(),
},
];
return {
label: tab.title,
label: (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span onContextMenu={(e) => e.preventDefault()}>{tab.title}</span>
</Dropdown>
),
key: tab.id,
children: content,
};
}), [tabs]);
}), [tabs, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
<style>{`
.ant-tabs-content { height: 100%; }
.ant-tabs-tabpane { height: 100%; }
.main-tabs {
height: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-nav {
flex: 0 0 auto;
}
.main-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
}
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
`}</style>
<Tabs
className="main-tabs"
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
style={{ height: '100%' }}
hideAdd
/>
</>

View File

@@ -550,7 +550,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
<div ref={containerRef} className="table-designer-wrapper" style={{ height: '100%', overflow: 'hidden', position: 'relative' }}>
<style>{`
.table-designer-wrapper .ant-table-body {
height: ${tableHeight}px !important;
max-height: ${tableHeight}px !important;
}
`}</style>

View File

@@ -29,6 +29,10 @@ interface AppState {
addTab: (tab: TabData) => void;
closeTab: (id: string) => void;
closeOtherTabs: (id: string) => void;
closeTabsToLeft: (id: string) => void;
closeTabsToRight: (id: string) => void;
closeAllTabs: () => void;
setActiveTab: (id: string) => void;
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
@@ -79,6 +83,30 @@ export const useStore = create<AppState>()(
}
return { tabs: newTabs, activeTabId: newActiveId };
}),
closeOtherTabs: (id) => set((state) => {
const keep = state.tabs.find(t => t.id === id);
if (!keep) return state;
return { tabs: [keep], activeTabId: id };
}),
closeTabsToLeft: (id) => set((state) => {
const index = state.tabs.findIndex(t => t.id === id);
if (index === -1) return state;
const newTabs = state.tabs.slice(index);
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
}),
closeTabsToRight: (id) => set((state) => {
const index = state.tabs.findIndex(t => t.id === id);
if (index === -1) return state;
const newTabs = state.tabs.slice(0, index + 1);
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
}),
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
setActiveTab: (id) => set({ activeTabId: id }),
setActiveContext: (context) => set({ activeContext: context }),
@@ -105,4 +133,4 @@ export const useStore = create<AppState>()(
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, darkMode: state.darkMode, sqlFormatOptions: state.sqlFormatOptions }), // Don't persist logs
}
)
);
);

View File

@@ -35,8 +35,12 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean):Promise<connection.QueryResult>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;

View File

@@ -66,10 +66,18 @@ export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
export function ExportDatabaseSQL(arg1, arg2, arg3) {
return window['go']['app']['App']['ExportDatabaseSQL'](arg1, arg2, arg3);
}
export function ExportTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
}
export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}

View File

@@ -1,11 +1,16 @@
package app
import (
"bufio"
"encoding/csv"
"encoding/json"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
@@ -213,12 +218,36 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
}
runConfig := normalizeRunConfig(config, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
format = strings.ToLower(format)
if format == "sql" {
f, err := os.Create(filename)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer f.Close()
w := bufio.NewWriterSize(f, 1024*1024)
defer w.Flush()
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := writeSQLFooter(w, runConfig); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Export successful"}
}
query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName))
data, columns, err := dbInst.Query(query)
@@ -232,7 +261,6 @@ data, columns, err := dbInst.Query(query)
}
defer f.Close()
format = strings.ToLower(format)
var csvWriter *csv.Writer
var jsonEncoder *json.Encoder
var isJsonFirstRow = true
@@ -301,6 +329,127 @@ data, columns, err := dbInst.Query(query)
return connection.QueryResult{Success: true, Message: "Export successful"}
}
func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult {
safeDbName := strings.TrimSpace(dbName)
if safeDbName == "" {
safeDbName = "export"
}
suffix := "schema"
if includeData {
suffix = "backup"
}
defaultFilename := fmt.Sprintf("%s_%s_%dtables.sql", safeDbName, suffix, len(tableNames))
if len(tableNames) == 1 && strings.TrimSpace(tableNames[0]) != "" {
defaultFilename = fmt.Sprintf("%s_%s.sql", strings.TrimSpace(tableNames[0]), suffix)
}
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export Tables (SQL)",
DefaultFilename: defaultFilename,
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
runConfig := normalizeRunConfig(config, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
tables := make([]string, 0, len(tableNames))
seen := make(map[string]struct{}, len(tableNames))
for _, t := range tableNames {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
tables = append(tables, t)
}
sort.Strings(tables)
f, err := os.Create(filename)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer f.Close()
w := bufio.NewWriterSize(f, 1024*1024)
defer w.Flush()
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
for _, t := range tables {
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
if err := writeSQLFooter(w, runConfig); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Export successful"}
}
func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName string, includeData bool) connection.QueryResult {
safeDbName := strings.TrimSpace(dbName)
if safeDbName == "" {
return connection.QueryResult{Success: false, Message: "dbName required"}
}
suffix := "schema"
if includeData {
suffix = "backup"
}
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: fmt.Sprintf("Export %s (SQL)", safeDbName),
DefaultFilename: fmt.Sprintf("%s_%s.sql", safeDbName, suffix),
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
runConfig := normalizeRunConfig(config, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
tables, err := dbInst.GetTables(dbName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
sort.Strings(tables)
f, err := os.Create(filename)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer f.Close()
w := bufio.NewWriterSize(f, 1024*1024)
defer w.Flush()
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
for _, t := range tables {
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
if err := writeSQLFooter(w, runConfig); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Export successful"}
}
func quoteIdentByType(dbType string, ident string) string {
if ident == "" {
return ident
@@ -340,6 +489,173 @@ func quoteQualifiedIdentByType(dbType string, ident string) string {
return strings.Join(quotedParts, ".")
}
func writeSQLHeader(w *bufio.Writer, config connection.ConnectionConfig, dbName string) error {
now := time.Now().Format("2006-01-02 15:04:05")
if _, err := w.WriteString(fmt.Sprintf("-- GoNavi SQL Export\n-- Time: %s\n", now)); err != nil {
return err
}
if strings.TrimSpace(dbName) != "" {
if _, err := w.WriteString(fmt.Sprintf("-- Database: %s\n\n", dbName)); err != nil {
return err
}
}
if strings.ToLower(strings.TrimSpace(config.Type)) == "mysql" && strings.TrimSpace(dbName) != "" {
if _, err := w.WriteString(fmt.Sprintf("USE %s;\n\n", quoteIdentByType("mysql", dbName))); err != nil {
return err
}
if _, err := w.WriteString("SET FOREIGN_KEY_CHECKS=0;\n\n"); err != nil {
return err
}
}
return nil
}
func writeSQLFooter(w *bufio.Writer, config connection.ConnectionConfig) error {
if strings.ToLower(strings.TrimSpace(config.Type)) == "mysql" {
if _, err := w.WriteString("\nSET FOREIGN_KEY_CHECKS=1;\n"); err != nil {
return err
}
}
return nil
}
func qualifyTable(schemaName, tableName string) string {
schemaName = strings.TrimSpace(schemaName)
tableName = strings.TrimSpace(tableName)
if schemaName == "" {
return tableName
}
return schemaName + "." + tableName
}
func ensureSQLTerminator(sql string) string {
trimmed := strings.TrimSpace(sql)
if trimmed == "" {
return sql
}
if strings.HasSuffix(trimmed, ";") {
return sql
}
return sql + ";"
}
func isMySQLHexLiteral(s string) bool {
if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) {
return false
}
for i := 2; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
func formatSQLValue(dbType string, v interface{}) string {
if v == nil {
return "NULL"
}
switch val := v.(type) {
case bool:
if val {
return "1"
}
return "0"
case int:
return strconv.Itoa(val)
case int8, int16, int32, int64:
return fmt.Sprintf("%d", val)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", val)
case float32:
f := float64(val)
if math.IsNaN(f) || math.IsInf(f, 0) {
return "NULL"
}
return strconv.FormatFloat(f, 'f', -1, 32)
case float64:
if math.IsNaN(val) || math.IsInf(val, 0) {
return "NULL"
}
return strconv.FormatFloat(val, 'f', -1, 64)
case time.Time:
return "'" + val.Format("2006-01-02 15:04:05") + "'"
case string:
if strings.ToLower(strings.TrimSpace(dbType)) == "mysql" && isMySQLHexLiteral(val) {
return val
}
escaped := strings.ReplaceAll(val, "'", "''")
return "'" + escaped + "'"
default:
escaped := strings.ReplaceAll(fmt.Sprintf("%v", v), "'", "''")
return "'" + escaped + "'"
}
}
func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeData bool) error {
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
return err
}
if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil {
return err
}
if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil {
return err
}
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
if err != nil {
return err
}
if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil {
return err
}
if _, err := w.WriteString("\n\n"); err != nil {
return err
}
if !includeData {
return nil
}
qualified := qualifyTable(schemaName, pureTableName)
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
data, columns, err := dbInst.Query(selectSQL)
if err != nil {
return err
}
if len(data) == 0 {
if _, err := w.WriteString("-- (0 rows)\n"); err != nil {
return err
}
return nil
}
quotedCols := make([]string, 0, len(columns))
for _, c := range columns {
quotedCols = append(quotedCols, quoteIdentByType(config.Type, c))
}
quotedTable := quoteQualifiedIdentByType(config.Type, qualified)
for _, row := range data {
values := make([]string, 0, len(columns))
for _, c := range columns {
values = append(values, formatSQLValue(config.Type, row[c]))
}
if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil {
return err
}
}
return nil
}
// ExportData exports provided data to a file
func (a *App) ExportData(data []map[string]interface{}, columns []string, defaultName string, format string) connection.QueryResult {
if defaultName == "" {

View File

@@ -380,14 +380,13 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
schema := "public"
if dbName != "" {
schema = dbName
}
query := fmt.Sprintf(`SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = '%s'`, schema)
// dbName 在本项目语义里是“数据库”schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg_%'
ORDER BY table_schema, table_name, ordinal_position`
data, _, err := k.Query(query)
if err != nil {
@@ -396,8 +395,14 @@ func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition
var cols []connection.ColumnDefinitionWithTable
for _, row := range data {
schema := fmt.Sprintf("%v", row["table_schema"])
table := fmt.Sprintf("%v", row["table_name"])
tableName := table
if strings.TrimSpace(schema) != "" {
tableName = fmt.Sprintf("%s.%s", schema, table)
}
col := connection.ColumnDefinitionWithTable{
TableName: fmt.Sprintf("%v", row["table_name"]),
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
}

View File

@@ -6,6 +6,7 @@ import (
"net"
"net/url"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
@@ -167,21 +168,306 @@ func (p *PostgresDB) GetCreateStatement(dbName, tableName string) (string, error
}
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return []connection.ColumnDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT
a.attname AS column_name,
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
col_description(a.attrelid, a.attnum) AS comment,
CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
LEFT JOIN (
SELECT i.indrelid, a3.attname
FROM pg_index i
JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey)
WHERE i.indisprimary
) pk ON pk.indrelid = c.oid AND pk.attname = a.attname
WHERE c.relkind IN ('r', 'p')
AND n.nspname = '%s'
AND c.relname = '%s'
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum`, esc(schema), esc(table))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var columns []connection.ColumnDefinition
for _, row := range data {
col := connection.ColumnDefinition{
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
Key: fmt.Sprintf("%v", row["column_key"]),
Extra: "",
Comment: "",
}
if v, ok := row["comment"]; ok && v != nil {
col.Comment = fmt.Sprintf("%v", v)
}
if v, ok := row["column_default"]; ok && v != nil {
def := fmt.Sprintf("%v", v)
col.Default = &def
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
col.Extra = "auto_increment"
}
}
columns = append(columns, col)
}
return columns, nil
}
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT
i.relname AS index_name,
a.attname AS column_name,
ix.indisunique AS is_unique,
x.ordinality AS seq_in_index,
am.amname AS index_type
FROM pg_class t
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_am am ON i.relam = am.oid
JOIN unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) ON TRUE
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE t.relkind IN ('r', 'p')
AND t.relname = '%s'
AND n.nspname = '%s'
ORDER BY i.relname, x.ordinality`, esc(table), esc(schema))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
parseBool := func(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
s := strings.ToLower(strings.TrimSpace(val))
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
default:
s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
}
}
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
// best effort
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
var indexes []connection.IndexDefinition
for _, row := range data {
isUnique := false
if v, ok := row["is_unique"]; ok && v != nil {
isUnique = parseBool(v)
}
nonUnique := 1
if isUnique {
nonUnique = 0
}
seq := 0
if v, ok := row["seq_in_index"]; ok && v != nil {
seq = parseInt(v)
}
indexType := ""
if v, ok := row["index_type"]; ok && v != nil {
indexType = strings.ToUpper(fmt.Sprintf("%v", v))
}
if indexType == "" {
indexType = "BTREE"
}
idx := connection.IndexDefinition{
Name: fmt.Sprintf("%v", row["index_name"]),
ColumnName: fmt.Sprintf("%v", row["column_name"]),
NonUnique: nonUnique,
SeqInIndex: seq,
IndexType: indexType,
}
indexes = append(indexes, idx)
}
return indexes, nil
}
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT
tc.constraint_name AS constraint_name,
kcu.column_name AS column_name,
ccu.table_schema AS foreign_table_schema,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = '%s'
AND tc.table_schema = '%s'
ORDER BY tc.constraint_name, kcu.ordinal_position`, esc(table), esc(schema))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var fks []connection.ForeignKeyDefinition
for _, row := range data {
refSchema := ""
if v, ok := row["foreign_table_schema"]; ok && v != nil {
refSchema = fmt.Sprintf("%v", v)
}
refTable := fmt.Sprintf("%v", row["foreign_table_name"])
refTableName := refTable
if strings.TrimSpace(refSchema) != "" {
refTableName = fmt.Sprintf("%s.%s", refSchema, refTable)
}
fk := connection.ForeignKeyDefinition{
Name: fmt.Sprintf("%v", row["constraint_name"]),
ColumnName: fmt.Sprintf("%v", row["column_name"]),
RefTableName: refTableName,
RefColumnName: fmt.Sprintf("%v", row["foreign_column_name"]),
ConstraintName: fmt.Sprintf("%v", row["constraint_name"]),
}
fks = append(fks, fk)
}
return fks, nil
}
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT trigger_name, action_timing, event_manipulation, action_statement
FROM information_schema.triggers
WHERE event_object_table = '%s'
AND event_object_schema = '%s'
ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var triggers []connection.TriggerDefinition
for _, row := range data {
trig := connection.TriggerDefinition{
Name: fmt.Sprintf("%v", row["trigger_name"]),
Timing: fmt.Sprintf("%v", row["action_timing"]),
Event: fmt.Sprintf("%v", row["event_manipulation"]),
Statement: fmt.Sprintf("%v", row["action_statement"]),
}
triggers = append(triggers, trig)
}
return triggers, nil
}
func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return []connection.ColumnDefinitionWithTable{}, nil
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg_%'
ORDER BY table_schema, table_name, ordinal_position`
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var cols []connection.ColumnDefinitionWithTable
for _, row := range data {
schema := fmt.Sprintf("%v", row["table_schema"])
table := fmt.Sprintf("%v", row["table_name"])
tableName := table
if strings.TrimSpace(schema) != "" {
tableName = fmt.Sprintf("%s.%s", schema, table)
}
col := connection.ColumnDefinitionWithTable{
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
}
cols = append(cols, col)
}
return cols, nil
}

View File

@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"fmt"
"strings"
"time"
"GoNavi-Wails/internal/connection"
@@ -137,21 +138,336 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
}
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return []connection.ColumnDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
// cid, name, type, notnull, dflt_value, pk
data, _, err := s.Query(fmt.Sprintf("PRAGMA table_info('%s')", esc(table)))
if err != nil {
return nil, err
}
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
getStr := func(row map[string]interface{}, key string) string {
if v, ok := row[key]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
if v, ok := row[strings.ToUpper(key)]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}
var columns []connection.ColumnDefinition
for _, row := range data {
notnull := 0
if v, ok := row["notnull"]; ok && v != nil {
notnull = parseInt(v)
} else if v, ok := row["NOTNULL"]; ok && v != nil {
notnull = parseInt(v)
}
pk := 0
if v, ok := row["pk"]; ok && v != nil {
pk = parseInt(v)
} else if v, ok := row["PK"]; ok && v != nil {
pk = parseInt(v)
}
nullable := "YES"
if notnull == 1 {
nullable = "NO"
}
key := ""
if pk == 1 {
key = "PRI"
}
col := connection.ColumnDefinition{
Name: getStr(row, "name"),
Type: getStr(row, "type"),
Nullable: nullable,
Key: key,
Extra: "",
Comment: "",
}
if v, ok := row["dflt_value"]; ok && v != nil {
def := fmt.Sprintf("%v", v)
col.Default = &def
} else if v, ok := row["DFLT_VALUE"]; ok && v != nil {
def := fmt.Sprintf("%v", v)
col.Default = &def
}
columns = append(columns, col)
}
return columns, nil
}
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
data, _, err := s.Query(fmt.Sprintf("PRAGMA index_list('%s')", esc(table)))
if err != nil {
return nil, err
}
var indexes []connection.IndexDefinition
for _, row := range data {
indexName := ""
if v, ok := row["name"]; ok && v != nil {
indexName = fmt.Sprintf("%v", v)
} else if v, ok := row["NAME"]; ok && v != nil {
indexName = fmt.Sprintf("%v", v)
}
if strings.TrimSpace(indexName) == "" {
continue
}
unique := 0
if v, ok := row["unique"]; ok && v != nil {
unique = parseInt(v)
} else if v, ok := row["UNIQUE"]; ok && v != nil {
unique = parseInt(v)
}
nonUnique := 1
if unique == 1 {
nonUnique = 0
}
cols, _, err := s.Query(fmt.Sprintf("PRAGMA index_info('%s')", esc(indexName)))
if err != nil {
// skip broken index
continue
}
for _, c := range cols {
colName := ""
if v, ok := c["name"]; ok && v != nil {
colName = fmt.Sprintf("%v", v)
} else if v, ok := c["NAME"]; ok && v != nil {
colName = fmt.Sprintf("%v", v)
}
if strings.TrimSpace(colName) == "" {
continue
}
seq := 0
if v, ok := c["seqno"]; ok && v != nil {
seq = parseInt(v) + 1
} else if v, ok := c["SEQNO"]; ok && v != nil {
seq = parseInt(v) + 1
}
indexes = append(indexes, connection.IndexDefinition{
Name: indexName,
ColumnName: colName,
NonUnique: nonUnique,
SeqInIndex: seq,
IndexType: "BTREE",
})
}
}
return indexes, nil
}
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
data, _, err := s.Query(fmt.Sprintf("PRAGMA foreign_key_list('%s')", esc(table)))
if err != nil {
return nil, err
}
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
var fks []connection.ForeignKeyDefinition
for _, row := range data {
id := 0
if v, ok := row["id"]; ok && v != nil {
id = parseInt(v)
} else if v, ok := row["ID"]; ok && v != nil {
id = parseInt(v)
}
refTable := ""
if v, ok := row["table"]; ok && v != nil {
refTable = fmt.Sprintf("%v", v)
} else if v, ok := row["TABLE"]; ok && v != nil {
refTable = fmt.Sprintf("%v", v)
}
fromCol := ""
if v, ok := row["from"]; ok && v != nil {
fromCol = fmt.Sprintf("%v", v)
} else if v, ok := row["FROM"]; ok && v != nil {
fromCol = fmt.Sprintf("%v", v)
}
toCol := ""
if v, ok := row["to"]; ok && v != nil {
toCol = fmt.Sprintf("%v", v)
} else if v, ok := row["TO"]; ok && v != nil {
toCol = fmt.Sprintf("%v", v)
}
name := fmt.Sprintf("fk_%s_%d", table, id)
fks = append(fks, connection.ForeignKeyDefinition{
Name: name,
ColumnName: fromCol,
RefTableName: refTable,
RefColumnName: toCol,
ConstraintName: name,
})
}
return fks, nil
}
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
data, _, err := s.Query(fmt.Sprintf("SELECT name AS trigger_name, sql AS statement FROM sqlite_master WHERE type='trigger' AND tbl_name='%s' ORDER BY name", esc(table)))
if err != nil {
return nil, err
}
var triggers []connection.TriggerDefinition
for _, row := range data {
name := fmt.Sprintf("%v", row["trigger_name"])
stmt := ""
if v, ok := row["statement"]; ok && v != nil {
stmt = fmt.Sprintf("%v", v)
}
upper := strings.ToUpper(stmt)
timing := ""
switch {
case strings.Contains(upper, " BEFORE "):
timing = "BEFORE"
case strings.Contains(upper, " AFTER "):
timing = "AFTER"
case strings.Contains(upper, " INSTEAD OF "):
timing = "INSTEAD OF"
}
event := ""
switch {
case strings.Contains(upper, " INSERT "):
event = "INSERT"
case strings.Contains(upper, " UPDATE "):
event = "UPDATE"
case strings.Contains(upper, " DELETE "):
event = "DELETE"
}
triggers = append(triggers, connection.TriggerDefinition{
Name: name,
Timing: timing,
Event: event,
Statement: stmt,
})
}
return triggers, nil
}
func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return []connection.ColumnDefinitionWithTable{}, nil
tables, err := s.GetTables(dbName)
if err != nil {
return nil, err
}
var cols []connection.ColumnDefinitionWithTable
for _, table := range tables {
// Skip internal tables
if strings.HasPrefix(strings.ToLower(table), "sqlite_") {
continue
}
columns, err := s.GetColumns("", table)
if err != nil {
continue
}
for _, col := range columns {
cols = append(cols, connection.ColumnDefinitionWithTable{
TableName: table,
Name: col.Name,
Type: col.Type,
})
}
}
return cols, nil
}