diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 76d81d2e89..68e9e471be 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -282,14 +282,14 @@ jobs: RELEASE_JSON=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json tagName,name,url,body) export RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tagName') export RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.url') - export RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body' | head -c 800) + export RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body') python3 - <<'PYEOF' import os, re tag = os.environ["RELEASE_TAG"] url = os.environ["RELEASE_URL"] - body = os.environ.get("RELEASE_BODY", "") + body = os.environ.get("RELEASE_BODY", "")[:800] body = re.sub(r'#{1,6}\s*', '', body) body = re.sub(r'\*\*(.+?)\*\*', r'\1', body) diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 3d9f313c5c..11f2f9e60a 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -61,7 +61,7 @@ pub struct AppState { } pub fn metadata_connection_config(config: &ConnectionConfig) -> ConnectionConfig { - let mut db_config = config.clone(); + let mut db_config = config.canonicalized(); if database_capabilities::is_metadata_connection_scoped(&db_config.db_type) { db_config.database = None; } @@ -238,6 +238,7 @@ impl AppState { | DatabaseType::Bigquery | DatabaseType::Kylin | DatabaseType::Sundb + | DatabaseType::Tdengine | DatabaseType::Gaussdb => { let mut client = self.agent_manager.spawn(&db_config.db_type, db_config.driver_profile.as_deref()).await?; diff --git a/crates/dbx-core/src/database_capabilities.rs b/crates/dbx-core/src/database_capabilities.rs index f8973568e7..49a9c8ea72 100644 --- a/crates/dbx-core/src/database_capabilities.rs +++ b/crates/dbx-core/src/database_capabilities.rs @@ -24,6 +24,7 @@ pub fn agent_key(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option DatabaseType::Sundb => Some("sundb"), DatabaseType::Gaussdb => Some("gaussdb"), DatabaseType::MongoDb => Some("mongodb"), + DatabaseType::Tdengine => Some("tdengine"), _ => None, } } diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index a06fc460a7..e78482aeb7 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -135,6 +135,7 @@ pub enum DatabaseType { Bigquery, Kylin, Sundb, + Tdengine, Jdbc, } @@ -175,7 +176,24 @@ impl ConnectionConfig { .driver_profile .as_deref() .map(|p| p.to_lowercase()) - .is_some_and(|p| matches!(p.as_str(), "doris" | "starrocks" | "selectdb" | "tdengine" | "oceanbase")) + .is_some_and(|p| matches!(p.as_str(), "doris" | "starrocks" | "selectdb" | "oceanbase")) + } + + pub fn canonicalized(&self) -> Self { + let mut config = self.clone(); + if config.db_type == DatabaseType::Mysql + && config.driver_profile.as_deref().is_some_and(|profile| profile.eq_ignore_ascii_case("tdengine")) + { + config.db_type = DatabaseType::Tdengine; + if config.port == 0 || config.port == 6030 { + config.port = 6041; + } + config.driver_profile = Some("tdengine".to_string()); + if config.driver_label.as_deref().unwrap_or("").trim().is_empty() { + config.driver_label = Some("TDengine".to_string()); + } + } + config } pub fn connection_url(&self) -> String { @@ -250,6 +268,7 @@ impl ConnectionConfig { DatabaseType::Bigquery => format!("bigquery://{host}/{db_part}"), DatabaseType::Kylin => format!("kylin://{host}:{port}{db_part}"), DatabaseType::Sundb => format!("sundb://{host}:{port}{db_part}"), + DatabaseType::Tdengine => format!("tdengine://{host}:{port}{db_part}"), DatabaseType::Jdbc => "jdbc:".to_string(), } } @@ -369,6 +388,9 @@ impl ConnectionConfig { DatabaseType::Sundb => { format!("sundb://{}:{}@{host}:{port}{db_part}", username, password) } + DatabaseType::Tdengine => { + format!("tdengine://{}:{}@{host}:{port}{db_part}", username, password) + } DatabaseType::Jdbc => { self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string() } @@ -672,6 +694,22 @@ mod tests { assert_eq!(config.connection_url(), "mysql://user%40tenant%23cluster:secret@10.1.2.3:2883?ssl-mode=disabled"); } + #[test] + fn tdengine_profile_is_canonicalized_to_agent_database_type() { + let mut config = mysql_config("root", "taosdata", Some("power")); + config.driver_profile = Some("tdengine".to_string()); + config.driver_label = None; + config.port = 6030; + + let canonical = config.canonicalized(); + + assert_eq!(canonical.db_type, DatabaseType::Tdengine); + assert_eq!(canonical.port, 6041); + assert_eq!(canonical.driver_profile.as_deref(), Some("tdengine")); + assert_eq!(canonical.driver_label.as_deref(), Some("TDengine")); + assert!(!canonical.needs_bare_mysql()); + } + #[test] fn mysql_url_encodes_password_and_database() { let config = mysql_config("root", "p@ss:word#1", Some("db/name")); diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 27d5887371..98ee04464e 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -392,6 +392,7 @@ impl Storage { sqlx::query("DELETE FROM connections").execute(&mut *tx).await.map_err(|e| e.to_string())?; for config in configs { + let config = config.canonicalized(); // Store config without secrets let mut sanitized = config.clone(); sanitized.password = String::new(); @@ -456,7 +457,7 @@ impl Storage { config.ssh_key_passphrase = self.get_secret(&id, "ssh_key_passphrase").await?.unwrap_or_default(); config.proxy_password = self.get_secret(&id, "proxy_password").await?.unwrap_or_default(); config.connection_string = self.get_secret(&id, "connection_string").await?; - configs.push(config); + configs.push(config.canonicalized()); } Ok(configs) } diff --git a/crates/dbx-core/tests/database_capabilities.rs b/crates/dbx-core/tests/database_capabilities.rs index 136b5b7cbe..1d1ffbb969 100644 --- a/crates/dbx-core/tests/database_capabilities.rs +++ b/crates/dbx-core/tests/database_capabilities.rs @@ -8,6 +8,7 @@ fn maps_agent_database_types_to_driver_keys() { assert_eq!(agent_key(&DatabaseType::Trino, None), Some("trino")); assert_eq!(agent_key(&DatabaseType::Hive, None), Some("hive")); assert_eq!(agent_key(&DatabaseType::Gaussdb, None), Some("gaussdb")); + assert_eq!(agent_key(&DatabaseType::Tdengine, None), Some("tdengine")); assert_eq!(agent_key(&DatabaseType::Oracle, None), Some("oracle")); assert_eq!(agent_key(&DatabaseType::Oracle, Some("oracle-10g")), Some("oracle-10g")); assert_eq!(agent_key(&DatabaseType::Postgres, None), None); @@ -18,6 +19,7 @@ fn classifies_agent_database_types() { assert!(is_agent_type(&DatabaseType::Oracle)); assert!(is_agent_type(&DatabaseType::Trino)); assert!(is_agent_type(&DatabaseType::Hive)); + assert!(is_agent_type(&DatabaseType::Tdengine)); assert!(!is_agent_type(&DatabaseType::Mysql)); assert!(!is_agent_type(&DatabaseType::Jdbc)); } @@ -49,6 +51,7 @@ fn skips_tcp_probe_for_local_file_plugin_and_agent_types() { assert!(skips_tcp_probe(&DatabaseType::Jdbc)); assert!(skips_tcp_probe(&DatabaseType::Trino)); assert!(skips_tcp_probe(&DatabaseType::Oracle)); + assert!(skips_tcp_probe(&DatabaseType::Tdengine)); assert!(!skips_tcp_probe(&DatabaseType::Postgres)); assert!(!skips_tcp_probe(&DatabaseType::Mysql)); } diff --git a/docs/content/docs/databases.cn.mdx b/docs/content/docs/databases.cn.mdx index 405784df17..e81eb15aae 100644 --- a/docs/content/docs/databases.cn.mdx +++ b/docs/content/docs/databases.cn.mdx @@ -37,7 +37,7 @@ DBX 默认使用 Rust 原生驱动,不依赖 JDBC。需要连接更多数据 | Doris | 9030 | | SelectDB | 9030 | | StarRocks | 9030 | -| TDengine | 6030 | +| TDengine | 6041 | OceanBase 支持自动检测 Oracle 模式 — 当连接的 OceanBase 实例运行在 Oracle 模式下时,DBX 会自动切换为 Oracle 风格的 Schema 查询。 diff --git a/docs/content/docs/databases.mdx b/docs/content/docs/databases.mdx index 712e291f16..410656df84 100644 --- a/docs/content/docs/databases.mdx +++ b/docs/content/docs/databases.mdx @@ -37,7 +37,7 @@ These databases use MySQL protocol and work with the MySQL driver out of the box | Doris | 9030 | | SelectDB | 9030 | | StarRocks | 9030 | -| TDengine | 6030 | +| TDengine | 6041 | OceanBase supports automatic Oracle mode detection — when connected to an OceanBase instance running in Oracle mode, DBX automatically switches to Oracle-style schema queries. diff --git a/mcp/src/connections.ts b/mcp/src/connections.ts index 969de48efa..7694a2566b 100644 --- a/mcp/src/connections.ts +++ b/mcp/src/connections.ts @@ -24,6 +24,25 @@ export interface ConnectionConfig { ssl: boolean; } +export function canonicalizeConnection(config: ConnectionConfig): ConnectionConfig { + if (config.db_type === "mysql" && config.driver_profile?.toLowerCase() === "tdengine") { + return { + ...config, + db_type: "tdengine", + driver_profile: "tdengine", + port: config.port === 0 || config.port === 6030 ? 6041 : config.port, + }; + } + if (config.db_type === "tdengine") { + return { + ...config, + driver_profile: "tdengine", + port: config.port || 6041, + }; + } + return config; +} + function appDataDir(): string { const home = homedir(); switch (platform()) { @@ -42,7 +61,9 @@ function openDb(readonly = false): Database.Database { } function getSecret(db: Database.Database, connectionId: string, key: string): string { - const row = db.prepare("SELECT secret FROM connection_secrets WHERE connection_id = ? AND key = ?").get(connectionId, key) as { secret: string } | undefined; + const row = db + .prepare("SELECT secret FROM connection_secrets WHERE connection_id = ? AND key = ?") + .get(connectionId, key) as { secret: string } | undefined; return row?.secret ?? ""; } @@ -53,7 +74,7 @@ export async function loadConnections(): Promise { const configs: ConnectionConfig[] = []; for (const row of rows) { - const config: ConnectionConfig = JSON.parse(row.config_json); + const config: ConnectionConfig = canonicalizeConnection(JSON.parse(row.config_json)); config.id = row.id; if (!config.password) config.password = getSecret(db, row.id, "password"); if (!config.proxy_password) config.proxy_password = getSecret(db, row.id, "proxy_password"); @@ -75,21 +96,22 @@ export async function findConnection(name: string): Promise): Promise { const id = randomUUID(); const db = openDb(); + const normalized = canonicalizeConnection({ ...config, id } as ConnectionConfig); const full = { id, - name: config.name, - db_type: config.db_type, - driver_profile: config.driver_profile ?? config.db_type, + name: normalized.name, + db_type: normalized.db_type, + driver_profile: normalized.driver_profile ?? normalized.db_type, driver_label: null, - url_params: config.url_params ?? "", - host: config.host, - port: config.port, - username: config.username, + url_params: normalized.url_params ?? "", + host: normalized.host, + port: normalized.port, + username: normalized.username, password: "", - database: config.database ?? null, + database: normalized.database ?? null, color: null, - ssh_enabled: config.ssh_enabled ?? false, + ssh_enabled: normalized.ssh_enabled ?? false, ssh_host: "", ssh_port: 22, ssh_user: "", @@ -97,13 +119,13 @@ export async function addConnection(config: Omit): Promi ssh_key_path: "", ssh_key_passphrase: "", ssh_expose_lan: false, - proxy_enabled: config.proxy_enabled ?? false, - proxy_type: config.proxy_type ?? "socks5", - proxy_host: config.proxy_host ?? "", - proxy_port: config.proxy_port ?? 1080, - proxy_username: config.proxy_username ?? "", + proxy_enabled: normalized.proxy_enabled ?? false, + proxy_type: normalized.proxy_type ?? "socks5", + proxy_host: normalized.proxy_host ?? "", + proxy_port: normalized.proxy_port ?? 1080, + proxy_username: normalized.proxy_username ?? "", proxy_password: "", - ssl: config.ssl ?? false, + ssl: normalized.ssl ?? false, sysdba: false, connection_string: null, }; @@ -111,17 +133,25 @@ export async function addConnection(config: Omit): Promi const insert = db.transaction(() => { db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run(id, configJson); - if (config.password) { - db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "password", config.password); + if (normalized.password) { + db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run( + id, + "password", + normalized.password, + ); } - if (config.proxy_password) { - db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "proxy_password", config.proxy_password); + if (normalized.proxy_password) { + db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run( + id, + "proxy_password", + normalized.proxy_password, + ); } }); insert(); db.close(); - return { ...config, id }; + return normalized; } export async function removeConnection(name: string): Promise { diff --git a/mcp/src/index.ts b/mcp/src/index.ts index eef02281e9..96e11a5796 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -64,17 +64,12 @@ const server = new McpServer({ version: "0.3.0", }); -server.tool( - "dbx_list_connections", - "List all database connections configured in DBX", - {}, - async () => { - const connections = await backend.loadConnections(); - if (connections.length === 0) return text("No connections configured in DBX."); - const rows = connections.map((c) => [c.name, c.db_type, c.host, String(c.port), c.database || ""]); - return text(mdTable(["Name", "Type", "Host", "Port", "Database"], rows)); - }, -); +server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => { + const connections = await backend.loadConnections(); + if (connections.length === 0) return text("No connections configured in DBX."); + const rows = connections.map((c) => [c.name, c.db_type, c.host, String(c.port), c.database || ""]); + return text(mdTable(["Name", "Type", "Host", "Port", "Database"], rows)); +}); server.tool( "dbx_list_tables", @@ -144,9 +139,13 @@ server.tool( "Add a new database connection to DBX", { name: z.string().describe("Connection name"), - db_type: z.string().describe("Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, h2, snowflake, trino, hive, db2, informix, neo4j, cassandra, bigquery, kylin, sundb, jdbc"), + db_type: z + .string() + .describe( + "Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, h2, snowflake, trino, hive, db2, informix, neo4j, cassandra, bigquery, kylin, sundb, tdengine, jdbc", + ), host: z.string().describe("Database host"), - port: z.number().describe("Database port"), + port: z.number().optional().describe("Database port (TDengine defaults to 6041)"), username: z.string().default("").describe("Username"), password: z.string().default("").describe("Password"), database: z.string().optional().describe("Default database name"), @@ -155,9 +154,18 @@ server.tool( async ({ name, db_type, host, port, username, password, database, ssl }) => { const existing = await backend.findConnection(name); if (existing) return text(`Connection "${name}" already exists.`); + const resolvedPort = port ?? (db_type === "tdengine" ? 6041 : undefined); + if (!resolvedPort) return text("Port is required for this database type."); const config = await backend.addConnection({ - name, db_type, host, port, username, password, - database, ssl, ssh_enabled: false, + name, + db_type, + host, + port: resolvedPort, + username, + password, + database, + ssl, + ssh_enabled: false, } as Omit); await notifyReload(); return text(`Connection "${config.name}" added (id: ${config.id}).`); diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 66a0e7cbeb..2dfaef8534 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::sync::Arc; use tauri::{Emitter, State}; @@ -34,6 +35,7 @@ const AGENT_TYPES: &[(&str, &str)] = &[ ("kylin", "Apache Kylin"), ("sundb", "SunDB"), ("gaussdb", "GaussDB"), + ("tdengine", "TDengine"), ("mongodb", "MongoDB (Legacy)"), ]; @@ -67,6 +69,61 @@ fn build_agent_list(am: &AgentManager, registry: Option<&AgentRegistry>) -> Vec< .collect() } +fn local_agent_jar_candidates(db_type: &str) -> Vec { + let jar_name = format!("dbx-agent-{db_type}.jar"); + let relative = PathBuf::from("..").join("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name); + let nested = PathBuf::from("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name); + vec![relative, nested] +} + +fn find_local_agent_jar(db_type: &str) -> Option { + local_agent_jar_candidates(db_type).into_iter().find(|path| path.exists()) +} + +fn install_local_agent(am: &AgentManager, db_type: &str, source: PathBuf) -> Result<(), String> { + let jar_path = am.driver_jar_path(db_type); + let parent = jar_path.parent().ok_or_else(|| format!("Invalid driver path: {}", jar_path.display()))?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + std::fs::copy(&source, &jar_path).map_err(|e| format!("Failed to copy local agent jar: {e}"))?; + + let mut local_state = am.load_state(); + local_state.installed_drivers.insert( + db_type.to_string(), + InstalledDriver { + version: "0.1.0-local".to_string(), + installed_at: chrono::Utc::now().to_rfc3339(), + jre: DEFAULT_JRE_KEY.to_string(), + }, + ); + am.save_state(&local_state) +} + +#[cfg(test)] +mod tests { + use super::build_agent_list; + use dbx_core::agent_manager::AgentManager; + + #[test] + fn built_in_agent_list_includes_tdengine() { + let dir = std::env::temp_dir().join(format!("dbx-agent-list-test-{}", uuid::Uuid::new_v4())); + let manager = AgentManager::new_with_base_dir(dir.clone()); + + let agents = build_agent_list(&manager, None); + + assert!(agents.iter().any(|agent| agent.db_type == "tdengine" && agent.label == "TDengine")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn local_agent_jar_candidates_include_sibling_dbx_agents_build_output() { + let candidates = super::local_agent_jar_candidates("tdengine"); + + assert!(candidates + .iter() + .any(|path| { path.ends_with("dbx-agents/tdengine/build/libs/dbx-agent-tdengine.jar") })); + } +} + #[tauri::command] pub async fn list_installed_agents_local(state: State<'_, Arc>) -> Result, String> { Ok(build_agent_list(&state.agent_manager, None)) @@ -85,9 +142,26 @@ pub async fn install_agent( db_type: String, ) -> Result<(), String> { let am = &state.agent_manager; - let registry = fetch_registry().await?; + let registry = match fetch_registry().await { + Ok(registry) => registry, + Err(registry_err) => { + if let Some(local_jar) = find_local_agent_jar(&db_type) { + install_local_agent(am, &db_type, local_jar)?; + let _ = app.emit("agent-install-progress", serde_json::json!({ "step": "done" })); + return Ok(()); + } + return Err(registry_err); + } + }; - let driver = registry.drivers.get(&db_type).ok_or_else(|| format!("Unknown driver type: {db_type}"))?; + let Some(driver) = registry.drivers.get(&db_type) else { + if let Some(local_jar) = find_local_agent_jar(&db_type) { + install_local_agent(am, &db_type, local_jar)?; + let _ = app.emit("agent-install-progress", serde_json::json!({ "step": "done" })); + return Ok(()); + } + return Err(format!("Unknown driver type: {db_type}")); + }; let jre_key = &driver.jre; let needs_jre = am.load_state().java_runtime.mode == JavaRuntimeMode::Managed && !am.is_jre_installed(jre_key); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 1d06c1373a..30abe65db0 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -11,12 +11,17 @@ use dbx_core::models::connection::{rewrite_jdbc_url_host, ConnectionConfig, Data #[tauri::command] pub async fn save_connections(state: State<'_, Arc>, configs: Vec) -> Result<(), String> { + let configs: Vec = configs.into_iter().map(|config| config.canonicalized()).collect(); state.storage.save_connections(&configs).await } #[tauri::command] pub async fn load_connections(state: State<'_, Arc>) -> Result, String> { - state.storage.load_connections().await + state + .storage + .load_connections() + .await + .map(|configs| configs.into_iter().map(|config| config.canonicalized()).collect()) } #[tauri::command] @@ -161,6 +166,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection #[tauri::command] pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfig) -> Result { + let config = config.canonicalized(); let id = config.id.clone(); let db_config = metadata_connection_config(&config); diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index 85b6a27360..59f555b6d0 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -201,7 +201,7 @@ const driverProfiles: Record< kylin: { type: "kylin", port: 7070, user: "ADMIN", label: "Apache Kylin", icon: "kylin" }, sundb: { type: "sundb", port: 22000, user: "root", label: "SunDB", icon: "sundb" }, jdbc: { type: "jdbc", port: 0, user: "", label: "JDBC", icon: "jdbc" }, - tdengine: { type: "mysql", port: 6030, user: "root", label: "TDengine", icon: "tdengine" }, + tdengine: { type: "tdengine", port: 6041, user: "root", label: "TDengine", icon: "tdengine" }, custom_mysql: { type: "mysql", port: 3306, @@ -267,14 +267,15 @@ watch( if (config) { const profile = profileForConfig(config); editingId.value = config.id; + const profileConfig = driverProfiles[profile]; form.value = { name: config.name, - db_type: config.db_type, + db_type: profileConfig?.type || config.db_type, driver_profile: profile, driver_label: config.driver_label || driverProfiles[profile]?.label || config.db_type, url_params: config.url_params || "", host: config.host, - port: config.port, + port: profile === "tdengine" && (config.port === 0 || config.port === 6030) ? 6041 : config.port, username: config.username, password: config.password, database: config.database, diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index f27da448a4..98f1eac9b5 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -77,6 +77,7 @@ import { canEditExistingTableRows, hiveTablePropertiesIndicateTransactional, isHiddenGridColumn, + isTdengineExistingRowReadonlyColumn, usesSyntheticRowIdKey, } from "@/lib/tableEditing"; import { formatGridSqlLiteral } from "@/lib/dataGridSql"; @@ -1153,7 +1154,12 @@ function canEditRowItem(item: RowItem | undefined): boolean { } function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean { - return canEditRowItem(item) && canEditColumn(columnIndex); + if (!canEditRowItem(item) || !canEditColumn(columnIndex)) return false; + if (!item?.isNew) { + const column = props.result.columns[columnIndex] ?? ""; + if (isTdengineExistingRowReadonlyColumn(props.databaseType, column, props.tableMeta?.columns ?? [])) return false; + } + return true; } function canDeleteRowItem(item: RowItem | undefined): boolean { diff --git a/src/composables/useNavigationTargets.ts b/src/composables/useNavigationTargets.ts index 2f2bcac9fb..2e878a8fa2 100644 --- a/src/composables/useNavigationTargets.ts +++ b/src/composables/useNavigationTargets.ts @@ -74,13 +74,14 @@ async function openTableTarget(target: NavigationTarget) { columns, primaryKeys, }); - if (useRowId) { + if (useRowId || config.db_type === "tdengine") { const newSql = buildTableSelectSql({ databaseType: config.db_type, schema: target.schema, tableName: target.tableName, whereInput: target.whereInput, primaryKeys, + columns: columns.map((column) => column.name), includeRowId: true, }); queryStore.updateSql(tabId, newSql); diff --git a/src/lib/connectionPresentation.ts b/src/lib/connectionPresentation.ts index b578f2babc..e7e2e43412 100644 --- a/src/lib/connectionPresentation.ts +++ b/src/lib/connectionPresentation.ts @@ -63,6 +63,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string { case "dameng": return "dm://user:password@host:port"; + case "tdengine": + return "tdengine://user:password@host:6041/database"; + case "jdbc": return "jdbc:mysql://host:3306/database"; diff --git a/src/lib/connectionUrl.ts b/src/lib/connectionUrl.ts index 2dabdadd47..c81a97cfcb 100644 --- a/src/lib/connectionUrl.ts +++ b/src/lib/connectionUrl.ts @@ -41,6 +41,8 @@ const SCHEME_PROFILES: Record = { dameng: { type: "dameng", profile: "dm", label: "DM (Dameng)", defaultPort: 5236 }, gaussdb: { type: "gaussdb", profile: "gaussdb", label: "GaussDB", defaultPort: 5432 }, opengauss: { type: "gaussdb", profile: "opengauss", label: "openGauss", defaultPort: 5432 }, + tdengine: { type: "tdengine", profile: "tdengine", label: "TDengine", defaultPort: 6041 }, + "taos-ws": { type: "tdengine", profile: "tdengine", label: "TDengine", defaultPort: 6041 }, }; const HTTP_SELECTED_PROFILES: Record = { diff --git a/src/lib/dataGridSql.ts b/src/lib/dataGridSql.ts index 23958ec812..84b8ed0dba 100644 --- a/src/lib/dataGridSql.ts +++ b/src/lib/dataGridSql.ts @@ -1,5 +1,5 @@ import type { DatabaseType } from "@/types/database"; -import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "./tableEditing.ts"; +import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, DBX_TDENGINE_TBNAME_COLUMN } from "./tableEditing.ts"; import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts"; export type GridCellValue = string | number | boolean | null; @@ -8,6 +8,7 @@ export interface DataGridTableMeta { schema?: string; tableName: string; primaryKeys: string[]; + columns?: DataGridColumnInfo[]; } export interface DataGridColumnInfo { @@ -108,6 +109,7 @@ function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): st export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] { if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options); + if (options.databaseType === "tdengine") return buildTdengineDataGridSaveStatements(options); const saveColumns = effectiveColumns(options.sourceColumns, options.columns); const table = qualifiedTableName({ @@ -155,6 +157,81 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption return statements; } +function buildTdengineDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] { + const saveColumns = effectiveColumns(options.sourceColumns, options.columns); + const statements: string[] = []; + const canOverwriteExistingRows = saveColumns.some((column) => column?.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN); + + if (canOverwriteExistingRows) { + for (const [rowIndex, changes] of options.dirtyRows) { + const row = options.rows[rowIndex]; + if (!row) continue; + const afterRow = [...row]; + for (const [columnIndex, value] of changes) { + afterRow[columnIndex] = value; + } + const statement = buildTdengineInsertStatement(options, saveColumns, afterRow); + if (statement) statements.push(statement); + } + } + + for (const row of options.newRows) { + const statement = buildTdengineInsertStatement(options, saveColumns, row); + if (statement) statements.push(statement); + } + + return statements; +} + +function buildTdengineInsertStatement( + options: DataGridSaveStatementOptions, + saveColumns: Array, + row: GridCellValue[], +): string | undefined { + const tbname = tdengineTbnameValue(saveColumns, row); + const table = qualifiedTableName({ + databaseType: "tdengine", + schema: options.tableMeta.schema, + tableName: options.tableMeta.tableName, + }); + const tagColumns = tdengineTagColumns(options.tableMeta.columns); + const insertPairs = saveColumns + .map((column, index) => ({ column, value: row[index] })) + .filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column) + .filter((pair) => pair.value !== null && pair.value !== undefined) + .filter((pair) => tdengineCanInsertColumn(pair.column, options.tableMeta.tableName, tbname, tagColumns)); + if (!insertPairs.length) return undefined; + const columns = insertPairs.map((pair) => quoteIdent("tdengine", pair.column)).join(", "); + const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, "tdengine")).join(", "); + return `INSERT INTO ${table} (${columns}) VALUES (${values});`; +} + +function tdengineTbnameValue(saveColumns: Array, row: GridCellValue[]): string | undefined { + const index = saveColumns.findIndex((column) => column?.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN); + if (index < 0) return undefined; + const value = row[index]; + return value === null || value === undefined ? undefined : String(value); +} + +function tdengineCanInsertColumn( + column: string, + tableName: string, + tbname: string | undefined, + tagColumns: Set, +): boolean { + const normalized = column.toLowerCase(); + const targetIsChildTable = !tbname || tbname === tableName; + if (!targetIsChildTable) return true; + if (normalized === DBX_TDENGINE_TBNAME_COLUMN) return false; + return !tagColumns.has(normalized); +} + +function tdengineTagColumns(columns: DataGridColumnInfo[] | undefined): Set { + return new Set( + (columns ?? []).filter((column) => /\btag\b/i.test(column.extra ?? "")).map((column) => column.name.toLowerCase()), + ); +} + export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] { if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options); const saveColumns = effectiveColumns(options.sourceColumns, options.columns); @@ -253,10 +330,32 @@ export function formatGridSqlLiteral(value: GridCellValue, databaseType?: Databa if (typeof value === "number" && Number.isFinite(value)) return String(value); const text = String(value); if (text === "") return databaseType === "sqlserver" ? "N''" : "''"; - const escaped = `'${text.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; + const literalText = databaseType === "tdengine" ? formatTdengineTimestampLiteralText(text) : text; + const escaped = `'${literalText.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; return databaseType === "sqlserver" ? `N${escaped}` : escaped; } +function formatTdengineTimestampLiteralText(text: string): string { + const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d{1,9})?$/.exec(text); + if (!match) return text; + return `${match[1]}T${match[2]}${normalizeFractionalSeconds(match[3])}${localTimezoneOffsetSuffix(text)}`; +} + +function normalizeFractionalSeconds(fraction: string | undefined): string { + if (!fraction) return ".000"; + return fraction.length >= 4 ? fraction.slice(0, 4) : fraction.padEnd(4, "0"); +} + +function localTimezoneOffsetSuffix(text: string): string { + const date = new Date(text.replace(" ", "T")); + const offsetMinutes = Number.isNaN(date.getTime()) ? new Date().getTimezoneOffset() : date.getTimezoneOffset(); + const sign = offsetMinutes <= 0 ? "+" : "-"; + const abs = Math.abs(offsetMinutes); + const hours = String(Math.floor(abs / 60)).padStart(2, "0"); + const minutes = String(abs % 60).padStart(2, "0"); + return `${sign}${hours}:${minutes}`; +} + function buildPrimaryKeyWhere( databaseType: DatabaseType | undefined, primaryKeys: string[], diff --git a/src/lib/databaseCapabilitySets.ts b/src/lib/databaseCapabilitySets.ts index 0ef638dae8..1949a2c87e 100644 --- a/src/lib/databaseCapabilitySets.ts +++ b/src/lib/databaseCapabilitySets.ts @@ -15,6 +15,7 @@ export const SCHEMA_AWARE_TYPES = new Set([ "snowflake", "trino", "db2", + "tdengine", ]); export const SQL_FILE_UNSUPPORTED_TYPES = new Set(["redis", "mongodb", "elasticsearch"]); @@ -62,6 +63,7 @@ export const DATABASE_SEARCH_SUPPORTED_TYPES = new Set([ "bigquery", "kylin", "sundb", + "tdengine", ]); export const TABLE_IMPORT_SUPPORTED_TYPES = new Set([ @@ -124,6 +126,7 @@ export const TREE_SCHEMA_TYPES = new Set([ "jdbc", "trino", "h2", + "tdengine", ]); export const PG_LIKE_STRUCTURE_TYPES = new Set(["postgres", "redshift", "gaussdb"]); @@ -147,6 +150,7 @@ export const AGENT_DRIVER_TYPES = new Set([ "kylin", "sundb", "gaussdb", + "tdengine", ]); export const TRANSFER_SQL_TYPES = new Set([ diff --git a/src/lib/databaseTableDataCapabilities.ts b/src/lib/databaseTableDataCapabilities.ts index 7dfa4c169d..f7e44c014b 100644 --- a/src/lib/databaseTableDataCapabilities.ts +++ b/src/lib/databaseTableDataCapabilities.ts @@ -8,6 +8,7 @@ export interface TableDataCapability { updateRequiresPrimaryKey: boolean; deleteRequiresPrimaryKey: boolean; requiresTransactionalTableForExistingRows: boolean; + existingRowsReadonly?: boolean; transaction: boolean; readonly?: boolean; } @@ -77,6 +78,15 @@ const DATABASE_CAPABILITY_OVERRIDES: Partial column.is_primary_key).map((column) => column.name); + if (databaseType === "tdengine" && primaryKeys.length > 0) return [DBX_TDENGINE_TBNAME_COLUMN, ...primaryKeys]; const syntheticKey = getDatabaseCapability(databaseType).syntheticKey; if (syntheticKey === "oracle-rowid" && primaryKeys.length === 0) return [DBX_ROWID_COLUMN]; if (syntheticKey === "neo4j-element-id" && primaryKeys.length === 0) return [DBX_NEO4J_ELEMENT_ID_COLUMN]; @@ -30,7 +32,10 @@ export function canEditExistingTableRows( ): boolean { const tableData = getDatabaseCapability(databaseType).tableData; if (tableData.readonly) return false; + if (tableData.existingRowsReadonly) return false; if (tableData.requiresTransactionalTableForExistingRows && hiveTableTransactional !== true) return false; + if (databaseType === "tdengine" && !primaryKeys?.some((key) => key.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN)) + return false; if (tableData.updateRequiresPrimaryKey && primaryKeys && primaryKeys.length === 0) return false; return true; } @@ -63,3 +68,14 @@ export function isHiddenGridColumn( if (databaseType === "neo4j" && column === DBX_NEO4J_ELEMENT_ID_COLUMN) return true; return usesSyntheticRowIdKey(databaseType, primaryKeys) && column.toUpperCase() === DBX_ROWID_COLUMN; } + +export function isTdengineExistingRowReadonlyColumn( + databaseType: DatabaseType | undefined, + column: string, + columns: ColumnInfo[], +): boolean { + if (databaseType !== "tdengine") return false; + if (column.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN) return true; + const columnInfo = columns.find((info) => info.name.toLowerCase() === column.toLowerCase()); + return !!columnInfo?.is_primary_key || /\btag\b/i.test(columnInfo?.extra ?? ""); +} diff --git a/src/lib/tableSelectSql.ts b/src/lib/tableSelectSql.ts index 5e9b7e89de..d4209a07a9 100644 --- a/src/lib/tableSelectSql.ts +++ b/src/lib/tableSelectSql.ts @@ -1,6 +1,6 @@ import type { DatabaseType } from "../types/database.ts"; import { isSchemaAware, usesFetchFirst } from "./databaseCapabilities.ts"; -import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "./tableEditing.ts"; +import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, DBX_TDENGINE_TBNAME_COLUMN } from "./tableEditing.ts"; export interface BuildTableSelectSqlOptions { databaseType?: DatabaseType; @@ -17,7 +17,8 @@ export interface BuildTableSelectSqlOptions { } export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string { - if (databaseType === "mysql" || databaseType === "hive") return `\`${name.replace(/`/g, "``")}\``; + if (databaseType === "mysql" || databaseType === "hive" || databaseType === "tdengine") + return `\`${name.replace(/`/g, "``")}\``; if (databaseType === "informix" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(name)) return name; if (databaseType === "neo4j") return quoteCypherIdentifier(name); if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`; @@ -34,6 +35,7 @@ function isOracleRowId(databaseType: DatabaseType | undefined, name: string): bo function quoteOrderIdentifier(databaseType: DatabaseType | undefined, name: string, tableAlias?: string): string { if (isOracleRowId(databaseType, name)) return tableAlias ? `${tableAlias}.ROWID` : "ROWID"; + if (isTdengineTbname(databaseType, name)) return DBX_TDENGINE_TBNAME_COLUMN; return quoteTableIdentifier(databaseType, name); } @@ -90,7 +92,20 @@ export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string } function buildSelectColumns(databaseType: DatabaseType | undefined, columns?: string[]): string { - if (databaseType !== "hive" || !columns?.length) return "*"; + if (!columns?.length) return "*"; + if (databaseType === "tdengine") { + const tdengineColumns = columns.some((column) => column.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN) + ? columns + : [DBX_TDENGINE_TBNAME_COLUMN, ...columns]; + return tdengineColumns + .map((column) => { + if (isTdengineTbname(databaseType, column)) return DBX_TDENGINE_TBNAME_COLUMN; + const ident = quoteTableIdentifier(databaseType, column); + return `${ident} AS ${ident}`; + }) + .join(", "); + } + if (databaseType !== "hive") return "*"; return columns .map((column) => { const ident = quoteTableIdentifier(databaseType, column); @@ -99,6 +114,10 @@ function buildSelectColumns(databaseType: DatabaseType | undefined, columns?: st .join(", "); } +function isTdengineTbname(databaseType: DatabaseType | undefined, name: string): boolean { + return databaseType === "tdengine" && name.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN; +} + function buildNeo4jTableSelectSql(options: BuildTableSelectSqlOptions, limit: number): string { const label = quoteCypherIdentifier(options.tableName); const predicate = normalizeWhereInput(options.whereInput); diff --git a/src/stores/queryStore.ts b/src/stores/queryStore.ts index 9a92f1846e..566425a652 100644 --- a/src/stores/queryStore.ts +++ b/src/stores/queryStore.ts @@ -14,6 +14,7 @@ import { } from "@/lib/sqlAnalysis"; import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence"; import { mongoDocumentsToQueryResult, parseMongoFindCommand } from "@/lib/mongoShellCommand"; +import { editablePrimaryKeys } from "@/lib/tableEditing"; import * as api from "@/lib/api"; import { useConnectionStore } from "@/stores/connectionStore"; import { isTauriRuntime } from "@/lib/tauriRuntime"; @@ -364,7 +365,7 @@ export const useQueryStore = defineStore("query", () => { try { const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName); - const primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name); + const primaryKeys = editablePrimaryKeys(dbType as DatabaseType, columns); tab.tableMeta = { schema: schema || undefined, diff --git a/src/types/database.ts b/src/types/database.ts index 1c4e4f15ba..b88d01d31e 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -29,6 +29,7 @@ export type DatabaseType = | "bigquery" | "kylin" | "sundb" + | "tdengine" | "jdbc"; export interface ConnectionConfig { diff --git a/tests/agentDriverInstallHint.test.ts b/tests/agentDriverInstallHint.test.ts index f9a686410e..3b43997d52 100644 --- a/tests/agentDriverInstallHint.test.ts +++ b/tests/agentDriverInstallHint.test.ts @@ -10,6 +10,10 @@ test("shows the agent driver install hint when the selected driver is missing", assert.equal(showAgentDriverInstallHint("informix", [{ db_type: "informix", installed: false }]), true); }); +test("shows the agent driver install hint for TDengine when missing", () => { + assert.equal(showAgentDriverInstallHint("tdengine", [{ db_type: "tdengine", installed: false }]), true); +}); + test("does not show agent driver install hints for built-in database types", () => { assert.equal(showAgentDriverInstallHint("mysql", [{ db_type: "informix", installed: false }]), false); }); diff --git a/tests/connectionUrl.test.ts b/tests/connectionUrl.test.ts index d4cf13f2b9..9572227e0b 100644 --- a/tests/connectionUrl.test.ts +++ b/tests/connectionUrl.test.ts @@ -52,6 +52,20 @@ test("parses JDBC URLs by using the inner database URL", () => { assert.equal(mysql.urlParams, "charset=utf8mb4"); }); +test("parses TDengine WebSocket JDBC URLs", () => { + const parsed = parseConnectionUrl("jdbc:TAOS-WS://root:taosdata@td.example.com:6041/power?timezone=UTC"); + + assert.equal(parsed.dbType, "tdengine"); + assert.equal(parsed.driverProfile, "tdengine"); + assert.equal(parsed.driverLabel, "TDengine"); + assert.equal(parsed.host, "td.example.com"); + assert.equal(parsed.port, 6041); + assert.equal(parsed.username, "root"); + assert.equal(parsed.password, "taosdata"); + assert.equal(parsed.database, "power"); + assert.equal(parsed.urlParams, "timezone=UTC"); +}); + test("parses SQL Server JDBC URLs with semicolon properties", () => { const parsed = parseConnectionUrl( "jdbc:sqlserver://sql.example.com:1434;databaseName=erp;user=sa;password=s%40cret;encrypt=true", diff --git a/tests/connectionUrlPlaceholder.test.ts b/tests/connectionUrlPlaceholder.test.ts index 61942f2fb5..5424c8de22 100644 --- a/tests/connectionUrlPlaceholder.test.ts +++ b/tests/connectionUrlPlaceholder.test.ts @@ -18,6 +18,7 @@ const expected: Record = { oracle: "oracle://user:password@host:port/service_name", elasticsearch: "http://user:password@host:port", dameng: "dm://user:password@host:port", + tdengine: "tdengine://user:password@host:6041/database", jdbc: "jdbc:mysql://host:3306/database", }; diff --git a/tests/dataGridSql.test.ts b/tests/dataGridSql.test.ts index 048394fc2d..fa3b79386b 100644 --- a/tests/dataGridSql.test.ts +++ b/tests/dataGridSql.test.ts @@ -5,6 +5,7 @@ import { buildDataGridSaveStatements, dataGridSaveExecutionSchema, normalizeDataGridSaveError, + formatGridSqlLiteral, validateDataGridSave, } from "../src/lib/dataGridSql.ts"; import { DBX_NEO4J_ELEMENT_ID_COLUMN } from "../src/lib/tableEditing.ts"; @@ -133,6 +134,86 @@ test("builds Hive grid save statements without primary keys using row predicates ]); }); +test("builds TDengine append-only grid save statements", () => { + const statements = buildDataGridSaveStatements({ + databaseType: "tdengine", + tableMeta: { + schema: "test_db", + tableName: "meters", + primaryKeys: ["ts"], + }, + columns: ["ts", "current", "voltage", "location", "groupid"], + rows: [["2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]], + dirtyRows: [[0, [[1, 11.1]]]], + deletedRows: [0], + newRows: [["2026-05-16 09:40:00.000", 12.1, 220, "Shenzhen", 3]], + }); + + assert.deepEqual(statements, [ + "INSERT INTO `test_db`.`meters` (`ts`, `current`, `voltage`, `location`, `groupid`) VALUES ('2026-05-16T09:40:00.000+08:00', 12.1, 220, 'Shenzhen', 3);", + ]); +}); + +test("builds TDengine supertable overwrites as full-row inserts by tbname and timestamp", () => { + const statements = buildDataGridSaveStatements({ + databaseType: "tdengine", + tableMeta: { + schema: "test_db", + tableName: "meters", + primaryKeys: ["tbname", "ts"], + columns: [ + { name: "ts", data_type: "TIMESTAMP", is_nullable: false, is_primary_key: true }, + { name: "current", data_type: "FLOAT", is_nullable: true, is_primary_key: false }, + { name: "voltage", data_type: "INT", is_nullable: true, is_primary_key: false }, + { name: "location", data_type: "NCHAR", is_nullable: true, is_primary_key: false, extra: "TAG" }, + { name: "groupid", data_type: "INT", is_nullable: true, is_primary_key: false, extra: "TAG" }, + ], + }, + columns: ["tbname", "ts", "current", "voltage", "location", "groupid"], + rows: [["d1001", "2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]], + dirtyRows: [[0, [[2, 11.1]]]], + deletedRows: [0], + newRows: [["d1003", "2026-05-16 09:40:00.000", 12.1, 220, "Shenzhen", 3]], + }); + + assert.deepEqual(statements, [ + "INSERT INTO `test_db`.`meters` (`tbname`, `ts`, `current`, `voltage`, `location`, `groupid`) VALUES ('d1001', '2026-05-16T09:35:57.000+08:00', 11.1, 219, 'Beijing', 1);", + "INSERT INTO `test_db`.`meters` (`tbname`, `ts`, `current`, `voltage`, `location`, `groupid`) VALUES ('d1003', '2026-05-16T09:40:00.000+08:00', 12.1, 220, 'Shenzhen', 3);", + ]); +}); + +test("builds TDengine child table overwrites without pseudo or tag columns", () => { + const statements = buildDataGridSaveStatements({ + databaseType: "tdengine", + tableMeta: { + schema: "test_db", + tableName: "d1001", + primaryKeys: ["tbname", "ts"], + columns: [ + { name: "ts", data_type: "TIMESTAMP", is_nullable: false, is_primary_key: true }, + { name: "current", data_type: "FLOAT", is_nullable: true, is_primary_key: false }, + { name: "voltage", data_type: "INT", is_nullable: true, is_primary_key: false }, + { name: "location", data_type: "NCHAR", is_nullable: true, is_primary_key: false, extra: "TAG" }, + { name: "groupid", data_type: "INT", is_nullable: true, is_primary_key: false, extra: "TAG" }, + ], + }, + columns: ["tbname", "ts", "current", "voltage", "location", "groupid"], + rows: [["d1001", "2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]], + dirtyRows: [[0, [[2, 11.1]]]], + deletedRows: [], + newRows: [["d1001", "2026-05-16 09:40:00.000", 12.1, 220, "Beijing", 1]], + }); + + assert.deepEqual(statements, [ + "INSERT INTO `test_db`.`d1001` (`ts`, `current`, `voltage`) VALUES ('2026-05-16T09:35:57.000+08:00', 11.1, 219);", + "INSERT INTO `test_db`.`d1001` (`ts`, `current`, `voltage`) VALUES ('2026-05-16T09:40:00.000+08:00', 12.1, 220);", + ]); +}); + +test("formats TDengine timestamp literals with the local timezone offset", () => { + assert.equal(formatGridSqlLiteral("2026-05-16 09:35:57.975", "tdengine"), "'2026-05-16T09:35:57.975+08:00'"); +}); + test("builds Trino insert statements with schema-qualified identifiers", () => { const statements = buildDataGridSaveStatements({ databaseType: "trino", diff --git a/tests/databaseCapabilities.test.ts b/tests/databaseCapabilities.test.ts index c683377461..fea152b058 100644 --- a/tests/databaseCapabilities.test.ts +++ b/tests/databaseCapabilities.test.ts @@ -23,6 +23,12 @@ test("treats Trino catalogs as schema tree roots", () => { assert.equal(TREE_SCHEMA_TYPES.has("trino"), true); }); +test("treats TDengine databases as schema tree roots and agent driver databases", () => { + assert.equal(TREE_SCHEMA_TYPES.has("tdengine"), true); + assert.equal(SCHEMA_AWARE_TYPES.has("tdengine"), true); + assert.equal(supportsDriverManagement("tdengine"), true); +}); + test("describes schema tree mode through the capability helper", () => { assert.equal(usesTreeSchemaMode("trino"), true); assert.equal(usesTreeSchemaMode("h2"), true); diff --git a/tests/tableEditing.test.ts b/tests/tableEditing.test.ts index 6f456c5282..27ac629791 100644 --- a/tests/tableEditing.test.ts +++ b/tests/tableEditing.test.ts @@ -3,10 +3,12 @@ import test from "node:test"; import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, + DBX_TDENGINE_TBNAME_COLUMN, canEditExistingTableRows, editablePrimaryKeys, hiveTablePropertiesIndicateTransactional, isHiddenGridColumn, + isTdengineExistingRowReadonlyColumn, isTableDataEditable, supportsDataGridTransaction, usesSyntheticRowIdKey, @@ -36,10 +38,18 @@ test("does not synthesize ROWID for non-Oracle keyless tables", () => { assert.deepEqual(editablePrimaryKeys("mysql", [column("ID"), column("CITY")]), []); }); +test("uses tbname and timestamp as TDengine editable keys", () => { + assert.deepEqual(editablePrimaryKeys("tdengine", [column("ts", true), column("current")]), [ + DBX_TDENGINE_TBNAME_COLUMN, + "ts", + ]); +}); + test("allows Hive table data editing even without declared primary keys", () => { assert.equal(isTableDataEditable("hive", []), true); assert.equal(isTableDataEditable("trino", []), true); assert.equal(isTableDataEditable("informix", []), true); + assert.equal(isTableDataEditable("tdengine", []), true); assert.equal(isTableDataEditable("mysql", []), false); assert.equal(isTableDataEditable("postgres", ["id"]), true); }); @@ -58,6 +68,8 @@ test("allows existing row edits for Hive only when the table is transactional", assert.equal(canEditExistingTableRows("trino", undefined, ["id"]), true); assert.equal(canEditExistingTableRows("informix", undefined, []), false); assert.equal(canEditExistingTableRows("informix", undefined, ["id"]), true); + assert.equal(canEditExistingTableRows("tdengine", undefined, ["ts"]), false); + assert.equal(canEditExistingTableRows("tdengine", undefined, [DBX_TDENGINE_TBNAME_COLUMN, "ts"]), true); assert.equal(canEditExistingTableRows("postgres", undefined), true); }); @@ -82,6 +94,19 @@ test("uses elementId as Neo4j editable key when labels have no primary key", () assert.deepEqual(editablePrimaryKeys("neo4j", [column("name"), column("role")]), [DBX_NEO4J_ELEMENT_ID_COLUMN]); }); +test("keeps TDengine existing row identity and tag columns read-only", () => { + assert.equal(isTdengineExistingRowReadonlyColumn("tdengine", DBX_TDENGINE_TBNAME_COLUMN, [column("ts", true)]), true); + assert.equal(isTdengineExistingRowReadonlyColumn("tdengine", "ts", [column("ts", true)]), true); + assert.equal(isTdengineExistingRowReadonlyColumn("tdengine", "location", [column("location")]), false); + assert.equal(isTdengineExistingRowReadonlyColumn("mysql", "ts", [column("ts", true)]), false); + assert.equal( + isTdengineExistingRowReadonlyColumn("tdengine", "location", [ + { ...column("location"), extra: "TAG", comment: "TAG" }, + ]), + true, + ); +}); + test("detects the synthetic Oracle ROWID key case", () => { assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN]), true); assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN.toLowerCase()]), true); diff --git a/tests/tableSelectSql.test.ts b/tests/tableSelectSql.test.ts index 217b6b356d..ce1c2b547b 100644 --- a/tests/tableSelectSql.test.ts +++ b/tests/tableSelectSql.test.ts @@ -50,6 +50,22 @@ test("builds Hive table data queries with backtick identifiers", () => { assert.equal(sql, "SELECT * FROM `departments` ORDER BY `dept id` ASC LIMIT 100;"); }); +test("builds TDengine table data queries with backtick identifiers", () => { + const sql = buildTableSelectSql({ + databaseType: "tdengine", + schema: "test_db", + tableName: "meters", + columns: ["ts", "current", "voltage", "location", "groupid"], + primaryKeys: ["ts"], + limit: 100, + }); + + assert.equal( + sql, + "SELECT tbname, `ts` AS `ts`, `current` AS `current`, `voltage` AS `voltage`, `location` AS `location`, `groupid` AS `groupid` FROM `test_db`.`meters` ORDER BY `ts` ASC LIMIT 100;", + ); +}); + test("builds Informix table data queries without database-qualified delimited identifiers", () => { const sql = buildTableSelectSql({ databaseType: "informix", @@ -140,7 +156,10 @@ test("builds Neo4j table data queries as Cypher label matches", () => { offset: 200, }); - assert.equal(sql, "MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n ORDER BY n.`id` ASC SKIP 200 LIMIT 100;"); + assert.equal( + sql, + "MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n ORDER BY n.`id` ASC SKIP 200 LIMIT 100;", + ); }); test("expands Neo4j table data queries into node property columns", () => {