diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 13e6b66b..a7482976 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -1,11 +1,16 @@ use freya::prelude::*; use freya::router::RouterContext; +use oneclient_core::LauncherState; +use oneclient_db::console::{ConsoleQueryResult, run_console_query}; use crate::components::{Button, Icon, IconType, TextInput, toggle}; use crate::hooks::use_dispatch; use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::routes::Route; use crate::theme::colors; +use crate::ui::border_all_color; + +type SqlResult = Option>; #[derive(PartialEq)] pub struct Debug; @@ -63,6 +68,8 @@ impl Component for Debug { vec![ClusterUpdateSimulator.into_element()], )) .child(divider()) + .child(section("SQL Console", vec![SqlConsole.into_element()])) + .child(divider()) .child(section( "Other", vec![action_row(vec![ @@ -254,6 +261,170 @@ impl Component for ClusterUpdateSimulator { } } +#[derive(PartialEq)] +struct SqlConsole; + +impl Component for SqlConsole { + fn render(&self) -> impl IntoElement { + let query = use_state(|| "SELECT * FROM clusters;".to_string()); + let result = use_state(|| None::>); + let running = use_state(|| false); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(query) + .placeholder("SELECT * FROM …") + .on_submit(move |_| run_sql(query, result, running)), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::Terminal).size(16.)) + .text(if *running.read() { "Running…" } else { "Run" }) + .on_press(move |_| run_sql(query, result, running)), + ), + ) + .child(sql_result(&result.read())) + .into_element() + } +} + +fn run_sql(query: State, mut result: State, mut running: State) { + if *running.read() { + return; + } + let sql = query.read().clone(); + if sql.trim().is_empty() { + return; + } + + running.set(true); + spawn(async move { + let res = match LauncherState::get() { + Ok(state) => run_console_query(&state.services.db, &sql) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; + result.set(Some(res)); + running.set(false); + }); +} + +fn sql_result(state: &SqlResult) -> Element { + match state { + None => label() + .text("No query run yet.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(), + Some(Err(err)) => rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::danger())) + .child( + label() + .text(err.clone()) + .font_size(13.) + .color(colors::danger()), + ) + .into_element(), + Some(Ok(res)) if !res.is_select => label() + .text(format!("OK — {} row(s) affected.", res.rows_affected)) + .font_size(13.) + .color(colors::success()) + .into_element(), + Some(Ok(res)) => sql_table(res), + } +} + +fn sql_table(res: &ConsoleQueryResult) -> Element { + if res.columns.is_empty() { + return label() + .text("0 rows returned.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(); + } + + let header = table_row(&res.columns, true); + let mut table = rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border())) + .child(header); + + for row in &res.rows { + table = table.child(table_row(row, false)); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(format!("{} row(s)", res.rows.len())) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(table) + .into_element() +} + +fn table_row(cells: &[String], header: bool) -> Element { + let mut row = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .background(if header { + colors::page_elevated() + } else { + colors::component_bg() + }); + + for cell in cells { + row = row.child( + rect() + .width(Size::flex(1.0)) + .padding(Gaps::new_symmetric(6., 10.)) + .border(border_all_color(0.5, colors::component_border())) + .child( + label() + .text(cell.clone()) + .font_size(12.) + .max_lines(1) + .font_weight(if header { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(if header { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ); + } + + row.into_element() +} + fn split_csv(raw: &str) -> Vec { raw.split(',') .map(|s| s.trim().to_string()) diff --git a/packages/oneclient_db/src/console.rs b/packages/oneclient_db/src/console.rs new file mode 100644 index 00000000..5fb23824 --- /dev/null +++ b/packages/oneclient_db/src/console.rs @@ -0,0 +1,177 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +use sqlx::sqlite::SqliteRow; +use sqlx::{Column, Row, ValueRef}; + +use crate::{DbError, DbPool}; + +/// sqlx runs SQLite on a worker thread and requires the SQL string to be +/// `'static`. Intern each distinct console query so repeated runs of the same +/// text don't leak, while still accepting arbitrary runtime strings. +fn intern(sql: &str) -> &'static str { + static CACHE: Mutex>> = Mutex::new(None); + + let mut guard = CACHE.lock().unwrap(); + let cache = guard.get_or_insert_with(HashSet::new); + + if let Some(existing) = cache.get(sql) { + return existing; + } + + let leaked: &'static str = Box::leak(sql.to_owned().into_boxed_str()); + cache.insert(leaked); + leaked +} + +/// Outcome of a console query, stringified for display. +#[derive(Debug, Clone, Default)] +pub struct ConsoleQueryResult { + /// Column headers (empty when the statement returned no rows). + pub columns: Vec, + /// Row cells, already converted to display strings. + pub rows: Vec>, + /// Rows affected by a non-select statement. + pub rows_affected: u64, + /// Whether the statement returned a result set. + pub is_select: bool, +} + +/// Execute an arbitrary SQL statement. +/// +/// Statements that start with a row-returning keyword (`SELECT`, `WITH`, +/// `PRAGMA`, `EXPLAIN`) are fetched into a table; everything else is executed +/// and reports `rows_affected`. +pub async fn run_console_query(pool: &DbPool, sql: &str) -> Result { + let sql = intern(sql.trim().trim_end_matches(';').trim()); + + if returns_rows(sql) { + let fetched = sqlx::query(sql).persistent(false).fetch_all(pool).await?; + Ok(rows_to_result(&fetched)) + } else { + let done = sqlx::query(sql).persistent(false).execute(pool).await?; + Ok(ConsoleQueryResult { + rows_affected: done.rows_affected(), + is_select: false, + ..Default::default() + }) + } +} + +fn returns_rows(sql: &str) -> bool { + let head = sql + .trim_start() + .split(|c: char| c.is_whitespace() || c == '(') + .next() + .unwrap_or("") + .to_ascii_uppercase(); + + matches!(head.as_str(), "SELECT" | "WITH" | "PRAGMA" | "EXPLAIN") +} + +fn rows_to_result(fetched: &[SqliteRow]) -> ConsoleQueryResult { + let columns = fetched + .first() + .map(|row| { + row.columns() + .iter() + .map(|c| c.name().to_string()) + .collect::>() + }) + .unwrap_or_default(); + + let rows = fetched + .iter() + .map(|row| { + (0..row.columns().len()) + .map(|idx| cell_to_string(row, idx)) + .collect() + }) + .collect(); + + ConsoleQueryResult { + columns, + rows, + rows_affected: 0, + is_select: true, + } +} + +/// SQLite is dynamically typed, so probe the raw value and decode accordingly. +fn cell_to_string(row: &SqliteRow, idx: usize) -> String { + match row.try_get_raw(idx) { + Ok(raw) if raw.is_null() => return "NULL".to_string(), + Ok(_) => {} + Err(_) => return "".to_string(), + } + + if let Ok(v) = row.try_get::(idx) { + return v; + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::, _>(idx) { + return format!("", v.len()); + } + + "".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + async fn pool() -> DbPool { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, ratio REAL)") + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn select_returns_columns_and_rows() { + let pool = pool().await; + sqlx::query("INSERT INTO t (name, ratio) VALUES ('a', 1.5), (NULL, 2.0)") + .execute(&pool) + .await + .unwrap(); + + let res = run_console_query(&pool, "SELECT id, name, ratio FROM t ORDER BY id;") + .await + .unwrap(); + + assert!(res.is_select); + assert_eq!(res.columns, vec!["id", "name", "ratio"]); + assert_eq!(res.rows.len(), 2); + assert_eq!(res.rows[0], vec!["1", "a", "1.5"]); + assert_eq!(res.rows[1][1], "NULL"); + } + + #[tokio::test] + async fn non_select_reports_rows_affected() { + let pool = pool().await; + let res = run_console_query(&pool, "INSERT INTO t (name) VALUES ('x'), ('y')") + .await + .unwrap(); + + assert!(!res.is_select); + assert_eq!(res.rows_affected, 2); + assert!(res.rows.is_empty()); + } + + #[tokio::test] + async fn invalid_sql_errors() { + let pool = pool().await; + assert!(run_console_query(&pool, "SELECT * FROM nope").await.is_err()); + } +} diff --git a/packages/oneclient_db/src/lib.rs b/packages/oneclient_db/src/lib.rs index c5546a81..67096aa7 100644 --- a/packages/oneclient_db/src/lib.rs +++ b/packages/oneclient_db/src/lib.rs @@ -1,6 +1,7 @@ mod error; mod pool; +pub mod console; pub mod dao; pub mod models;