feat(grid): support DuckDB database files
This commit is contained in:
parent
f0a1d39a86
commit
e1930550e2
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "字符集",
|
||||
|
|
|
|||
|
|
@ -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, "''")}'`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue