Files
tusk/src-tauri/src/db/clickhouse.rs
Aleksey Shakhmatov 4f7afc17f4 feat: rescope to AI-first DB harness with multi-DB chat agent
Removes enterprise/DBA features and replaces the marginal AI bar with a
central chat agent that has progressive-discovery tools, cross-session
memory, saved-query reuse, and inline result actions. Adds ClickHouse
support alongside PostgreSQL/Greenplum.

Cleanup
- Drop ~10k LOC of advanced features: Docker, Snapshots, Validation,
  Index Advisor, Role/User Management, Data Generator, ERD, Lookup.
- Trim deps: drop @xyflow/react, dagre, @types/dagre; cut tokio features
  to rt-multi-thread/sync/time/net/macros.
- Remove unused TuskError variants and dead helpers (topological_sort,
  invalidate_schema_cache).

Multi-DB (PostgreSQL + ClickHouse)
- New src-tauri/src/db/ module: ChClient (HTTP-based, reuses reqwest),
  sql_guard (cross-flavor read-only whitelist with 8 tests).
- ConnectionConfig gains db_flavor and secure fields with serde defaults
  for backwards-compatible connections.json.
- All connection/query/schema/data commands dispatch by flavor; CH
  covers connect, execute_query, list_databases/schemas/tables/views/
  columns/completion_schema, paginated table fetch.
- Frontend: dbCapabilities matrix, ConnectionDialog engine selector
  with port auto-swap and HTTPS toggle, SqlEditor switches to
  StandardSQL dialect for CH, TableDataView surfaces CH connections as
  read-only.

AI-first chat agent
- New src/components/chat/ panel with composer, message rendering,
  collapsible tool-call/result blocks, top-level ErrorBoundary.
- Backend agent loop in commands/chat.rs with strict-JSON tool
  protocol. Nine tools: list_databases, list_tables, get_columns,
  switch_database, run_query, remember, save_query, find_queries, final.
  Forgiving parser accepts both flat and nested-input shapes.
- Compressed history: only the last 4 run_query results carry sample
  rows (≤10, cells truncated to 200 chars) into LLM context; older
  results marked omitted.
- System prompt uses lite OVERVIEW (DB list + active-DB tables only)
  instead of full DDL — schema details are loaded on demand via
  get_columns. CH OVERVIEW shows cross-DB tables since CH allows
  db.table queries.

Cross-session memory (F1)
- Per-connection markdown file at app_data_dir/memory/<connection_id>.md,
  16KB cap with oldest-block eviction. Agent appends via remember()
  tool; the file is injected into LEARNED NOTES section of every system
  prompt.
- New Memory sidebar tab with editable textarea, badge for note count,
  empty-state with template. Edits picked up on the next agent turn.

Saved-query reuse (F2)
- Tools save_query and find_queries scoped to current connection.
  save_query attaches a UUID + timestamp; find_queries returns top 10
  matches with SQL preview ≤500 chars.
- Storage shared with the sidebar Saved panel.

Inline result actions (F3)
- run_query result block in chat gets Open-full (90vw × 80vh modal with
  full ResultsTable, no row cap) and Export (reuses ExportDialog for
  CSV/JSON via existing exportCsv/exportJson commands).

Verification
- cargo check clean, zero warnings.
- cargo test --lib: 50 pass (20 chat parser + 4 memory + 8 sql_guard +
  6 clean_sql + 12 escape_ident).
- npx tsc --noEmit clean.
- npx vitest run: 20 pass.
2026-05-06 19:30:44 +03:00

169 lines
5.6 KiB
Rust

