0.8.0: MariaDB + PlanetScale, TimescaleDB, multi-result sets, streaming export, schema diff #27
@@ -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<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]
|
||||
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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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_indexes,
|
||||
db_viewer::get_constraints,
|
||||
db_viewer::get_hypertables,
|
||||
objects::create_schema,
|
||||
objects::rename_schema,
|
||||
objects::drop_schema,
|
||||
|
||||
@@ -132,6 +132,23 @@ pub struct ExtensionInfo {
|
||||
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)]
|
||||
pub struct ObjectSearchHit {
|
||||
pub name: String,
|
||||
|
||||
Reference in New Issue
Block a user