feat(grid): support DuckDB database files

This commit is contained in:
t8y2 2026-05-18 13:18:02 +08:00
parent f0a1d39a86
commit e1930550e2
18 changed files with 417 additions and 23 deletions

View File

@ -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
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
<FileCode class="w-4 h-4" /> {{ t("sqlFile.title") }}
</ContextMenuItem>
<ContextMenuItem v-if="canCreateDatabase" @click="openCreateDatabaseDialog">
<Plus class="w-4 h-4" /> {{ t("contextMenu.createDatabase") }}
<ContextMenuItem v-if="canCreateDatabase" @click="openCreateDatabase">
<Plus class="w-4 h-4" />
{{ isDuckDbConnection ? t("contextMenu.createDuckDbFile") : t("contextMenu.createDatabase") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub v-if="availableGroups.length > 0 || currentGroupId">

View File

@ -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",

View File

@ -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",

View File

@ -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: "字符集",

View File

@ -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, "''")}'`;
}

View File

@ -88,7 +88,13 @@ export const TABLE_IMPORT_SUPPORTED_TYPES = new Set<DatabaseType>([
"access",
]);
export const TABLE_STRUCTURE_SUPPORTED_TYPES = new Set<DatabaseType>(["mysql", "postgres", "sqlite", "sqlserver"]);
export const TABLE_STRUCTURE_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"duckdb",
"sqlserver",
]);
export const CREATE_DATABASE_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",

View File

@ -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,

View File

@ -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;

View File

@ -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(),

View File

@ -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(),

View File

@ -19,6 +19,8 @@ pub struct ConnectionConfig {
pub database: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_databases: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attached_databases: Vec<AttachedDatabaseConfig>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
@ -66,6 +68,12 @@ pub struct ConnectionConfig {
pub jdbc_driver_paths: Vec<String>,
}
#[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);

View File

@ -79,6 +79,27 @@ pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result<db::QueryRe
}
}
fn duckdb_execute_for_database(
con: &duckdb::Connection,
attached_names: &[String],
database: Option<&str>,
sql: &str,
) -> Result<db::QueryResult, String> {
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<CancellationToken>,
options: QueryExecutionOptions,
) -> Result<db::QueryResult, String> {
let duckdb_attached_names = state
.configs
.read()
.await
.get(pool_key)
.map(|config| config.attached_databases.iter().map(|database| database.name.clone()).collect::<Vec<_>>())
.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(),

View File

@ -6,18 +6,113 @@ use crate::db;
use crate::models::connection::DatabaseType;
pub fn duckdb_query_tables(con: &duckdb::Connection) -> Result<Vec<db::TableInfo>, String> {
duckdb_query_tables_in_database(con, "main")
}
pub fn duckdb_query_tables_in_database(con: &duckdb::Connection, database: &str) -> Result<Vec<db::TableInfo>, 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<Vec<db::TableInfo>, 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<Vec<db::DatabaseInfo>, String> {
duckdb_list_databases_with_attached(con, &[])
}
pub fn duckdb_list_databases_with_attached(
con: &duckdb::Connection,
attached_names: &[String],
) -> Result<Vec<db::DatabaseInfo>, 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<String, String> {
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<String, String> {
if attached_names.is_empty() {
return duckdb_current_database(con);
}
let attached: std::collections::HashSet<String> = 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<String, String> {
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<Vec<db::ColumnInfo>, String> {
duckdb_query_columns_in_database(con, "main", table)
}
pub fn duckdb_query_columns_in_database(
con: &duckdb::Connection,
database: &str,
table: &str,
) -> Result<Vec<db::ColumnInfo>, 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<Vec<db::ColumnInfo>, 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<Vec
AND tc.table_schema = kcu.table_schema
AND tc.table_name = kcu.table_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_catalog = ?
AND tc.table_schema = 'main'
AND tc.table_name = ?
ORDER BY kcu.ordinal_position",
)
.map_err(|e| e.to_string())?;
let pk_rows = pk_stmt.query_map([table], |row| row.get::<_, String>(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<String> = 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<String> {
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<Vec<db::DatabaseInfo>, 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<usize>,
) -> Result<Vec<db::TableInfo>, 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<db::TableInfo>, 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<Vec<db::ColumnInfo>, 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);

View File

@ -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(),

View File

@ -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");
});

View File

@ -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);

View File

@ -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",

View File

@ -193,6 +193,9 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, 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 => {