diff --git a/README.md b/README.md index 470d143..2596349 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ - **Oracle**:基础数据访问与编辑支持。 - **Dameng(达梦)**:基础数据访问与编辑支持。 - **Kingbase(人大金仓)**:基础数据访问与编辑支持。 +- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。 - **Redis**:Key/Value 浏览、命令执行、视图与编码切换。 - **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。 - **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。 diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a7661c0..0f8f4fe 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file +5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 4b6f9ba..afb2863 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -194,6 +194,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal case 'mysql': defaultPort = 3306; break; case 'postgres': defaultPort = 5432; break; case 'redis': defaultPort = 6379; break; + case 'tdengine': defaultPort = 6041; break; case 'oracle': defaultPort = 1521; break; case 'dameng': defaultPort = 5236; break; case 'kingbase': defaultPort = 54321; break; @@ -234,6 +235,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal { key: 'mongodb', name: 'MongoDB', icon: }, { key: 'redis', name: 'Redis', icon: }, ]}, + { label: '时序数据库', items: [ + { key: 'tdengine', name: 'TDengine', icon: }, + ]}, { label: '其他', items: [ { key: 'custom', name: 'Custom (自定义)', icon: }, ]}, diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index f0ec973..c678324 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } import { createPortal } from 'react-dom'; import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd'; import type { SortOrder } from 'antd/es/table/interface'; -import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined } from '@ant-design/icons'; +import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App'; import { useStore } from '../store'; @@ -11,11 +11,62 @@ import 'react-resizable/css/styles.css'; import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance'; +// --- Error Boundary --- +interface DataGridErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class DataGridErrorBoundary extends React.Component< + { children: React.ReactNode }, + DataGridErrorBoundaryState +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): DataGridErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('DataGrid render error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+

渲染错误

+

数据表格渲染时发生错误,可能是数据格式问题。

