From e1930550e22135de900654f3a6f3aeb2e46b3770 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 18 May 2026 13:18:02 +0800 Subject: [PATCH] feat(grid): support DuckDB database files --- .../src/components/sidebar/TreeItem.vue | 74 +++++++- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/lib/createDatabaseSql.ts | 28 +++ .../desktop/src/lib/databaseCapabilitySets.ts | 8 +- apps/desktop/src/stores/connectionStore.ts | 3 + apps/desktop/src/types/database.ts | 6 + crates/dbx-core/src/connection.rs | 7 + crates/dbx-core/src/connection_secrets.rs | 1 + crates/dbx-core/src/models/connection.rs | 32 ++++ crates/dbx-core/src/query.rs | 50 ++++- crates/dbx-core/src/schema.rs | 172 +++++++++++++++++- crates/dbx-web/src/routes/connection.rs | 1 + packages/app-tests/createDatabaseSql.test.ts | 26 ++- .../app-tests/databaseCapabilities.test.ts | 1 + .../app-tests/tableStructureEditorSql.test.ts | 19 ++ src-tauri/src/commands/connection.rs | 3 + 18 files changed, 417 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 514226ac1..326f8784c 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -94,7 +94,13 @@ import { } from "@/lib/treeNodeClick"; import { formatCsv, formatJson, formatSqlInsert } from "@/lib/exportFormats"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; -import { buildCreateDatabaseSql, supportsCreateDatabaseCharset } from "@/lib/createDatabaseSql"; +import { + buildCreateDatabaseSql, + buildDuckDbAttachDatabaseSql, + duckDbAttachedDatabaseNameFromPath, + supportsCreateDatabaseCharset, + uniqueDuckDbAttachedDatabaseName, +} from "@/lib/createDatabaseSql"; import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } from "@/lib/objectRenameSql"; import { hexToRgba } from "@/lib/color"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; @@ -755,7 +761,14 @@ const canCreateTable = computed(() => { const canCreateDatabase = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "connection" && supportsDatabaseCreation(config?.db_type); + return ( + props.node.type === "connection" && (supportsDatabaseCreation(config?.db_type) || config?.db_type === "duckdb") + ); +}); + +const isDuckDbConnection = computed(() => { + const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; + return props.node.type === "connection" && config?.db_type === "duckdb"; }); const canSetCreateDatabaseCharset = computed(() => { @@ -865,6 +878,14 @@ function buildDropSchemaSql(): string { return `DROP SCHEMA ${name};`; } +async function openCreateDatabase() { + if (isDuckDbConnection.value) { + await createDuckDbAttachedDatabaseFile(); + return; + } + openCreateDatabaseDialog(); +} + function openCreateDatabaseDialog() { createDatabaseName.value = ""; createDatabaseCharset.value = "utf8mb4"; @@ -872,6 +893,50 @@ function openCreateDatabaseDialog() { showCreateDatabaseDialog.value = true; } +function ensureDuckDbFileExtension(path: string): string { + return /\.(duckdb|db)$/i.test(path) ? path : `${path}.duckdb`; +} + +async function createDuckDbAttachedDatabaseFile() { + const node = props.node; + if (!node.connectionId) return; + if (!isTauriRuntime()) { + toast(t("contextMenu.createDuckDbFileDesktopOnly"), 4000); + return; + } + + try { + const { save } = await import("@tauri-apps/plugin-dialog"); + const selectedPath = await save({ + defaultPath: "database.duckdb", + filters: [{ name: "DuckDB", extensions: ["duckdb", "db"] }], + }); + if (!selectedPath) return; + + const path = ensureDuckDbFileExtension(selectedPath); + await connectionStore.ensureConnected(node.connectionId); + const existingDatabases = await api.listDatabases(node.connectionId); + const name = uniqueDuckDbAttachedDatabaseName( + duckDbAttachedDatabaseNameFromPath(path), + existingDatabases.map((database) => database.name), + ); + await api.executeQuery(node.connectionId, "", buildDuckDbAttachDatabaseSql(path, name)); + + const config = connectionStore.getConfig(node.connectionId); + if (config) { + await connectionStore.updateConnection({ + ...config, + attached_databases: [...(config.attached_databases ?? []), { name, path }], + }); + } + await connectionStore.loadDatabases(node.connectionId, { force: true }); + connectionStore.selectedTreeNodeId = `${node.connectionId}:${name}`; + toast(t("contextMenu.createDuckDbFileSuccess", { name }), 3000); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + async function confirmCreateDatabase() { const node = props.node; const name = createDatabaseName.value.trim(); @@ -1691,8 +1756,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr {{ t("sqlFile.title") }} - - {{ t("contextMenu.createDatabase") }} + + + {{ isDuckDbConnection ? t("contextMenu.createDuckDbFile") : t("contextMenu.createDatabase") }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9fa4e95d9..253d91f04 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -695,11 +695,14 @@ export default { duplicateNameTitle: "Duplicate Structure", duplicateNamePlaceholder: "New table name", createDatabase: "Create Database", + createDuckDbFile: "Create Database File", dropDatabase: "Drop Database", confirmDropDatabaseTitle: "Drop Database", confirmDropDatabaseMessage: 'Are you sure you want to drop database "{name}"? This will permanently delete the database and all its data.', createDatabaseSuccess: 'Database "{name}" created', + createDuckDbFileSuccess: 'DuckDB database file "{name}" created and attached', + createDuckDbFileDesktopOnly: "Creating DuckDB database files is only available in the desktop app", dropDatabaseSuccess: 'Database "{name}" dropped', createDatabaseNamePlaceholder: "Database name", createDatabaseCharset: "Character set", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5f33c6d0d..6bf2042e5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -604,11 +604,14 @@ export default { duplicateNameTitle: "Duplicar estructura", duplicateNamePlaceholder: "Nombre de la nueva tabla", createDatabase: "Crear base de datos", + createDuckDbFile: "Crear archivo de base de datos", dropDatabase: "Eliminar base de datos", confirmDropDatabaseTitle: "Eliminar base de datos", confirmDropDatabaseMessage: '¿Estás seguro de que deseas eliminar la base de datos "{name}"? Esto borrará permanentemente la base de datos y todos sus datos.', createDatabaseSuccess: 'Base de datos "{name}" creada', + createDuckDbFileSuccess: 'Archivo de base de datos DuckDB "{name}" creado y adjuntado', + createDuckDbFileDesktopOnly: "Crear archivos de base de datos DuckDB solo está disponible en la app de escritorio", dropDatabaseSuccess: 'Base de datos "{name}" eliminada', createDatabaseNamePlaceholder: "Nombre de la base de datos", createSchema: "Crear esquema", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 68377c751..d408649a3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -678,10 +678,13 @@ export default { duplicateNameTitle: "复制表结构", duplicateNamePlaceholder: "新表名", createDatabase: "新建数据库", + createDuckDbFile: "新建数据库文件", dropDatabase: "删除数据库", confirmDropDatabaseTitle: "删除数据库", confirmDropDatabaseMessage: "确定要删除数据库「{name}」吗?这将永久删除该数据库及其所有数据。", createDatabaseSuccess: "数据库「{name}」已创建", + createDuckDbFileSuccess: "DuckDB 数据库文件「{name}」已创建并附加", + createDuckDbFileDesktopOnly: "新建 DuckDB 数据库文件仅支持桌面端", dropDatabaseSuccess: "数据库「{name}」已删除", createDatabaseNamePlaceholder: "数据库名称", createDatabaseCharset: "字符集", diff --git a/apps/desktop/src/lib/createDatabaseSql.ts b/apps/desktop/src/lib/createDatabaseSql.ts index 720744221..1cba4f1e2 100644 --- a/apps/desktop/src/lib/createDatabaseSql.ts +++ b/apps/desktop/src/lib/createDatabaseSql.ts @@ -38,6 +38,34 @@ export function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): strin return `CREATE DATABASE ${name} CHARACTER SET ${charset}${collateClause};`; } +export function buildDuckDbAttachDatabaseSql(path: string, name: string): string { + return `ATTACH ${quoteSqlString(path)} AS ${quoteTableIdentifier("duckdb", name)};`; +} + +export function duckDbAttachedDatabaseNameFromPath(path: string): string { + const fileName = path.split(/[\\/]/).pop() ?? ""; + const withoutExtension = fileName.replace(/\.[^.\\/]+$/, ""); + const normalized = withoutExtension + .trim() + .replace(/[^\p{L}\p{N}_]+/gu, "_") + .replace(/^_+|_+$/g, ""); + return normalized || "duckdb_database"; +} + +export function uniqueDuckDbAttachedDatabaseName(baseName: string, existingNames: string[]): string { + const existing = new Set(existingNames.map((name) => name.toLowerCase())); + if (!existing.has(baseName.toLowerCase())) return baseName; + for (let index = 2; index < Number.MAX_SAFE_INTEGER; index++) { + const candidate = `${baseName}_${index}`; + if (!existing.has(candidate.toLowerCase())) return candidate; + } + return `${baseName}_${Date.now()}`; +} + function cleanSqlOption(value: string | undefined): string { return value?.trim().replace(/[;\s]+/g, "") ?? ""; } + +function quoteSqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} diff --git a/apps/desktop/src/lib/databaseCapabilitySets.ts b/apps/desktop/src/lib/databaseCapabilitySets.ts index fea45cc9e..36e82d2d7 100644 --- a/apps/desktop/src/lib/databaseCapabilitySets.ts +++ b/apps/desktop/src/lib/databaseCapabilitySets.ts @@ -88,7 +88,13 @@ export const TABLE_IMPORT_SUPPORTED_TYPES = new Set([ "access", ]); -export const TABLE_STRUCTURE_SUPPORTED_TYPES = new Set(["mysql", "postgres", "sqlite", "sqlserver"]); +export const TABLE_STRUCTURE_SUPPORTED_TYPES = new Set([ + "mysql", + "postgres", + "sqlite", + "duckdb", + "sqlserver", +]); export const CREATE_DATABASE_SUPPORTED_TYPES = new Set([ "mysql", diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 6f47a17b1..3505442eb 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -232,6 +232,9 @@ export const useConnectionStore = defineStore("connection", () => { driver_profile: profile, driver_label: config.driver_label || labelMap[profile] || config.db_type, url_params: config.url_params || "", + attached_databases: Array.isArray(config.attached_databases) + ? config.attached_databases.filter((database) => database.name?.trim() && database.path?.trim()) + : [], ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5, proxy_type: config.proxy_type || "socks5", proxy_port: config.proxy_port || 1080, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index fe3fda9c8..249370896 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -46,6 +46,7 @@ export interface ConnectionConfig { password: string; database?: string; visible_databases?: string[]; + attached_databases?: AttachedDatabaseConfig[]; color?: string; ssh_enabled?: boolean; ssh_host?: string; @@ -69,6 +70,11 @@ export interface ConnectionConfig { jdbc_driver_paths?: string[]; } +export interface AttachedDatabaseConfig { + name: string; + path: string; +} + export interface PluginDriverManifest { id: string; label: string; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index b47c4272f..74b099365 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -169,6 +169,12 @@ impl AppState { } DatabaseType::DuckDb => { let con = db::duckdb_driver::connect_path(&expand_tilde(&db_config.host))?; + { + let locked = con.lock().map_err(|e| e.to_string())?; + for attached in &db_config.attached_databases { + crate::schema::duckdb_attach_database(&locked, &attached.name, &expand_tilde(&attached.path))?; + } + } PoolKind::DuckDb(con) } DatabaseType::MongoDb => { @@ -489,6 +495,7 @@ mod tests { password: "secret".to_string(), database: database.map(str::to_string), visible_databases: None, + attached_databases: Vec::new(), color: None, ssh_enabled: false, ssh_host: String::new(), diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index 35b56759b..9c193d4b6 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -289,6 +289,7 @@ mod tests { password: password.to_string(), database: Some("postgres".to_string()), visible_databases: None, + attached_databases: Vec::new(), color: None, ssh_enabled: !ssh_password.is_empty(), ssh_host: String::new(), diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 112e881d4..4db3524de 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -19,6 +19,8 @@ pub struct ConnectionConfig { pub database: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub visible_databases: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attached_databases: Vec, #[serde(default)] pub color: Option, #[serde(default)] @@ -66,6 +68,12 @@ pub struct ConnectionConfig { pub jdbc_driver_paths: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AttachedDatabaseConfig { + pub name: String, + pub path: String, +} + fn default_ssh_port() -> u16 { 22 } @@ -578,6 +586,7 @@ mod tests { password: password.to_string(), database: database.map(str::to_string), visible_databases: None, + attached_databases: Vec::new(), color: None, ssh_enabled: false, ssh_host: String::new(), @@ -670,6 +679,29 @@ mod tests { assert_eq!(saved["visible_databases"], serde_json::json!(["app", "billing"])); } + #[test] + fn duckdb_attached_databases_round_trip_through_connection_config() { + let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "id", + "name": "DuckDB", + "db_type": "duckdb", + "host": "/tmp/main.duckdb", + "port": 0, + "username": "", + "password": "", + "database": null, + "attached_databases": [{ "name": "analytics", "path": "/tmp/analytics.duckdb" }] + })) + .unwrap(); + + let saved = serde_json::to_value(config).unwrap(); + + assert_eq!( + saved["attached_databases"], + serde_json::json!([{ "name": "analytics", "path": "/tmp/analytics.duckdb" }]) + ); + } + #[test] fn ssh_connect_timeout_zero_uses_default() { let mut config = mysql_config("root", "", None); diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index d4781fff1..e2b05f58a 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -79,6 +79,27 @@ pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result, + sql: &str, +) -> Result { + if let Some(database) = database.map(str::trim).filter(|database| !database.is_empty()) { + let catalog = if database == "main" { + crate::schema::duckdb_primary_catalog(con, attached_names)? + } else { + database.to_string() + }; + con.execute_batch(&format!("USE {}", duckdb_quote_ident(&catalog))).map_err(|e| e.to_string())?; + } + duckdb_execute(con, sql) +} + +fn duckdb_quote_ident(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + pub fn truncate_result(mut result: db::QueryResult) -> db::QueryResult { if result.rows.len() > MAX_ROWS { result.rows.truncate(MAX_ROWS); @@ -200,11 +221,19 @@ where pub async fn do_execute( state: &AppState, pool_key: &str, + database: Option<&str>, sql: &str, schema: Option<&str>, cancel_token: Option, options: QueryExecutionOptions, ) -> Result { + let duckdb_attached_names = state + .configs + .read() + .await + .get(pool_key) + .map(|config| config.attached_databases.iter().map(|database| database.name.clone()).collect::>()) + .unwrap_or_default(); let connections = state.connections.read().await; let pool = connections.get(pool_key).ok_or("Connection not found")?; @@ -212,11 +241,13 @@ pub async fn do_execute( PoolKind::DuckDb(con) => { let con = con.clone(); let sql = sql.to_string(); + let database = database.map(str::to_string); + let attached_names = duckdb_attached_names; drop(connections); wait_for_query(cancel_token, async move { let task = tokio::task::spawn_blocking(move || { let con = con.lock().map_err(|e| e.to_string())?; - duckdb_execute(&con, &sql) + duckdb_execute_for_database(&con, &attached_names, database.as_deref(), &sql) }); task.await.map_err(|e| e.to_string())? }) @@ -381,13 +412,13 @@ pub async fn execute_sql_statement_with_options( return Err(canceled_error()); } - let result = do_execute(state, &pool_key, sql, schema, cancel_token.clone(), options.clone()).await; + let result = do_execute(state, &pool_key, Some(database), sql, schema, cancel_token.clone(), options.clone()).await; match &result { Err(e) if is_connection_error(e) && !is_canceled(&cancel_token) => { let db_opt = if database.is_empty() { None } else { Some(database) }; let new_key = state.reconnect_pool(connection_id, db_opt).await?; - do_execute(state, &new_key, sql, schema, cancel_token, options).await + do_execute(state, &new_key, Some(database), sql, schema, cancel_token, options).await } _ => result, } @@ -599,7 +630,7 @@ pub async fn execute_statements( let start = std::time::Instant::now(); for (i, sql) in statements.iter().enumerate() { - match do_execute(state, &pool_key, sql, schema, None, QueryExecutionOptions::default()).await { + match do_execute(state, &pool_key, Some(database), sql, schema, None, QueryExecutionOptions::default()).await { Ok(result) => { total_affected += result.affected_rows; } @@ -824,19 +855,19 @@ async fn exec_tx_explicit_inner( } drop(conns); - do_execute(state, pool_key, "BEGIN", schema, None, QueryExecutionOptions::default()) + do_execute(state, pool_key, None, "BEGIN", schema, None, QueryExecutionOptions::default()) .await .map_err(|e| format!("Failed to begin transaction: {}", e))?; let mut total_affected: u64 = 0; for (i, sql) in statements.iter().enumerate() { - match do_execute(state, pool_key, sql, schema, None, QueryExecutionOptions::default()).await { + match do_execute(state, pool_key, None, sql, schema, None, QueryExecutionOptions::default()).await { Ok(result) => { total_affected += result.affected_rows; } Err(e) => { if let Err(rb_err) = - do_execute(state, pool_key, "ROLLBACK", schema, None, QueryExecutionOptions::default()).await + do_execute(state, pool_key, None, "ROLLBACK", schema, None, QueryExecutionOptions::default()).await { log::error!("ROLLBACK failed after statement {} error: {}", i + 1, rb_err); } @@ -845,7 +876,7 @@ async fn exec_tx_explicit_inner( } } - do_execute(state, pool_key, "COMMIT", schema, None, QueryExecutionOptions::default()) + do_execute(state, pool_key, None, "COMMIT", schema, None, QueryExecutionOptions::default()) .await .map_err(|e| format!("COMMIT failed: {}", e))?; @@ -870,7 +901,7 @@ async fn exec_tx_none_inner( let mut total_affected: u64 = 0; for (i, sql) in statements.iter().enumerate() { log::info!("[query][tx-none:statement:start] index={} sql={}", i + 1, sql); - match do_execute(state, pool_key, sql, schema, None, QueryExecutionOptions::default()).await { + match do_execute(state, pool_key, None, sql, schema, None, QueryExecutionOptions::default()).await { Ok(result) => { total_affected += result.affected_rows; log::info!("[query][tx-none:statement:done] index={} affected_rows={}", i + 1, result.affected_rows); @@ -991,6 +1022,7 @@ mod tests { password: String::new(), database: None, visible_databases: None, + attached_databases: Vec::new(), color: None, ssh_enabled: false, ssh_host: String::new(), diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 7d79be4f7..2ef69f141 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -6,18 +6,113 @@ use crate::db; use crate::models::connection::DatabaseType; pub fn duckdb_query_tables(con: &duckdb::Connection) -> Result, String> { + duckdb_query_tables_in_database(con, "main") +} + +pub fn duckdb_query_tables_in_database(con: &duckdb::Connection, database: &str) -> Result, String> { + duckdb_query_tables_in_database_with_attached(con, database, &[]) +} + +pub fn duckdb_query_tables_in_database_with_attached( + con: &duckdb::Connection, + database: &str, + attached_names: &[String], +) -> Result, String> { + let database = duckdb_catalog_name(con, database, attached_names)?; let mut stmt = con.prepare( - "SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name" + "SELECT table_name, table_type FROM information_schema.tables WHERE table_catalog = ? AND table_schema = 'main' ORDER BY table_name" ).map_err(|e| e.to_string())?; let rows = stmt - .query_map([], |row| { + .query_map([database.as_str()], |row| { Ok(db::TableInfo { name: row.get::<_, String>(0)?, table_type: row.get::<_, String>(1)?, comment: None }) }) .map_err(|e| e.to_string())?; Ok(rows.filter_map(|r| r.ok()).collect()) } +pub fn duckdb_attach_database(con: &duckdb::Connection, name: &str, path: &str) -> Result<(), String> { + let name = name.trim(); + let path = path.trim(); + if name.is_empty() || path.is_empty() { + return Err("DuckDB attached database name and path are required".to_string()); + } + let sql = format!("ATTACH {} AS {}", duckdb_quote_string(path), duckdb_quote_ident(name)); + con.execute_batch(&sql).map_err(|e| e.to_string()) +} + +pub fn duckdb_list_databases(con: &duckdb::Connection) -> Result, String> { + duckdb_list_databases_with_attached(con, &[]) +} + +pub fn duckdb_list_databases_with_attached( + con: &duckdb::Connection, + attached_names: &[String], +) -> Result, String> { + let primary = duckdb_primary_catalog(con, attached_names)?; + let mut stmt = con.prepare("SHOW DATABASES").map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| { + let name = row.get::<_, String>(0)?; + Ok(db::DatabaseInfo { name: if name == primary { "main".to_string() } else { name } }) + }) + .map_err(|e| e.to_string())?; + Ok(rows.filter_map(|row| row.ok()).collect()) +} + +fn duckdb_catalog_name(con: &duckdb::Connection, database: &str, attached_names: &[String]) -> Result { + if database.trim().is_empty() || database == "main" { + return duckdb_primary_catalog(con, attached_names); + } + Ok(database.to_string()) +} + +pub fn duckdb_primary_catalog(con: &duckdb::Connection, attached_names: &[String]) -> Result { + if attached_names.is_empty() { + return duckdb_current_database(con); + } + let attached: std::collections::HashSet = attached_names.iter().map(|name| name.to_lowercase()).collect(); + let mut stmt = con.prepare("SHOW DATABASES").map_err(|e| e.to_string())?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|e| e.to_string())?; + for row in rows { + let name = row.map_err(|e| e.to_string())?; + if !attached.contains(&name.to_lowercase()) { + return Ok(name); + } + } + duckdb_current_database(con) +} + +fn duckdb_current_database(con: &duckdb::Connection) -> Result { + con.query_row("SELECT current_database()", [], |row| row.get::<_, String>(0)).map_err(|e| e.to_string()) +} + +fn duckdb_quote_ident(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn duckdb_quote_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + pub fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result, String> { + duckdb_query_columns_in_database(con, "main", table) +} + +pub fn duckdb_query_columns_in_database( + con: &duckdb::Connection, + database: &str, + table: &str, +) -> Result, String> { + duckdb_query_columns_in_database_with_attached(con, database, table, &[]) +} + +pub fn duckdb_query_columns_in_database_with_attached( + con: &duckdb::Connection, + database: &str, + table: &str, + attached_names: &[String], +) -> Result, String> { + let database = duckdb_catalog_name(con, database, attached_names)?; let mut pk_stmt = con .prepare( "SELECT kcu.column_name @@ -27,24 +122,26 @@ pub fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result(0)).map_err(|e| e.to_string())?; + let pk_rows = + pk_stmt.query_map([database.as_str(), table], |row| row.get::<_, String>(0)).map_err(|e| e.to_string())?; let primary_keys: std::collections::HashSet = pk_rows.filter_map(|r| r.ok()).collect(); let mut stmt = con .prepare( "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns - WHERE table_schema = 'main' AND table_name = ? + WHERE table_catalog = ? AND table_schema = 'main' AND table_name = ? ORDER BY ordinal_position", ) .map_err(|e| e.to_string())?; let rows = stmt - .query_map([table], |row| { + .query_map([database.as_str(), table], |row| { let name = row.get::<_, String>(0)?; Ok(db::ColumnInfo { is_primary_key: primary_keys.contains(&name), @@ -113,6 +210,16 @@ pub fn extract_agent( } } +async fn duckdb_attached_database_names(state: &AppState, connection_id: &str) -> Vec { + state + .configs + .read() + .await + .get(connection_id) + .map(|config| config.attached_databases.iter().map(|database| database.name.clone()).collect()) + .unwrap_or_default() +} + pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Result, String> { log::info!("[list_databases] connection_id={connection_id}"); { @@ -151,6 +258,7 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul } } + let duckdb_attached_names = duckdb_attached_database_names(state, connection_id).await; let connections = state.connections.read().await; let pool = connections.get(connection_id).ok_or("Connection not found")?; @@ -164,7 +272,10 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul } PoolKind::Postgres(p) => db::postgres::list_databases(p).await, PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await, - PoolKind::DuckDb(_) => Ok(vec![db::DatabaseInfo { name: "main".to_string() }]), + PoolKind::DuckDb(con) => { + let con = con.lock().map_err(|e| e.to_string())?; + duckdb_list_databases_with_attached(&con, &duckdb_attached_names) + } _ => Ok(vec![]), } } @@ -212,6 +323,7 @@ pub async fn list_tables_core( limit: Option, ) -> Result, String> { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + let duckdb_attached_names = duckdb_attached_database_names(state, connection_id).await; { let connections = state.connections.read().await; @@ -239,7 +351,7 @@ pub async fn list_tables_core( if let Some(con) = extract_duckdb(&connections, &pool_key) { drop(connections); let con = con.lock().map_err(|e| e.to_string())?; - return duckdb_query_tables(&con); + return duckdb_query_tables_in_database_with_attached(&con, database, &duckdb_attached_names); } if let Some(client) = extract_clickhouse(&connections, &pool_key) { drop(connections); @@ -288,6 +400,49 @@ fn filter_table_infos(tables: Vec, filter: Option<&str>, limit: O .collect() } +#[cfg(test)] +mod tests { + use super::{duckdb_attach_database, duckdb_list_databases, duckdb_query_tables_in_database}; + + #[test] + fn duckdb_list_databases_includes_attached_database() { + let unique = uuid::Uuid::new_v4(); + let path = std::env::temp_dir().join(format!("dbx-attached-{unique}.duckdb")); + let _ = std::fs::remove_file(&path); + let con = duckdb::Connection::open_in_memory().unwrap(); + + duckdb_attach_database(&con, "analytics", path.to_str().unwrap()).unwrap(); + let databases = duckdb_list_databases(&con).unwrap(); + + assert!(databases.iter().any(|database| database.name == "main")); + assert!(databases.iter().any(|database| database.name == "analytics")); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn duckdb_query_tables_filters_by_attached_database() { + let unique = uuid::Uuid::new_v4(); + let path = std::env::temp_dir().join(format!("dbx-attached-tables-{unique}.duckdb")); + let _ = std::fs::remove_file(&path); + let con = duckdb::Connection::open_in_memory().unwrap(); + + con.execute_batch("CREATE TABLE main_table(id INTEGER);").unwrap(); + duckdb_attach_database(&con, "analytics", path.to_str().unwrap()).unwrap(); + con.execute_batch("CREATE TABLE analytics.attached_table(id INTEGER);").unwrap(); + + let main_tables = duckdb_query_tables_in_database(&con, "main").unwrap(); + let attached_tables = duckdb_query_tables_in_database(&con, "analytics").unwrap(); + + assert!(main_tables.iter().any(|table| table.name == "main_table")); + assert!(!main_tables.iter().any(|table| table.name == "attached_table")); + assert!(attached_tables.iter().any(|table| table.name == "attached_table")); + assert!(!attached_tables.iter().any(|table| table.name == "main_table")); + + let _ = std::fs::remove_file(path); + } +} + pub async fn list_objects_core( state: &AppState, connection_id: &str, @@ -375,6 +530,7 @@ pub async fn get_columns_core( table: &str, ) -> Result, String> { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + let duckdb_attached_names = duckdb_attached_database_names(state, connection_id).await; { let connections = state.connections.read().await; @@ -408,7 +564,7 @@ pub async fn get_columns_core( if let Some(con) = extract_duckdb(&connections, &pool_key) { drop(connections); let con = con.lock().map_err(|e| e.to_string())?; - return duckdb_query_columns(&con, table); + return duckdb_query_columns_in_database_with_attached(&con, database, table, &duckdb_attached_names); } if let Some(client) = extract_clickhouse(&connections, &pool_key) { drop(connections); diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 389bacf14..dc8dd1a7e 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -142,6 +142,7 @@ mod tests { password: String::new(), database: None, visible_databases: None, + attached_databases: Vec::new(), color: None, ssh_enabled: false, ssh_host: String::new(), diff --git a/packages/app-tests/createDatabaseSql.test.ts b/packages/app-tests/createDatabaseSql.test.ts index e2c855aea..c1ba1cc19 100644 --- a/packages/app-tests/createDatabaseSql.test.ts +++ b/packages/app-tests/createDatabaseSql.test.ts @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildCreateDatabaseSql, supportsCreateDatabaseCharset } from "../../apps/desktop/src/lib/createDatabaseSql.ts"; +import { + buildCreateDatabaseSql, + buildDuckDbAttachDatabaseSql, + duckDbAttachedDatabaseNameFromPath, + uniqueDuckDbAttachedDatabaseName, + supportsCreateDatabaseCharset, +} from "../../apps/desktop/src/lib/createDatabaseSql.ts"; test("builds MySQL create database SQL with charset and collation", () => { assert.equal( @@ -32,3 +38,21 @@ test("recognizes MySQL-compatible driver profiles", () => { assert.equal(supportsCreateDatabaseCharset("mysql", "doris"), true); assert.equal(supportsCreateDatabaseCharset("postgres", undefined), false); }); + +test("builds DuckDB attach SQL with escaped path and alias", () => { + assert.equal( + buildDuckDbAttachDatabaseSql("/Users/me/O'Reilly analytics.duckdb", "report db"), + `ATTACH '/Users/me/O''Reilly analytics.duckdb' AS "report db";`, + ); +}); + +test("derives a stable DuckDB attached database name from a file path", () => { + assert.equal(duckDbAttachedDatabaseNameFromPath("/Users/me/sales.duckdb"), "sales"); + assert.equal(duckDbAttachedDatabaseNameFromPath("C:\\data\\2026 report.db"), "2026_report"); + assert.equal(duckDbAttachedDatabaseNameFromPath("/tmp/.duckdb"), "duckdb_database"); +}); + +test("deduplicates DuckDB attached database aliases", () => { + assert.equal(uniqueDuckDbAttachedDatabaseName("analytics", ["main", "analytics"]), "analytics_2"); + assert.equal(uniqueDuckDbAttachedDatabaseName("analytics", ["analytics", "analytics_2"]), "analytics_3"); +}); diff --git a/packages/app-tests/databaseCapabilities.test.ts b/packages/app-tests/databaseCapabilities.test.ts index 528874909..e2a4fe56a 100644 --- a/packages/app-tests/databaseCapabilities.test.ts +++ b/packages/app-tests/databaseCapabilities.test.ts @@ -96,6 +96,7 @@ test("describes feature support through capability helpers", () => { assert.equal(supportsTableImport("duckdb"), true); assert.equal(supportsTableImport("hive"), false); assert.equal(supportsTableStructureEditing("postgres"), true); + assert.equal(supportsTableStructureEditing("duckdb"), true); assert.equal(supportsTableStructureEditing("oracle"), false); assert.equal(supportsDatabaseCreation("clickhouse"), true); assert.equal(supportsDatabaseCreation("sqlite"), false); diff --git a/packages/app-tests/tableStructureEditorSql.test.ts b/packages/app-tests/tableStructureEditorSql.test.ts index f35b36c4d..ea691e91b 100644 --- a/packages/app-tests/tableStructureEditorSql.test.ts +++ b/packages/app-tests/tableStructureEditorSql.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildCreateTableSql, buildTableStructureChangeSql, type EditableStructureColumn, type EditableStructureIndex, @@ -186,6 +187,24 @@ test("quotes SQL Server table, column, and index names with brackets", () => { ]); }); +test("builds DuckDB create table statements", () => { + const result = buildCreateTableSql({ + databaseType: "duckdb", + tableName: "events", + columns: [ + column({ id: "name", name: "name", dataType: "VARCHAR", isNullable: false }), + column({ id: "created_at", name: "created_at", dataType: "TIMESTAMP", defaultValue: "current_timestamp" }), + ], + indexes: [index({ id: "idx_name", name: "idx_events_name", columns: ["name"] })], + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual(result.statements, [ + 'CREATE TABLE "events" (\n "name" VARCHAR NOT NULL,\n "created_at" TIMESTAMP DEFAULT current_timestamp\n);', + 'CREATE INDEX "idx_events_name" ON "events" ("name");', + ]); +}); + test("PostgreSQL index with INCLUDE clause", () => { const result = buildTableStructureChangeSql({ databaseType: "postgres", diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 30abe65db..f967acb07 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -193,6 +193,9 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi } DatabaseType::DuckDb => { let con = duckdb::Connection::open(&expand_tilde(&db_config.host)).map_err(|e| e.to_string())?; + for attached in &db_config.attached_databases { + dbx_core::schema::duckdb_attach_database(&con, &attached.name, &expand_tilde(&attached.path))?; + } PoolKind::DuckDb(std::sync::Arc::new(std::sync::Mutex::new(con))) } DatabaseType::MongoDb => {