Files
tusk/src-tauri/src/commands/queries.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

160 lines
4.9 KiB
Rust

use crate::db::sql_guard::ensure_readonly_sql;
use crate::error::{TuskError, TuskResult};
use crate::models::query_result::QueryResult;
use crate::state::{AppState, DbFlavor};
use serde_json::Value;
use sqlx::postgres::PgRow;
use sqlx::{Column, Row, TypeInfo};
use std::sync::Arc;
use std::time::Instant;
use tauri::State;
pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
let col = &row.columns()[index];
let type_name = col.type_info().name();
macro_rules! try_get {
($t:ty) => {
match row.try_get::<Option<$t>, _>(index) {
Ok(Some(v)) => return serde_json::to_value(v).unwrap_or(Value::Null),
Ok(None) => return Value::Null,
Err(_) => {}
}
};
}
match type_name {
"BOOL" => try_get!(bool),
"INT2" => try_get!(i16),
"INT4" => try_get!(i32),
"INT8" => try_get!(i64),
"FLOAT4" => try_get!(f32),
"FLOAT8" => try_get!(f64),
"NUMERIC" => {
try_get!(bigdecimal::BigDecimal);
}
"TEXT" | "VARCHAR" | "CHAR" | "BPCHAR" | "NAME" => try_get!(String),
"JSON" | "JSONB" => try_get!(Value),
"UUID" => try_get!(uuid::Uuid),
"TIMESTAMP" => {
try_get!(chrono::NaiveDateTime);
}
"TIMESTAMPTZ" => {
try_get!(chrono::DateTime<chrono::Utc>);
}
"DATE" => try_get!(chrono::NaiveDate),
"TIME" => try_get!(chrono::NaiveTime),
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
Ok(None) => return Value::Null,
Err(_) => {}
},
"OID" => match row.try_get::<Option<i32>, _>(index) {
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
Ok(None) => return Value::Null,
Err(_) => {}
},
"VOID" => return Value::Null,
// Array types (PG prefixes array type names with underscore)
"_BOOL" => try_get!(Vec<bool>),
"_INT2" => try_get!(Vec<i16>),
"_INT4" => try_get!(Vec<i32>),
"_INT8" => try_get!(Vec<i64>),
"_FLOAT4" => try_get!(Vec<f32>),
"_FLOAT8" => try_get!(Vec<f64>),
"_TEXT" | "_VARCHAR" | "_CHAR" | "_BPCHAR" | "_NAME" => try_get!(Vec<String>),
"_UUID" => try_get!(Vec<uuid::Uuid>),
"_JSON" | "_JSONB" => try_get!(Vec<Value>),
_ => {}
}
// Fallback: try as string
match row.try_get::<Option<String>, _>(index) {
Ok(Some(v)) => Value::String(v),
Ok(None) => Value::Null,
Err(_) => Value::String(format!("<unsupported type: {}>", type_name)),
}
}
pub async fn execute_query_core(
state: &AppState,
connection_id: &str,
sql: &str,
) -> TuskResult<QueryResult> {
let read_only = state.is_read_only(connection_id).await;
let flavor = state.get_flavor(connection_id).await;
if read_only {
ensure_readonly_sql(sql)?;
}
if matches!(flavor, DbFlavor::ClickHouse) {
let client = state.get_ch_client(connection_id).await?;
return client.execute_query(sql, read_only).await;
}
let pools = state.pools.read().await;
let pool = pools
.get(connection_id)
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
let start = Instant::now();
let rows = if read_only {
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
sqlx::query("SET TRANSACTION READ ONLY")
.execute(&mut *tx)
.await
.map_err(TuskError::Database)?;
let result = sqlx::query(sql)
.fetch_all(&mut *tx)
.await
.map_err(TuskError::Database);
tx.rollback().await.map_err(TuskError::Database)?;
result?
} else {
sqlx::query(sql)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?
};
let execution_time_ms = start.elapsed().as_millis() as u64;
let mut columns = Vec::new();
let mut types = Vec::new();
if let Some(first_row) = rows.first() {
for col in first_row.columns() {
columns.push(col.name().to_string());
types.push(col.type_info().name().to_string());
}
}
let result_rows: Vec<Vec<Value>> = rows
.iter()
.map(|row| {
(0..columns.len())
.map(|i| pg_value_to_json(row, i))
.collect()
})
.collect();
let row_count = result_rows.len();
Ok(QueryResult {
columns,
types,
rows: result_rows,
row_count,
execution_time_ms,
})
}
#[tauri::command]
pub async fn execute_query(
state: State<'_, Arc<AppState>>,
connection_id: String,
sql: String,
) -> TuskResult<QueryResult> {
execute_query_core(&state, &connection_id, &sql).await
}