feat: add ER diagram and enhance TableStructure with FK details, triggers, comments

- Add interactive ER diagram with ReactFlow + dagre auto-layout, accessible
  via right-click context menu on schema nodes in the sidebar
- Enhance TableStructure: column comments, FK referenced table/columns,
  ON UPDATE/DELETE rules, new Triggers tab
- Backend: rewrite get_table_constraints using pg_constraint for proper
  composite FK support, add get_table_triggers and get_schema_erd commands

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 16:37:38 +03:00
parent b44254bb29
commit 94df94db7c
14 changed files with 993 additions and 31 deletions

View File

@@ -1,5 +1,8 @@
use crate::error::{TuskError, TuskResult};
use crate::models::schema::{ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
use crate::models::schema::{
ColumnDetail, ColumnInfo, ConstraintInfo, ErdColumn, ErdData, ErdRelationship, ErdTable,
IndexInfo, SchemaObject, TriggerInfo,
};
use crate::state::{AppState, DbFlavor};
use sqlx::Row;
use std::collections::HashMap;
@@ -271,7 +274,13 @@ pub async fn get_table_columns_core(
AND tc.table_name = $2 \
AND kcu.column_name = c.column_name \
LIMIT 1 \
), false) as is_pk \
), 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",
@@ -292,6 +301,7 @@ pub async fn get_table_columns_core(
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())
}
@@ -320,16 +330,49 @@ pub async fn get_table_constraints(
let rows = sqlx::query(
"SELECT \
tc.constraint_name, \
tc.constraint_type, \
array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] as columns \
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.table_schema = $1 AND tc.table_name = $2 \
GROUP BY tc.constraint_name, tc.constraint_type \
ORDER BY tc.constraint_type, tc.constraint_name",
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)
@@ -343,6 +386,11 @@ pub async fn get_table_constraints(
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())
}
@@ -483,3 +531,186 @@ pub async fn get_column_details(
})
.collect())
}
#[tauri::command]
pub async fn get_table_triggers(
state: State<'_, Arc<AppState>>,
connection_id: String,
schema: String,
table: String,
) -> TuskResult<Vec<TriggerInfo>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
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())
}
#[tauri::command]
pub async fn get_schema_erd(
state: State<'_, Arc<AppState>>,
connection_id: String,
schema: String,
) -> TuskResult<ErdData> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
// Get all tables with columns
let col_rows = sqlx::query(
"SELECT \
c.table_name, \
c.column_name, \
c.data_type, \
c.is_nullable = 'YES' AS is_nullable, \
COALESCE(( \
SELECT true FROM pg_constraint con \
JOIN pg_class cl ON cl.oid = con.conrelid \
JOIN pg_namespace ns ON ns.oid = cl.relnamespace \
WHERE con.contype = 'p' \
AND ns.nspname = $1 AND cl.relname = c.table_name \
AND EXISTS ( \
SELECT 1 FROM unnest(con.conkey) k \
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k \
WHERE a.attname = c.column_name \
) \
LIMIT 1 \
), false) AS is_pk \
FROM information_schema.columns c \
JOIN information_schema.tables t \
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
WHERE c.table_schema = $1 AND t.table_type = 'BASE TABLE' \
ORDER BY c.table_name, c.ordinal_position",
)
.bind(&schema)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
// Build tables map
let mut tables_map: HashMap<String, ErdTable> = HashMap::new();
for row in &col_rows {
let table_name: String = row.get(0);
let entry = tables_map.entry(table_name.clone()).or_insert_with(|| ErdTable {
schema: schema.clone(),
name: table_name,
columns: Vec::new(),
});
entry.columns.push(ErdColumn {
name: row.get(1),
data_type: row.get(2),
is_nullable: row.get(3),
is_primary_key: row.get(4),
});
}
let tables: Vec<ErdTable> = tables_map.into_values().collect();
// Get all FK relationships
let fk_rows = sqlx::query(
"SELECT \
c.conname AS constraint_name, \
src_ns.nspname AS source_schema, \
src_cl.relname AS source_table, \
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 source_columns, \
ref_ns.nspname AS target_schema, \
ref_cl.relname AS target_table, \
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[] AS target_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' \
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' \
END AS delete_rule \
FROM pg_constraint c \
JOIN pg_class src_cl ON src_cl.oid = c.conrelid \
JOIN pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace \
JOIN pg_class ref_cl ON ref_cl.oid = c.confrelid \
JOIN pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace \
WHERE c.contype = 'f' AND src_ns.nspname = $1 \
ORDER BY c.conname",
)
.bind(&schema)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let relationships: Vec<ErdRelationship> = fk_rows
.iter()
.map(|r| ErdRelationship {
constraint_name: r.get(0),
source_schema: r.get(1),
source_table: r.get(2),
source_columns: r.get(3),
target_schema: r.get(4),
target_table: r.get(5),
target_columns: r.get(6),
update_rule: r.get(7),
delete_rule: r.get(8),
})
.collect();
Ok(ErdData {
tables,
relationships,
})
}

View File

@@ -59,6 +59,8 @@ pub fn run() {
commands::schema::get_table_indexes,
commands::schema::get_completion_schema,
commands::schema::get_column_details,
commands::schema::get_table_triggers,
commands::schema::get_schema_erd,
// data
commands::data::get_table_data,
commands::data::update_row,

View File

@@ -18,6 +18,7 @@ pub struct ColumnInfo {
pub ordinal_position: i32,
pub character_maximum_length: Option<i32>,
pub is_primary_key: bool,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -34,6 +35,11 @@ pub struct ConstraintInfo {
pub name: String,
pub constraint_type: String,
pub columns: Vec<String>,
pub referenced_schema: Option<String>,
pub referenced_table: Option<String>,
pub referenced_columns: Option<Vec<String>>,
pub update_rule: Option<String>,
pub delete_rule: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -43,3 +49,48 @@ pub struct IndexInfo {
pub is_unique: bool,
pub is_primary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerInfo {
pub name: String,
pub event: String,
pub timing: String,
pub orientation: String,
pub function_name: String,
pub is_enabled: bool,
pub definition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdColumn {
pub name: String,
pub data_type: String,
pub is_nullable: bool,
pub is_primary_key: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdTable {
pub schema: String,
pub name: String,
pub columns: Vec<ErdColumn>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdRelationship {
pub constraint_name: String,
pub source_schema: String,
pub source_table: String,
pub source_columns: Vec<String>,
pub target_schema: String,
pub target_table: String,
pub target_columns: Vec<String>,
pub update_rule: String,
pub delete_rule: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdData {
pub tables: Vec<ErdTable>,
pub relationships: Vec<ErdRelationship>,
}