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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user