diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue
index f880346612..8133c69ad5 100644
--- a/apps/desktop/src/components/connection/ConnectionDialog.vue
+++ b/apps/desktop/src/components/connection/ConnectionDialog.vue
@@ -684,6 +684,12 @@ const filePathPlaceholder = computed(() => {
return "/path/to/database.db or :memory:";
});
const supportsMemoryDatabasePath = computed(() => form.value.db_type === "sqlite" || form.value.db_type === "duckdb");
+const sqliteExtensionPaths = computed({
+ get: () => sqliteExtensionPathsFromParams(form.value.url_params),
+ set: (value: string) => {
+ form.value.url_params = setSqliteExtensionPaths(form.value.url_params, value);
+ },
+});
const tlsCapableDatabaseTypes = new Set([
"mysql",
"postgres",
@@ -956,6 +962,29 @@ function getUrlParam(params: string | undefined, key: string): string {
return parsed.get(key) || "";
}
+function sqliteExtensionPathsFromParams(params: string | undefined): string {
+ const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
+ return [
+ ...parsed.getAll("sqlite_extension"),
+ ...parsed.getAll("sqlite_extensions").flatMap((value) => value.split(/\r?\n/)),
+ ]
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .join("\n");
+}
+
+function setSqliteExtensionPaths(params: string | undefined, paths: string): string {
+ const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
+ parsed.delete("sqlite_extension");
+ parsed.delete("sqlite_extensions");
+ paths
+ .split(/\r?\n/)
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .forEach((value) => parsed.append("sqlite_extension", value));
+ return parsed.toString();
+}
+
function setUrlParam(params: string | undefined, key: string, value: string): string {
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
const normalized = value.trim();
@@ -1462,6 +1491,32 @@ async function browseDbFilePath() {
}
}
+async function browseSqliteExtensionPath() {
+ if (isTauriRuntime()) {
+ const { open } = await import("@tauri-apps/plugin-dialog");
+ const selected = await open({
+ title: t("connection.sqliteExtensionBrowse"),
+ multiple: true,
+ filters: [
+ { name: "SQLite Extension", extensions: ["dylib", "so", "dll"] },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ });
+ const selectedPaths = Array.isArray(selected)
+ ? selected
+ : selected && typeof selected === "string"
+ ? [selected]
+ : [];
+ if (selectedPaths.length) {
+ const existing = sqliteExtensionPaths.value
+ .split(/\r?\n/)
+ .map((path) => path.trim())
+ .filter(Boolean);
+ sqliteExtensionPaths.value = [...existing, ...selectedPaths].join("\n");
+ }
+ }
+}
+
function ensureDuckDbFileExtension(path: string): string {
return /\.(duckdb|db)$/i.test(path) ? path : `${path}.duckdb`;
}
@@ -1890,6 +1945,35 @@ function openExternalUrl(url: string) {
+
+
+
+
+
+
+
+
+
+ {{ t("connection.sqliteExtensionBrowse") }}
+
+
+
+ {{ t("connection.sqliteExtensionsHint") }}
+
+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 0b878ebd5c..c0d94d8cb2 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -111,6 +111,11 @@ export default {
filePath: "File Path",
createDuckDbFile: "Create DuckDB file",
memoryDatabasePathHint: "Use :memory: to create an in-memory SQLite or DuckDB database.",
+ sqliteExtensions: "SQLite Extensions",
+ sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
+ sqliteExtensionsHint:
+ "One .dylib/.so/.dll per line. Use “path|entry point” when an entry point is required. Loaded on connect, then load_extension is disabled.",
+ sqliteExtensionBrowse: "Select SQLite extension",
user: "User",
password: "Password",
database: "Database",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 5a38841028..553193e90d 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -110,6 +110,11 @@ export default {
filePath: "文件路径",
createDuckDbFile: "新建 DuckDB 文件",
memoryDatabasePathHint: "输入 :memory: 可创建 SQLite 或 DuckDB 内存数据库。",
+ sqliteExtensions: "SQLite 扩展库",
+ sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
+ sqliteExtensionsHint:
+ "每行一个 .dylib/.so/.dll;需要入口函数时使用“路径|入口函数”。连接时加载,加载后会关闭 load_extension。",
+ sqliteExtensionBrowse: "选择 SQLite 扩展库",
user: "用户名",
password: "密码",
database: "数据库",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 1007c76c6b..b6a5cad2d6 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -110,6 +110,11 @@ export default {
filePath: "檔案路徑",
createDuckDbFile: "建立 DuckDB 檔案",
memoryDatabasePathHint: "輸入 :memory: 可建立 SQLite 或 DuckDB 記憶體資料庫。",
+ sqliteExtensions: "SQLite 擴充庫",
+ sqliteExtensionsPlaceholder: "/path/to/regexp.dylib\n/path/to/text.dylib|sqlite3_text_init",
+ sqliteExtensionsHint:
+ "每行一個 .dylib/.so/.dll;需要入口函式時使用「路徑|入口函式」。連線時載入,載入後會關閉 load_extension。",
+ sqliteExtensionBrowse: "選擇 SQLite 擴充庫",
user: "使用者名稱",
password: "密碼",
database: "資料庫",
diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml
index a7225d96dd..029f0204a6 100644
--- a/crates/dbx-core/Cargo.toml
+++ b/crates/dbx-core/Cargo.toml
@@ -24,7 +24,7 @@ tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1"
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
tokio-postgres-rustls = "0.13"
webpki-roots = "0.26"
-rusqlite = { version = "0.32", features = ["bundled"] }
+rusqlite = { version = "0.32", features = ["bundled", "load_extension"] }
mysql_async = { version = "0.36", default-features = false, features = ["default-rustls", "client_ed25519", "chrono", "rust_decimal"] }
sqlparser = "0.62.0"
redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp", "sentinel", "cluster-async"] }
diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs
index c4bdfa2c96..d748aa1cd8 100644
--- a/crates/dbx-core/src/connection.rs
+++ b/crates/dbx-core/src/connection.rs
@@ -215,7 +215,18 @@ impl AppState {
DatabaseType::Postgres | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::OpenGauss => {
PoolKind::Postgres(db::postgres::connect(&url, connect_timeout).await?)
}
- DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect_path(&expand_tilde(&db_config.host)).await?),
+ DatabaseType::Sqlite => {
+ let extensions = db::sqlite::sqlite_extension_specs_from_url_params(db_config.url_params.as_deref())
+ .into_iter()
+ .map(|mut extension| {
+ extension.path = expand_tilde(&extension.path);
+ extension
+ })
+ .collect();
+ PoolKind::Sqlite(
+ db::sqlite::connect_path_with_extensions(&expand_tilde(&db_config.host), extensions).await?,
+ )
+ }
DatabaseType::Redis => {
let con = if db_config.uses_redis_cluster() {
db::redis_driver::RedisConnection::Cluster(db::redis_driver::connect_cluster(&db_config).await?)
diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs
index 57b97c5ab3..f81fe172c5 100644
--- a/crates/dbx-core/src/db/sqlite.rs
+++ b/crates/dbx-core/src/db/sqlite.rs
@@ -1,5 +1,6 @@
+use percent_encoding::percent_decode_str;
use rusqlite::types::ValueRef;
-use rusqlite::{Connection, OpenFlags};
+use rusqlite::{Connection, LoadExtensionGuard, OpenFlags};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Instant;
@@ -13,6 +14,12 @@ pub struct SqliteHandle {
conn: Arc>,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SqliteExtensionSpec {
+ pub path: String,
+ pub entry_point: Option,
+}
+
impl SqliteHandle {
pub fn with_connection(&self, f: F) -> Result
where
@@ -24,21 +31,36 @@ impl SqliteHandle {
}
pub async fn connect_path(path: &str) -> Result {
- connect_path_with_options(path, false).await
+ connect_path_with_options(path, false, Vec::new()).await
+}
+
+pub async fn connect_path_with_extensions(
+ path: &str,
+ extensions: Vec,
+) -> Result {
+ connect_path_with_options(path, false, extensions).await
}
pub async fn connect_path_create_if_missing(path: &str) -> Result {
- connect_path_with_options(path, true).await
+ connect_path_with_options(path, true, Vec::new()).await
}
-async fn connect_path_with_options(path: &str, create_if_missing: bool) -> Result {
+async fn connect_path_with_options(
+ path: &str,
+ create_if_missing: bool,
+ extensions: Vec,
+) -> Result {
let path = path.to_string();
- tokio::task::spawn_blocking(move || open_sqlite_handle(&path, create_if_missing))
+ tokio::task::spawn_blocking(move || open_sqlite_handle(&path, create_if_missing, extensions))
.await
.map_err(|e| e.to_string())?
}
-fn open_sqlite_handle(path: &str, create_if_missing: bool) -> Result {
+fn open_sqlite_handle(
+ path: &str,
+ create_if_missing: bool,
+ extensions: Vec,
+) -> Result {
let is_memory = is_memory_database_path(path);
if !is_memory && !create_if_missing {
validate_file_path(path, is_network_path)?;
@@ -65,10 +87,62 @@ fn open_sqlite_handle(path: &str, create_if_missing: bool) -> Result Result<(), String> {
+ if extensions.is_empty() {
+ return Ok(());
+ }
+
+ // Extension loading is enabled only for the trusted paths from the connection config.
+ let _guard =
+ unsafe { LoadExtensionGuard::new(conn) }.map_err(|e| format!("SQLite extension loading failed: {e}"))?;
+ for extension in extensions {
+ unsafe { conn.load_extension(&extension.path, extension.entry_point.as_deref()) }
+ .map_err(|e| format!("SQLite extension load failed ({}): {e}", extension.path))?;
+ }
+ Ok(())
+}
+
+pub fn sqlite_extension_specs_from_url_params(params: Option<&str>) -> Vec {
+ params
+ .unwrap_or("")
+ .trim()
+ .trim_start_matches('?')
+ .split('&')
+ .filter_map(|part| {
+ let (raw_key, raw_value) = part.split_once('=').unwrap_or((part, ""));
+ let key = decode_url_param(raw_key);
+ if key != "sqlite_extension" && key != "sqlite_extensions" {
+ return None;
+ }
+ Some(decode_url_param(raw_value))
+ })
+ .flat_map(|value| value.lines().filter_map(parse_sqlite_extension_spec).collect::>())
+ .collect()
+}
+
+fn parse_sqlite_extension_spec(value: &str) -> Option {
+ let value = value.trim();
+ if value.is_empty() {
+ return None;
+ }
+ let (path, entry_point) = match value.rsplit_once('|') {
+ Some((path, entry_point)) if !path.trim().is_empty() && !entry_point.trim().is_empty() => {
+ (path.trim(), Some(entry_point.trim().to_string()))
+ }
+ _ => (value, None),
+ };
+ Some(SqliteExtensionSpec { path: path.to_string(), entry_point })
+}
+
+fn decode_url_param(value: &str) -> String {
+ percent_decode_str(&value.replace('+', " ")).decode_utf8_lossy().into_owned()
+}
+
fn ensure_parent_dir(path: &str) -> Result<(), String> {
if let Some(parent) = Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
@@ -103,6 +177,28 @@ mod tests {
assert_eq!(result.rows[0][0], serde_json::json!("Ada"));
}
+ #[test]
+ fn sqlite_extension_specs_parse_repeated_and_multiline_url_params() {
+ let params = "cache=shared&sqlite_extension=%2Fopt%2Fregexp.dylib&sqlite_extensions=%2Fopt%2Ftext.dylib%7Csqlite3_text_init%0A%2Fopt%2Fcrypto.dylib";
+
+ assert_eq!(
+ sqlite_extension_specs_from_url_params(Some(params)),
+ vec![
+ SqliteExtensionSpec { path: "/opt/regexp.dylib".to_string(), entry_point: None },
+ SqliteExtensionSpec {
+ path: "/opt/text.dylib".to_string(),
+ entry_point: Some("sqlite3_text_init".to_string()),
+ },
+ SqliteExtensionSpec { path: "/opt/crypto.dylib".to_string(), entry_point: None },
+ ],
+ );
+ }
+
+ #[test]
+ fn sqlite_extension_specs_ignore_empty_values() {
+ assert!(sqlite_extension_specs_from_url_params(Some("sqlite_extension=&sqlite_extensions=%0A")).is_empty());
+ }
+
#[test]
fn normalize_if_to_iif_basic() {
assert_eq!(normalize_sqlite_sql("SELECT if(1, 'a', 'b')"), "SELECT IIF(1, 'a', 'b')");
diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs
index b14b44660a..5d8e02e563 100644
--- a/src-tauri/src/commands/connection.rs
+++ b/src-tauri/src/commands/connection.rs
@@ -298,10 +298,19 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection
Err(e) => Err(e),
}
}
- DatabaseType::Sqlite => match db::sqlite::connect_path(&expand_tilde(&config.host)).await {
- Ok(_) => Ok("Connection successful".to_string()),
- Err(e) => Err(e),
- },
+ DatabaseType::Sqlite => {
+ let extensions = db::sqlite::sqlite_extension_specs_from_url_params(config.url_params.as_deref())
+ .into_iter()
+ .map(|mut extension| {
+ extension.path = expand_tilde(&extension.path);
+ extension
+ })
+ .collect();
+ match db::sqlite::connect_path_with_extensions(&expand_tilde(&config.host), extensions).await {
+ Ok(_) => Ok("Connection successful".to_string()),
+ Err(e) => Err(e),
+ }
+ }
DatabaseType::Redis => {
let con = if config.uses_redis_cluster() {
db::redis_driver::connect_cluster(&config).await?;
@@ -434,7 +443,18 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi
DatabaseType::Postgres | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::OpenGauss => {
PoolKind::Postgres(db::postgres::connect(&url, connect_timeout).await?)
}
- DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect_path(&expand_tilde(&db_config.host)).await?),
+ DatabaseType::Sqlite => {
+ let extensions = db::sqlite::sqlite_extension_specs_from_url_params(db_config.url_params.as_deref())
+ .into_iter()
+ .map(|mut extension| {
+ extension.path = expand_tilde(&extension.path);
+ extension
+ })
+ .collect();
+ PoolKind::Sqlite(
+ db::sqlite::connect_path_with_extensions(&expand_tilde(&db_config.host), extensions).await?,
+ )
+ }
DatabaseType::Redis => {
let con = if db_config.uses_redis_cluster() {
PoolKind::Redis(db::redis_driver::RedisConnection::Cluster(