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 => {