diff --git a/package-lock.json b/package-lock.json index 36fe2e3..ef84349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.7.2", "license": "Apache-2.0", "dependencies": { + "@iconify-json/lucide": "^1.2.114", "@monaco-editor/loader": "^1.7.0", "@tanstack/vue-virtual": "^3.13.28", "@tauri-apps/api": "^2.11.0", @@ -227,6 +228,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1662,6 +1664,15 @@ "@iconify/types": "*" } }, + "node_modules/@iconify-json/lucide": { + "version": "1.2.114", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.114.tgz", + "integrity": "sha512-NbvH3B1BYo6wBtS7joLi7f2UVQOqK2dtZodMFf3kkBs+Tnh9TkRuy8oVHr1RM8UK6bUtvAXxfNlGAah0CuvPCw==", + "license": "ISC", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify/json": { "version": "2.2.482", "resolved": "https://registry.npmjs.org/@iconify/json/-/json-2.2.482.tgz", @@ -2709,6 +2720,7 @@ "integrity": "sha512-PsSugIf9ip1H/mWKj4bi/BlEoerxXAda9ByRFsYuwsmr6af9NxJL0AaiNXs8Le7R21QR5KMiD/KdxZZ71LjAxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.52.0", @@ -3275,6 +3287,7 @@ "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", @@ -3304,6 +3317,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -3594,6 +3608,7 @@ "integrity": "sha512-6i+kNVVGb5I+XOCUgLDHol6sxb28ahbJrmLL/eiAWflT850MUqtAlFeyHDYHzScnOFcPy1AcmeY0yM77GgERmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/utils": "^8.53.0", "@unocss/config": "66.6.0", @@ -4296,6 +4311,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.26", @@ -4486,6 +4502,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4812,6 +4829,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5609,6 +5627,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5839,6 +5858,7 @@ "integrity": "sha512-nK96Gnt6/9wj8KhTFg+D80Mc01cffrcB15NO6pkTJmPpO0vHV+9yxegr+wVry4O3uGbu83HN86inCO3IsML9Rw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@dprint/formatter": "^0.5.1", "@dprint/markdown": "^0.20.0", @@ -7294,6 +7314,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7873,6 +7894,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -7970,6 +7992,7 @@ "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", @@ -9726,6 +9749,7 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -11144,6 +11168,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11290,6 +11315,7 @@ "resolved": "https://registry.npmjs.org/unocss/-/unocss-66.6.0.tgz", "integrity": "sha512-B5QsMJzFKeTHPzF5Ehr8tSMuhxzbCR9n+XP0GyhK9/2jTcBdI0/T+rCDDr9m6vUz+lku/coCVz7VAQ2BRAbZJw==", "license": "MIT", + "peer": true, "dependencies": { "@unocss/astro": "66.6.0", "@unocss/cli": "66.6.0", @@ -11335,6 +11361,7 @@ "resolved": "https://registry.npmjs.org/unocss-preset-animations/-/unocss-preset-animations-1.3.0.tgz", "integrity": "sha512-NLsBzPB98Jc8b6+t8nwLItI12ZE/48IZVccZ2uA2owLoeAvhRaDoRvs6eLcJfIUZfRycL25xxOeQoOBMo1p/aw==", "license": "MIT", + "peer": true, "peerDependencies": { "@unocss/preset-wind3": ">=0.56.0 < 101", "unocss": ">=0.56.0 < 101" @@ -11475,6 +11502,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -11556,6 +11584,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -11578,6 +11607,7 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", diff --git a/package.json b/package.json index 5b3afb6..f0bf49a 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "test:ci": "jest --runInBand --ci --coverage --coverageReporters json-summary text html lcov" }, "dependencies": { + "@iconify-json/lucide": "^1.2.114", "@monaco-editor/loader": "^1.7.0", "@tanstack/vue-virtual": "^3.13.28", "@tauri-apps/api": "^2.11.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0676f00..715bc31 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6423,6 +6423,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tempfile", "thiserror 1.0.69", "tiberius", "tokio", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0cb1fdd..b759054 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -104,6 +104,7 @@ pageant = "0.2" [dev-dependencies] mockall = "0.13" +tempfile = "3" [features] default = [] diff --git a/src-tauri/src/commands/browse.rs b/src-tauri/src/commands/browse.rs index 5a46274..f0ac4ad 100644 --- a/src-tauri/src/commands/browse.rs +++ b/src-tauri/src/commands/browse.rs @@ -5,10 +5,41 @@ use crate::connection::handle::ConnectionHandle; use crate::database::{ - search, ColumnInfo, DatabaseAdapter, DatabaseSchema, ForeignKeyInfo, IndexInfo, MySQLAdapter, - ObjectInfo, PostgresAdapter, QueryResult, SqlServerAdapter, TableInfo, TriggerInfo, + search, ClickHouseAdapter, ColumnInfo, DatabaseAdapter, DatabaseSchema, ForeignKeyInfo, + HttpSqlAdapter, IndexInfo, JdbcBridgeAdapter, MySQLAdapter, ObjectInfo, PostgresAdapter, + QueryResult, RqliteAdapter, SqlServerAdapter, TableInfo, TriggerInfo, TursoAdapter, }; use crate::state::{ActiveConnection, AppState}; + +/// Switch to a different database by creating a temporary connection and executing the query. +/// Falls back to the original connection if no database switch is needed. +macro_rules! with_db_switch { + ($adapter:expr, $adapter_type:ty, $sql:expr, $database:expr, $connection:expr, $error_msg:expr) => {{ + let __adapter = $adapter; + if let Some(ref __db) = $database { + let __guard = __adapter.lock().await; + let db_str: &str = __db; + if Some(db_str) != __guard.config.database.as_deref() { + let mut __temp_config = __guard.config.clone(); + drop(__guard); + __temp_config.database = Some(db_str.to_string()); + let mut __temp = <$adapter_type>::new(__temp_config); + __temp + .connect() + .await + .map_err(|e| format!("Failed to connect to database '{}': {}", db_str, e))?; + return __temp + .execute_query(&$sql) + .await + .map_err(|e| format!("{}: {}", $error_msg, e)); + } + } + $connection + .execute_query(&$sql) + .await + .map_err(|e| format!("{}: {}", $error_msg, e)) + }}; +} use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::HashMap; @@ -360,63 +391,37 @@ pub async fn get_table_data( let db_type = get_db_type_string(&connection); // Execute query based on connection type with proper identifier quoting - let result = match &connection { - ActiveConnection::Postgres(adapter) => { - let qualified = build_qualified_table(query.schema.as_deref(), &query.table, db_type); - let sql = - build_paginated_select(&qualified, filter_ref, limit_val, offset_val, db_type); - if let Some(ref db) = query.database { - let guard = adapter.lock().await; - if Some(db.as_str()) != guard.config.database.as_deref() { - let mut temp_config = guard.config.clone(); - drop(guard); - temp_config.database = Some(db.clone()); - let mut temp = PostgresAdapter::new(temp_config); - temp.connect() - .await - .map_err(|e| format!("Failed to connect to database '{}': {}", db, e))?; - return temp - .execute_query(&sql) - .await - .map_err(|e| format!("Failed to get table data: {}", e)); - } - } - connection.execute_query(&sql).await - } - ActiveConnection::SQLServer(adapter) => { - let qualified = build_qualified_table(query.schema.as_deref(), &query.table, db_type); - let sql = - build_paginated_select(&qualified, filter_ref, limit_val, offset_val, db_type); - if let Some(ref db) = query.database { - let guard = adapter.lock().await; - if Some(db.as_str()) != guard.config.database.as_deref() { - let mut temp_config = guard.config.clone(); - drop(guard); - temp_config.database = Some(db.clone()); - let mut temp = SqlServerAdapter::new(temp_config); - temp.connect() - .await - .map_err(|e| format!("Failed to connect to database '{}': {}", db, e))?; - return temp - .execute_query(&sql) - .await - .map_err(|e| format!("Failed to get table data: {}", e)); - } - } - connection.execute_query(&sql).await - } - _ => { - let qualified = build_qualified_table(query.schema.as_deref(), &query.table, db_type); - let sql = - build_paginated_select(&qualified, filter_ref, limit_val, offset_val, db_type); - connection.execute_query(&sql).await - } - } - .map_err(|e| format!("Failed to get table data: {}", e))?; + let result = self::get_table_data_inner(&connection, &query, filter_ref, limit_val, offset_val, db_type) + .await + .map_err(|e| format!("Failed to get table data: {}", e))?; Ok(result) } +async fn get_table_data_inner( + connection: &ActiveConnection, + query: &TableDataQuery, + filter_ref: Option<&str>, + limit_val: u32, + offset_val: u32, + db_type: &str, +) -> Result { + let qualified = build_qualified_table(query.schema.as_deref(), &query.table, db_type); + let sql = build_paginated_select(&qualified, filter_ref, limit_val, offset_val, db_type); + + match connection { + ActiveConnection::Postgres(a) => with_db_switch!(a, PostgresAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::MySQL(a) => with_db_switch!(a, MySQLAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::SQLServer(a) => with_db_switch!(a, SqlServerAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::ClickHouse(a) => with_db_switch!(a, ClickHouseAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::JdbcBridge(a) => with_db_switch!(a, JdbcBridgeAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::Rqlite(a) => with_db_switch!(a, RqliteAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::Turso(a) => with_db_switch!(a, TursoAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::HttpSql(a) => with_db_switch!(a, HttpSqlAdapter, sql, query.database, connection, "Failed to get table data"), + ActiveConnection::SQLite(_) => connection.execute_query(&sql).await.map_err(|e| format!("Failed to get table data: {}", e)), + } +} + /// Get the total row count for a table, optionally filtered by a WHERE clause. /// /// # Arguments @@ -450,62 +455,37 @@ pub async fn get_table_count( let filter_ref = filter.as_deref(); let db_type = get_db_type_string(&connection); - let result = match &connection { - ActiveConnection::Postgres(adapter) => { - let qualified = build_qualified_table(schema.as_deref(), &table, db_type); - let query = build_count_query(&qualified, filter_ref); - if let Some(ref db) = database { - let guard = adapter.lock().await; - if Some(db.as_str()) != guard.config.database.as_deref() { - let mut temp_config = guard.config.clone(); - drop(guard); - temp_config.database = Some(db.clone()); - let mut temp = PostgresAdapter::new(temp_config); - temp.connect() - .await - .map_err(|e| format!("Failed to connect to database '{}': {}", db, e))?; - let r = temp - .execute_query(&query) - .await - .map_err(|e| format!("Failed to get table count: {}", e))?; - return extract_count(r); - } - } - connection.execute_query(&query).await - } - ActiveConnection::SQLServer(adapter) => { - let qualified = build_qualified_table(schema.as_deref(), &table, db_type); - let query = build_count_query(&qualified, filter_ref); - if let Some(ref db) = database { - let guard = adapter.lock().await; - if Some(db.as_str()) != guard.config.database.as_deref() { - let mut temp_config = guard.config.clone(); - drop(guard); - temp_config.database = Some(db.clone()); - let mut temp = SqlServerAdapter::new(temp_config); - temp.connect() - .await - .map_err(|e| format!("Failed to connect to database '{}': {}", db, e))?; - let r = temp - .execute_query(&query) - .await - .map_err(|e| format!("Failed to get table count: {}", e))?; - return extract_count(r); - } - } - connection.execute_query(&query).await - } - _ => { - let qualified = build_qualified_table(schema.as_deref(), &table, db_type); - let query = build_count_query(&qualified, filter_ref); - connection.execute_query(&query).await - } - } - .map_err(|e| format!("Failed to get table count: {}", e))?; + let result = get_table_count_inner(&connection, &table, schema.as_deref(), database.as_deref(), filter_ref, db_type) + .await + .map_err(|e| format!("Failed to get table count: {}", e))?; extract_count(result) } +async fn get_table_count_inner( + connection: &ActiveConnection, + table: &str, + schema: Option<&str>, + database: Option<&str>, + filter_ref: Option<&str>, + db_type: &str, +) -> Result { + let qualified = build_qualified_table(schema, table, db_type); + let query = build_count_query(&qualified, filter_ref); + + match connection { + ActiveConnection::Postgres(a) => with_db_switch!(a, PostgresAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::MySQL(a) => with_db_switch!(a, MySQLAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::SQLServer(a) => with_db_switch!(a, SqlServerAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::ClickHouse(a) => with_db_switch!(a, ClickHouseAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::JdbcBridge(a) => with_db_switch!(a, JdbcBridgeAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::Rqlite(a) => with_db_switch!(a, RqliteAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::Turso(a) => with_db_switch!(a, TursoAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::HttpSql(a) => with_db_switch!(a, HttpSqlAdapter, query, database, connection, "Failed to get table count"), + ActiveConnection::SQLite(_) => connection.execute_query(&query).await.map_err(|e| format!("Failed to get table count: {}", e)), + } +} + /// Convert a JSON value to a SQL literal for safe embedding in UPDATE/DELETE queries. /// /// # Security Note diff --git a/src-tauri/src/commands/file_operations.rs b/src-tauri/src/commands/file_operations.rs index 94eb09d..b76f442 100644 --- a/src-tauri/src/commands/file_operations.rs +++ b/src-tauri/src/commands/file_operations.rs @@ -38,6 +38,23 @@ pub struct SavedQueryInfo { pub size_bytes: u64, } +/// Metadata for a saved query entry in the metadata file +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SavedQueryMetadata { + pub connection_id: Option, + pub connection_name: Option, + pub created_at: u64, + pub modified_at: u64, +} + +/// Collection of saved query metadata entries keyed by file path +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SavedQueriesMetadata { + pub queries: std::collections::HashMap, +} + /// Get the queries directory path, creating it if necessary fn get_queries_dir(app_handle: &AppHandle) -> Result { let app_data_dir = app_handle @@ -56,6 +73,11 @@ fn get_queries_dir(app_handle: &AppHandle) -> Result { Ok(queries_dir) } +fn get_metadata_file_path(app_handle: &AppHandle) -> Result { + let queries_dir = get_queries_dir(app_handle)?; + Ok(queries_dir.join("metadata.json")) +} + /// Save a SQL query to a file. /// /// If file_path is provided, saves to that path. @@ -245,3 +267,157 @@ pub async fn write_text_file(path: String, content: String) -> Result<(), String } fs::write(target, content).map_err(|e| format!("Failed to write file: {}", e)) } + +#[tauri::command] +pub async fn read_saved_queries_metadata(app_handle: AppHandle) -> Result { + let path = get_metadata_file_path(&app_handle)?; + if !path.exists() { + return Ok(SavedQueriesMetadata::default()); + } + let content = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read metadata file: {}", e))?; + match serde_json::from_str::(&content) { + Ok(metadata) => Ok(metadata), + Err(e) => { + eprintln!("Corrupt metadata file, returning empty: {}", e); + Ok(SavedQueriesMetadata::default()) + } + } +} + +#[tauri::command] +pub async fn write_saved_queries_metadata( + app_handle: AppHandle, + metadata: SavedQueriesMetadata, +) -> Result<(), String> { + let path = get_metadata_file_path(&app_handle)?; + let tmp_path = path.with_extension("json.tmp"); + let content = serde_json::to_string_pretty(&metadata) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + fs::write(&tmp_path, content) + .map_err(|e| format!("Failed to write temp metadata file: {}", e))?; + fs::rename(&tmp_path, &path) + .map_err(|e| format!("Failed to rename metadata file: {}", e))?; + Ok(()) +} + +#[tauri::command] +pub async fn save_query_metadata( + app_handle: AppHandle, + file_path: String, + metadata: SavedQueryMetadata, +) -> Result<(), String> { + let existing = read_saved_queries_metadata(app_handle.clone()).await?; + let mut updated = existing; + updated.queries.insert(file_path, metadata); + write_saved_queries_metadata(app_handle, updated).await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_test_metadata() -> SavedQueriesMetadata { + let mut queries = HashMap::new(); + queries.insert( + "/path/to/query1.sql".to_string(), + SavedQueryMetadata { + connection_id: Some("conn-uuid-1234".to_string()), + connection_name: Some("pg-prod".to_string()), + created_at: 1718926200, + modified_at: 1719185400, + }, + ); + queries.insert( + "/path/to/query2.sql".to_string(), + SavedQueryMetadata { + connection_id: None, + connection_name: None, + created_at: 1718000000, + modified_at: 1719000000, + }, + ); + SavedQueriesMetadata { queries } + } + + #[test] + fn test_metadata_write_then_read_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + + let original = make_test_metadata(); + + let json = serde_json::to_string_pretty(&original).unwrap(); + std::fs::write(&metadata_path, &json).unwrap(); + + let content = std::fs::read_to_string(&metadata_path).unwrap(); + let read_back: SavedQueriesMetadata = serde_json::from_str(&content).unwrap(); + + assert_eq!(read_back.queries.len(), 2); + assert!(read_back.queries.contains_key("/path/to/query1.sql")); + assert!(read_back.queries.contains_key("/path/to/query2.sql")); + + let q1 = read_back.queries.get("/path/to/query1.sql").unwrap(); + assert_eq!(q1.connection_id, Some("conn-uuid-1234".to_string())); + assert_eq!(q1.connection_name, Some("pg-prod".to_string())); + assert_eq!(q1.created_at, 1718926200); + assert_eq!(q1.modified_at, 1719185400); + + let q2 = read_back.queries.get("/path/to/query2.sql").unwrap(); + assert!(q2.connection_id.is_none()); + assert!(q2.connection_name.is_none()); + } + + #[test] + fn test_metadata_read_missing_file_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + + assert!(!metadata_path.exists()); + + let result: SavedQueriesMetadata = if !metadata_path.exists() { + SavedQueriesMetadata::default() + } else { + let content = std::fs::read_to_string(&metadata_path).unwrap(); + serde_json::from_str(&content).unwrap_or_default() + }; + + assert_eq!(result.queries.len(), 0); + } + + #[test] + fn test_metadata_read_corrupt_json_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + + std::fs::write(&metadata_path, "not valid json {{{{").unwrap(); + + let content = std::fs::read_to_string(&metadata_path).unwrap(); + let result: SavedQueriesMetadata = match serde_json::from_str(&content) { + Ok(m) => m, + Err(_) => SavedQueriesMetadata::default(), + }; + + assert_eq!(result.queries.len(), 0); + } + + #[test] + fn test_metadata_write_is_atomic() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + let tmp_path = metadata_path.with_extension("json.tmp"); + + let metadata = make_test_metadata(); + + let json = serde_json::to_string_pretty(&metadata).unwrap(); + std::fs::write(&tmp_path, &json).unwrap(); + std::fs::rename(&tmp_path, &metadata_path).unwrap(); + + assert!(metadata_path.exists()); + assert!(!tmp_path.exists()); + + let content = std::fs::read_to_string(&metadata_path).unwrap(); + let _: SavedQueriesMetadata = serde_json::from_str(&content).unwrap(); + } +} diff --git a/src-tauri/src/database/postgres.rs b/src-tauri/src/database/postgres.rs index 74ba15c..801c9f5 100644 --- a/src-tauri/src/database/postgres.rs +++ b/src-tauri/src/database/postgres.rs @@ -1545,17 +1545,6 @@ impl DatabaseAdapter for PostgresAdapter { let schema_filter = schema.unwrap_or("public"); - let query = r#" - SELECT - table_name as name, - 'VIEW' as object_type, - table_schema as schema_name, - view_definition as definition - FROM information_schema.views - WHERE table_schema = $1 - ORDER BY table_name - "#; - let pool = self .pool .as_ref() @@ -1567,12 +1556,23 @@ impl DatabaseAdapter for PostgresAdapter { .await .map_err(|e| DbError::Connection(format!("Failed to get connection: {}", e)))?; + // Query regular views from information_schema.views — works on all PG-wire databases. + let views_query = r#" + SELECT + table_name as name, + 'VIEW' as object_type, + table_schema as schema_name, + view_definition as definition + FROM information_schema.views + WHERE table_schema = $1 + "#; + let rows = client - .query(query, &[&schema_filter]) + .query(views_query, &[&schema_filter]) .await .map_err(|e| DbError::QueryExecution(e.to_string()))?; - let views = rows + let mut views: Vec = rows .iter() .map(|row| { let name: String = row.get(0); @@ -1595,6 +1595,44 @@ impl DatabaseAdapter for PostgresAdapter { }) .collect(); + // Query materialized views from pg_matviews separately. + // pg_matviews is PostgreSQL-specific and may not exist on PG-wire-compat + // databases (CockroachDB, CrateDB, QuestDB, Redshift). If the query fails, + // we simply skip materialized views rather than breaking view listing entirely. + let matviews_query = r#" + SELECT + matviewname as name, + 'MATERIALIZED VIEW' as object_type, + schemaname as schema_name, + definition as definition + FROM pg_matviews + WHERE schemaname = $1 + "#; + + if let Ok(matview_rows) = client.query(matviews_query, &[&schema_filter]).await { + views.extend(matview_rows.iter().map(|row| { + let name: String = row.get(0); + let object_type: String = row.get(1); + let schema_name: String = row.get(2); + let definition: Option = row.get(3); + let detail = definition.map(|def| { + if def.len() > 100 { + format!("{}...", &def[..100]) + } else { + def + } + }); + ObjectInfo { + name, + object_type, + schema: Some(schema_name), + detail, + } + })); + } + + views.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(views) } @@ -2022,6 +2060,20 @@ impl DatabaseAdapter for PostgresAdapter { .map(|r| r.get::<_, String>(0)) .ok_or_else(|| DbError::DatabaseNotFound(object_name.to_string())) } + "MATERIALIZED VIEW" => { + let query = r#" + SELECT definition + FROM pg_matviews + WHERE schemaname = $1 AND matviewname = $2 + "#; + let rows = client + .query(query, &[&schema_filter, &object_name]) + .await + .map_err(|e| DbError::QueryExecution(e.to_string()))?; + rows.first() + .map(|r| r.get::<_, String>(0)) + .ok_or_else(|| DbError::DatabaseNotFound(object_name.to_string())) + } "PROCEDURE" | "FUNCTION" => { let query = r#" SELECT pg_get_functiondef(p.oid) @@ -2082,6 +2134,7 @@ impl DatabaseAdapter for PostgresAdapter { let sql = match object_type.to_uppercase().as_str() { "VIEW" => format!("DROP VIEW IF EXISTS {} CASCADE", qualified), + "MATERIALIZED VIEW" => format!("DROP MATERIALIZED VIEW IF EXISTS {} CASCADE", qualified), "PROCEDURE" => format!("DROP PROCEDURE IF EXISTS {} CASCADE", qualified), "FUNCTION" => format!("DROP FUNCTION IF EXISTS {} CASCADE", qualified), "TRIGGER" => { @@ -2143,6 +2196,7 @@ impl DatabaseAdapter for PostgresAdapter { let sql = match object_type.to_uppercase().as_str() { "TABLE" => format!("ALTER TABLE {} RENAME TO {}", qualified, new_quoted), "VIEW" => format!("ALTER VIEW {} RENAME TO {}", qualified, new_quoted), + "MATERIALIZED VIEW" => format!("ALTER MATERIALIZED VIEW {} RENAME TO {}", qualified, new_quoted), "PROCEDURE" => format!("ALTER PROCEDURE {} RENAME TO {}", qualified, new_quoted), "FUNCTION" => format!("ALTER FUNCTION {} RENAME TO {}", qualified, new_quoted), _ => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0d2c5d..bf78899 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,6 +273,9 @@ pub fn run() { commands::list_saved_queries, commands::delete_query_file, commands::write_text_file, + commands::read_saved_queries_metadata, + commands::write_saved_queries_metadata, + commands::save_query_metadata, commands::preview_export_data, commands::execute_export_data, commands::detect_file_format, diff --git a/src/assets/index.css b/src/assets/index.css index 7e6fb30..8e97e98 100644 --- a/src/assets/index.css +++ b/src/assets/index.css @@ -95,3 +95,28 @@ button:not(:disabled), opacity: 0.6; } } + +/* Thin scrollbars — subtle, non-intrusive */ +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.25); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.4); +} + +/* Overlay scrollbar behavior: only show on hover of the scrollable area */ +* { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.25) transparent; +} diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index cc9bef4..9b2ac80 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -821,13 +821,13 @@ watch(