+
+                        {this.state.error?.message}
+                    
+ +
+ ); + } + return this.props.children; + } +} + // 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。 export const GONAVI_ROW_KEY = '__gonavi_row_key__'; // Normalize RFC3339-like datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing. +// Also handle invalid datetime values like '0000-00-00 00:00:00' const normalizeDateTimeString = (val: string) => { + // 检查是否为无效日期时间(0000-00-00 或类似格式) + if (/^0{4}-0{2}-0{2}/.test(val)) { + return val; // 保持原样显示,不尝试转换 + } + const match = val.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/); if (!match) return val; return `${match[1]} ${match[2]}`; @@ -23,12 +74,23 @@ const normalizeDateTimeString = (val: string) => { // --- Helper: Format Value --- const formatCellValue = (val: any) => { - if (val === null) return NULL; - if (typeof val === 'object') return JSON.stringify(val); - if (typeof val === 'string') { - return normalizeDateTimeString(val); + try { + if (val === null) return NULL; + if (typeof val === 'object') { + try { + return JSON.stringify(val); + } catch { + return '[Object]'; + } + } + if (typeof val === 'string') { + return normalizeDateTimeString(val); + } + return String(val); + } catch (e) { + console.error('formatCellValue error:', e); + return '[Error]'; } - return String(val); }; const toEditableText = (val: any): string => { @@ -49,6 +111,46 @@ const toFormText = (val: any): string => { const INLINE_EDIT_MAX_CHARS = 2000; +/** + * 智能自增算法: + * - 纯数字:+1 + * - 字符串末尾数字:末尾数字 +1(保持前导零位数) + * - 无数字:原值不变 + */ +const smartIncrement = (value: any, step: number = 1): any => { + if (value === null || value === undefined) return value; + + // 纯数字类型 + if (typeof value === 'number') { + return value + step; + } + + const str = String(value); + + // 纯数字字符串 + if (/^-?\d+(\.\d+)?$/.test(str)) { + const num = parseFloat(str); + if (Number.isInteger(num)) { + return String(num + step); + } + return String((num + step).toFixed((str.split('.')[1] || '').length)); + } + + // 字符串末尾数字模式(如 item_1, user001) + const match = str.match(/^(.*?)(\d+)$/); + if (match) { + const prefix = match[1]; + const numStr = match[2]; + const num = parseInt(numStr, 10) + step; + // 保持前导零位数 + const newNumStr = String(num).padStart(numStr.length, '0'); + return prefix + newNumStr; + } + + // 无法自增,返回原值 + return value; +}; + const shouldOpenModalEditor = (val: any): boolean => { if (val === null || val === undefined) return false; if (typeof val === 'string') { @@ -129,6 +231,8 @@ const ResizableTitle = (props: any) => { const EditableContext = React.createContext(null); const CellContextMenuContext = React.createContext<{ showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void; + handleBatchFillToSelected: (record: Item, dataIndex: string) => void; + handleDragFillStart: (record: Item, dataIndex: string, cellElement: HTMLElement) => void; } | null>(null); const DataContext = React.createContext<{ selectedRowKeysRef: React.MutableRefObject; @@ -167,6 +271,7 @@ const EditableCell: React.FC = React.memo(({ ...restProps }) => { const [editing, setEditing] = useState(false); + const [isHovered, setIsHovered] = useState(false); const inputRef = useRef(null); const cellRef = useRef(null); const form = useContext(EditableContext); @@ -247,10 +352,37 @@ const EditableCell: React.FC = React.memo(({ ) : (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > {children} + {/* 填充柄 - 仅在悬停时显示 */} + {isHovered && cellContextMenuContext && ( +
{ + e.preventDefault(); + e.stopPropagation(); + if (cellRef.current && cellContextMenuContext) { + cellContextMenuContext.handleDragFillStart(record, dataIndex, cellRef.current); + } + }} + /> + )}
); } @@ -420,6 +552,34 @@ const DataGrid: React.FC = ({ const containerRef = useRef(null); const pendingScrollToBottomRef = useRef(false); + // 拖拽填充状态 - 只保留必要的 React 状态 + const [dragFillActive, setDragFillActive] = useState(false); + const dragFillGhostRef = useRef(null); + const dragFillRafRef = useRef(null); + // 使用 ref 存储拖拽数据,避免状态更新导致重渲染 + const dragFillDataRef = useRef<{ + startRecord: Item | null; + dataIndex: string; + startRowIndex: number; + currentRowIndex: number; + startCellRect: DOMRect | null; + colIndex: number; + // 缓存 DOM 查询结果 + cachedRows: HTMLElement[]; + cachedRowKeys: string[]; + cachedStartEl: HTMLElement | null; + }>({ + startRecord: null, + dataIndex: '', + startRowIndex: -1, + currentRowIndex: -1, + startCellRect: null, + colIndex: -1, + cachedRows: [], + cachedRowKeys: [], + cachedStartEl: null, + }); + const scrollTableBodyToBottom = useCallback(() => { const root = containerRef.current; if (!root) return; @@ -580,6 +740,270 @@ const DataGrid: React.FC = ({ const rowKeyStr = useCallback((k: React.Key) => String(k), []); + // 批量填充到选中行 + const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => { + const sourceValue = sourceRecord[dataIndex]; + const selKeys = selectedRowKeysRef.current; + + if (selKeys.length === 0) { + message.info('请先选择要填充的行'); + return; + } + + const sourceKey = sourceRecord?.[GONAVI_ROW_KEY]; + // 过滤掉源行本身 + const targetKeys = selKeys.filter(k => k !== sourceKey); + + if (targetKeys.length === 0) { + message.info('没有其他选中的行可以填充'); + return; + } + + // 批量更新 + let updatedCount = 0; + targetKeys.forEach(key => { + const keyStr = rowKeyStr(key); + const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); + + if (isAdded) { + setAddedRows(prev => prev.map(r => { + if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) { + updatedCount++; + return { ...r, [dataIndex]: sourceValue }; + } + return r; + })); + } else { + setModifiedRows(prev => { + const existing = prev[keyStr] || {}; + // 获取原始行数据 + const originalRow = displayDataRef.current.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); + updatedCount++; + return { + ...prev, + [keyStr]: { ...originalRow, ...existing, [dataIndex]: sourceValue } + }; + }); + } + }); + + message.success(`已填充 ${updatedCount} 行`); + setCellContextMenu(prev => ({ ...prev, visible: false })); + }, [addedRows, rowKeyStr]); + + // 拖拽填充开始 + const handleDragFillStart = useCallback((record: Item, dataIndex: string, cellElement: HTMLElement) => { + const currentData = displayDataRef.current; + const rowKey = record?.[GONAVI_ROW_KEY]; + const rowIndex = currentData.findIndex(r => r?.[GONAVI_ROW_KEY] === rowKey); + + if (rowIndex === -1) return; + + const cellRect = cellElement.getBoundingClientRect(); + + // 预先计算列索引 + let colIndex = -1; + const headerRow = containerRef.current?.querySelector('.ant-table-thead tr'); + if (headerRow) { + const headerCells = headerRow.querySelectorAll('th'); + headerCells.forEach((th, idx) => { + const titleSpan = th.querySelector('.ant-table-column-title'); + const titleText = titleSpan?.textContent?.trim() || th.textContent?.trim(); + if (titleText === dataIndex) { + colIndex = idx; + } + }); + } + + // 预先缓存所有行的 DOM 元素和 key + const tableBody = containerRef.current?.querySelector('.ant-table-body'); + const rows = tableBody ? Array.from(tableBody.querySelectorAll('tr[data-row-key]')) as HTMLElement[] : []; + const rowKeys = rows.map(r => r.getAttribute('data-row-key') || ''); + const startKey = String(rowKey); + const startEl = rows.find((_, i) => rowKeys[i] === startKey) || null; + + // 存储到 ref + dragFillDataRef.current = { + startRecord: record, + dataIndex, + startRowIndex: rowIndex, + currentRowIndex: rowIndex, + startCellRect: cellRect, + colIndex, + cachedRows: rows, + cachedRowKeys: rowKeys, + cachedStartEl: startEl, + }; + + setDragFillActive(true); + document.body.style.cursor = 'crosshair'; + document.body.style.userSelect = 'none'; + }, []); + + // 拖拽填充移动(极致优化:最小化 DOM 操作) + const handleDragFillMove = useCallback((e: MouseEvent) => { + const data = dragFillDataRef.current; + if (!data.startRecord) return; + + const ghost = dragFillGhostRef.current; + if (!ghost) return; + + const mouseY = e.clientY; + const rows = data.cachedRows; + const rowKeys = data.cachedRowKeys; + const startEl = data.cachedStartEl; + + if (!startEl || rows.length === 0) { + ghost.style.display = 'none'; + return; + } + + // 二分查找优化:找到鼠标所在的行 + let endEl: HTMLElement = startEl; + let endIdx = data.startRowIndex; + + // 使用简单遍历(行数通常不多,二分查找收益有限) + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rect = row.getBoundingClientRect(); + + // 只需要检查行的底部边界 + if (mouseY >= rect.top) { + const currentData = displayDataRef.current; + const rowKey = rowKeys[i]; + const dataIdx = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === rowKey); + + if (dataIdx > data.startRowIndex) { + endEl = row; + endIdx = dataIdx; + } + } + } + + data.currentRowIndex = endIdx; + + // 直接读取位置并更新样式(单次 reflow) + const startRect = startEl.getBoundingClientRect(); + const endRect = endEl.getBoundingClientRect(); + + const cells = startEl.querySelectorAll('td'); + const targetCell = (data.colIndex >= 0 && cells[data.colIndex]) ? cells[data.colIndex] : null; + const cellLeft = targetCell ? targetCell.getBoundingClientRect().left : data.startCellRect!.left; + const cellWidth = targetCell ? targetCell.getBoundingClientRect().width : data.startCellRect!.width; + + // 批量设置样式(浏览器会合并为一次重绘) + ghost.style.cssText = ` + position: fixed; + display: block; + left: ${cellLeft}px; + top: ${startRect.top}px; + width: ${cellWidth}px; + height: ${endRect.bottom - startRect.top}px; + border: 2px solid #1890ff; + background: rgba(24, 144, 255, 0.1); + pointer-events: none; + z-index: 9998; + `; + }, []); + + // 拖拽填充结束 + const handleDragFillEnd = useCallback(() => { + // 清理 RAF + if (dragFillRafRef.current !== null) { + cancelAnimationFrame(dragFillRafRef.current); + dragFillRafRef.current = null; + } + + const data = dragFillDataRef.current; + + if (!data.startRecord) { + setDragFillActive(false); + return; + } + + const { startRecord, dataIndex, startRowIndex, currentRowIndex } = data; + const sourceValue = startRecord[dataIndex]; + const currentData = displayDataRef.current; + + // 计算需要填充的行 + if (currentRowIndex > startRowIndex) { + let updatedCount = 0; + for (let i = startRowIndex + 1; i <= currentRowIndex && i < currentData.length; i++) { + const targetRow = currentData[i]; + const targetKey = targetRow?.[GONAVI_ROW_KEY]; + if (targetKey === undefined) continue; + + const keyStr = rowKeyStr(targetKey); + const step = i - startRowIndex; + const fillValue = smartIncrement(sourceValue, step); + + const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); + + if (isAdded) { + setAddedRows(prev => prev.map(r => { + if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) { + updatedCount++; + return { ...r, [dataIndex]: fillValue }; + } + return r; + })); + } else { + setModifiedRows(prev => { + const existing = prev[keyStr] || {}; + updatedCount++; + return { + ...prev, + [keyStr]: { ...targetRow, ...existing, [dataIndex]: fillValue } + }; + }); + } + } + + if (updatedCount > 0) { + message.success(`已填充 ${updatedCount} 行`); + } + } + + // 重置状态 + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + if (dragFillGhostRef.current) { + dragFillGhostRef.current.style.display = 'none'; + } + + // 重置 ref + dragFillDataRef.current = { + startRecord: null, + dataIndex: '', + startRowIndex: -1, + currentRowIndex: -1, + startCellRect: null, + colIndex: -1, + cachedRows: [], + cachedRowKeys: [], + cachedStartEl: null, + }; + + setDragFillActive(false); + }, [addedRows, rowKeyStr]); + + // 全局鼠标事件监听(拖拽填充) + useEffect(() => { + if (!dragFillActive) return; + + const handleMouseMove = (e: MouseEvent) => handleDragFillMove(e); + const handleMouseUp = () => handleDragFillEnd(); + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [dragFillActive, handleDragFillMove, handleDragFillEnd]); + const displayData = useMemo(() => { return [...data, ...addedRows].filter(item => { const k = item?.[GONAVI_ROW_KEY]; @@ -1528,7 +1952,7 @@ const DataGrid: React.FC = ({
- + = ({ > 设置为 NULL +
0 ? 'pointer' : 'not-allowed', + transition: 'background 0.2s', + opacity: selectedRowKeys.length > 0 ? 1 : 0.5, + }} + onMouseEnter={(e) => { + if (selectedRowKeys.length > 0) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'; + }} + onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} + onClick={() => { + if (selectedRowKeys.length > 0 && cellContextMenu.record) { + handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex); + } + }} + > + + 填充到选中行 ({selectedRowKeys.length}) +
= ({ .${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; } .${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; } .${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; } + .${gridId} .fill-handle:hover { background: #0050b3 !important; transform: scale(1.2); } `} {/* Ghost Resize Line for Columns */} -
= ({ willChange: 'transform' }} /> + {/* 拖拽填充选区指示器 - 使用 fixed 定位基于视口 */} + {dragFillActive && createPortal( +
, + document.body + )}
); }; -export default React.memo(DataGrid); +// 使用 ErrorBoundary 包裹 DataGrid,防止数据渲染错误导致应用崩溃 +const MemoizedDataGrid = React.memo(DataGrid); + +const DataGridWithErrorBoundary: React.FC = (props) => ( + + + +); + +export default DataGridWithErrorBoundary; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index f81af80..79552c3 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -30,6 +30,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [showFilter, setShowFilter] = useState(false); const [filterConditions, setFilterConditions] = useState([]); + const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase(); + const forceReadOnly = currentConnType === 'tdengine'; useEffect(() => { setPkColumns([]); @@ -241,6 +243,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { showFilter={showFilter} onToggleFilter={handleToggleFilter} onApplyFilter={handleApplyFilter} + readOnly={forceReadOnly} />
); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index f5044b8..35f4918 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -919,7 +919,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => { const normalizedType = (dbType || 'mysql').toLowerCase(); - const supportsLimit = normalizedType === 'mysql' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === ''; + const supportsLimit = normalizedType === 'mysql' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'tdengine' || normalizedType === ''; if (!supportsLimit) return { sql, applied: false, maxRows }; if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows }; @@ -997,6 +997,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; const dbType = String((config as any).type || 'mysql'); + const normalizedDbType = dbType.toLowerCase(); + const forceReadOnlyResult = normalizedDbType === 'tdengine'; const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0; let anyTruncated = false; @@ -1053,7 +1055,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); if (tableMatch) { simpleTableName = tableMatch[1]; - pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName }); + if (!forceReadOnlyResult) { + pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName }); + } } nextResultSets.push({ diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index fb8ccfa..c618447 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -10,6 +10,10 @@ const { Search } = Input; const KEY_GROUP_DELIMITER = ':'; const EMPTY_SEGMENT_LABEL = '(empty)'; +const REDIS_TREE_KEY_TYPE_WIDTH = 92; +const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84; +const REDIS_TREE_KEY_TTL_WIDTH = 92; +const REDIS_TREE_HIDE_TTL_THRESHOLD = 460; interface RedisViewerProps { connectionId: string; @@ -263,7 +267,8 @@ const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => { const buildRedisKeyTree = ( keys: RedisKeyInfo[], formatTTL: (ttl: number) => string, - getTypeColor: (type: string) => string + getTypeColor: (type: string) => string, + showTTL: boolean ): RedisKeyTreeResult => { const root = createTreeGroup('__root__', '__root__'); @@ -330,48 +335,66 @@ const buildRedisKeyTree = ( title: (
- - +
+ {leaf.label} - +
{leaf.keyInfo.type} - - {formatTTL(leaf.keyInfo.ttl)} - + {showTTL && ( + + {formatTTL(leaf.keyInfo.ttl)} + + )}
), }; @@ -424,6 +447,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { // 面板宽度状态和 ref - 默认占据 50% 宽度 const [leftPanelWidth, setLeftPanelWidth] = useState('50%'); const leftPanelRef = useRef(null); + const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true); const [expandedGroupKeys, setExpandedGroupKeys] = useState([]); const getConfig = useCallback(() => { @@ -614,9 +638,36 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return `${Math.floor(ttl / 86400)}天${Math.floor((ttl % 86400) / 3600)}时`; }; + useEffect(() => { + const target = leftPanelRef.current; + if (!target) return; + + const updateTTLVisibility = (width: number) => { + const nextShowTTL = width > REDIS_TREE_HIDE_TTL_THRESHOLD; + setShowTreeKeyTTL((prev) => (prev === nextShowTTL ? prev : nextShowTTL)); + }; + + updateTTLVisibility(Math.round(target.getBoundingClientRect().width)); + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver((entries) => { + const width = Math.round(entries[0]?.contentRect.width || target.getBoundingClientRect().width); + updateTTLVisibility(width); + }); + observer.observe(target); + return () => observer.disconnect(); + } + + const handleWindowResize = () => { + updateTTLVisibility(Math.round(target.getBoundingClientRect().width)); + }; + window.addEventListener('resize', handleWindowResize); + return () => window.removeEventListener('resize', handleWindowResize); + }, []); + const keyTree = useMemo(() => { - return buildRedisKeyTree(keys, formatTTL, getTypeColor); - }, [keys]); + return buildRedisKeyTree(keys, formatTTL, getTypeColor, showTreeKeyTTL); + }, [keys, showTreeKeyTTL]); const selectedTreeNodeKeys = useMemo(() => { if (!selectedKey) { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 289cc7c..eed1e07 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, import { DatabaseOutlined, TableOutlined, + EyeOutlined, ConsoleSqlOutlined, HddOutlined, FolderOpenOutlined, @@ -28,7 +29,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, } from '@ant-design/icons'; import { useStore } from '../store'; import { SavedConnection } from '../types'; - import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App'; + import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App'; import { normalizeOpacityForPlatform } from '../utils/appearance'; const { Search } = Input; @@ -40,7 +41,7 @@ interface TreeNode { children?: TreeNode[]; icon?: React.ReactNode; dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db'; + type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db'; } type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; @@ -53,6 +54,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const removeConnection = useStore(state => state.removeConnection); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); + const tableAccessCount = useStore(state => state.tableAccessCount); + const tableSortPreference = useStore(state => state.tableSortPreference); + const recordTableAccess = useStore(state => state.recordTableAccess); + const setTableSortPreference = useStore(state => state.setTableSortPreference); const darkMode = theme === 'dark'; const opacity = normalizeOpacityForPlatform(appearance.opacity); const [treeData, setTreeData] = useState([]); @@ -165,6 +170,199 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }); }; + const SIDEBAR_SCHEMA_DB_TYPES = new Set([ + 'postgres', + 'kingbase', + 'highgo', + 'vastbase', + 'sqlserver', + 'oracle', + 'dameng', + ]); + + const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([ + 'postgres', + 'kingbase', + 'highgo', + 'vastbase', + 'sqlserver', + 'oracle', + 'dm', + ]); + + const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => { + const dbType = String(conn?.config?.type || '').trim().toLowerCase(); + if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true; + if (dbType !== 'custom') return false; + + const customDriver = String((conn?.config as any)?.driver || '').trim().toLowerCase(); + return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver); + }; + + const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => { + const rawName = String(tableName || '').trim(); + if (!rawName) return rawName; + if (!shouldHideSchemaPrefix(conn)) return rawName; + const lastDotIndex = rawName.lastIndexOf('.'); + if (lastDotIndex <= 0 || lastDotIndex >= rawName.length - 1) return rawName; + return rawName.substring(lastDotIndex + 1); + }; + + const getMetadataDialect = (conn: SavedConnection | undefined): string => { + const type = String(conn?.config?.type || '').trim().toLowerCase(); + if (type === 'custom') { + return String((conn?.config as any)?.driver || '').trim().toLowerCase(); + } + if (type === 'mariadb') return 'mysql'; + if (type === 'dameng') return 'dm'; + return type; + }; + + const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); + const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; + + const getCaseInsensitiveValue = (row: Record, candidateKeys: string[]): string => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const getFirstRowValue = (row: Record): string => { + for (const value of Object.values(row || {})) { + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const buildQualifiedName = (schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const name = String(objectName || '').trim(); + if (!name) return ''; + if (!schema) return name; + if (name.includes('.')) return name; + return `${schema}.${name}`; + }; + + const buildViewsMetadataQuery = (dialect: string, dbName: string): string => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + if (!safeDbName) return ''; + return `SELECT TABLE_NAME AS view_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME`; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg_%' ORDER BY schemaname, viewname`; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`; + } + case 'oracle': + case 'dm': { + if (!safeDbName) { + return `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME`; + } + return `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME`; + } + case 'sqlite': + return `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name`; + default: + return ''; + } + }; + + const buildTriggersMetadataQuery = (dialect: string, dbName: string): string => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + if (!safeDbName) return ''; + return `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME`; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg_%' ORDER BY event_object_schema, event_object_table, trigger_name`; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name`; + } + case 'oracle': + case 'dm': { + if (!safeDbName) { + return `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME`; + } + return `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME`; + } + case 'sqlite': + return `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name`; + default: + return ''; + } + }; + + const queryMetadataRows = async (conn: any, dbName: string, query: string): Promise[]> => { + if (!query) return []; + try { + const config = buildRuntimeConfig(conn, dbName); + const result = await DBQuery(config as any, dbName, query); + if (!result.success || !Array.isArray(result.data)) return []; + return result.data as Record[]; + } catch { + return []; + } + }; + + const loadViews = async (conn: any, dbName: string): Promise => { + const dialect = getMetadataDialect(conn as SavedConnection); + const query = buildViewsMetadataQuery(dialect, dbName); + const rows = await queryMetadataRows(conn, dbName, query); + const seen = new Set(); + const views: string[] = []; + + rows.forEach((row) => { + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema']); + const viewName = getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) || getFirstRowValue(row); + const fullName = buildQualifiedName(schemaName, viewName); + if (!fullName || seen.has(fullName)) return; + seen.add(fullName); + views.push(fullName); + }); + return views; + }; + + const loadDatabaseTriggers = async (conn: any, dbName: string): Promise> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const query = buildTriggersMetadataQuery(dialect, dbName); + const rows = await queryMetadataRows(conn, dbName, query); + const seen = new Set(); + const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = []; + + rows.forEach((row) => { + const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'name']) || getFirstRowValue(row); + if (!triggerName) return; + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema']); + const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name']); + const fullTableName = buildQualifiedName(schemaName, tableName); + const uniqueKey = `${triggerName}@@${fullTableName}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName; + triggers.push({ displayName, triggerName, tableName: fullTableName }); + }); + return triggers; + }; + const loadDatabases = async (node: any) => { const conn = node.dataRef as SavedConnection; const loadKey = `dbs-${conn.id}`; @@ -280,8 +478,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); const tables = (res.data as any[]).map((row: any) => { const tableName = Object.values(row)[0] as string; + const tableDisplayName = getSidebarTableDisplayName(conn, tableName); return { - title: tableName, + title: tableDisplayName, key: `${conn.id}-${conn.dbName}-${tableName}`, icon: , type: 'table' as const, @@ -289,8 +488,76 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> isLeaf: false, }; }); - - setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables])); + + const [views, triggers] = await Promise.all([ + loadViews(conn, conn.dbName), + loadDatabaseTriggers(conn, conn.dbName), + ]); + + // 获取当前数据库的排序偏好 + const sortPreferenceKey = `${conn.id}-${conn.dbName}`; + const sortBy = tableSortPreference[sortPreferenceKey] || 'name'; + + // 根据排序偏好排序表 + if (sortBy === 'frequency') { + // 按使用频率排序(降序) + tables.sort((a, b) => { + const keyA = `${conn.id}-${conn.dbName}-${a.dataRef.tableName}`; + const keyB = `${conn.id}-${conn.dbName}-${b.dataRef.tableName}`; + const countA = tableAccessCount[keyA] || 0; + const countB = tableAccessCount[keyB] || 0; + if (countA !== countB) { + return countB - countA; // 降序 + } + // 频率相同时按名称排序 + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + } else { + // 按名称排序(字母顺序) + tables.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + } + + // Sort views by name (case-insensitive) + views.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + // Sort triggers by display name (case-insensitive) + triggers.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + + const viewNodes: TreeNode[] = views.map((viewName) => ({ + title: getSidebarTableDisplayName(conn, viewName), + key: `${conn.id}-${conn.dbName}-view-${viewName}`, + icon: , + type: 'view', + dataRef: { ...conn, viewName, tableName: viewName }, + isLeaf: true, + })); + + const triggerNodes: TreeNode[] = triggers.map((trigger) => ({ + title: trigger.displayName, + key: `${conn.id}-${conn.dbName}-trigger-${trigger.triggerName}-${trigger.tableName}`, + icon: , + type: 'db-trigger', + dataRef: { ...conn, triggerName: trigger.triggerName, triggerTableName: trigger.tableName }, + isLeaf: true, + })); + + const buildObjectGroup = (groupKey: string, groupTitle: string, groupIcon: React.ReactNode, children: TreeNode[]): TreeNode => ({ + title: `${groupTitle} (${children.length})`, + key: `${key}-${groupKey}`, + icon: groupIcon, + type: 'object-group', + isLeaf: children.length === 0, + children: children.length > 0 ? children : undefined, + dataRef: { ...conn, dbName: conn.dbName, groupKey } + }); + + const groupedNodes: TreeNode[] = [ + buildObjectGroup('tables', '表', , tables), + buildObjectGroup('views', '视图', , viewNodes), + buildObjectGroup('triggers', '触发器', , triggerNodes), + ]; + + setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes])); } else { setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); message.error({ content: res.message, key: `db-${key}-tables` }); @@ -309,7 +576,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> await loadTables({ key, dataRef }); } else if (type === 'table') { // Expand table to show object categories - const { tableName, dbName, id } = dataRef; const conn = dataRef; const folders: TreeNode[] = [ @@ -398,6 +664,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> setActiveContext({ connectionId: dataRef.id, dbName: title }); } else if (type === 'table') { setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); + } else if (type === 'view' || type === 'db-trigger') { + setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); } else if (type === 'saved-query') { setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); } else if (type === 'redis-db') { @@ -418,6 +686,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const onDoubleClick = (e: any, node: any) => { if (node.type === 'table') { const { tableName, dbName, id } = node.dataRef; + // 记录表访问 + recordTableAccess(id, dbName, tableName); addTab({ id: node.key, title: tableName, @@ -427,6 +697,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> tableName, }); return; + } else if (node.type === 'view') { + const { viewName, dbName, id } = node.dataRef; + addTab({ + id: node.key, + title: viewName, + type: 'table', + connectionId: id, + dbName, + tableName: viewName, + }); + return; } else if (node.type === 'saved-query') { const q = node.dataRef; addTab({ @@ -448,14 +729,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> redisDB: redisDB }); return; + } else if (node.type === 'db-trigger') { + const { triggerName, dbName, id } = node.dataRef; + addTab({ + id: `trigger-${node.key}`, + title: `触发器: ${triggerName}`, + type: 'trigger', + connectionId: id, + dbName, + triggerName + }); + return; } const key = node.key; const isExpanded = expandedKeys.includes(key); - const newExpandedKeys = isExpanded - ? expandedKeys.filter(k => k !== key) + const newExpandedKeys = isExpanded + ? expandedKeys.filter(k => k !== key) : [...expandedKeys, key]; - + setExpandedKeys(newExpandedKeys); if (!isExpanded) setAutoExpandParent(false); }; @@ -1055,6 +1347,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const conn = node.dataRef as SavedConnection; const isRedis = conn?.config?.type === 'redis'; + // 表分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { + const groupData = node.dataRef; // { ...conn, dbName, groupKey } + const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; + const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; + + return [ + { + key: 'sort-by-name', + label: '按名称排序', + icon: currentSort === 'name' ? : null, + onClick: () => { + setTableSortPreference(groupData.id, groupData.dbName, 'name'); + const dbNode = { + key: `${groupData.id}-${groupData.dbName}`, + dataRef: groupData + }; + loadTables(dbNode); + } + }, + { + key: 'sort-by-frequency', + label: '按使用频率排序', + icon: currentSort === 'frequency' ? : null, + onClick: () => { + setTableSortPreference(groupData.id, groupData.dbName, 'frequency'); + const dbNode = { + key: `${groupData.id}-${groupData.dbName}`, + dataRef: groupData + }; + loadTables(dbNode); + } + } + ]; + } + if (node.type === 'connection') { // Redis connection menu if (isRedis) { @@ -1319,6 +1647,30 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> onClick: () => handleRunSQLFile(node) } ]; + } else if (node.type === 'view') { + return [ + { + key: 'open-view', + label: '浏览视图数据', + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'new-query', + label: '新建查询', + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: `新建查询`, + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: '' + }); + } + } + ]; } else if (node.type === 'table') { return [ { @@ -1397,12 +1749,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> if (connectionStates[node.key] === 'success') status = 'success'; else if (connectionStates[node.key] === 'error') status = 'error'; } - + const statusBadge = node.type === 'connection' || node.type === 'database' ? ( ) : null; - return {statusBadge}{node.title}; + const displayTitle = String(node.title ?? ''); + let hoverTitle = displayTitle; + if (node.type === 'table' || node.type === 'view') { + const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || '').trim(); + const conn = node?.dataRef as SavedConnection | undefined; + if (rawTableName && shouldHideSchemaPrefix(conn)) { + const lastDotIndex = rawTableName.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < rawTableName.length - 1) { + hoverTitle = rawTableName; + } + } + } + + return {statusBadge}{displayTitle}; }; const onRightClick = ({ event, node }: any) => { diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 01d1e4d..c10a610 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -7,6 +7,7 @@ import QueryEditor from './QueryEditor'; import TableDesigner from './TableDesigner'; import RedisViewer from './RedisViewer'; import RedisCommandEditor from './RedisCommandEditor'; +import TriggerViewer from './TriggerViewer'; const TabManager: React.FC = () => { const tabs = useStore(state => state.tabs); @@ -40,6 +41,8 @@ const TabManager: React.FC = () => { content = ; } else if (tab.type === 'redis-command') { content = ; + } else if (tab.type === 'trigger') { + content = ; } const menuItems: MenuProps['items'] = [ diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 21caff1..ff0748f 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useState, useContext, useMemo, useRef } from 'react'; -import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select } from 'antd'; -import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined } from '@ant-design/icons'; +import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space } from 'antd'; +import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Resizable } from 'react-resizable'; +import Editor, { loader } from '@monaco-editor/react'; import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types'; import { useStore } from '../store'; import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App'; @@ -162,13 +163,47 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const [previewSql, setPreviewSql] = useState(''); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [activeKey, setActiveKey] = useState(tab.initialTab || "columns"); + const [selectedTrigger, setSelectedTrigger] = useState(null); + const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false); + const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false); + const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create'); + const [triggerEditSql, setTriggerEditSql] = useState(''); + const [triggerExecuting, setTriggerExecuting] = useState(false); const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const darkMode = theme === 'dark'; const readOnly = !!tab.readOnly; const [tableHeight, setTableHeight] = useState(500); const containerRef = useRef(null); + // 初始化透明 Monaco Editor 主题 + useEffect(() => { + loader.init().then(monaco => { + monaco.editor.defineTheme('transparent-dark', { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#ffffff10', + 'editorGutter.background': '#00000000', + } + }); + monaco.editor.defineTheme('transparent-light', { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#00000010', + 'editorGutter.background': '#00000000', + } + }); + }); + }, []); + useEffect(() => { if (!containerRef.current) return; const resizeObserver = new ResizeObserver(entries => { @@ -365,6 +400,215 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { fetchData(); }, [tab]); + // --- Trigger Handlers --- + + const getDbType = (): string => { + const conn = connections.find(c => c.id === tab.connectionId); + const type = String(conn?.config?.type || '').toLowerCase(); + if (type === 'mariadb') return 'mysql'; + if (type === 'dameng') return 'dm'; + return type; + }; + + const generateTriggerTemplate = (): string => { + const dbType = getDbType(); + const tblName = tab.tableName || 'table_name'; + + switch (dbType) { + case 'mysql': + return `CREATE TRIGGER trigger_name +BEFORE INSERT ON \`${tblName}\` +FOR EACH ROW +BEGIN + -- 触发器逻辑 +END;`; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `CREATE OR REPLACE FUNCTION trigger_function_name() +RETURNS TRIGGER AS $$ +BEGIN + -- 触发器逻辑 + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_name +BEFORE INSERT ON "${tblName}" +FOR EACH ROW +EXECUTE FUNCTION trigger_function_name();`; + case 'sqlserver': + return `CREATE TRIGGER trigger_name +ON [${tblName}] +AFTER INSERT +AS +BEGIN + SET NOCOUNT ON; + -- 触发器逻辑 +END;`; + case 'oracle': + case 'dm': + return `CREATE OR REPLACE TRIGGER trigger_name +BEFORE INSERT ON "${tblName}" +FOR EACH ROW +BEGIN + -- 触发器逻辑 + NULL; +END;`; + case 'sqlite': + return `CREATE TRIGGER trigger_name +AFTER INSERT ON "${tblName}" +BEGIN + -- 触发器逻辑 +END;`; + default: + return `-- 请输入 CREATE TRIGGER 语句`; + } + }; + + const buildDropTriggerSql = (triggerName: string): string => { + const dbType = getDbType(); + const tblName = tab.tableName || ''; + + switch (dbType) { + case 'mysql': + return `DROP TRIGGER IF EXISTS \`${triggerName}\``; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`; + case 'sqlserver': + return `DROP TRIGGER IF EXISTS [${triggerName}]`; + case 'oracle': + case 'dm': + return `DROP TRIGGER "${triggerName}"`; + case 'sqlite': + return `DROP TRIGGER IF EXISTS "${triggerName}"`; + default: + return `DROP TRIGGER ${triggerName}`; + } + }; + + const handleCreateTrigger = () => { + setTriggerEditMode('create'); + setTriggerEditSql(generateTriggerTemplate()); + setIsTriggerEditModalOpen(true); + }; + + const handleEditTrigger = () => { + if (!selectedTrigger) return; + setTriggerEditMode('edit'); + // 构建完整的 CREATE TRIGGER 语句 + const dbType = getDbType(); + const tblName = tab.tableName || ''; + let createSql = ''; + + if (dbType === 'mysql') { + createSql = `CREATE TRIGGER \`${selectedTrigger.name}\` +${selectedTrigger.timing} ${selectedTrigger.event} ON \`${tblName}\` +FOR EACH ROW +${selectedTrigger.statement}`; + } else { + createSql = selectedTrigger.statement || '-- 无法获取完整的触发器定义'; + } + + setTriggerEditSql(createSql); + setIsTriggerEditModalOpen(true); + }; + + const handleDeleteTrigger = () => { + if (!selectedTrigger) return; + + Modal.confirm({ + title: '确认删除触发器', + icon: , + content: `确定要删除触发器 "${selectedTrigger.name}" 吗?此操作不可撤销。`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) { + message.error('未找到连接'); + return; + } + + const config = { + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || "", + database: conn.config.database || "", + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + }; + + const dropSql = buildDropTriggerSql(selectedTrigger.name); + + try { + const res = await DBQuery(config as any, tab.dbName || '', dropSql); + if (res.success) { + message.success('触发器删除成功'); + setSelectedTrigger(null); + fetchData(); // 刷新列表 + } else { + message.error('删除失败: ' + res.message); + } + } catch (e: any) { + message.error('删除失败: ' + (e?.message || String(e))); + } + } + }); + }; + + const handleExecuteTriggerSql = async () => { + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) { + message.error('未找到连接'); + return; + } + + const config = { + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || "", + database: conn.config.database || "", + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + }; + + setTriggerExecuting(true); + + try { + // 如果是编辑模式,先删除旧触发器 + if (triggerEditMode === 'edit' && selectedTrigger) { + const dropSql = buildDropTriggerSql(selectedTrigger.name); + const dropRes = await DBQuery(config as any, tab.dbName || '', dropSql); + if (!dropRes.success) { + message.error('删除旧触发器失败: ' + dropRes.message); + setTriggerExecuting(false); + return; + } + } + + // 执行创建语句 + const res = await DBQuery(config as any, tab.dbName || '', triggerEditSql); + if (res.success) { + message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功'); + setIsTriggerEditModalOpen(false); + setSelectedTrigger(null); + fetchData(); // 刷新列表 + } else { + message.error('执行失败: ' + res.message); + } + } catch (e: any) { + message.error('执行失败: ' + (e?.message || String(e))); + } finally { + setTriggerExecuting(false); + } + }; + // --- Handlers --- const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => { @@ -680,19 +924,61 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { key: 'triggers', label: '触发器', children: ( -
+
+
+ + + + + + {selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'} + +
+
}} + rowSelection={{ + type: 'radio', + selectedRowKeys: selectedTrigger ? [selectedTrigger.name] : [], + onChange: (_, selectedRows) => setSelectedTrigger(selectedRows[0] || null), + onSelect: (record, selected) => { + // 点击单选按钮时,如果已选中则取消 + if (selectedTrigger?.name === record.name) { + setSelectedTrigger(null); + } else { + setSelectedTrigger(record); + } + }, + }} + onRow={(record) => ({ + onClick: () => { + // 点击已选中的行时取消选择 + if (selectedTrigger?.name === record.name) { + setSelectedTrigger(null); + } else { + setSelectedTrigger(record); + } + }, + style: { cursor: 'pointer' } + })} + /> + ) } ] : []), @@ -701,8 +987,22 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { label: 'DDL', icon: , children: ( -
-
{ddl}
+
+
) }] : []) @@ -725,6 +1025,75 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {

请仔细检查 SQL,执行后不可撤销。

+ + setIsTriggerModalOpen(false)} + footer={null} + width={700} + > + {selectedTrigger && ( +
+
+ 时机: {selectedTrigger.timing} + 事件: {selectedTrigger.event} +
+
+ +
+
+ )} +
+ + setIsTriggerEditModalOpen(false)} + width={800} + okText={triggerEditMode === 'create' ? '创建' : '保存'} + cancelText="取消" + confirmLoading={triggerExecuting} + onOk={handleExecuteTriggerSql} + > +
+ {triggerEditMode === 'edit' && selectedTrigger && ( + 修改触发器时会先删除原触发器,再创建新触发器。 + )} +
+
+ setTriggerEditSql(val || '')} + options={{ + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: 'on', + scrollBeyondLastLine: false, + wordWrap: 'on', + automaticLayout: true, + }} + /> +
+

请仔细检查 SQL 语句,执行后不可撤销。

+
); }; diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx new file mode 100644 index 0000000..87df819 --- /dev/null +++ b/frontend/src/components/TriggerViewer.tsx @@ -0,0 +1,240 @@ +import React, { useState, useEffect } from 'react'; +import Editor, { loader } from '@monaco-editor/react'; +import { Spin, Alert } from 'antd'; +import { TabData } from '../types'; +import { useStore } from '../store'; +import { DBQuery } from '../../wailsjs/go/app/App'; + +interface TriggerViewerProps { + tab: TabData; +} + +const TriggerViewer: React.FC = ({ tab }) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [triggerDefinition, setTriggerDefinition] = useState(''); + + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const darkMode = theme === 'dark'; + + // 初始化透明 Monaco Editor 主题 + useEffect(() => { + loader.init().then(monaco => { + monaco.editor.defineTheme('transparent-dark', { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#ffffff10', + 'editorGutter.background': '#00000000', + } + }); + monaco.editor.defineTheme('transparent-light', { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#00000010', + 'editorGutter.background': '#00000000', + } + }); + }); + }, []); + + const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); + const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; + + const getMetadataDialect = (conn: any): string => { + const type = String(conn?.config?.type || '').trim().toLowerCase(); + if (type === 'custom') { + return String(conn?.config?.driver || '').trim().toLowerCase(); + } + if (type === 'mariadb') return 'mysql'; + if (type === 'dameng') return 'dm'; + return type; + }; + + const buildShowTriggerQuery = (dialect: string, triggerName: string, dbName: string): string => { + const safeTriggerName = escapeSQLLiteral(triggerName); + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + return `SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition +FROM pg_trigger t +JOIN pg_class c ON t.tgrelid = c.oid +WHERE t.tgname = '${safeTriggerName}' + AND NOT t.tgisinternal +LIMIT 1`; + case 'sqlserver': { + return `SELECT OBJECT_DEFINITION(OBJECT_ID('${safeTriggerName.replace(/'/g, "''")}')) AS trigger_definition`; + } + case 'oracle': + case 'dm': + if (!safeDbName) { + return `SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`; + } + return `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`; + case 'sqlite': + return `SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`; + case 'tdengine': + return `-- TDengine 不支持触发器`; + case 'mongodb': + return `-- MongoDB 不支持触发器`; + default: + return `-- 暂不支持该数据库类型的触发器定义查看`; + } + }; + + const extractTriggerDefinition = (dialect: string, data: any[]): string => { + if (!data || data.length === 0) { + return '-- 未找到触发器定义'; + } + + const row = data[0]; + + switch (dialect) { + case 'mysql': { + // MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ... + const keys = Object.keys(row); + const sqlKey = keys.find(k => k.toLowerCase().includes('statement') || k.toLowerCase() === 'sql original statement'); + if (sqlKey) return row[sqlKey]; + // Fallback: try to find any key containing CREATE TRIGGER + for (const key of keys) { + const val = String(row[key] || ''); + if (val.toUpperCase().includes('CREATE TRIGGER')) { + return val; + } + } + return JSON.stringify(row, null, 2); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': { + return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || ''; + } + case 'sqlserver': { + return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || ''; + } + case 'oracle': + case 'dm': { + return row.trigger_body || row.TRIGGER_BODY || Object.values(row)[0] || ''; + } + case 'sqlite': { + return row.sql || row.SQL || Object.values(row)[0] || ''; + } + default: + return JSON.stringify(row, null, 2); + } + }; + + useEffect(() => { + const loadTriggerDefinition = async () => { + setLoading(true); + setError(null); + + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) { + setError('未找到数据库连接'); + setLoading(false); + return; + } + + const triggerName = tab.triggerName || ''; + const dbName = tab.dbName || ''; + + if (!triggerName) { + setError('触发器名称为空'); + setLoading(false); + return; + } + + const dialect = getMetadataDialect(conn); + const query = buildShowTriggerQuery(dialect, triggerName, dbName); + + if (query.startsWith('--')) { + setTriggerDefinition(query); + setLoading(false); + return; + } + + try { + const config = { + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || '', + database: conn.config.database || '', + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' } + }; + + const result = await DBQuery(config as any, dbName, query); + + if (result.success && Array.isArray(result.data)) { + const definition = extractTriggerDefinition(dialect, result.data); + setTriggerDefinition(definition); + } else { + setError(result.message || '查询触发器定义失败'); + } + } catch (e: any) { + setError('查询触发器定义失败: ' + (e?.message || String(e))); + } finally { + setLoading(false); + } + }; + + loadTriggerDefinition(); + }, [tab.connectionId, tab.dbName, tab.triggerName, connections]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+
+ 触发器: {tab.triggerName} + {tab.dbName && 数据库: {tab.dbName}} +
+
+ +
+
+ ); +}; + +export default TriggerViewer; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index e431b79..d8d1fce 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -37,11 +37,13 @@ interface AppState { sqlFormatOptions: { keywordCase: 'upper' | 'lower' }; queryOptions: { maxRows: number }; sqlLogs: SqlLog[]; - + tableAccessCount: Record; + tableSortPreference: Record; + addConnection: (conn: SavedConnection) => void; updateConnection: (conn: SavedConnection) => void; removeConnection: (id: string) => void; - + addTab: (tab: TabData) => void; closeTab: (id: string) => void; closeOtherTabs: (id: string) => void; @@ -58,9 +60,12 @@ interface AppState { setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void; setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void; setQueryOptions: (options: Partial<{ maxRows: number }>) => void; - + addSqlLog: (log: SqlLog) => void; clearSqlLogs: () => void; + + recordTableAccess: (connectionId: string, dbName: string, tableName: string) => void; + setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void; } export const useStore = create()( @@ -76,10 +81,12 @@ export const useStore = create()( sqlFormatOptions: { keywordCase: 'upper' }, queryOptions: { maxRows: 5000 }, sqlLogs: [], + tableAccessCount: {}, + tableSortPreference: {}, addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })), - updateConnection: (conn) => set((state) => ({ - connections: state.connections.map(c => c.id === conn.id ? conn : c) + updateConnection: (conn) => set((state) => ({ + connections: state.connections.map(c => c.id === conn.id ? conn : c) })), removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })), @@ -145,9 +152,30 @@ export const useStore = create()( setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })), setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }), setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })), - + addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs clearSqlLogs: () => set({ sqlLogs: [] }), + + recordTableAccess: (connectionId, dbName, tableName) => set((state) => { + const key = `${connectionId}-${dbName}-${tableName}`; + const currentCount = state.tableAccessCount[key] || 0; + return { + tableAccessCount: { + ...state.tableAccessCount, + [key]: currentCount + 1 + } + }; + }), + + setTableSortPreference: (connectionId, dbName, sortBy) => set((state) => { + const key = `${connectionId}-${dbName}`; + return { + tableSortPreference: { + ...state.tableSortPreference, + [key]: sortBy + } + }; + }), }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) @@ -178,7 +206,16 @@ export const useStore = create()( return nextState as AppState; }, - partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs + partialize: (state) => ({ + connections: state.connections, + savedQueries: state.savedQueries, + theme: state.theme, + appearance: state.appearance, + sqlFormatOptions: state.sqlFormatOptions, + queryOptions: state.queryOptions, + tableAccessCount: state.tableAccessCount, + tableSortPreference: state.tableSortPreference + }), // Don't persist logs } ) ); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a4a8b66..aa114a1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -62,7 +62,7 @@ export interface TriggerDefinition { export interface TabData { id: string; title: string; - type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command'; + type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger'; connectionId: string; dbName?: string; tableName?: string; @@ -70,6 +70,7 @@ export interface TabData { initialTab?: string; readOnly?: boolean; redisDB?: number; // Redis database index for redis tabs + triggerName?: string; // Trigger name for trigger tabs } export interface DatabaseNode { diff --git a/frontend/src/utils/sql.ts b/frontend/src/utils/sql.ts index 332e656..f9eb7ad 100644 --- a/frontend/src/utils/sql.ts +++ b/frontend/src/utils/sql.ts @@ -35,7 +35,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => { if (!raw) return raw; const dbTypeLower = (dbType || '').toLowerCase(); - if (dbTypeLower === 'mysql') { + if (dbTypeLower === 'mysql' || dbTypeLower === 'tdengine') { return `\`${raw.replace(/`/g, '``')}\``; } @@ -197,4 +197,3 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) => return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; }; - diff --git a/go.mod b/go.mod index 0ef3911..3e3bc04 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/microsoft/go-mssqldb v1.9.6 github.com/redis/go-redis/v9 v9.17.3 github.com/sijms/go-ora/v2 v2.9.0 + github.com/taosdata/driver-go/v3 v3.7.8 github.com/wailsapp/wails/v2 v2.11.0 go.mongodb.org/mongo-driver/v2 v2.5.0 golang.org/x/crypto v0.47.0 @@ -29,7 +30,9 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.6 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -39,6 +42,8 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 2ece0ff..0e70b1b 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -47,16 +48,22 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -87,6 +94,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw= github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -108,8 +119,18 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg= github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk= +github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -182,6 +203,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/app/db_context.go b/internal/app/db_context.go index 1b87b01..b7bc437 100644 --- a/internal/app/db_context.go +++ b/internal/app/db_context.go @@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne } switch strings.ToLower(strings.TrimSpace(config.Type)) { - case "mysql", "mariadb", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb": + case "mysql", "mariadb", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine": // 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。 runConfig.Database = name case "dameng": @@ -56,4 +56,3 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string, return rawDB, rawTable } } - diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 498bb48..278b3c3 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -51,6 +51,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) if dbType == "postgres" || dbType == "kingbase" || dbType == "highgo" || dbType == "vastbase" { escapedDbName = strings.ReplaceAll(dbName, `"`, `""`) query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName) + } else if dbType == "tdengine" { + query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName)) } else if dbType == "mariadb" { // MariaDB uses same syntax as MySQL } @@ -176,7 +178,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co sql string ) switch dbType { - case "mysql", "mariadb": + case "mysql", "mariadb", "tdengine": runConfig = config runConfig.Database = "" sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName)) @@ -264,7 +266,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver": + case "mysql", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)} } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index e96537b..9ebec06 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -408,7 +408,7 @@ func quoteIdentByType(dbType string, ident string) string { } switch dbType { - case "mysql", "mariadb": + case "mysql", "mariadb", "tdengine": return "`" + strings.ReplaceAll(ident, "`", "``") + "`" case "sqlserver": escaped := strings.ReplaceAll(ident, "]", "]]") diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 893e3aa..d9f4f75 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -221,14 +221,18 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult { return connection.QueryResult{Success: false, Message: errMsg} } - stagedDir, err := os.MkdirTemp(workspaceDir, ".gonavi-update-work-") - if err != nil { - errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", workspaceDir) + // 使用版本号命名的工作目录,便于识别和调试 + stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion)) + // 清理可能残留的旧目录(上次下载失败后未清理) + _ = os.RemoveAll(stagedDir) + if err := os.MkdirAll(stagedDir, 0o755); err != nil { + errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", stagedDir) a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg) return connection.QueryResult{Success: false, Message: errMsg} } - assetPath := filepath.Join(workspaceDir, info.AssetName) + // 下载到 staging 目录,避免覆盖正在运行的可执行文件 + assetPath := filepath.Join(stagedDir, info.AssetName) actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) { reportTotal := total if reportTotal <= 0 { diff --git a/internal/db/database.go b/internal/db/database.go index 9c03ccc..af0e1c8 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -50,6 +50,8 @@ func NewDatabase(dbType string) (Database, error) { return &MariaDB{}, nil case "vastbase": return &VastbaseDB{}, nil + case "tdengine": + return &TDengineDB{}, nil case "custom": return &CustomDB{}, nil default: diff --git a/internal/db/dsn_test.go b/internal/db/dsn_test.go index 8ffee14..c9feb9a 100644 --- a/internal/db/dsn_test.go +++ b/internal/db/dsn_test.go @@ -95,3 +95,20 @@ func TestKingbaseDSN_QuotesPasswordWithSpaces(t *testing.T) { t.Fatalf("dsn 未对包含空格的密码进行引号包裹:%s", dsn) } } + +func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) { + td := &TDengineDB{} + cfg := connection.ConnectionConfig{ + Type: "tdengine", + Host: "127.0.0.1", + Port: 6041, + User: "root", + Password: "taosdata", + Database: "power", + } + + dsn := td.getDSN(cfg) + if !strings.HasPrefix(dsn, "root:taosdata@ws(127.0.0.1:6041)/power") { + t.Fatalf("tdengine dsn 格式不正确:%s", dsn) + } +} diff --git a/internal/db/tdengine_impl.go b/internal/db/tdengine_impl.go new file mode 100644 index 0000000..1f6021d --- /dev/null +++ b/internal/db/tdengine_impl.go @@ -0,0 +1,398 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "net" + "strconv" + "strings" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/ssh" + "GoNavi-Wails/internal/utils" + + _ "github.com/taosdata/driver-go/v3/taosWS" +) + +// TDengineDB implements Database interface for TDengine. +// Uses taosWS driver via WebSocket (通常通过 taosAdapter 提供服务)。 +type TDengineDB struct { + conn *sql.DB + pingTimeout time.Duration + forwarder *ssh.LocalForwarder +} + +func (t *TDengineDB) getDSN(config connection.ConnectionConfig) string { + user := strings.TrimSpace(config.User) + if user == "" { + user = "root" + } + + pass := config.Password + dbName := strings.TrimSpace(config.Database) + path := "/" + if dbName != "" { + path = "/" + dbName + } + + return fmt.Sprintf("%s:%s@ws(%s)%s", user, pass, net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), path) +} + +func (t *TDengineDB) Connect(config connection.ConnectionConfig) error { + var dsn string + + if config.UseSSH { + logger.Infof("TDengine 使用 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User) + + forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port) + if err != nil { + return fmt.Errorf("创建 SSH 隧道失败:%w", err) + } + t.forwarder = forwarder + + host, portStr, err := net.SplitHostPort(forwarder.LocalAddr) + if err != nil { + return fmt.Errorf("解析本地转发地址失败:%w", err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("解析本地端口失败:%w", err) + } + + localConfig := config + localConfig.Host = host + localConfig.Port = port + localConfig.UseSSH = false + dsn = t.getDSN(localConfig) + logger.Infof("TDengine 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port) + } else { + dsn = t.getDSN(config) + } + + db, err := sql.Open("taosWS", dsn) + if err != nil { + return fmt.Errorf("打开数据库连接失败:%w", err) + } + t.conn = db + t.pingTimeout = getConnectTimeout(config) + + if err := t.Ping(); err != nil { + return fmt.Errorf("连接建立后验证失败:%w", err) + } + return nil +} + +func (t *TDengineDB) Close() error { + if t.forwarder != nil { + if err := t.forwarder.Close(); err != nil { + logger.Warnf("关闭 TDengine SSH 端口转发失败:%v", err) + } + t.forwarder = nil + } + + if t.conn != nil { + return t.conn.Close() + } + return nil +} + +func (t *TDengineDB) Ping() error { + if t.conn == nil { + return fmt.Errorf("connection not open") + } + timeout := t.pingTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + ctx, cancel := utils.ContextWithTimeout(timeout) + defer cancel() + return t.conn.PingContext(ctx) +} + +func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + if t.conn == nil { + return nil, nil, fmt.Errorf("connection not open") + } + + rows, err := t.conn.QueryContext(ctx, query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + return scanRows(rows) +} + +func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, error) { + if t.conn == nil { + return nil, nil, fmt.Errorf("connection not open") + } + + rows, err := t.conn.Query(query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + return scanRows(rows) +} + +func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, error) { + if t.conn == nil { + return 0, fmt.Errorf("connection not open") + } + res, err := t.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (t *TDengineDB) Exec(query string) (int64, error) { + if t.conn == nil { + return 0, fmt.Errorf("connection not open") + } + res, err := t.conn.Exec(query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (t *TDengineDB) GetDatabases() ([]string, error) { + data, _, err := t.Query("SHOW DATABASES") + if err != nil { + return nil, err + } + + var dbs []string + for _, row := range data { + if val, ok := getValueFromRow(row, "name", "database", "Database", "db_name"); ok { + dbs = append(dbs, fmt.Sprintf("%v", val)) + continue + } + for _, val := range row { + dbs = append(dbs, fmt.Sprintf("%v", val)) + break + } + } + return dbs, nil +} + +func (t *TDengineDB) GetTables(dbName string) ([]string, error) { + queries := make([]string, 0, 2) + if strings.TrimSpace(dbName) != "" { + queries = append(queries, fmt.Sprintf("SHOW TABLES FROM `%s`", escapeBacktickIdent(dbName))) + } + queries = append(queries, "SHOW TABLES") + + var lastErr error + for _, query := range queries { + data, _, err := t.Query(query) + if err != nil { + lastErr = err + continue + } + + var tables []string + for _, row := range data { + if val, ok := getValueFromRow(row, "table_name", "tablename", "name", "Table", "table"); ok { + tables = append(tables, fmt.Sprintf("%v", val)) + continue + } + for _, val := range row { + tables = append(tables, fmt.Sprintf("%v", val)) + break + } + } + return tables, nil + } + + if lastErr != nil { + return nil, lastErr + } + return []string{}, nil +} + +func (t *TDengineDB) GetCreateStatement(dbName, tableName string) (string, error) { + qualified := quoteTDengineTable(dbName, tableName) + queries := []string{ + fmt.Sprintf("SHOW CREATE TABLE %s", qualified), + fmt.Sprintf("SHOW CREATE STABLE %s", qualified), + } + + var lastErr error + for _, query := range queries { + data, _, err := t.Query(query) + if err != nil { + lastErr = err + continue + } + if len(data) == 0 { + continue + } + + row := data[0] + if val, ok := getValueFromRow(row, "Create Table", "create table", "Create Stable", "create stable", "SQL", "sql"); ok { + return fmt.Sprintf("%v", val), nil + } + + longest := "" + for _, val := range row { + text := fmt.Sprintf("%v", val) + if strings.Contains(strings.ToUpper(text), "CREATE ") && len(text) > len(longest) { + longest = text + } + } + if longest != "" { + return longest, nil + } + } + + if lastErr != nil { + return "", lastErr + } + return "", fmt.Errorf("create statement not found") +} + +func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + query := fmt.Sprintf("DESCRIBE %s", quoteTDengineTable(dbName, tableName)) + data, _, err := t.Query(query) + if err != nil { + return nil, err + } + + columns := make([]connection.ColumnDefinition, 0, len(data)) + for _, row := range data { + name, _ := getValueFromRow(row, "Field", "field", "col_name", "column_name", "name") + colType, _ := getValueFromRow(row, "Type", "type", "data_type") + note, _ := getValueFromRow(row, "Note", "note", "Extra", "extra") + nullable, okNull := getValueFromRow(row, "Null", "null", "nullable") + comment, _ := getValueFromRow(row, "Comment", "comment") + defaultVal, hasDefault := getValueFromRow(row, "Default", "default") + + col := connection.ColumnDefinition{ + Name: fmt.Sprintf("%v", name), + Type: fmt.Sprintf("%v", colType), + Nullable: "YES", + Key: "", + Extra: fmt.Sprintf("%v", note), + Comment: fmt.Sprintf("%v", comment), + } + + if okNull { + col.Nullable = strings.ToUpper(fmt.Sprintf("%v", nullable)) + } + + noteUpper := strings.ToUpper(fmt.Sprintf("%v", note)) + if strings.Contains(noteUpper, "TAG") { + col.Key = "TAG" + } + + if hasDefault && defaultVal != nil { + def := fmt.Sprintf("%v", defaultVal) + if def != "" { + col.Default = &def + } + } + + columns = append(columns, col) + } + return columns, nil +} + +func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + if strings.TrimSpace(dbName) == "" { + return nil, fmt.Errorf("database name required for GetAllColumns") + } + + tables, err := t.GetTables(dbName) + if err != nil { + return nil, err + } + + cols := make([]connection.ColumnDefinitionWithTable, 0) + for _, table := range tables { + tableCols, err := t.GetColumns(dbName, table) + if err != nil { + continue + } + for _, col := range tableCols { + cols = append(cols, connection.ColumnDefinitionWithTable{ + TableName: table, + Name: col.Name, + Type: col.Type, + }) + } + } + + return cols, nil +} + +func (t *TDengineDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return []connection.IndexDefinition{}, nil +} + +func (t *TDengineDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return []connection.ForeignKeyDefinition{}, nil +} + +func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return []connection.TriggerDefinition{}, nil +} + +func getValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) { + if len(row) == 0 { + return nil, false + } + + for _, key := range keys { + if val, ok := row[key]; ok { + return val, true + } + } + + for existingKey, val := range row { + for _, key := range keys { + if strings.EqualFold(existingKey, key) { + return val, true + } + } + } + + return nil, false +} + +func escapeBacktickIdent(ident string) string { + return strings.ReplaceAll(strings.TrimSpace(ident), "`", "``") +} + +func quoteTDengineTable(dbName, tableName string) string { + t := escapeBacktickIdent(tableName) + if t == "" { + return "``" + } + if strings.Contains(t, ".") { + parts := strings.Split(t, ".") + quoted := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + quoted = append(quoted, fmt.Sprintf("`%s`", escapeBacktickIdent(part))) + } + if len(quoted) > 0 { + return strings.Join(quoted, ".") + } + } + + db := escapeBacktickIdent(dbName) + if db == "" { + return fmt.Sprintf("`%s`", t) + } + return fmt.Sprintf("`%s`.`%s`", db, t) +}