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/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/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 289cc7c..27eebdb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -165,6 +165,44 @@ 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 loadDatabases = async (node: any) => { const conn = node.dataRef as SavedConnection; const loadKey = `dbs-${conn.id}`; @@ -280,8 +318,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, @@ -1402,7 +1441,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> ) : null; - return {statusBadge}{node.title}; + const displayTitle = String(node.title ?? ''); + let hoverTitle = displayTitle; + if (node.type === 'table') { + const rawTableName = String(node?.dataRef?.tableName || '').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/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/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) +}