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.
729 lines
24 KiB
Rust
729 lines
24 KiB
Rust
use crate::error::{TuskError, TuskResult};
|
|
use crate::models::schema::{
|
|
ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject, TriggerInfo,
|
|
};
|
|
use crate::state::{AppState, DbFlavor};
|
|
use serde_json::Value;
|
|
use sqlx::Row;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tauri::State;
|
|
|
|
fn ch_string_literal(s: &str) -> String {
|
|
let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
|
|
format!("'{}'", escaped)
|
|
}
|
|
|
|
fn ch_obj_string(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
|
|
obj.get(key).and_then(|v| match v {
|
|
Value::String(s) => Some(s.clone()),
|
|
Value::Number(n) => Some(n.to_string()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn ch_obj_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
|
|
obj.get(key).and_then(|v| match v {
|
|
Value::Number(n) => n.as_i64(),
|
|
Value::String(s) => s.parse::<i64>().ok(),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
pub async fn list_databases_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
|
let flavor = state.get_flavor(connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
let client = state.get_ch_client(connection_id).await?;
|
|
let rows = client
|
|
.fetch_objects(
|
|
"SELECT name FROM system.databases \
|
|
WHERE name NOT IN ('system','INFORMATION_SCHEMA','information_schema') \
|
|
ORDER BY name",
|
|
)
|
|
.await?;
|
|
return Ok(rows
|
|
.iter()
|
|
.filter_map(|o| ch_obj_string(o, "name"))
|
|
.collect());
|
|
}
|
|
|
|
let pool = state.get_pool(connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT datname FROM pg_database \
|
|
WHERE datistemplate = false \
|
|
ORDER BY datname",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_databases(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
) -> TuskResult<Vec<String>> {
|
|
list_databases_core(&state, &connection_id).await
|
|
}
|
|
|
|
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
|
let flavor = state.get_flavor(connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
// ClickHouse has no schema layer — surface the active database as a virtual schema.
|
|
let client = state.get_ch_client(connection_id).await?;
|
|
return Ok(vec![client.database.clone()]);
|
|
}
|
|
|
|
let pool = state.get_pool(connection_id).await?;
|
|
|
|
let sql = if flavor == DbFlavor::Greenplum {
|
|
"SELECT schema_name FROM information_schema.schemata \
|
|
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
|
|
ORDER BY schema_name"
|
|
} else {
|
|
"SELECT schema_name FROM information_schema.schemata \
|
|
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
|
ORDER BY schema_name"
|
|
};
|
|
|
|
let rows = sqlx::query(sql)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_schemas(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
) -> TuskResult<Vec<String>> {
|
|
list_schemas_core(&state, &connection_id).await
|
|
}
|
|
|
|
pub async fn list_tables_core(
|
|
state: &AppState,
|
|
connection_id: &str,
|
|
schema: &str,
|
|
) -> TuskResult<Vec<SchemaObject>> {
|
|
let flavor = state.get_flavor(connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
let client = state.get_ch_client(connection_id).await?;
|
|
let escaped = ch_string_literal(schema);
|
|
let sql = format!(
|
|
"SELECT name, total_rows, total_bytes FROM system.tables \
|
|
WHERE database = {} AND engine NOT LIKE '%View' \
|
|
ORDER BY name",
|
|
escaped
|
|
);
|
|
let rows = client.fetch_objects(&sql).await?;
|
|
return Ok(rows
|
|
.iter()
|
|
.map(|o| SchemaObject {
|
|
name: ch_obj_string(o, "name").unwrap_or_default(),
|
|
object_type: "table".to_string(),
|
|
schema: schema.to_string(),
|
|
row_count: ch_obj_i64(o, "total_rows"),
|
|
size_bytes: ch_obj_i64(o, "total_bytes"),
|
|
})
|
|
.collect());
|
|
}
|
|
|
|
let pool = state.get_pool(connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT t.table_name, \
|
|
c.reltuples::bigint as row_count, \
|
|
pg_total_relation_size(c.oid)::bigint as size_bytes \
|
|
FROM information_schema.tables t \
|
|
LEFT JOIN pg_class c ON c.relname = t.table_name \
|
|
AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) \
|
|
WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' \
|
|
ORDER BY t.table_name",
|
|
)
|
|
.bind(schema)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| SchemaObject {
|
|
name: r.get(0),
|
|
object_type: "table".to_string(),
|
|
schema: schema.to_string(),
|
|
row_count: r.get::<Option<i64>, _>(1),
|
|
size_bytes: r.get::<Option<i64>, _>(2),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_tables(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
) -> TuskResult<Vec<SchemaObject>> {
|
|
list_tables_core(&state, &connection_id, &schema).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_views(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
) -> TuskResult<Vec<SchemaObject>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
let client = state.get_ch_client(&connection_id).await?;
|
|
let sql = format!(
|
|
"SELECT name FROM system.tables \
|
|
WHERE database = {} AND engine LIKE '%View' \
|
|
ORDER BY name",
|
|
ch_string_literal(&schema)
|
|
);
|
|
let rows = client.fetch_objects(&sql).await?;
|
|
return Ok(rows
|
|
.iter()
|
|
.map(|o| SchemaObject {
|
|
name: ch_obj_string(o, "name").unwrap_or_default(),
|
|
object_type: "view".to_string(),
|
|
schema: schema.clone(),
|
|
row_count: None,
|
|
size_bytes: None,
|
|
})
|
|
.collect());
|
|
}
|
|
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT table_name FROM information_schema.views \
|
|
WHERE table_schema = $1 \
|
|
ORDER BY table_name",
|
|
)
|
|
.bind(&schema)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| SchemaObject {
|
|
name: r.get(0),
|
|
object_type: "view".to_string(),
|
|
schema: schema.clone(),
|
|
row_count: None,
|
|
size_bytes: None,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_functions(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
) -> TuskResult<Vec<SchemaObject>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
// ClickHouse functions are global, not schema-scoped — surface empty here.
|
|
return Ok(vec![]);
|
|
}
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT routine_name FROM information_schema.routines \
|
|
WHERE routine_schema = $1 AND routine_type = 'FUNCTION' \
|
|
ORDER BY routine_name",
|
|
)
|
|
.bind(&schema)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| SchemaObject {
|
|
name: r.get(0),
|
|
object_type: "function".to_string(),
|
|
schema: schema.clone(),
|
|
row_count: None,
|
|
size_bytes: None,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_indexes(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
) -> TuskResult<Vec<SchemaObject>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
return Ok(vec![]);
|
|
}
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT indexname FROM pg_indexes \
|
|
WHERE schemaname = $1 \
|
|
ORDER BY indexname",
|
|
)
|
|
.bind(&schema)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| SchemaObject {
|
|
name: r.get(0),
|
|
object_type: "index".to_string(),
|
|
schema: schema.clone(),
|
|
row_count: None,
|
|
size_bytes: None,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_sequences(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
) -> TuskResult<Vec<SchemaObject>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
return Ok(vec![]);
|
|
}
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT sequence_name FROM information_schema.sequences \
|
|
WHERE sequence_schema = $1 \
|
|
ORDER BY sequence_name",
|
|
)
|
|
.bind(&schema)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| SchemaObject {
|
|
name: r.get(0),
|
|
object_type: "sequence".to_string(),
|
|
schema: schema.clone(),
|
|
row_count: None,
|
|
size_bytes: None,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn get_table_columns_core(
|
|
state: &AppState,
|
|
connection_id: &str,
|
|
schema: &str,
|
|
table: &str,
|
|
) -> TuskResult<Vec<ColumnInfo>> {
|
|
let flavor = state.get_flavor(connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
let client = state.get_ch_client(connection_id).await?;
|
|
let sql = format!(
|
|
"SELECT name, type, default_expression, is_in_primary_key, comment, position \
|
|
FROM system.columns WHERE database = {} AND table = {} \
|
|
ORDER BY position",
|
|
ch_string_literal(schema),
|
|
ch_string_literal(table)
|
|
);
|
|
let rows = client.fetch_objects(&sql).await?;
|
|
return Ok(rows
|
|
.iter()
|
|
.map(|o| {
|
|
let type_str = ch_obj_string(o, "type").unwrap_or_default();
|
|
let is_nullable = type_str.starts_with("Nullable(");
|
|
ColumnInfo {
|
|
name: ch_obj_string(o, "name").unwrap_or_default(),
|
|
data_type: type_str,
|
|
is_nullable,
|
|
column_default: ch_obj_string(o, "default_expression"),
|
|
ordinal_position: ch_obj_i64(o, "position").unwrap_or(0) as i32,
|
|
character_maximum_length: None,
|
|
is_primary_key: ch_obj_i64(o, "is_in_primary_key").unwrap_or(0) != 0,
|
|
comment: ch_obj_string(o, "comment"),
|
|
}
|
|
})
|
|
.collect());
|
|
}
|
|
|
|
let pool = state.get_pool(connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT \
|
|
c.column_name, \
|
|
c.data_type, \
|
|
c.is_nullable, \
|
|
c.column_default, \
|
|
c.ordinal_position::int, \
|
|
c.character_maximum_length::int, \
|
|
COALESCE(( \
|
|
SELECT true FROM information_schema.table_constraints tc \
|
|
JOIN information_schema.key_column_usage kcu \
|
|
ON tc.constraint_name = kcu.constraint_name \
|
|
AND tc.table_schema = kcu.table_schema \
|
|
WHERE tc.constraint_type = 'PRIMARY KEY' \
|
|
AND tc.table_schema = $1 \
|
|
AND tc.table_name = $2 \
|
|
AND kcu.column_name = c.column_name \
|
|
LIMIT 1 \
|
|
), false) as is_pk, \
|
|
col_description( \
|
|
(SELECT oid FROM pg_class \
|
|
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace \
|
|
WHERE relname = $2 AND nspname = $1), \
|
|
c.ordinal_position \
|
|
) as col_comment \
|
|
FROM information_schema.columns c \
|
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
|
ORDER BY c.ordinal_position",
|
|
)
|
|
.bind(schema)
|
|
.bind(table)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| ColumnInfo {
|
|
name: r.get::<String, _>(0),
|
|
data_type: r.get::<String, _>(1),
|
|
is_nullable: r.get::<String, _>(2) == "YES",
|
|
column_default: r.get::<Option<String>, _>(3),
|
|
ordinal_position: r.get::<i32, _>(4),
|
|
character_maximum_length: r.get::<Option<i32>, _>(5),
|
|
is_primary_key: r.get::<bool, _>(6),
|
|
comment: r.get::<Option<String>, _>(7),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_table_columns(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
table: String,
|
|
) -> TuskResult<Vec<ColumnInfo>> {
|
|
get_table_columns_core(&state, &connection_id, &schema, &table).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_table_constraints(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
table: String,
|
|
) -> TuskResult<Vec<ConstraintInfo>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
return Ok(vec![]);
|
|
}
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT \
|
|
c.conname AS constraint_name, \
|
|
CASE c.contype \
|
|
WHEN 'p' THEN 'PRIMARY KEY' \
|
|
WHEN 'f' THEN 'FOREIGN KEY' \
|
|
WHEN 'u' THEN 'UNIQUE' \
|
|
WHEN 'c' THEN 'CHECK' \
|
|
WHEN 'x' THEN 'EXCLUDE' \
|
|
END AS constraint_type, \
|
|
ARRAY( \
|
|
SELECT a.attname FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) \
|
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum \
|
|
ORDER BY k.ord \
|
|
)::text[] AS columns, \
|
|
ref_ns.nspname AS referenced_schema, \
|
|
ref_cl.relname AS referenced_table, \
|
|
CASE WHEN c.confrelid > 0 THEN ARRAY( \
|
|
SELECT a.attname FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord) \
|
|
JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum \
|
|
ORDER BY k.ord \
|
|
)::text[] ELSE NULL END AS referenced_columns, \
|
|
CASE c.confupdtype \
|
|
WHEN 'a' THEN 'NO ACTION' \
|
|
WHEN 'r' THEN 'RESTRICT' \
|
|
WHEN 'c' THEN 'CASCADE' \
|
|
WHEN 'n' THEN 'SET NULL' \
|
|
WHEN 'd' THEN 'SET DEFAULT' \
|
|
ELSE NULL \
|
|
END AS update_rule, \
|
|
CASE c.confdeltype \
|
|
WHEN 'a' THEN 'NO ACTION' \
|
|
WHEN 'r' THEN 'RESTRICT' \
|
|
WHEN 'c' THEN 'CASCADE' \
|
|
WHEN 'n' THEN 'SET NULL' \
|
|
WHEN 'd' THEN 'SET DEFAULT' \
|
|
ELSE NULL \
|
|
END AS delete_rule \
|
|
FROM pg_constraint c \
|
|
JOIN pg_class cl ON cl.oid = c.conrelid \
|
|
JOIN pg_namespace ns ON ns.oid = cl.relnamespace \
|
|
LEFT JOIN pg_class ref_cl ON ref_cl.oid = c.confrelid \
|
|
LEFT JOIN pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace \
|
|
WHERE ns.nspname = $1 AND cl.relname = $2 \
|
|
ORDER BY c.contype, c.conname",
|
|
)
|
|
.bind(&schema)
|
|
.bind(&table)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| ConstraintInfo {
|
|
name: r.get::<String, _>(0),
|
|
constraint_type: r.get::<String, _>(1),
|
|
columns: r.get::<Vec<String>, _>(2),
|
|
referenced_schema: r.get::<Option<String>, _>(3),
|
|
referenced_table: r.get::<Option<String>, _>(4),
|
|
referenced_columns: r.get::<Option<Vec<String>>, _>(5),
|
|
update_rule: r.get::<Option<String>, _>(6),
|
|
delete_rule: r.get::<Option<String>, _>(7),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_table_indexes(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
table: String,
|
|
) -> TuskResult<Vec<IndexInfo>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
return Ok(vec![]);
|
|
}
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT \
|
|
i.relname as index_name, \
|
|
pg_get_indexdef(i.oid) as index_def, \
|
|
ix.indisunique as is_unique, \
|
|
ix.indisprimary as is_primary \
|
|
FROM pg_index ix \
|
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
|
JOIN pg_class t ON t.oid = ix.indrelid \
|
|
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
|
WHERE n.nspname = $1 AND t.relname = $2 \
|
|
ORDER BY i.relname",
|
|
)
|
|
.bind(&schema)
|
|
.bind(&table)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| IndexInfo {
|
|
name: r.get::<String, _>(0),
|
|
definition: r.get::<String, _>(1),
|
|
is_unique: r.get::<bool, _>(2),
|
|
is_primary: r.get::<bool, _>(3),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_completion_schema(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
let client = state.get_ch_client(&connection_id).await?;
|
|
let sql = format!(
|
|
"SELECT database, table, name FROM system.columns \
|
|
WHERE database = {} \
|
|
ORDER BY database, table, position",
|
|
ch_string_literal(&client.database)
|
|
);
|
|
let rows = client.fetch_objects(&sql).await?;
|
|
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
|
|
for row in rows {
|
|
let db = ch_obj_string(&row, "database").unwrap_or_default();
|
|
let table = ch_obj_string(&row, "table").unwrap_or_default();
|
|
let column = ch_obj_string(&row, "name").unwrap_or_default();
|
|
result.entry(db).or_default().entry(table).or_default().push(column);
|
|
}
|
|
return Ok(result);
|
|
}
|
|
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let sql = if flavor == DbFlavor::Greenplum {
|
|
"SELECT table_schema, table_name, column_name \
|
|
FROM information_schema.columns \
|
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
|
|
ORDER BY table_schema, table_name, ordinal_position"
|
|
} else {
|
|
"SELECT table_schema, table_name, column_name \
|
|
FROM information_schema.columns \
|
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
|
ORDER BY table_schema, table_name, ordinal_position"
|
|
};
|
|
|
|
let rows = sqlx::query(sql)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
|
|
for row in &rows {
|
|
let schema: String = row.get(0);
|
|
let table: String = row.get(1);
|
|
let column: String = row.get(2);
|
|
|
|
result
|
|
.entry(schema)
|
|
.or_default()
|
|
.entry(table)
|
|
.or_default()
|
|
.push(column);
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_column_details(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
table: String,
|
|
) -> TuskResult<Vec<ColumnDetail>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
let columns = get_table_columns_core(&state, &connection_id, &schema, &table).await?;
|
|
return Ok(columns
|
|
.into_iter()
|
|
.map(|c| ColumnDetail {
|
|
column_name: c.name,
|
|
data_type: c.data_type,
|
|
is_nullable: c.is_nullable,
|
|
column_default: c.column_default,
|
|
is_identity: false,
|
|
})
|
|
.collect());
|
|
}
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let sql = if flavor == DbFlavor::Greenplum {
|
|
"SELECT c.column_name, c.data_type, \
|
|
c.is_nullable = 'YES' as is_nullable, \
|
|
c.column_default, \
|
|
false as is_identity \
|
|
FROM information_schema.columns c \
|
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
|
ORDER BY c.ordinal_position"
|
|
} else {
|
|
"SELECT c.column_name, c.data_type, \
|
|
c.is_nullable = 'YES' as is_nullable, \
|
|
c.column_default, \
|
|
c.is_identity = 'YES' as is_identity \
|
|
FROM information_schema.columns c \
|
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
|
ORDER BY c.ordinal_position"
|
|
};
|
|
|
|
let rows = sqlx::query(sql)
|
|
.bind(&schema)
|
|
.bind(&table)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| ColumnDetail {
|
|
column_name: r.get::<String, _>(0),
|
|
data_type: r.get::<String, _>(1),
|
|
is_nullable: r.get::<bool, _>(2),
|
|
column_default: r.get::<Option<String>, _>(3),
|
|
is_identity: r.get::<bool, _>(4),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_table_triggers(
|
|
state: State<'_, Arc<AppState>>,
|
|
connection_id: String,
|
|
schema: String,
|
|
table: String,
|
|
) -> TuskResult<Vec<TriggerInfo>> {
|
|
let flavor = state.get_flavor(&connection_id).await;
|
|
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
return Ok(vec![]);
|
|
}
|
|
let pool = state.get_pool(&connection_id).await?;
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT \
|
|
t.tgname AS trigger_name, \
|
|
CASE \
|
|
WHEN t.tgtype::int & 2 = 2 THEN 'BEFORE' \
|
|
WHEN t.tgtype::int & 2 = 0 AND t.tgtype::int & 64 = 64 THEN 'INSTEAD OF' \
|
|
ELSE 'AFTER' \
|
|
END AS timing, \
|
|
array_to_string(ARRAY[ \
|
|
CASE WHEN t.tgtype::int & 4 = 4 THEN 'INSERT' ELSE NULL END, \
|
|
CASE WHEN t.tgtype::int & 8 = 8 THEN 'DELETE' ELSE NULL END, \
|
|
CASE WHEN t.tgtype::int & 16 = 16 THEN 'UPDATE' ELSE NULL END, \
|
|
CASE WHEN t.tgtype::int & 32 = 32 THEN 'TRUNCATE' ELSE NULL END \
|
|
], ' OR ') AS event, \
|
|
CASE WHEN t.tgtype::int & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, \
|
|
p.proname AS function_name, \
|
|
t.tgenabled != 'D' AS is_enabled, \
|
|
pg_get_triggerdef(t.oid) AS definition \
|
|
FROM pg_trigger t \
|
|
JOIN pg_class c ON c.oid = t.tgrelid \
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace \
|
|
JOIN pg_proc p ON p.oid = t.tgfoid \
|
|
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal \
|
|
ORDER BY t.tgname",
|
|
)
|
|
.bind(&schema)
|
|
.bind(&table)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(TuskError::Database)?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|r| TriggerInfo {
|
|
name: r.get::<String, _>(0),
|
|
timing: r.get::<String, _>(1),
|
|
event: r.get::<String, _>(2),
|
|
orientation: r.get::<String, _>(3),
|
|
function_name: r.get::<String, _>(4),
|
|
is_enabled: r.get::<bool, _>(5),
|
|
definition: r.get::<String, _>(6),
|
|
})
|
|
.collect())
|
|
}
|
|
|