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)
+}