INTERVAL handling
- pg_value_to_json now decodes PG INTERVAL via PgInterval and renders
it psql-style: `1 year 2 mons 3 days 04:05:06`. Previously
AVG(timestamp - timestamp) and similar interval-returning queries
showed `<unsupported type: INTERVAL>` in chat results.
- 7 unit tests covering zero, days-only, mixed, negative, microsecond
fraction, and the singular/plural unit rules.
Compact reliability
- Sharper system prompt: explicitly instructs plain text starting with
`-`, no JSON, no fences, no field names. qwen3-coder is heavily
trained on the agent JSON protocol and was sometimes returning
`{"action":"final","text":"..."}` even for the compact prompt.
- New clean_summary helper strips ``` fences (with or without lang
identifier) and extracts the underlying string from a JSON envelope
if the model still wraps the answer (looks for text/summary/content/
answer/output keys). 6 unit tests.
- Frontend useChat.compact: success/no-op/error toasts via sonner so
the user sees what happened. "Nothing to compact" appears when there
is no older history beyond the last user turn (previously silent).
Verification: cargo test --lib 66 pass (+13), tsc clean, vitest 20
pass.
257 lines
8.1 KiB
Rust
257 lines
8.1 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),
|
|
"INTERVAL" => match row.try_get::<Option<sqlx::postgres::types::PgInterval>, _>(index) {
|
|
Ok(Some(v)) => return Value::String(format_pg_interval(&v)),
|
|
Ok(None) => return Value::Null,
|
|
Err(_) => {}
|
|
},
|
|
"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)),
|
|
}
|
|
}
|
|
|
|
/// Render a PostgreSQL INTERVAL the same way `psql` does:
|
|
/// "1 year 2 mons 3 days 04:05:06.789012"
|
|
/// Components are emitted only when non-zero; the time component fires
|
|
/// whenever microseconds != 0 OR everything else is zero.
|
|
fn format_pg_interval(iv: &sqlx::postgres::types::PgInterval) -> String {
|
|
let years = iv.months / 12;
|
|
let months = iv.months % 12;
|
|
let mut parts: Vec<String> = Vec::new();
|
|
if years != 0 {
|
|
parts.push(format!("{} year{}", years, if years.abs() == 1 { "" } else { "s" }));
|
|
}
|
|
if months != 0 {
|
|
parts.push(format!("{} mon{}", months, if months.abs() == 1 { "" } else { "s" }));
|
|
}
|
|
if iv.days != 0 {
|
|
parts.push(format!("{} day{}", iv.days, if iv.days.abs() == 1 { "" } else { "s" }));
|
|
}
|
|
if iv.microseconds != 0 || parts.is_empty() {
|
|
let total_us = iv.microseconds.unsigned_abs();
|
|
let total_seconds = total_us / 1_000_000;
|
|
let micros = (total_us % 1_000_000) as u32;
|
|
let h = total_seconds / 3600;
|
|
let m = (total_seconds / 60) % 60;
|
|
let s = total_seconds % 60;
|
|
let sign = if iv.microseconds < 0 { "-" } else { "" };
|
|
let time_part = if micros == 0 {
|
|
format!("{}{:02}:{:02}:{:02}", sign, h, m, s)
|
|
} else {
|
|
format!(
|
|
"{}{:02}:{:02}:{:02}.{:06}",
|
|
sign, h, m, s, micros
|
|
)
|
|
};
|
|
parts.push(time_part);
|
|
}
|
|
parts.join(" ")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::format_pg_interval;
|
|
use sqlx::postgres::types::PgInterval;
|
|
|
|
#[test]
|
|
fn interval_zero_renders_as_zero_time() {
|
|
let iv = PgInterval { months: 0, days: 0, microseconds: 0 };
|
|
assert_eq!(format_pg_interval(&iv), "00:00:00");
|
|
}
|
|
|
|
#[test]
|
|
fn interval_pure_time_micros() {
|
|
// 1h 30m
|
|
let iv = PgInterval { months: 0, days: 0, microseconds: 90 * 60 * 1_000_000 };
|
|
assert_eq!(format_pg_interval(&iv), "01:30:00");
|
|
}
|
|
|
|
#[test]
|
|
fn interval_days_only() {
|
|
let iv = PgInterval { months: 0, days: 3, microseconds: 0 };
|
|
assert_eq!(format_pg_interval(&iv), "3 days");
|
|
}
|
|
|
|
#[test]
|
|
fn interval_one_day() {
|
|
let iv = PgInterval { months: 0, days: 1, microseconds: 0 };
|
|
assert_eq!(format_pg_interval(&iv), "1 day");
|
|
}
|
|
|
|
#[test]
|
|
fn interval_mixed_components() {
|
|
// 1 year 2 mons 3 days 04:05:06
|
|
let iv = PgInterval {
|
|
months: 14,
|
|
days: 3,
|
|
microseconds: ((4 * 3600) + (5 * 60) + 6) * 1_000_000,
|
|
};
|
|
assert_eq!(format_pg_interval(&iv), "1 year 2 mons 3 days 04:05:06");
|
|
}
|
|
|
|
#[test]
|
|
fn interval_negative_time() {
|
|
let iv = PgInterval { months: 0, days: 0, microseconds: -3_600_000_000 };
|
|
assert_eq!(format_pg_interval(&iv), "-01:00:00");
|
|
}
|
|
|
|
#[test]
|
|
fn interval_with_microseconds_fraction() {
|
|
let iv = PgInterval { months: 0, days: 0, microseconds: 1_500_000 };
|
|
assert_eq!(format_pg_interval(&iv), "00:00:01.500000");
|
|
}
|
|
}
|