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, key: &str) -> Option { 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, key: &str) -> Option { obj.get(key).and_then(|v| match v { Value::Number(n) => n.as_i64(), Value::String(s) => s.parse::().ok(), _ => None, }) } pub async fn list_databases_core(state: &AppState, connection_id: &str) -> TuskResult> { 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::(0)).collect()) } #[tauri::command] pub async fn list_databases( state: State<'_, Arc>, connection_id: String, ) -> TuskResult> { list_databases_core(&state, &connection_id).await } pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult> { 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::(0)).collect()) } #[tauri::command] pub async fn list_schemas( state: State<'_, Arc>, connection_id: String, ) -> TuskResult> { list_schemas_core(&state, &connection_id).await } pub async fn list_tables_core( state: &AppState, connection_id: &str, schema: &str, ) -> TuskResult> { 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::, _>(1), size_bytes: r.get::, _>(2), }) .collect()) } #[tauri::command] pub async fn list_tables( state: State<'_, Arc>, connection_id: String, schema: String, ) -> TuskResult> { list_tables_core(&state, &connection_id, &schema).await } #[tauri::command] pub async fn list_views( state: State<'_, Arc>, connection_id: String, schema: String, ) -> TuskResult> { 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>, connection_id: String, schema: String, ) -> TuskResult> { 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>, connection_id: String, schema: String, ) -> TuskResult> { 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>, connection_id: String, schema: String, ) -> TuskResult> { 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> { 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::(0), data_type: r.get::(1), is_nullable: r.get::(2) == "YES", column_default: r.get::, _>(3), ordinal_position: r.get::(4), character_maximum_length: r.get::, _>(5), is_primary_key: r.get::(6), comment: r.get::, _>(7), }) .collect()) } #[tauri::command] pub async fn get_table_columns( state: State<'_, Arc>, connection_id: String, schema: String, table: String, ) -> TuskResult> { get_table_columns_core(&state, &connection_id, &schema, &table).await } #[tauri::command] pub async fn get_table_constraints( state: State<'_, Arc>, connection_id: String, schema: String, table: String, ) -> TuskResult> { 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::(0), constraint_type: r.get::(1), columns: r.get::, _>(2), referenced_schema: r.get::, _>(3), referenced_table: r.get::, _>(4), referenced_columns: r.get::>, _>(5), update_rule: r.get::, _>(6), delete_rule: r.get::, _>(7), }) .collect()) } #[tauri::command] pub async fn get_table_indexes( state: State<'_, Arc>, connection_id: String, schema: String, table: String, ) -> TuskResult> { 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::(0), definition: r.get::(1), is_unique: r.get::(2), is_primary: r.get::(3), }) .collect()) } #[tauri::command] pub async fn get_completion_schema( state: State<'_, Arc>, connection_id: String, ) -> TuskResult>>> { 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>> = 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>> = 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>, connection_id: String, schema: String, table: String, ) -> TuskResult> { 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::(0), data_type: r.get::(1), is_nullable: r.get::(2), column_default: r.get::, _>(3), is_identity: r.get::(4), }) .collect()) } #[tauri::command] pub async fn get_table_triggers( state: State<'_, Arc>, connection_id: String, schema: String, table: String, ) -> TuskResult> { 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::(0), timing: r.get::(1), event: r.get::(2), orientation: r.get::(3), function_name: r.get::(4), is_enabled: r.get::(5), definition: r.get::(6), }) .collect()) }