diff --git a/desktop/src-tauri/src/commands/db_viewer.rs b/desktop/src-tauri/src/commands/db_viewer.rs index 9ed0b73..61d7e8c 100644 --- a/desktop/src-tauri/src/commands/db_viewer.rs +++ b/desktop/src-tauri/src/commands/db_viewer.rs @@ -5,8 +5,8 @@ use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle}; use crate::models::db_viewer::{ - Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo, - QueryResult, SequenceInfo, TableInfo, TriggerInfo, + Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo, + HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo, }; use bytes::BytesMut; use std::collections::HashMap; @@ -2640,6 +2640,58 @@ pub async fn get_functions( } } +#[tauri::command] +pub async fn get_hypertables( + connection_id: String, + schema: Option, + state: State<'_, crate::AppState>, +) -> Result { + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(DbHandle::Postgresql(client, _)) => { + let detected: bool = client + .query_one(&crate::db::introspection::pg_timescale_detect_query(), &[]) + .await + .map(|r| r.get::<_, bool>(0)) + .unwrap_or(false); + if !detected { + return Ok(HypertableListResponse { + available: false, + reason: Some("TimescaleDB extension is not installed".into()), + items: vec![], + }); + } + let schema = schema.unwrap_or_else(|| "public".to_string()); + let query = crate::db::introspection::pg_hypertables_query(&schema); + let rows = client + .query(&query, &[&schema]) + .await + .map_err(|e| format!("TimescaleDB introspection failed: {e}"))?; + Ok(HypertableListResponse { + available: true, + reason: None, + items: rows + .iter() + .map(|r| HypertableInfo { + name: r.get(0), + schema: r.get(1), + num_dimensions: r.get::<_, Option>(2).unwrap_or(0), + compression_enabled: r.get::<_, Option>(3).unwrap_or(false), + num_chunks: r.get::<_, Option>(4).unwrap_or(0), + total_size_bytes: r.get::<_, Option>(5), + }) + .collect(), + }) + } + Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(HypertableListResponse { + available: false, + reason: Some("Hypertables are a TimescaleDB (PostgreSQL) feature".into()), + items: vec![], + }), + None => Err("Connection not found".into()), + } +} + #[tauri::command] pub async fn get_indexes( connection_id: String, @@ -3637,3 +3689,7 @@ mod tests { assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999")); } } + +#[cfg(test)] +#[path = "db_viewer.test.rs"] +mod db_viewer_extra_tests; diff --git a/desktop/src-tauri/src/commands/db_viewer.test.rs b/desktop/src-tauri/src/commands/db_viewer.test.rs new file mode 100644 index 0000000..8debc19 --- /dev/null +++ b/desktop/src-tauri/src/commands/db_viewer.test.rs @@ -0,0 +1,73 @@ +use crate::db::pool::{ConnectionPoolManager, DbHandle}; + +#[test] +fn hypertables_query_targets_timescale_information_schema() { + let q = crate::db::introspection::pg_hypertables_query("public"); + assert!(q.contains("timescaledb_information.hypertables")); + assert!(q.contains("hypertable_schema = $1")); + assert!(q.contains("timescaledb_information.chunks")); +} + +#[test] +fn timescale_detect_query_checks_pg_extension() { + let q = crate::db::introspection::pg_timescale_detect_query(); + assert!(q.contains("pg_extension")); + assert!(q.contains("'timescaledb'")); +} + +#[test] +fn hypertable_response_degrades_gracefully() { + let resp = crate::models::db_viewer::HypertableListResponse { + available: false, + reason: Some("TimescaleDB extension is not installed".into()), + items: vec![], + }; + let json = serde_json::to_string(&resp).expect("serializes"); + assert!(json.contains("\"available\":false")); +} + +/// Live test. On a plain PostgreSQL connection (no TimescaleDB) the detection +/// helper must report `available: false`, never an error. Follows the exact +/// env-var + client-construction convention used by the live PG tests in +/// `commands/objects.test.rs`. +#[tokio::test] +#[ignore] +async fn hypertables_unavailable_on_plain_pg() { + let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST"); + let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT") + .unwrap_or_else(|_| "5432".into()) + .parse() + .unwrap(); + let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER"); + let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB"); + let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default(); + let (client, conn) = tokio_postgres::connect( + &format!("host={h} port={p} user={u} dbname={d} password={pw}"), + tokio_postgres::NoTls, + ) + .await + .expect("connect to test PG"); + let handle = tokio::spawn(async move { + let _ = conn.await; + }); + let mut pm = ConnectionPoolManager::new(); + let id = "test-pg".to_string(); + pm.register(&id, DbHandle::Postgresql(client, handle)); + let pm = tokio::sync::Mutex::new(pm); + + // Exercise the detection helper directly (the command needs `State`, which + // is not constructible in tests). + let detected = { + let mut g = pm.lock().await; + match g.get(&id) { + Some(DbHandle::Postgresql(c, _)) => { + c.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[]) + .await + } + _ => panic!("expected a postgresql handle"), + } + }; + assert!(detected.is_ok(), "detection query must run on plain PG"); + let available: bool = detected.unwrap().get(0); + assert!(!available, "plain PG must report available=false"); +} diff --git a/desktop/src-tauri/src/db/introspection.rs b/desktop/src-tauri/src/db/introspection.rs index b8c6602..22832f5 100644 --- a/desktop/src-tauri/src/db/introspection.rs +++ b/desktop/src-tauri/src/db/introspection.rs @@ -887,3 +887,28 @@ mod tests { assert!(sql.contains("$1") && sql.contains("$2")); } } + +// --------------------------------------------------------------------------- +// TimescaleDB (hypertables) +// --------------------------------------------------------------------------- + +/// Detect whether the timescaledb extension is installed on this database. +pub fn pg_timescale_detect_query() -> String { + "SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')".to_string() +} + +/// Query hypertables in a schema (TimescaleDB 2.x information views). +/// Columns: name, schema, num_dimensions, compression_enabled, num_chunks, total_size_bytes. +pub fn pg_hypertables_query(_schema: &str) -> String { + "SELECT h.hypertable_name, h.hypertable_schema, \ + h.num_dimensions, \ + COALESCE(h.compression_enabled, false), \ + (SELECT COUNT(*) FROM timescaledb_information.chunks c \ + WHERE c.hypertable_schema = h.hypertable_schema \ + AND c.hypertable_name = h.hypertable_name), \ + pg_total_relation_size(format('%I.%I', h.hypertable_schema, h.hypertable_name)::regclass)::bigint \ + FROM timescaledb_information.hypertables h \ + WHERE h.hypertable_schema = $1 \ + ORDER BY h.hypertable_name" + .to_string() +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2d7394b..e8f75ff 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -136,6 +136,7 @@ pub fn run() { db_viewer::get_extensions, db_viewer::get_indexes, db_viewer::get_constraints, + db_viewer::get_hypertables, objects::create_schema, objects::rename_schema, objects::drop_schema, diff --git a/desktop/src-tauri/src/models/db_viewer.rs b/desktop/src-tauri/src/models/db_viewer.rs index ecf72a7..371af06 100644 --- a/desktop/src-tauri/src/models/db_viewer.rs +++ b/desktop/src-tauri/src/models/db_viewer.rs @@ -132,6 +132,23 @@ pub struct ExtensionInfo { pub comment: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HypertableInfo { + pub name: String, + pub schema: String, + pub num_dimensions: i64, + pub compression_enabled: bool, + pub num_chunks: i64, + pub total_size_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HypertableListResponse { + pub available: bool, + pub reason: Option, + pub items: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObjectSearchHit { pub name: String,