feat: TimescaleDB detection + get_hypertables command with graceful unavailable state (Task 4)
This commit is contained in:
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||||
use crate::models::db_viewer::{
|
use crate::models::db_viewer::{
|
||||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo,
|
||||||
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||||
};
|
};
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use std::collections::HashMap;
|
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<String>,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<HypertableListResponse, String> {
|
||||||
|
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<i64>>(2).unwrap_or(0),
|
||||||
|
compression_enabled: r.get::<_, Option<bool>>(3).unwrap_or(false),
|
||||||
|
num_chunks: r.get::<_, Option<i64>>(4).unwrap_or(0),
|
||||||
|
total_size_bytes: r.get::<_, Option<i64>>(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]
|
#[tauri::command]
|
||||||
pub async fn get_indexes(
|
pub async fn get_indexes(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -3637,3 +3689,7 @@ mod tests {
|
|||||||
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "db_viewer.test.rs"]
|
||||||
|
mod db_viewer_extra_tests;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -887,3 +887,28 @@ mod tests {
|
|||||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ pub fn run() {
|
|||||||
db_viewer::get_extensions,
|
db_viewer::get_extensions,
|
||||||
db_viewer::get_indexes,
|
db_viewer::get_indexes,
|
||||||
db_viewer::get_constraints,
|
db_viewer::get_constraints,
|
||||||
|
db_viewer::get_hypertables,
|
||||||
objects::create_schema,
|
objects::create_schema,
|
||||||
objects::rename_schema,
|
objects::rename_schema,
|
||||||
objects::drop_schema,
|
objects::drop_schema,
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ pub struct ExtensionInfo {
|
|||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HypertableListResponse {
|
||||||
|
pub available: bool,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub items: Vec<HypertableInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObjectSearchHit {
|
pub struct ObjectSearchHit {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user