feat(sqlite): support custom database file suffixes
This commit is contained in:
parent
25bac0c67a
commit
49705a1a8f
|
|
@ -69,6 +69,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage";
|
|||
import { rankSavedSqlHistory } from "@/lib/savedSqlHistory";
|
||||
import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/databaseFeatureSupport";
|
||||
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { detectDatabaseFileType } from "@/lib/databaseFileDetection";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -624,21 +625,12 @@ async function openPendingSqlFiles() {
|
|||
}
|
||||
}
|
||||
|
||||
const DB_EXTENSIONS = [".db", ".db3", ".sqlite", ".sqlite3", ".duckdb"];
|
||||
|
||||
function getDbTypeFromPath(path: string): "sqlite" | "duckdb" | null {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".duckdb")) return "duckdb";
|
||||
if (DB_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "sqlite";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openDbFilePath(path: string) {
|
||||
if (!isTauriRuntime()) return;
|
||||
await connectionStore.initFromDisk();
|
||||
try {
|
||||
const name = path.split("/").pop()?.split("\\").pop() || path;
|
||||
const dbType = getDbTypeFromPath(path);
|
||||
const dbType = await detectDatabaseFileType(path);
|
||||
if (!dbType) return;
|
||||
|
||||
// Check for existing connection with the same file path
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/m
|
|||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
|
||||
import { prestoSqlBuiltinDriverPaths } from "@/lib/prestoSqlBuiltinDriver";
|
||||
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/databaseFileDetection";
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue";
|
||||
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connectionVisibleDatabases";
|
||||
import { canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases";
|
||||
|
|
@ -2158,18 +2159,11 @@ async function browseEtcdTlsFile(target: "ca" | "cert" | "key") {
|
|||
async function browseDbFilePath() {
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const filters =
|
||||
form.value.db_type === "duckdb"
|
||||
? [{ name: "DuckDB", extensions: ["duckdb", "db"] }]
|
||||
: form.value.db_type === "access"
|
||||
? [{ name: "Microsoft Access", extensions: ["accdb", "mdb"] }]
|
||||
: form.value.db_type === "h2"
|
||||
? [{ name: "H2", extensions: ["db"] }]
|
||||
: [{ name: "SQLite", extensions: ["db", "db3", "sqlite", "sqlite3"] }];
|
||||
const filters = form.value.db_type === "duckdb" ? [{ name: "DuckDB", extensions: ["duckdb", "db"] }] : form.value.db_type === "access" ? [{ name: "Microsoft Access", extensions: ["accdb", "mdb"] }] : form.value.db_type === "h2" ? [{ name: "H2", extensions: ["db"] }] : undefined;
|
||||
const selected = await open({
|
||||
title: "Select Database File",
|
||||
multiple: false,
|
||||
filters,
|
||||
...(filters ? { filters } : {}),
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
form.value.host = selected;
|
||||
|
|
@ -2218,7 +2212,8 @@ async function createDuckDbFilePath() {
|
|||
}
|
||||
|
||||
function ensureSqliteFileExtension(path: string): string {
|
||||
return /\.(db|db3|sqlite|sqlite3)$/i.test(path) ? path : `${path}.db`;
|
||||
const extensionPattern = new RegExp(`\\.(${SQLITE_DATABASE_FILE_EXTENSIONS.join("|")})$`, "i");
|
||||
return extensionPattern.test(path) ? path : `${path}.db`;
|
||||
}
|
||||
|
||||
async function createSqliteFilePath() {
|
||||
|
|
@ -2227,7 +2222,7 @@ async function createSqliteFilePath() {
|
|||
const selected = await save({
|
||||
title: t("connection.createSqliteFile"),
|
||||
defaultPath: "database.db",
|
||||
filters: [{ name: "SQLite", extensions: ["db", "db3", "sqlite", "sqlite3"] }],
|
||||
filters: [{ name: "SQLite", extensions: SQLITE_DATABASE_FILE_EXTENSIONS }],
|
||||
});
|
||||
if (!selected) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,15 +6,7 @@ import { useQueryStore } from "@/stores/queryStore";
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/api";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
const DB_EXTENSIONS = [".db", ".db3", ".sqlite", ".sqlite3", ".duckdb"];
|
||||
|
||||
function getDbType(path: string): "sqlite" | "duckdb" | null {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".duckdb")) return "duckdb";
|
||||
if (DB_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "sqlite";
|
||||
return null;
|
||||
}
|
||||
import { detectDatabaseFileType } from "@/lib/databaseFileDetection";
|
||||
|
||||
function isSqlFilePath(path: string): boolean {
|
||||
return /\.sql$/i.test(path);
|
||||
|
|
@ -81,7 +73,7 @@ export function useFileDrop() {
|
|||
continue;
|
||||
}
|
||||
|
||||
const dbType = getDbType(path);
|
||||
const dbType = await detectDatabaseFileType(path);
|
||||
if (!dbType) continue;
|
||||
const config: ConnectionConfig = {
|
||||
id: uuid(),
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export const deleteSavedSqlFile = forward("deleteSavedSqlFile");
|
|||
export const savedSqlStorageDir = forward("savedSqlStorageDir");
|
||||
export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir");
|
||||
export const revealPathInFileManager = forward("revealPathInFileManager");
|
||||
export const isSqliteDatabaseFile = forward("isSqliteDatabaseFile");
|
||||
export const backupSqliteDatabase = forward("backupSqliteDatabase");
|
||||
export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import * as api from "@/lib/api";
|
||||
|
||||
export const SQLITE_DATABASE_FILE_EXTENSIONS = ["db", "db3", "sqlite", "sqlite3", "sqlitedb"];
|
||||
|
||||
const SQLITE_DATABASE_EXTENSION_SET = new Set(SQLITE_DATABASE_FILE_EXTENSIONS.map((extension) => `.${extension}`));
|
||||
|
||||
export function databaseTypeFromKnownExtension(path: string): "sqlite" | "duckdb" | null {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".duckdb")) return "duckdb";
|
||||
if ([...SQLITE_DATABASE_EXTENSION_SET].some((extension) => lower.endsWith(extension))) return "sqlite";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detectDatabaseFileType(path: string): Promise<"sqlite" | "duckdb" | null> {
|
||||
const knownType = databaseTypeFromKnownExtension(path);
|
||||
if (knownType) return knownType;
|
||||
return (await api.isSqliteDatabaseFile(path).catch(() => false)) ? "sqlite" : null;
|
||||
}
|
||||
|
|
@ -403,6 +403,10 @@ export async function revealPathInFileManager(_path: string): Promise<void> {
|
|||
throw new Error("Reveal in file manager is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function isSqliteDatabaseFile(_path: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function backupSqliteDatabase(_connectionId: string, _destinationPath: string): Promise<void> {
|
||||
throw new Error("SQLite backup is only available in the desktop app.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1044,6 +1044,10 @@ export async function revealPathInFileManager(path: string): Promise<void> {
|
|||
return invoke("reveal_path_in_file_manager", { path });
|
||||
}
|
||||
|
||||
export async function isSqliteDatabaseFile(path: string): Promise<boolean> {
|
||||
return invoke("is_sqlite_database_file", { path });
|
||||
}
|
||||
|
||||
export async function backupSqliteDatabase(connectionId: string, destinationPath: string): Promise<void> {
|
||||
return invoke("backup_sqlite_database", { connectionId, destinationPath });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use percent_encoding::percent_decode_str;
|
|||
use rusqlite::types::ValueRef;
|
||||
use rusqlite::{Connection, LoadExtensionGuard, OpenFlags};
|
||||
use std::collections::HashSet;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
|
@ -10,6 +11,8 @@ use super::file_validator::validate_file_path;
|
|||
use crate::sql::starts_with_executable_sql_keyword;
|
||||
use crate::types::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
|
||||
|
||||
const SQLITE_DATABASE_HEADER: &[u8; 16] = b"SQLite format 3\0";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteHandle {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
|
|
@ -77,6 +80,9 @@ fn open_sqlite_handle(
|
|||
if !is_memory && create_if_missing {
|
||||
ensure_parent_dir(path)?;
|
||||
}
|
||||
if !is_memory && !is_network_path(path) {
|
||||
validate_existing_sqlite_file(path)?;
|
||||
}
|
||||
|
||||
let conn = if is_memory {
|
||||
Connection::open_in_memory().map_err(|e| format!("SQLite connection failed: {e}"))?
|
||||
|
|
@ -100,6 +106,31 @@ fn open_sqlite_handle(
|
|||
Ok(SqliteHandle { conn: Arc::new(Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
pub fn path_has_sqlite_header(path: &Path) -> Result<bool, String> {
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("failed to open file: {e}"))?;
|
||||
let mut header = [0_u8; 16];
|
||||
match file.read_exact(&mut header) {
|
||||
Ok(()) => Ok(&header == SQLITE_DATABASE_HEADER),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
|
||||
Err(e) => Err(format!("failed to read file header: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_existing_sqlite_file(path: &str) -> Result<(), String> {
|
||||
let path = Path::new(path);
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let metadata = path.metadata().map_err(|e| format!("failed to inspect SQLite database file: {e}"))?;
|
||||
if metadata.len() == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if path_has_sqlite_header(path)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err("Selected file is not a valid SQLite database file.".to_string())
|
||||
}
|
||||
|
||||
fn load_sqlite_extensions(conn: &Connection, extensions: &[SqliteExtensionSpec]) -> Result<(), String> {
|
||||
if extensions.is_empty() {
|
||||
return Ok(());
|
||||
|
|
@ -186,6 +217,48 @@ mod tests {
|
|||
assert_eq!(result.rows[0][0], serde_json::json!("Ada"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_if_missing_rejects_existing_non_sqlite_file() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-not-sqlite-{}.png", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, b"\x89PNG\r\n\x1a\nnot sqlite").unwrap();
|
||||
|
||||
let err = match connect_path_create_if_missing(path.to_str().unwrap()).await {
|
||||
Ok(_) => panic!("non-SQLite file should be rejected"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(err.contains("not a valid SQLite database"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_if_missing_allows_empty_custom_suffix_file() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-empty-sqlite-{}.conf", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
|
||||
let pool = connect_path_create_if_missing(path.to_str().unwrap()).await.expect("empty file can become SQLite");
|
||||
execute_query(&pool, "CREATE TABLE t (id INTEGER);").await.expect("write sqlite schema");
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_if_missing_allows_sqlite_database_with_custom_suffix() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-custom-sqlite-{}.conf", uuid::Uuid::new_v4()));
|
||||
{
|
||||
let pool = connect_path_create_if_missing(path.to_str().unwrap()).await.expect("create sqlite");
|
||||
execute_query(&pool, "CREATE TABLE t (id INTEGER);").await.expect("write sqlite schema");
|
||||
}
|
||||
|
||||
let reopened = connect_path_create_if_missing(path.to_str().unwrap()).await.expect("reopen sqlite");
|
||||
let result = execute_query(&reopened, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 't';")
|
||||
.await
|
||||
.expect("query sqlite schema");
|
||||
assert_eq!(result.rows[0][0], serde_json::json!("t"));
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[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";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dbx_core::db::sqlite::path_has_sqlite_header;
|
||||
use dbx_core::path_utils::expand_tilde;
|
||||
|
||||
/// Reveal a file in the platform's file manager.
|
||||
|
|
@ -75,6 +76,12 @@ pub async fn reveal_path_in_file_manager(path: String) -> Result<(), String> {
|
|||
reveal_in_file_manager(&resolved)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_sqlite_database_file(path: String) -> Result<bool, String> {
|
||||
let resolved = validate_path(&path)?;
|
||||
path_has_sqlite_header(&resolved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -120,6 +127,26 @@ mod tests {
|
|||
assert_eq!(resolved, dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_header_is_detected() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-sqlite-header-{}.conf", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, b"SQLite format 3\0extra").unwrap();
|
||||
|
||||
assert!(path_has_sqlite_header(&path).unwrap());
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_sqlite_header_is_rejected() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-sqlite-header-{}.conf", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, b"not sqlite").unwrap();
|
||||
|
||||
assert!(!path_has_sqlite_header(&path).unwrap());
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_is_expanded_when_home_set() {
|
||||
// Only run when HOME (or USERPROFILE) actually points somewhere we can
|
||||
|
|
|
|||
|
|
@ -580,6 +580,7 @@ pub fn run() {
|
|||
commands::saved_sql::open_saved_sql_storage_dir,
|
||||
commands::saved_sql::sync_saved_sql_directory,
|
||||
commands::fs_open::reveal_path_in_file_manager,
|
||||
commands::fs_open::is_sqlite_database_file,
|
||||
commands::sqlite_backup::backup_sqlite_database,
|
||||
commands::mongo_cmd::mongo_list_databases,
|
||||
commands::mongo_cmd::mongo_list_collections,
|
||||
|
|
|
|||
Loading…
Reference in New Issue