mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 22:39:40 +08:00
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
@@ -71,6 +71,7 @@ const ConnectionModal: React.FC<{
|
||||
const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null);
|
||||
const [driverStatusMap, setDriverStatusMap] = useState<Record<string, DriverStatusSnapshot>>({});
|
||||
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
|
||||
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -578,6 +579,30 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSSHKeyFile = async () => {
|
||||
if (selectingSSHKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSelectingSSHKey(true);
|
||||
const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim();
|
||||
const res = await SelectSSHKeyFile(currentPath);
|
||||
if (res?.success) {
|
||||
const data = res.data || {};
|
||||
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
|
||||
if (selectedPath) {
|
||||
form.setFieldValue('sshKeyPath', selectedPath);
|
||||
}
|
||||
} else if (res?.message !== 'Cancelled') {
|
||||
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(`选择私钥文件失败: ${e?.message || String(e)}`);
|
||||
} finally {
|
||||
setSelectingSSHKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTestResult(null); // Reset test result
|
||||
@@ -1493,8 +1518,15 @@ const ConnectionModal: React.FC<{
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="sshKeyPath" label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Input placeholder="绝对路径" />
|
||||
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="sshKeyPath" noStyle>
|
||||
<Input placeholder="绝对路径" />
|
||||
</Form.Item>
|
||||
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
|
||||
浏览...
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -90,6 +90,14 @@ const normalizeDateTimeString = (val: string) => {
|
||||
return `${match[1]} ${match[2]}`;
|
||||
};
|
||||
|
||||
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'year';
|
||||
};
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
try {
|
||||
@@ -764,6 +772,35 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return next;
|
||||
}, [columnMetaMap]);
|
||||
|
||||
const normalizeCommitCellValue = useCallback(
|
||||
(columnName: string, value: any, mode: 'insert' | 'update') => {
|
||||
if (value === undefined) return undefined;
|
||||
const normalizedName = String(columnName || '').trim();
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
const temporal = isTemporalColumnType(meta?.type);
|
||||
|
||||
if (!temporal) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const raw = value.trim();
|
||||
if (raw === '') {
|
||||
// INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。
|
||||
return mode === 'insert' ? undefined : null;
|
||||
}
|
||||
return normalizeDateTimeString(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
[columnMetaMap, columnMetaMapByLowerName]
|
||||
);
|
||||
|
||||
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
|
||||
const normalizedName = String(name || '');
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
@@ -1814,7 +1851,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const updates: any[] = [];
|
||||
const deletes: any[] = [];
|
||||
|
||||
addedRows.forEach(row => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; inserts.push(vals); });
|
||||
addedRows.forEach(row => {
|
||||
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row;
|
||||
const normalizedValues: Record<string, any> = {};
|
||||
Object.entries(vals).forEach(([col, val]) => {
|
||||
const normalizedVal = normalizeCommitCellValue(col, val, 'insert');
|
||||
if (normalizedVal !== undefined) {
|
||||
normalizedValues[col] = normalizedVal;
|
||||
}
|
||||
});
|
||||
inserts.push(normalizedValues);
|
||||
});
|
||||
deletedRowKeys.forEach(keyStr => {
|
||||
// Find original data
|
||||
const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr);
|
||||
@@ -1847,8 +1894,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(values).length === 0) return;
|
||||
updates.push({ keys: pkData, values });
|
||||
const normalizedValues: Record<string, any> = {};
|
||||
Object.entries(values).forEach(([col, val]) => {
|
||||
const normalizedVal = normalizeCommitCellValue(col, val, 'update');
|
||||
if (normalizedVal !== undefined) {
|
||||
normalizedValues[col] = normalizedVal;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(normalizedValues).length === 0) return;
|
||||
updates.push({ keys: pkData, values: normalizedValues });
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
|
||||
@@ -46,6 +46,15 @@ interface TreeNode {
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
type BatchObjectType = 'table' | 'view';
|
||||
|
||||
interface BatchObjectItem {
|
||||
title: string;
|
||||
key: string;
|
||||
objectName: string;
|
||||
objectType: BatchObjectType;
|
||||
dataRef: any;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
@@ -118,12 +127,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
// Batch Operations Modal
|
||||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||||
const [batchTables, setBatchTables] = useState<any[]>([]);
|
||||
const [batchTables, setBatchTables] = useState<BatchObjectItem[]>([]);
|
||||
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
|
||||
const [batchDbContext, setBatchDbContext] = useState<any>(null);
|
||||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||||
const groupedBatchObjects = useMemo(() => {
|
||||
const tables = batchTables.filter(item => item.objectType === 'table');
|
||||
const views = batchTables.filter(item => item.objectType === 'view');
|
||||
return { tables, views };
|
||||
}, [batchTables]);
|
||||
|
||||
// Batch Database Operations Modal
|
||||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||||
@@ -1097,7 +1111,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (type === 'folder-columns') openDesign(info.node, 'columns', false);
|
||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
|
||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
|
||||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true);
|
||||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
|
||||
};
|
||||
|
||||
const onExpand = (newExpandedKeys: React.Key[]) => {
|
||||
@@ -1288,7 +1302,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (node.type === 'database') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.title;
|
||||
} else if (node.type === 'table') {
|
||||
} else if (node.type === 'table' || node.type === 'view') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.dataRef.dbName;
|
||||
}
|
||||
@@ -1356,23 +1370,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
return {
|
||||
title: tableName,
|
||||
key: `${conn.id}-${dbName}-${tableName}`,
|
||||
tableName: tableName,
|
||||
dataRef: { ...conn, tableName, dbName }
|
||||
};
|
||||
});
|
||||
const [res, viewResult] = await Promise.all([
|
||||
DBGetTables(config as any, dbName),
|
||||
loadViews(conn, dbName).catch(() => ({ views: [], supported: false })),
|
||||
]);
|
||||
|
||||
setBatchTables(tables);
|
||||
setCheckedTableKeys([]);
|
||||
} else {
|
||||
if (!res.success) {
|
||||
message.error('获取表列表失败: ' + res.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewSet = new Set(viewResult.views.map(view => view.toLowerCase()));
|
||||
|
||||
const tableObjects: BatchObjectItem[] = (res.data as any[])
|
||||
.map((row: any) => Object.values(row)[0] as string)
|
||||
.filter((tableName: string) => !viewSet.has(tableName.toLowerCase()))
|
||||
.map((tableName: string) => ({
|
||||
title: getSidebarTableDisplayName(conn, tableName),
|
||||
key: `${conn.id}-${dbName}-table-${tableName}`,
|
||||
objectName: tableName,
|
||||
objectType: 'table' as const,
|
||||
dataRef: { ...conn, tableName, dbName, objectType: 'table' },
|
||||
}));
|
||||
|
||||
const viewObjects: BatchObjectItem[] = viewResult.views.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' },
|
||||
}));
|
||||
|
||||
tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
|
||||
setBatchTables([...tableObjects, ...viewObjects]);
|
||||
setCheckedTableKeys([]);
|
||||
};
|
||||
|
||||
const handleConnectionChange = async (connId: string) => {
|
||||
@@ -1397,31 +1430,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const handleBatchExport = async (mode: BatchTableExportMode) => {
|
||||
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedTables.length === 0) {
|
||||
message.warning('请至少选择一张表');
|
||||
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedObjects.length === 0) {
|
||||
message.warning('请至少选择一个对象');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchModalOpen(false);
|
||||
|
||||
const { conn, dbName } = batchDbContext;
|
||||
const tableNames = selectedTables.map(t => t.tableName);
|
||||
const objectNames = selectedObjects.map(t => t.objectName);
|
||||
const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length;
|
||||
|
||||
const loadingText = mode === 'backup'
|
||||
? `正在备份选中表 (${tableNames.length})...`
|
||||
? `正在备份选中对象 (${objectNames.length})...`
|
||||
: mode === 'dataOnly'
|
||||
? `正在导出选中表数据 (INSERT) (${tableNames.length})...`
|
||||
: `正在导出选中表结构 (${tableNames.length})...`;
|
||||
? `正在导出选中对象数据 (INSERT) (${objectNames.length})...`
|
||||
: `正在导出选中对象结构 (${objectNames.length})...`;
|
||||
const hide = message.loading(loadingText, 0);
|
||||
try {
|
||||
const app = (window as any).go.app.App;
|
||||
const res = mode === 'dataOnly'
|
||||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames)
|
||||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup');
|
||||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames)
|
||||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup');
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
if (mode !== 'schema' && selectedViewCount > 0) {
|
||||
message.success(`导出成功(已自动跳过 ${selectedViewCount} 个视图的数据导出)`);
|
||||
} else {
|
||||
message.success('导出成功');
|
||||
}
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
@@ -2859,7 +2897,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
反选
|
||||
</Button>
|
||||
<span style={{ color: '#999' }}>
|
||||
已选择 {checkedTableKeys.length} / {batchTables.length} 张表
|
||||
已选择 {checkedTableKeys.length} / {batchTables.length} 个对象
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -2869,14 +2907,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{batchTables.map(table => (
|
||||
<Checkbox key={table.key} value={table.key}>
|
||||
<TableOutlined style={{ marginRight: 8 }} />
|
||||
{table.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{groupedBatchObjects.tables.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||||
表 ({groupedBatchObjects.tables.length})
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{groupedBatchObjects.tables.map(table => (
|
||||
<Checkbox key={table.key} value={table.key}>
|
||||
<TableOutlined style={{ marginRight: 8 }} />
|
||||
{table.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{groupedBatchObjects.views.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||||
视图 ({groupedBatchObjects.views.length})
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{groupedBatchObjects.views.map(view => (
|
||||
<Checkbox key={view.key} value={view.key}>
|
||||
<EyeOutlined style={{ marginRight: 8 }} />
|
||||
{view.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -75,6 +75,22 @@ const MYSQL_INDEX_TYPE_OPTIONS = [
|
||||
{ label: 'RTREE', value: 'RTREE' },
|
||||
];
|
||||
|
||||
const PGLIKE_INDEX_TYPE_OPTIONS = [
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
{ label: 'BTREE', value: 'BTREE' },
|
||||
{ label: 'HASH', value: 'HASH' },
|
||||
{ label: 'GIN', value: 'GIN' },
|
||||
{ label: 'GIST', value: 'GIST' },
|
||||
{ label: 'BRIN', value: 'BRIN' },
|
||||
{ label: 'SPGIST', value: 'SPGIST' },
|
||||
];
|
||||
|
||||
const SQLSERVER_INDEX_TYPE_OPTIONS = [
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
{ label: 'CLUSTERED', value: 'CLUSTERED' },
|
||||
{ label: 'NONCLUSTERED', value: 'NONCLUSTERED' },
|
||||
];
|
||||
|
||||
const CHARSETS = [
|
||||
{ label: 'utf8mb4 (Recommended)', value: 'utf8mb4' },
|
||||
{ label: 'utf8', value: 'utf8' },
|
||||
@@ -612,9 +628,41 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
// --- Trigger Handlers ---
|
||||
|
||||
const normalizeDbType = (rawType: string): string => {
|
||||
const normalized = String(rawType || '').trim().toLowerCase();
|
||||
if (normalized === 'postgresql' || normalized === 'pg') return 'postgres';
|
||||
if (normalized === 'mssql' || normalized === 'sql_server' || normalized === 'sql-server') return 'sqlserver';
|
||||
if (normalized === 'doris') return 'diros';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const inferDialectFromCustomDriver = (driver: string): string => {
|
||||
const customDriver = normalizeDbType(driver);
|
||||
if (!customDriver) return 'custom';
|
||||
if (
|
||||
customDriver === 'mariadb'
|
||||
|| customDriver === 'diros'
|
||||
|| customDriver === 'sphinx'
|
||||
|| customDriver === 'tidb'
|
||||
|| customDriver === 'oceanbase'
|
||||
|| customDriver === 'starrocks'
|
||||
|| customDriver.includes('mysql')
|
||||
) {
|
||||
return 'mysql';
|
||||
}
|
||||
if (customDriver === 'dameng') return 'dm';
|
||||
return customDriver;
|
||||
};
|
||||
|
||||
const getDbType = (): string => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
const type = String(conn?.config?.type || '').toLowerCase();
|
||||
const type = normalizeDbType(String(conn?.config?.type || ''));
|
||||
if (!type) return '';
|
||||
|
||||
if (type === 'custom') {
|
||||
return inferDialectFromCustomDriver(String((conn?.config as any)?.driver || ''));
|
||||
}
|
||||
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
@@ -1037,24 +1085,141 @@ ${selectedTrigger.statement}`;
|
||||
}, [groupedForeignKeys, selectedForeignKey]);
|
||||
|
||||
const escapeBacktickIdentifier = (name: string) => String(name || '').replace(/`/g, '``');
|
||||
const escapeBracketIdentifier = (name: string) => String(name || '').replace(/]/g, ']]');
|
||||
const escapeDoubleQuoteIdentifier = (name: string) => String(name || '').replace(/"/g, '""');
|
||||
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
|
||||
|
||||
const quoteMysqlIdentifierPath = (path: string): string => {
|
||||
const trimmed = String(path || '').trim();
|
||||
if (!trimmed) return '';
|
||||
// If user already provided backticks, respect as-is.
|
||||
if (trimmed.includes('`')) return trimmed;
|
||||
return trimmed
|
||||
.split('.')
|
||||
.map(seg => `\`${escapeBacktickIdentifier(seg)}\``)
|
||||
.join('.');
|
||||
const stripIdentifierQuotes = (part: string): string => {
|
||||
const text = String(part || '').trim();
|
||||
if (!text) return '';
|
||||
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||
return text.slice(1, -1).trim();
|
||||
}
|
||||
if (text.startsWith('[') && text.endsWith(']')) {
|
||||
return text.slice(1, -1).trim();
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const getMysqlTableRef = (): string => {
|
||||
const tbl = String(tab.tableName || '').trim();
|
||||
const schema = String(tab.dbName || '').trim();
|
||||
if (!schema) return `\`${escapeBacktickIdentifier(tbl)}\``;
|
||||
return `\`${escapeBacktickIdentifier(schema)}\`.\`${escapeBacktickIdentifier(tbl)}\``;
|
||||
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
|
||||
const raw = String(qualifiedName || '').trim();
|
||||
if (!raw) return { schemaName: '', objectName: '' };
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
|
||||
return {
|
||||
schemaName: stripIdentifierQuotes(raw.substring(0, idx)),
|
||||
objectName: stripIdentifierQuotes(raw.substring(idx + 1)),
|
||||
};
|
||||
};
|
||||
|
||||
const isPgLikeDialect = (dbType: string): boolean =>
|
||||
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
|
||||
const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm';
|
||||
const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver';
|
||||
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
|
||||
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
|
||||
const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine';
|
||||
const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite';
|
||||
|
||||
const quoteIdentifierPartByDialect = (part: string, dbType: string): string => {
|
||||
const ident = stripIdentifierQuotes(part);
|
||||
if (!ident) return '';
|
||||
if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') {
|
||||
return `\`${escapeBacktickIdentifier(ident)}\``;
|
||||
}
|
||||
if (isSqlServerDialect(dbType)) {
|
||||
return `[${escapeBracketIdentifier(ident)}]`;
|
||||
}
|
||||
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
|
||||
};
|
||||
|
||||
const quoteIdentifierPathByDialect = (path: string, dbType: string): string => {
|
||||
const raw = String(path || '').trim();
|
||||
if (!raw) return '';
|
||||
const parts = raw
|
||||
.split('.')
|
||||
.map(part => stripIdentifierQuotes(part))
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return '';
|
||||
return parts.map(part => quoteIdentifierPartByDialect(part, dbType)).join('.');
|
||||
};
|
||||
|
||||
const resolveTableInfo = () => {
|
||||
const dbType = getDbType();
|
||||
const rawTable = String(tab.tableName || '').trim();
|
||||
const rawDb = String(tab.dbName || '').trim();
|
||||
const parsed = splitQualifiedName(rawTable);
|
||||
const table = parsed.objectName || stripIdentifierQuotes(rawTable);
|
||||
let schema = parsed.schemaName;
|
||||
|
||||
if (!schema) {
|
||||
if (isPgLikeDialect(dbType)) {
|
||||
schema = rawDb || 'public';
|
||||
} else if (isSqlServerDialect(dbType)) {
|
||||
schema = 'dbo';
|
||||
} else if (isOracleLikeDialect(dbType)) {
|
||||
schema = rawDb;
|
||||
} else {
|
||||
schema = rawDb;
|
||||
}
|
||||
}
|
||||
|
||||
const qualifiedName = schema ? `${schema}.${table}` : table;
|
||||
return {
|
||||
dbType,
|
||||
schema: stripIdentifierQuotes(schema),
|
||||
table: stripIdentifierQuotes(table),
|
||||
qualifiedName,
|
||||
tableRef: quoteIdentifierPathByDialect(qualifiedName, dbType),
|
||||
};
|
||||
};
|
||||
|
||||
const supportsIndexSchemaOps = (): boolean => {
|
||||
const dbType = getDbType();
|
||||
if (!dbType) return false;
|
||||
if (isNonRelationalDialect(dbType)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const supportsForeignKeySchemaOps = (): boolean => {
|
||||
const dbType = getDbType();
|
||||
if (!dbType) return false;
|
||||
if (isNonRelationalDialect(dbType)) return false;
|
||||
if (lacksAlterForeignKeySupport(dbType)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const supportsTableCommentOps = (): boolean => {
|
||||
const dbType = getDbType();
|
||||
if (!dbType) return false;
|
||||
if (isNonRelationalDialect(dbType)) return false;
|
||||
if (lacksTableCommentSupport(dbType)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getIndexKindOptions = () => {
|
||||
const dbType = getDbType();
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
return [
|
||||
{ label: '普通索引(非聚合)', value: 'NORMAL' },
|
||||
{ label: '唯一索引', value: 'UNIQUE' },
|
||||
{ label: '主键索引(聚合)', value: 'PRIMARY' },
|
||||
{ label: '全文索引', value: 'FULLTEXT' },
|
||||
{ label: '空间索引', value: 'SPATIAL' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: '普通索引', value: 'NORMAL' },
|
||||
{ label: '唯一索引', value: 'UNIQUE' },
|
||||
];
|
||||
};
|
||||
|
||||
const getIndexTypeOptions = () => {
|
||||
const dbType = getDbType();
|
||||
if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS;
|
||||
if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS;
|
||||
if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS;
|
||||
return [{ label: '默认', value: 'DEFAULT' }];
|
||||
};
|
||||
|
||||
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
|
||||
@@ -1127,8 +1292,6 @@ ${selectedTrigger.statement}`;
|
||||
}
|
||||
};
|
||||
|
||||
const supportsMysqlSchemaOps = () => getDbType() === 'mysql';
|
||||
|
||||
const executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
@@ -1163,13 +1326,59 @@ ${selectedTrigger.statement}`;
|
||||
setIsTableCommentModalOpen(true);
|
||||
};
|
||||
|
||||
const buildTableCommentSql = (nextComment: string): string | null => {
|
||||
const tableInfo = resolveTableInfo();
|
||||
const dbType = tableInfo.dbType;
|
||||
const escapedComment = escapeSqlString(nextComment);
|
||||
if (isNonRelationalDialect(dbType)) return null;
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
return `ALTER TABLE ${tableInfo.tableRef} COMMENT = '${escapedComment}';`;
|
||||
}
|
||||
if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) {
|
||||
return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`;
|
||||
}
|
||||
if (isSqlServerDialect(dbType)) {
|
||||
const schemaName = escapeSqlString(tableInfo.schema || 'dbo');
|
||||
const tableName = escapeSqlString(tableInfo.table);
|
||||
return `IF EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.extended_properties ep
|
||||
JOIN sys.tables t ON ep.major_id = t.object_id AND ep.minor_id = 0
|
||||
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
WHERE ep.name = N'MS_Description'
|
||||
AND s.name = N'${schemaName}'
|
||||
AND t.name = N'${tableName}'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_updateextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'${escapedComment}',
|
||||
@level0type = N'SCHEMA', @level0name = N'${schemaName}',
|
||||
@level1type = N'TABLE', @level1name = N'${tableName}';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'${escapedComment}',
|
||||
@level0type = N'SCHEMA', @level0name = N'${schemaName}',
|
||||
@level1type = N'TABLE', @level1name = N'${tableName}';
|
||||
END;`;
|
||||
}
|
||||
return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`;
|
||||
};
|
||||
|
||||
const handleSaveTableComment = async () => {
|
||||
if (!supportsMysqlSchemaOps()) {
|
||||
if (!supportsTableCommentOps()) {
|
||||
message.warning('当前数据库暂不支持在此修改表备注');
|
||||
return;
|
||||
}
|
||||
if (!tab.tableName) return;
|
||||
const sql = `ALTER TABLE ${getMysqlTableRef()} COMMENT = '${escapeSqlString(tableCommentDraft)}';`;
|
||||
const sql = buildTableCommentSql(tableCommentDraft);
|
||||
if (!sql) {
|
||||
message.warning('当前数据库暂不支持在此修改表备注');
|
||||
return;
|
||||
}
|
||||
setTableCommentSaving(true);
|
||||
const ok = await executeSchemaSql(sql, '表备注更新成功');
|
||||
setTableCommentSaving(false);
|
||||
@@ -1209,6 +1418,10 @@ ${selectedTrigger.statement}`;
|
||||
} else if (selectedIndex.nonUnique === 0) {
|
||||
kind = 'UNIQUE';
|
||||
}
|
||||
const supportedKinds = new Set(getIndexKindOptions().map(item => item.value));
|
||||
if (!supportedKinds.has(kind)) {
|
||||
kind = selectedIndex.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
|
||||
}
|
||||
|
||||
setIndexForm({
|
||||
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
|
||||
@@ -1221,51 +1434,132 @@ ${selectedTrigger.statement}`;
|
||||
setIsIndexModalOpen(true);
|
||||
};
|
||||
|
||||
const buildIndexAddClause = (form: IndexFormState): string | null => {
|
||||
const buildIndexCreateSql = (form: IndexFormState): string | null => {
|
||||
const tableInfo = resolveTableInfo();
|
||||
const dbType = tableInfo.dbType;
|
||||
const kind: IndexKind = form.kind || 'NORMAL';
|
||||
const indexName = String(form.name || '').trim();
|
||||
const colSql = form.columnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', ');
|
||||
const cleanedCols = form.columnNames.map(col => String(col || '').trim()).filter(Boolean);
|
||||
if (cleanedCols.length === 0) {
|
||||
message.error('请至少选择一个字段');
|
||||
return null;
|
||||
}
|
||||
const colSql = cleanedCols
|
||||
.map(col => quoteIdentifierPartByDialect(col, dbType))
|
||||
.join(', ');
|
||||
|
||||
if (kind === 'PRIMARY') {
|
||||
return `ADD PRIMARY KEY (${colSql})`;
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
if (kind === 'PRIMARY') {
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\nADD PRIMARY KEY (${colSql});`;
|
||||
}
|
||||
|
||||
if (!indexName) {
|
||||
message.error('请输入索引名');
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexRef = quoteIdentifierPartByDialect(indexName, dbType);
|
||||
if (kind === 'FULLTEXT') {
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\nADD FULLTEXT INDEX ${indexRef} (${colSql});`;
|
||||
}
|
||||
if (kind === 'SPATIAL') {
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\nADD SPATIAL INDEX ${indexRef} (${colSql});`;
|
||||
}
|
||||
|
||||
const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT';
|
||||
if (normalizedType === 'FULLTEXT' || normalizedType === 'SPATIAL') {
|
||||
message.error(`请将“索引类别”切换为 ${normalizedType} 索引`);
|
||||
return null;
|
||||
}
|
||||
const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : '';
|
||||
const prefix = kind === 'UNIQUE' ? 'ADD UNIQUE INDEX' : 'ADD INDEX';
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\n${prefix} ${indexRef}${usingSql} (${colSql});`;
|
||||
}
|
||||
|
||||
if (kind === 'PRIMARY' || kind === 'FULLTEXT' || kind === 'SPATIAL') {
|
||||
message.warning('当前数据库仅支持普通索引与唯一索引维护');
|
||||
return null;
|
||||
}
|
||||
if (!indexName) {
|
||||
message.error('请输入索引名');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kind === 'FULLTEXT') {
|
||||
return `ADD FULLTEXT INDEX \`${escapeBacktickIdentifier(indexName)}\` (${colSql})`;
|
||||
}
|
||||
if (kind === 'SPATIAL') {
|
||||
return `ADD SPATIAL INDEX \`${escapeBacktickIdentifier(indexName)}\` (${colSql})`;
|
||||
const indexRef = quoteIdentifierPartByDialect(indexName, dbType);
|
||||
const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT';
|
||||
const uniquePrefix = kind === 'UNIQUE' ? 'UNIQUE ' : '';
|
||||
|
||||
if (isPgLikeDialect(dbType)) {
|
||||
const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : '';
|
||||
return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef}${usingSql} (${colSql});`;
|
||||
}
|
||||
|
||||
const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT';
|
||||
if (normalizedType === 'FULLTEXT' || normalizedType === 'SPATIAL') {
|
||||
message.error(`请将“索引类别”切换为 ${normalizedType} 索引`);
|
||||
if (isSqlServerDialect(dbType)) {
|
||||
const methodSql = normalizedType === 'CLUSTERED' || normalizedType === 'NONCLUSTERED'
|
||||
? `${normalizedType} `
|
||||
: '';
|
||||
return `CREATE ${uniquePrefix}${methodSql}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`;
|
||||
}
|
||||
|
||||
if (isOracleLikeDialect(dbType) || dbType === 'sqlite') {
|
||||
return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`;
|
||||
}
|
||||
|
||||
if (isNonRelationalDialect(dbType)) {
|
||||
message.warning('当前数据源不支持关系型索引维护');
|
||||
return null;
|
||||
}
|
||||
|
||||
const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : '';
|
||||
const prefix = kind === 'UNIQUE' ? 'ADD UNIQUE INDEX' : 'ADD INDEX';
|
||||
return `${prefix} \`${escapeBacktickIdentifier(indexName)}\`${usingSql} (${colSql})`;
|
||||
return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`;
|
||||
};
|
||||
|
||||
const buildIndexDropClause = (indexName: string) => {
|
||||
if (String(indexName || '').trim().toUpperCase() === 'PRIMARY') {
|
||||
return 'DROP PRIMARY KEY';
|
||||
const buildIndexDropSql = (indexName: string): string | null => {
|
||||
const tableInfo = resolveTableInfo();
|
||||
const dbType = tableInfo.dbType;
|
||||
const name = String(indexName || '').trim();
|
||||
if (!name) return null;
|
||||
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
if (name.toUpperCase() === 'PRIMARY') {
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\nDROP PRIMARY KEY;`;
|
||||
}
|
||||
const indexRef = quoteIdentifierPartByDialect(name, dbType);
|
||||
return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`;
|
||||
}
|
||||
return `DROP INDEX \`${escapeBacktickIdentifier(indexName)}\``;
|
||||
|
||||
if (isSqlServerDialect(dbType)) {
|
||||
const indexRef = quoteIdentifierPartByDialect(name, dbType);
|
||||
return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`;
|
||||
}
|
||||
|
||||
if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType) || dbType === 'sqlite') {
|
||||
const fullIndexName = name.includes('.') || !tableInfo.schema
|
||||
? name
|
||||
: `${tableInfo.schema}.${name}`;
|
||||
const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType);
|
||||
return `DROP INDEX ${indexRef};`;
|
||||
}
|
||||
|
||||
if (isNonRelationalDialect(dbType)) {
|
||||
return null;
|
||||
}
|
||||
const fullIndexName = name.includes('.') || !tableInfo.schema
|
||||
? name
|
||||
: `${tableInfo.schema}.${name}`;
|
||||
const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType);
|
||||
return `DROP INDEX ${indexRef};`;
|
||||
};
|
||||
|
||||
const handleSubmitIndex = async () => {
|
||||
if (!supportsMysqlSchemaOps()) {
|
||||
if (!supportsIndexSchemaOps()) {
|
||||
message.warning('当前数据库暂不支持在此维护索引');
|
||||
return;
|
||||
}
|
||||
if (!tab.tableName) return;
|
||||
const supportedKinds = new Set(getIndexKindOptions().map(item => item.value));
|
||||
if (!supportedKinds.has(indexForm.kind)) {
|
||||
message.warning('当前数据库不支持该索引类型');
|
||||
return;
|
||||
}
|
||||
const nextName = indexForm.kind === 'PRIMARY' ? 'PRIMARY' : String(indexForm.name || '').trim();
|
||||
if (indexForm.kind !== 'PRIMARY' && !nextName) {
|
||||
message.error('请输入索引名');
|
||||
@@ -1287,16 +1581,21 @@ ${selectedTrigger.statement}`;
|
||||
}
|
||||
|
||||
setIndexSaving(true);
|
||||
const addClause = buildIndexAddClause({ ...indexForm, name: nextName });
|
||||
if (!addClause) {
|
||||
const addSql = buildIndexCreateSql({ ...indexForm, name: nextName });
|
||||
if (!addSql) {
|
||||
setIndexSaving(false);
|
||||
return;
|
||||
}
|
||||
let sql = `ALTER TABLE ${getMysqlTableRef()}\n${addClause};`;
|
||||
let sql = addSql;
|
||||
|
||||
if (indexModalMode === 'edit' && selectedIndex) {
|
||||
const dropClause = buildIndexDropClause(selectedIndex.name);
|
||||
sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause},\n${addClause};`;
|
||||
const dropSql = buildIndexDropSql(selectedIndex.name);
|
||||
if (!dropSql) {
|
||||
setIndexSaving(false);
|
||||
message.warning('当前数据库暂不支持删除该索引');
|
||||
return;
|
||||
}
|
||||
sql = `${dropSql}\n${addSql}`;
|
||||
}
|
||||
|
||||
const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功');
|
||||
@@ -1311,7 +1610,7 @@ ${selectedTrigger.statement}`;
|
||||
message.warning('请先选择一个索引');
|
||||
return;
|
||||
}
|
||||
if (!supportsMysqlSchemaOps()) {
|
||||
if (!supportsIndexSchemaOps()) {
|
||||
message.warning('当前数据库暂不支持在此维护索引');
|
||||
return;
|
||||
}
|
||||
@@ -1323,8 +1622,11 @@ ${selectedTrigger.statement}`;
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const dropClause = buildIndexDropClause(selectedIndex.name);
|
||||
const sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause};`;
|
||||
const sql = buildIndexDropSql(selectedIndex.name);
|
||||
if (!sql) {
|
||||
message.warning('当前数据库暂不支持删除该索引');
|
||||
return;
|
||||
}
|
||||
await executeSchemaSql(sql, '索引删除成功');
|
||||
}
|
||||
});
|
||||
@@ -1356,18 +1658,40 @@ ${selectedTrigger.statement}`;
|
||||
setIsForeignKeyModalOpen(true);
|
||||
};
|
||||
|
||||
const buildForeignKeyAddClause = (form: ForeignKeyFormState) => {
|
||||
const localColsSql = form.columnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', ');
|
||||
const refColsSql = form.refColumnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', ');
|
||||
const refTableSql = quoteMysqlIdentifierPath(form.refTableName);
|
||||
return `ADD CONSTRAINT \`${escapeBacktickIdentifier(form.constraintName)}\` FOREIGN KEY (${localColsSql}) REFERENCES ${refTableSql} (${refColsSql})`;
|
||||
const buildForeignKeyAddSql = (form: ForeignKeyFormState): string | null => {
|
||||
const tableInfo = resolveTableInfo();
|
||||
const dbType = tableInfo.dbType;
|
||||
if (!supportsForeignKeySchemaOps()) return null;
|
||||
|
||||
const localColsSql = form.columnNames
|
||||
.map(col => quoteIdentifierPartByDialect(col, dbType))
|
||||
.join(', ');
|
||||
const refColsSql = form.refColumnNames
|
||||
.map(col => quoteIdentifierPartByDialect(col, dbType))
|
||||
.join(', ');
|
||||
const refParts = splitQualifiedName(form.refTableName);
|
||||
const refObjectName = refParts.objectName || String(form.refTableName || '').trim();
|
||||
const refTableName = !refParts.schemaName && tableInfo.schema && (isPgLikeDialect(dbType) || isSqlServerDialect(dbType) || isOracleLikeDialect(dbType))
|
||||
? `${tableInfo.schema}.${refObjectName}`
|
||||
: String(form.refTableName || '').trim();
|
||||
const refTableSql = quoteIdentifierPathByDialect(refTableName, dbType);
|
||||
const constraintSql = quoteIdentifierPartByDialect(form.constraintName, dbType);
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\nADD CONSTRAINT ${constraintSql} FOREIGN KEY (${localColsSql}) REFERENCES ${refTableSql} (${refColsSql});`;
|
||||
};
|
||||
|
||||
const buildForeignKeyDropClause = (constraintName: string) =>
|
||||
`DROP FOREIGN KEY \`${escapeBacktickIdentifier(constraintName)}\``;
|
||||
const buildForeignKeyDropSql = (constraintName: string): string | null => {
|
||||
const tableInfo = resolveTableInfo();
|
||||
const dbType = tableInfo.dbType;
|
||||
if (!supportsForeignKeySchemaOps()) return null;
|
||||
const constraintSql = quoteIdentifierPartByDialect(constraintName, dbType);
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\nDROP FOREIGN KEY ${constraintSql};`;
|
||||
}
|
||||
return `ALTER TABLE ${tableInfo.tableRef}\nDROP CONSTRAINT ${constraintSql};`;
|
||||
};
|
||||
|
||||
const handleSubmitForeignKey = async () => {
|
||||
if (!supportsMysqlSchemaOps()) {
|
||||
if (!supportsForeignKeySchemaOps()) {
|
||||
message.warning('当前数据库暂不支持在此维护外键');
|
||||
return;
|
||||
}
|
||||
@@ -1408,17 +1732,27 @@ ${selectedTrigger.statement}`;
|
||||
}
|
||||
|
||||
setForeignKeySaving(true);
|
||||
const addClause = buildForeignKeyAddClause({
|
||||
const addSql = buildForeignKeyAddSql({
|
||||
...foreignKeyForm,
|
||||
constraintName: nextConstraint,
|
||||
columnNames: localCols,
|
||||
refTableName: refTable,
|
||||
refColumnNames: refCols,
|
||||
});
|
||||
let sql = `ALTER TABLE ${getMysqlTableRef()}\n${addClause};`;
|
||||
if (!addSql) {
|
||||
setForeignKeySaving(false);
|
||||
message.warning('当前数据库暂不支持在此维护外键');
|
||||
return;
|
||||
}
|
||||
let sql = addSql;
|
||||
if (foreignKeyModalMode === 'edit' && selectedForeignKey) {
|
||||
const dropClause = buildForeignKeyDropClause(selectedForeignKey.constraintName);
|
||||
sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause},\n${addClause};`;
|
||||
const dropSql = buildForeignKeyDropSql(selectedForeignKey.constraintName);
|
||||
if (!dropSql) {
|
||||
setForeignKeySaving(false);
|
||||
message.warning('当前数据库暂不支持删除该外键');
|
||||
return;
|
||||
}
|
||||
sql = `${dropSql}\n${addSql}`;
|
||||
}
|
||||
|
||||
const ok = await executeSchemaSql(sql, foreignKeyModalMode === 'create' ? '外键新增成功' : '外键修改成功');
|
||||
@@ -1433,7 +1767,7 @@ ${selectedTrigger.statement}`;
|
||||
message.warning('请先选择一个外键');
|
||||
return;
|
||||
}
|
||||
if (!supportsMysqlSchemaOps()) {
|
||||
if (!supportsForeignKeySchemaOps()) {
|
||||
message.warning('当前数据库暂不支持在此维护外键');
|
||||
return;
|
||||
}
|
||||
@@ -1445,7 +1779,11 @@ ${selectedTrigger.statement}`;
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const sql = `ALTER TABLE ${getMysqlTableRef()}\n${buildForeignKeyDropClause(selectedForeignKey.constraintName)};`;
|
||||
const sql = buildForeignKeyDropSql(selectedForeignKey.constraintName);
|
||||
if (!sql) {
|
||||
message.warning('当前数据库暂不支持删除该外键');
|
||||
return;
|
||||
}
|
||||
await executeSchemaSql(sql, '外键删除成功');
|
||||
}
|
||||
});
|
||||
@@ -1677,7 +2015,7 @@ ${selectedTrigger.statement}`;
|
||||
)}
|
||||
{!readOnly && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||
{!isNewTable && !readOnly && supportsMysqlSchemaOps() && (
|
||||
{!isNewTable && !readOnly && supportsTableCommentOps() && (
|
||||
<Button icon={<EditOutlined />} onClick={openTableCommentModal}>表备注</Button>
|
||||
)}
|
||||
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}>添加字段</Button>}
|
||||
@@ -1710,15 +2048,15 @@ ${selectedTrigger.statement}`;
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!readOnly && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={openCreateIndexModal}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!selectedIndex} onClick={openEditIndexModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedIndex} onClick={handleDeleteIndex}>删除</Button>
|
||||
{!supportsMysqlSchemaOps() && (
|
||||
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={openEditIndexModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={handleDeleteIndex}>删除</Button>
|
||||
{!supportsIndexSchemaOps() && (
|
||||
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
|
||||
当前数据库暂不支持索引编辑,仅支持查看
|
||||
</span>
|
||||
)}
|
||||
{supportsMysqlSchemaOps() && selectedIndex && (
|
||||
{supportsIndexSchemaOps() && selectedIndex && (
|
||||
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
|
||||
已选择:{selectedIndex.name}
|
||||
</span>
|
||||
@@ -1813,15 +2151,15 @@ ${selectedTrigger.statement}`;
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!readOnly && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={openCreateForeignKeyModal}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!selectedForeignKey} onClick={openEditForeignKeyModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedForeignKey} onClick={handleDeleteForeignKey}>删除</Button>
|
||||
{!supportsMysqlSchemaOps() && (
|
||||
<Button size="small" icon={<PlusOutlined />} disabled={!supportsForeignKeySchemaOps()} onClick={openCreateForeignKeyModal}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={openEditForeignKeyModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={handleDeleteForeignKey}>删除</Button>
|
||||
{!supportsForeignKeySchemaOps() && (
|
||||
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
|
||||
当前数据库暂不支持外键编辑,仅支持查看
|
||||
</span>
|
||||
)}
|
||||
{supportsMysqlSchemaOps() && selectedForeignKey && (
|
||||
{supportsForeignKeySchemaOps() && selectedForeignKey && (
|
||||
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
|
||||
已选择:{selectedForeignKey.constraintName}
|
||||
</span>
|
||||
@@ -2077,13 +2415,7 @@ ${selectedTrigger.statement}`;
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={indexForm.kind}
|
||||
options={[
|
||||
{ label: '普通索引(非聚合)', value: 'NORMAL' },
|
||||
{ label: '唯一索引', value: 'UNIQUE' },
|
||||
{ label: '主键索引(聚合)', value: 'PRIMARY' },
|
||||
{ label: '全文索引', value: 'FULLTEXT' },
|
||||
{ label: '空间索引', value: 'SPATIAL' },
|
||||
]}
|
||||
options={getIndexKindOptions()}
|
||||
onChange={(val: IndexKind) =>
|
||||
setIndexForm(prev => ({
|
||||
...prev,
|
||||
@@ -2097,7 +2429,7 @@ ${selectedTrigger.statement}`;
|
||||
<Select
|
||||
value={indexForm.indexType}
|
||||
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
|
||||
options={MYSQL_INDEX_TYPE_OPTIONS}
|
||||
options={getIndexTypeOptions()}
|
||||
style={{ width: 160 }}
|
||||
disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,21 @@ import App from './App'
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'
|
||||
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js'
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
|
||||
|
||||
(self as any).MonacoEnvironment = {
|
||||
getWorker(_: unknown, label: string) {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
},
|
||||
}
|
||||
|
||||
loader.config({ monaco })
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/')
|
||||
|
||||
const sanitizeChunkToken = (raw: string): string =>
|
||||
String(raw || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'misc'
|
||||
|
||||
const firstSegmentAfter = (id: string, marker: string): string => {
|
||||
const idx = id.indexOf(marker)
|
||||
if (idx < 0) return ''
|
||||
const rest = id.substring(idx + marker.length)
|
||||
const [segment] = rest.split('/')
|
||||
return sanitizeChunkToken(segment)
|
||||
}
|
||||
|
||||
const resolveMonacoChunk = (id: string, prefix: string): string | undefined => {
|
||||
if (!id.includes('/node_modules/monaco-editor/')) return undefined
|
||||
|
||||
if (id.includes('/esm/vs/language/typescript/')) {
|
||||
if (id.includes('typescriptServices')) return `${prefix}-ts-services`
|
||||
return `${prefix}-typescript`
|
||||
}
|
||||
if (id.includes('/esm/vs/language/json/')) return `${prefix}-json`
|
||||
if (id.includes('/esm/vs/language/css/')) return `${prefix}-css`
|
||||
if (id.includes('/esm/vs/language/html/')) return `${prefix}-html`
|
||||
|
||||
if (id.includes('/esm/vs/editor/contrib/')) {
|
||||
return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/browser/')) {
|
||||
return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/common/')) {
|
||||
return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/')) return `${prefix}-editor`
|
||||
|
||||
if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser`
|
||||
if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common`
|
||||
if (id.includes('/esm/vs/base/')) return `${prefix}-base`
|
||||
|
||||
if (id.includes('/esm/vs/platform/')) return `${prefix}-platform`
|
||||
|
||||
return `${prefix}-misc`
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -11,5 +59,61 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
}
|
||||
})
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
|
||||
const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco')
|
||||
if (monacoChunk) {
|
||||
return monacoChunk
|
||||
}
|
||||
if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react'
|
||||
|
||||
if (moduleId.includes('/node_modules/antd/es/')) {
|
||||
return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}`
|
||||
}
|
||||
if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd'
|
||||
if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons'
|
||||
if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css'
|
||||
if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc'
|
||||
|
||||
if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit'
|
||||
if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter'
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/react/')
|
||||
|| moduleId.includes('/node_modules/react-dom/')
|
||||
|| moduleId.includes('/node_modules/scheduler/')
|
||||
) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/zustand/')
|
||||
|| moduleId.includes('/node_modules/uuid/')
|
||||
|| moduleId.includes('/node_modules/clsx/')
|
||||
|| moduleId.includes('/node_modules/react-resizable/')
|
||||
) {
|
||||
return 'vendor-utils'
|
||||
}
|
||||
|
||||
return 'vendor-misc'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
return resolveMonacoChunk(moduleId, 'worker-monaco')
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -156,6 +156,8 @@ export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.Qu
|
||||
|
||||
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -306,6 +306,10 @@ export function SelectDriverPackageFile(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
|
||||
}
|
||||
|
||||
export function SelectSSHKeyFile(arg1) {
|
||||
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
|
||||
}
|
||||
|
||||
export function SetWindowTranslucency(arg1, arg2) {
|
||||
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -77,6 +78,48 @@ func (a *App) ImportConfigFile() connection.QueryResult {
|
||||
return connection.QueryResult{Success: true, Data: string(content)}
|
||||
}
|
||||
|
||||
func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
|
||||
defaultDir := strings.TrimSpace(currentPath)
|
||||
if defaultDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
defaultDir = filepath.Join(home, ".ssh")
|
||||
}
|
||||
}
|
||||
if filepath.Ext(defaultDir) != "" {
|
||||
defaultDir = filepath.Dir(defaultDir)
|
||||
}
|
||||
if defaultDir != "" && !filepath.IsAbs(defaultDir) {
|
||||
if abs, err := filepath.Abs(defaultDir); err == nil {
|
||||
defaultDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "选择 SSH 私钥文件",
|
||||
DefaultDirectory: defaultDir,
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "私钥文件",
|
||||
Pattern: "*.pem;*.key;*.ppk;*id_rsa*",
|
||||
},
|
||||
{
|
||||
DisplayName: "所有文件",
|
||||
Pattern: "*",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
|
||||
}
|
||||
|
||||
// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据
|
||||
func (a *App) PreviewImportFile(filePath string) connection.QueryResult {
|
||||
if filePath == "" {
|
||||
@@ -485,7 +528,8 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
||||
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, true); err != nil {
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if err := writeSQLFooter(w, runConfig); err != nil {
|
||||
@@ -556,7 +600,7 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
tables := make([]string, 0, len(tableNames))
|
||||
objects := make([]string, 0, len(tableNames))
|
||||
seen := make(map[string]struct{}, len(tableNames))
|
||||
for _, t := range tableNames {
|
||||
t = strings.TrimSpace(t)
|
||||
@@ -567,9 +611,10 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
tables = append(tables, t)
|
||||
objects = append(objects, t)
|
||||
}
|
||||
sort.Strings(tables)
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
objects = buildExportObjectOrder(runConfig, dbName, objects, viewLookup, false)
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
@@ -583,8 +628,8 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
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, includeSchema, includeData); err != nil {
|
||||
for _, objectName := range objects {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, includeSchema, includeData, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +668,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
sort.Strings(tables)
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
objects := buildExportObjectOrder(runConfig, dbName, tables, viewLookup, true)
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
@@ -637,8 +683,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
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, true, includeData); err != nil {
|
||||
for _, objectName := range objects {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, true, includeData, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
@@ -743,6 +789,404 @@ func ensureSQLTerminator(sql string) string {
|
||||
return sql + ";"
|
||||
}
|
||||
|
||||
func buildExportObjectOrder(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
rawObjects []string,
|
||||
viewLookup map[string]string,
|
||||
includeAllViews bool,
|
||||
) []string {
|
||||
tableSet := make(map[string]string, len(rawObjects))
|
||||
viewSet := make(map[string]string, len(rawObjects))
|
||||
|
||||
for _, rawName := range rawObjects {
|
||||
objectName := strings.TrimSpace(rawName)
|
||||
if objectName == "" {
|
||||
continue
|
||||
}
|
||||
key := normalizeExportObjectKey(config, dbName, objectName)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if canonicalViewName, ok := viewLookup[key]; ok {
|
||||
if strings.TrimSpace(canonicalViewName) == "" {
|
||||
canonicalViewName = objectName
|
||||
}
|
||||
viewSet[key] = canonicalViewName
|
||||
delete(tableSet, key)
|
||||
continue
|
||||
}
|
||||
if _, isView := viewSet[key]; isView {
|
||||
continue
|
||||
}
|
||||
if _, exists := tableSet[key]; !exists {
|
||||
tableSet[key] = objectName
|
||||
}
|
||||
}
|
||||
|
||||
if includeAllViews {
|
||||
for key, viewName := range viewLookup {
|
||||
canonicalViewName := strings.TrimSpace(viewName)
|
||||
if canonicalViewName == "" {
|
||||
continue
|
||||
}
|
||||
viewSet[key] = canonicalViewName
|
||||
delete(tableSet, key)
|
||||
}
|
||||
}
|
||||
|
||||
tables := mapValuesSorted(tableSet)
|
||||
views := mapValuesSorted(viewSet)
|
||||
return append(tables, views...)
|
||||
}
|
||||
|
||||
func mapValuesSorted(values map[string]string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeExportObjectKey(config connection.ConnectionConfig, dbName string, objectName string) string {
|
||||
schemaName, pureName := normalizeSchemaAndTable(config, dbName, objectName)
|
||||
return normalizeExportObjectKeyByParts(schemaName, pureName)
|
||||
}
|
||||
|
||||
func normalizeExportObjectKeyByParts(schemaName, objectName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(qualifyTable(schemaName, objectName)))
|
||||
}
|
||||
|
||||
func listViewNameLookup(dbInst db.Database, config connection.ConnectionConfig, dbName string) map[string]string {
|
||||
viewLookup := make(map[string]string)
|
||||
queries := buildListViewQueries(config, dbName)
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, row := range rows {
|
||||
tableType := strings.ToUpper(exportRowValueCI(row, "table_type", "type"))
|
||||
if tableType != "" && tableType != "VIEW" {
|
||||
continue
|
||||
}
|
||||
schemaName := exportRowValueCI(row, "schema_name", "table_schema", "owner", "schema", "db")
|
||||
viewName := exportRowValueCI(row, "object_name", "view_name", "table_name", "name")
|
||||
if viewName == "" {
|
||||
viewName = exportInferObjectName(row)
|
||||
}
|
||||
if strings.TrimSpace(viewName) == "" {
|
||||
continue
|
||||
}
|
||||
fullName := strings.TrimSpace(qualifyTable(schemaName, viewName))
|
||||
if fullName == "" {
|
||||
fullName = strings.TrimSpace(viewName)
|
||||
}
|
||||
key := normalizeExportObjectKey(config, dbName, fullName)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := viewLookup[key]; !exists {
|
||||
viewLookup[key] = fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
return viewLookup
|
||||
}
|
||||
|
||||
func buildListViewQueries(config connection.ConnectionConfig, dbName string) []string {
|
||||
dbType := resolveDDLDBType(config)
|
||||
escapedDbName := escapeSQLLiteral(dbName)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
queries := []string{
|
||||
fmt.Sprintf(`SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name, TABLE_TYPE AS table_type FROM information_schema.tables WHERE TABLE_TYPE='VIEW' AND TABLE_SCHEMA='%s' ORDER BY TABLE_NAME`, escapedDbName),
|
||||
}
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
queries = append(queries, fmt.Sprintf("SHOW FULL TABLES FROM %s WHERE Table_type = 'VIEW'", quoteIdentByType("mysql", dbName)))
|
||||
}
|
||||
return queries
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name`,
|
||||
}
|
||||
case "sqlserver":
|
||||
safeDBName := strings.TrimSpace(config.Database)
|
||||
if safeDBName == "" {
|
||||
safeDBName = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeDBName == "" {
|
||||
return nil
|
||||
}
|
||||
safeDB := quoteIdentByType("sqlserver", safeDBName)
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT s.name AS schema_name, v.name AS object_name FROM %s.sys.views v JOIN %s.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`, safeDB, safeDB),
|
||||
}
|
||||
case "oracle", "dameng":
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
`SELECT VIEW_NAME AS object_name FROM user_views ORDER BY VIEW_NAME`,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT OWNER AS schema_name, VIEW_NAME AS object_name FROM all_views WHERE OWNER = '%s' ORDER BY VIEW_NAME", strings.ToUpper(escapedDbName)),
|
||||
}
|
||||
case "sqlite":
|
||||
return []string{
|
||||
"SELECT name AS object_name FROM sqlite_master WHERE type='view' ORDER BY name",
|
||||
}
|
||||
case "duckdb":
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`,
|
||||
}
|
||||
default:
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views`,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema='%s'`, escapedDbName),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tryGetViewCreateStatement(
|
||||
dbInst db.Database,
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
schemaName string,
|
||||
viewName string,
|
||||
) (string, bool) {
|
||||
queries := buildViewCreateQueries(config, dbName, schemaName, viewName)
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
if err != nil || len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
createSQL := strings.TrimSpace(extractViewCreateSQL(rows[0]))
|
||||
if createSQL == "" {
|
||||
continue
|
||||
}
|
||||
if looksLikeSelectOrWith(createSQL) {
|
||||
qualifiedView := qualifyTable(schemaName, viewName)
|
||||
createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteQualifiedIdentByType(config.Type, qualifiedView), strings.TrimSuffix(strings.TrimSpace(createSQL), ";"))
|
||||
}
|
||||
return ensureSQLTerminator(createSQL), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func buildViewCreateQueries(config connection.ConnectionConfig, dbName, schemaName, viewName string) []string {
|
||||
dbType := resolveDDLDBType(config)
|
||||
safeSchema := strings.TrimSpace(schemaName)
|
||||
safeView := strings.TrimSpace(viewName)
|
||||
if safeView == "" {
|
||||
return nil
|
||||
}
|
||||
escapedSchema := escapeSQLLiteral(safeSchema)
|
||||
escapedView := escapeSQLLiteral(safeView)
|
||||
escapedDB := escapeSQLLiteral(dbName)
|
||||
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
if safeSchema == "" {
|
||||
safeSchema = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE VIEW %s.%s", quoteIdentByType("mysql", safeSchema), quoteIdentByType("mysql", safeView)),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE VIEW %s", quoteIdentByType("mysql", safeView)),
|
||||
}
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
if safeSchema == "" {
|
||||
safeSchema = "public"
|
||||
}
|
||||
regClassName := fmt.Sprintf(`"%s"."%s"`, strings.ReplaceAll(safeSchema, `"`, `""`), strings.ReplaceAll(safeView, `"`, `""`))
|
||||
regClassName = strings.ReplaceAll(regClassName, "'", "''")
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT pg_get_viewdef('%s'::regclass, true) AS ddl", regClassName),
|
||||
}
|
||||
case "sqlserver":
|
||||
schema := safeSchema
|
||||
if schema == "" {
|
||||
schema = "dbo"
|
||||
}
|
||||
safeDBName := strings.TrimSpace(config.Database)
|
||||
if safeDBName == "" {
|
||||
safeDBName = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeDBName == "" {
|
||||
return nil
|
||||
}
|
||||
safeDB := quoteIdentByType("sqlserver", safeDBName)
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT m.definition AS ddl
|
||||
FROM %s.sys.views v
|
||||
JOIN %s.sys.schemas s ON v.schema_id = s.schema_id
|
||||
JOIN %s.sys.sql_modules m ON v.object_id = m.object_id
|
||||
WHERE s.name = '%s' AND v.name = '%s'`,
|
||||
safeDB, safeDB, safeDB, escapeSQLLiteral(schema), escapedView),
|
||||
}
|
||||
case "oracle", "dameng":
|
||||
if safeSchema == "" {
|
||||
safeSchema = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView), strings.ToUpper(escapeSQLLiteral(safeSchema))),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView)),
|
||||
}
|
||||
case "sqlite":
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT sql AS ddl FROM sqlite_master WHERE type='view' AND name='%s'", escapedView),
|
||||
}
|
||||
case "duckdb":
|
||||
if safeSchema == "" {
|
||||
safeSchema = "main"
|
||||
escapedSchema = "main"
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
}
|
||||
default:
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedDB),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' LIMIT 1", escapedView),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractViewCreateSQL(row map[string]interface{}) string {
|
||||
if row == nil {
|
||||
return ""
|
||||
}
|
||||
ddl := exportRowValueCI(row, "create view", "create_statement", "create_sql", "ddl", "sql", "view_definition", "definition")
|
||||
if ddl != "" {
|
||||
return ddl
|
||||
}
|
||||
for _, value := range row {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
if strings.HasPrefix(lower, "create ") || strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exportRowValueCI(row map[string]interface{}, candidates ...string) string {
|
||||
if len(row) == 0 || len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for key, value := range row {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalizedKey != candidate {
|
||||
continue
|
||||
}
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exportInferObjectName(row map[string]interface{}) string {
|
||||
if len(row) == 0 {
|
||||
return ""
|
||||
}
|
||||
for key, value := range row {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalizedKey == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedKey, "type") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedKey, "table") || strings.Contains(normalizedKey, "view") || strings.Contains(normalizedKey, "name") || strings.Contains(normalizedKey, "ddl") || strings.Contains(normalizedKey, "sql") {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
for _, value := range row {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeSelectOrWith(sql string) bool {
|
||||
trimmed := strings.TrimSpace(strings.TrimSuffix(sql, ";"))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
return strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") || lower == "select" || lower == "with"
|
||||
}
|
||||
|
||||
func escapeSQLLiteral(value string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(value), "'", "''")
|
||||
}
|
||||
|
||||
func isMySQLHexLiteral(s string) bool {
|
||||
if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) {
|
||||
return false
|
||||
@@ -798,13 +1242,63 @@ func formatSQLValue(dbType string, v interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeSchema bool, includeData bool) error {
|
||||
func dumpTableSQL(
|
||||
w *bufio.Writer,
|
||||
dbInst db.Database,
|
||||
config connection.ConnectionConfig,
|
||||
dbName,
|
||||
tableName string,
|
||||
includeSchema bool,
|
||||
includeData bool,
|
||||
viewLookup map[string]string,
|
||||
) error {
|
||||
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||
objectKey := normalizeExportObjectKeyByParts(schemaName, pureTableName)
|
||||
_, isView := viewLookup[objectKey]
|
||||
var createSQL string
|
||||
|
||||
if includeSchema {
|
||||
if isView {
|
||||
viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName)
|
||||
if ok {
|
||||
createSQL = viewDDL
|
||||
} else {
|
||||
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createSQL = ddl
|
||||
}
|
||||
} else {
|
||||
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
|
||||
createSQL = viewDDL
|
||||
isView = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
createSQL = ddl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if includeData && !includeSchema && !isView {
|
||||
if _, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
|
||||
isView = true
|
||||
}
|
||||
}
|
||||
|
||||
objectLabel := "Table"
|
||||
if isView {
|
||||
objectLabel = "View"
|
||||
}
|
||||
|
||||
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil {
|
||||
if _, err := w.WriteString(fmt.Sprintf("-- %s: %s\n", objectLabel, qualifyTable(schemaName, pureTableName))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil {
|
||||
@@ -812,10 +1306,6 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
|
||||
}
|
||||
|
||||
if includeSchema {
|
||||
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -828,6 +1318,13 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
|
||||
return nil
|
||||
}
|
||||
|
||||
if isView {
|
||||
if _, err := w.WriteString("-- View data export skipped (INSERT for views is not emitted).\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
qualified := qualifyTable(schemaName, pureTableName)
|
||||
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
|
||||
data, columns, err := dbInst.Query(selectSQL)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
stdRuntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -857,55 +858,55 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||
}
|
||||
|
||||
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
|
||||
return fmt.Sprintf(`@echo off
|
||||
script := `@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
set "SOURCE=%s"
|
||||
set "TARGET=%s"
|
||||
set "STAGED=%s"
|
||||
set "LOG_FILE=%s"
|
||||
set PID=%d
|
||||
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
|
||||
set "TARGET=__GONAVI_UPDATE_TARGET__"
|
||||
set "STAGED=__GONAVI_UPDATE_STAGED__"
|
||||
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
|
||||
set PID=__GONAVI_UPDATE_PID__
|
||||
|
||||
call :log updater started
|
||||
if not exist "%%SOURCE%%" (
|
||||
call :log source file not found: %%SOURCE%%
|
||||
if not exist "%SOURCE%" (
|
||||
call :log source file not found: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
for %%I in ("%%TARGET%%") do set "TARGET_NAME=%%~nxI"
|
||||
for %%I in ("%%SOURCE%%") do set "SOURCE_EXT=%%~xI"
|
||||
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
|
||||
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
|
||||
set "SOURCE_EXE="
|
||||
|
||||
if /I "%%SOURCE_EXT%%"==".zip" (
|
||||
set "EXTRACT_DIR=%%STAGED%%\_extract"
|
||||
if exist "%%EXTRACT_DIR%%" (
|
||||
rmdir /S /Q "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if /I "%SOURCE_EXT%"==".zip" (
|
||||
set "EXTRACT_DIR=%STAGED%\_extract"
|
||||
if exist "%EXTRACT_DIR%" (
|
||||
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||
)
|
||||
mkdir "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
call :log expand zip failed: %%SOURCE%%
|
||||
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log expand zip failed: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
if exist "%%EXTRACT_DIR%%\%%TARGET_NAME%%" (
|
||||
set "SOURCE_EXE=%%EXTRACT_DIR%%\%%TARGET_NAME%%"
|
||||
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
|
||||
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
|
||||
) else (
|
||||
for /R "%%EXTRACT_DIR%%" %%F in (*.exe) do (
|
||||
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
|
||||
if not defined SOURCE_EXE (
|
||||
set "SOURCE_EXE=%%~fF"
|
||||
)
|
||||
)
|
||||
)
|
||||
if not defined SOURCE_EXE (
|
||||
call :log no executable found in portable zip: %%SOURCE%%
|
||||
call :log no executable found in portable zip: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
set "SOURCE_EXE=%%SOURCE%%"
|
||||
set "SOURCE_EXE=%SOURCE%"
|
||||
)
|
||||
|
||||
:waitloop
|
||||
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
|
||||
if %%ERRORLEVEL%%==0 (
|
||||
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
|
||||
if %ERRORLEVEL%==0 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto waitloop
|
||||
)
|
||||
@@ -913,11 +914,11 @@ call :log host process exited
|
||||
|
||||
set /a RETRY=0
|
||||
:move_retry
|
||||
move /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%%==0 goto move_done
|
||||
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
copy /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%%==0 goto move_done
|
||||
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
set /a RETRY+=1
|
||||
if !RETRY! LSS 20 (
|
||||
@@ -929,23 +930,30 @@ call :log replace failed after retries (portable mode, no elevation): check dire
|
||||
exit /b 1
|
||||
|
||||
:move_done
|
||||
start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log cmd start failed, trying powershell Start-Process
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%'" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log relaunch failed
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1
|
||||
rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1
|
||||
call :log update finished
|
||||
exit /b 0
|
||||
|
||||
:log
|
||||
echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%"
|
||||
echo [%date% %time%] %*>>"%LOG_FILE%"
|
||||
exit /b 0
|
||||
`, source, target, stagedDir, logPath, pid)
|
||||
`
|
||||
return strings.NewReplacer(
|
||||
"__GONAVI_UPDATE_SOURCE__", source,
|
||||
"__GONAVI_UPDATE_TARGET__", target,
|
||||
"__GONAVI_UPDATE_STAGED__", stagedDir,
|
||||
"__GONAVI_UPDATE_LOG__", logPath,
|
||||
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
|
||||
).Replace(script)
|
||||
}
|
||||
|
||||
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
|
||||
|
||||
40
internal/app/methods_update_windows_script_test.go
Normal file
40
internal/app/methods_update_windows_script_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) {
|
||||
script := buildWindowsScript(
|
||||
`C:\tmp\GoNavi-v0.4.0-windows-amd64.zip`,
|
||||
`C:\Program Files\GoNavi\GoNavi.exe`,
|
||||
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.4.0`,
|
||||
`C:\Program Files\GoNavi\logs\update-install.log`,
|
||||
13579,
|
||||
)
|
||||
|
||||
mustContain := []string{
|
||||
`for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"`,
|
||||
`for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"`,
|
||||
`for /R "%EXTRACT_DIR%" %%F in (*.exe) do (`,
|
||||
`set "SOURCE_EXE=%%~fF"`,
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("windows update script missing required token: %s\nscript:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
`for %I in ("%TARGET%") do set "TARGET_NAME=%~nxI"`,
|
||||
`for %I in ("%SOURCE%") do set "SOURCE_EXT=%~xI"`,
|
||||
`for /R "%EXTRACT_DIR%" %F in (*.exe) do (`,
|
||||
`set "SOURCE_EXE=%~fF"`,
|
||||
}
|
||||
for _, bad := range mustNotContain {
|
||||
if strings.Contains(script, bad) {
|
||||
t.Fatalf("windows update script contains invalid batch syntax: %s\nscript:\n%s", bad, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,6 +501,8 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
columnTypeMap := m.loadColumnTypeMap(tableName)
|
||||
|
||||
tx, err := m.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -513,7 +515,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var args []interface{}
|
||||
for k, v := range pk {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
@@ -535,7 +537,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
|
||||
for k, v := range update.Values {
|
||||
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
@@ -545,7 +547,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
@@ -569,12 +571,24 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var args []interface{}
|
||||
|
||||
for k, v := range row {
|
||||
normalizedValue, omit := normalizeMySQLValueForInsert(k, v, columnTypeMap)
|
||||
if omit {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, fmt.Sprintf("`%s`", k))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizedValue)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName)
|
||||
res, err := tx.Exec(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("插入未生效:未影响任何行")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -629,6 +643,69 @@ func normalizeMySQLDateTimeValue(value interface{}) interface{} {
|
||||
return value
|
||||
}
|
||||
|
||||
func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string {
|
||||
result := map[string]string{}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
columns, err := m.GetColumns("", table)
|
||||
if err != nil {
|
||||
logger.Warnf("加载列元数据失败(不影响提交):表=%s err=%v", table, err)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
name := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
result[name] = strings.TrimSpace(col.Type)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) {
|
||||
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
|
||||
if !isMySQLTemporalColumnType(columnType) {
|
||||
return value, false
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if ok && strings.TrimSpace(text) == "" {
|
||||
// INSERT 空时间字段不写入,交给 DB 默认值处理(如 CURRENT_TIMESTAMP)。
|
||||
return nil, true
|
||||
}
|
||||
return normalizeMySQLDateTimeValue(value), false
|
||||
}
|
||||
|
||||
func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
|
||||
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
|
||||
if !isMySQLTemporalColumnType(columnType) {
|
||||
return value
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if ok && strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
return normalizeMySQLDateTimeValue(value)
|
||||
}
|
||||
|
||||
func isMySQLTemporalColumnType(columnType string) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(columnType))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(raw, "datetime") || strings.Contains(raw, "timestamp") {
|
||||
return true
|
||||
}
|
||||
base := raw
|
||||
if idx := strings.IndexAny(base, "( "); idx >= 0 {
|
||||
base = base[:idx]
|
||||
}
|
||||
return base == "date" || base == "time" || base == "year"
|
||||
}
|
||||
|
||||
func hasTimezoneOffset(text string) bool {
|
||||
pos := strings.LastIndexAny(text, "+-")
|
||||
if pos < 0 || pos < 10 || pos+1 >= len(text) {
|
||||
|
||||
Reference in New Issue
Block a user