use crate::error::{TuskError, TuskResult};
use crate::models::query_result::QueryResult;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::sync::LazyLock;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
fn http_client() -> &'static reqwest::Client {
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(DEFAULT_TIMEOUT)
.build()
.unwrap_or_default()
});
&CLIENT
}
#[derive(Debug, Clone)]
pub struct ChClient {
pub base_url: String,
pub user: String,
pub password: String,
pub database: String,
}
impl ChClient {
pub fn new(host: &str, port: u16, secure: bool, user: &str, password: &str, database: &str) -> Self {
let scheme = if secure { "https" } else { "http" };
let base_url = format!("{}://{}:{}", scheme, host, port);
Self {
base_url,
user: user.to_string(),
password: password.to_string(),
database: database.to_string(),
}
}
fn endpoint(&self, database: Option<&str>, format: Option<&str>, read_only: bool) -> String {
let db = database.unwrap_or(&self.database);
let mut params = vec![
format!("database={}", urlencode(db)),
format!("user={}", urlencode(&self.user)),
];
if !self.password.is_empty() {
params.push(format!("password={}", urlencode(&self.password)));
}
if let Some(fmt) = format {
params.push(format!("default_format={}", urlencode(fmt)));
}
if read_only {
params.push("readonly=1".to_string());
}
format!("{}/?{}", self.base_url, params.join("&"))
}
/// Execute SQL and return raw response body.
pub async fn execute_raw(&self, sql: &str, format: Option<&str>, read_only: bool) -> TuskResult<String> {
let url = self.endpoint(None, format, read_only);
let resp = http_client()
.post(&url)
.body(sql.to_string())
.send()
.await
.map_err(|e| TuskError::Custom(format!("ClickHouse request failed: {}", e)))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| TuskError::Custom(format!("Failed to read ClickHouse response: {}", e)))?;
if !status.is_success() {
return Err(TuskError::Custom(format!(
"ClickHouse error ({}): {}",
status,
body.trim()
)));
}
Ok(body)
}
/// Test connection by running `SELECT 1` and return the server version.
pub async fn ping(&self) -> TuskResult<String> {
// Use raw FORMAT TabSeparated to fetch version
let body = self.execute_raw("SELECT version()", Some("TabSeparated"), false).await?;
Ok(body.trim().to_string())
}
/// Execute SQL and parse rows via JSONCompact to preserve column metadata + types.
pub async fn execute_query(&self, sql: &str, read_only: bool) -> TuskResult<QueryResult> {
let start = Instant::now();
let body = self.execute_raw(sql, Some("JSONCompact"), read_only).await?;
let execution_time_ms = start.elapsed().as_millis() as u64;
// Empty body for statements without result set (DDL etc.) — return zero rows
if body.trim().is_empty() {
return Ok(QueryResult {
columns: vec![],
types: vec![],
rows: vec![],
row_count: 0,
execution_time_ms,
});
}
let parsed: ChJsonCompactResponse = serde_json::from_str(&body).map_err(|e| {
TuskError::Custom(format!(
"Failed to parse ClickHouse JSONCompact response: {} (body head: {})",
e,
body.chars().take(200).collect::<String>()
))
})?;
let columns: Vec<String> = parsed.meta.iter().map(|m| m.name.clone()).collect();
let types: Vec<String> = parsed.meta.iter().map(|m| m.r#type.clone()).collect();
let row_count = parsed.data.len();
Ok(QueryResult {
columns,
types,
rows: parsed.data,
row_count,
execution_time_ms,
})
}
/// Execute SQL expecting result rows as objects (for schema introspection helpers).
pub async fn fetch_objects(&self, sql: &str) -> TuskResult<Vec<Map<String, Value>>> {
let body = self.execute_raw(sql, Some("JSONEachRow"), false).await?;
let mut out = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let value: Value = serde_json::from_str(line).map_err(|e| {
TuskError::Custom(format!("Failed to parse JSONEachRow line: {}", e))
})?;
if let Value::Object(obj) = value {
out.push(obj);
}
}
Ok(out)
}
}
#[derive(Debug, Deserialize)]
struct ChJsonCompactResponse {
meta: Vec<ChMetaEntry>,
data: Vec<Vec<Value>>,
}
#[derive(Debug, Deserialize)]
struct ChMetaEntry {
name: String,
r#type: String,
}
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| match c {
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
| '+' | ',' | ';' | '=' | '%' | ' ' => format!("%{:02X}", c as u8),
_ => c.to_string(),
})
.collect()
}