diff --git a/package-lock.json b/package-lock.json index 4f0c4c9..a420f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,13 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-shell": "^2.3.5", + "@types/dagre": "^0.7.53", "@uiw/react-codemirror": "^4.25.4", + "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dagre": "^0.8.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", @@ -4469,6 +4472,61 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.53", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", + "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4874,6 +4932,66 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5269,6 +5387,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -5607,6 +5731,122 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -6757,6 +6997,15 @@ "dev": true, "license": "ISC" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/graphql": { "version": "16.12.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", @@ -7609,6 +7858,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index a63957b..43ce77f 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,13 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-shell": "^2.3.5", + "@types/dagre": "^0.7.53", "@uiw/react-codemirror": "^4.25.4", + "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dagre": "^0.8.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index a240b67..97cba8c 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -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::(4), character_maximum_length: r.get::, _>(5), is_primary_key: r.get::(6), + comment: r.get::, _>(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::(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()) } @@ -483,3 +531,186 @@ pub async fn get_column_details( }) .collect()) } + +#[tauri::command] +pub async fn get_table_triggers( + state: State<'_, Arc>, + connection_id: String, + schema: String, + table: String, +) -> TuskResult> { + 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::(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()) +} + +#[tauri::command] +pub async fn get_schema_erd( + state: State<'_, Arc>, + connection_id: String, + schema: String, +) -> TuskResult { + 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 = 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 = 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 = 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, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4bd2458..db9498c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/models/schema.rs b/src-tauri/src/models/schema.rs index 5375cb1..eaf75ca 100644 --- a/src-tauri/src/models/schema.rs +++ b/src-tauri/src/models/schema.rs @@ -18,6 +18,7 @@ pub struct ColumnInfo { pub ordinal_position: i32, pub character_maximum_length: Option, pub is_primary_key: bool, + pub comment: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -34,6 +35,11 @@ pub struct ConstraintInfo { pub name: String, pub constraint_type: String, pub columns: Vec, + pub referenced_schema: Option, + pub referenced_table: Option, + pub referenced_columns: Option>, + pub update_rule: Option, + pub delete_rule: Option, } #[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErdRelationship { + pub constraint_name: String, + pub source_schema: String, + pub source_table: String, + pub source_columns: Vec, + pub target_schema: String, + pub target_table: String, + pub target_columns: Vec, + pub update_rule: String, + pub delete_rule: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErdData { + pub tables: Vec, + pub relationships: Vec, +} diff --git a/src/components/erd/ErdDiagram.tsx b/src/components/erd/ErdDiagram.tsx new file mode 100644 index 0000000..fe3d957 --- /dev/null +++ b/src/components/erd/ErdDiagram.tsx @@ -0,0 +1,186 @@ +import { useMemo, useCallback, useEffect, useState } from "react"; +import { + ReactFlow, + Background, + Controls, + MiniMap, + MarkerType, + PanOnScrollMode, + applyNodeChanges, + applyEdgeChanges, + type Node, + type Edge, + type NodeTypes, + type NodeChange, + type EdgeChange, +} from "@xyflow/react"; +import dagre from "dagre"; +import "@xyflow/react/dist/style.css"; + +import { useSchemaErd } from "@/hooks/use-schema"; +import { ErdTableNode, type ErdTableNodeData } from "./ErdTableNode"; +import type { ErdData } from "@/types"; + +const nodeTypes: NodeTypes = { + erdTable: ErdTableNode, +}; + +const NODE_WIDTH = 250; +const NODE_ROW_HEIGHT = 24; +const NODE_HEADER_HEIGHT = 36; + +function buildLayout(data: ErdData): { nodes: Node[]; edges: Edge[] } { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: "LR", nodesep: 60, ranksep: 150 }); + + // Build list of FK column names per table for icon display + const fkColumnsPerTable = new Map(); + for (const rel of data.relationships) { + const key = `${rel.source_schema}.${rel.source_table}`; + if (!fkColumnsPerTable.has(key)) fkColumnsPerTable.set(key, []); + for (const col of rel.source_columns) { + const arr = fkColumnsPerTable.get(key)!; + if (!arr.includes(col)) arr.push(col); + } + } + + for (const table of data.tables) { + const height = NODE_HEADER_HEIGHT + table.columns.length * NODE_ROW_HEIGHT; + g.setNode(table.name, { width: NODE_WIDTH, height }); + } + + for (const rel of data.relationships) { + g.setEdge(rel.source_table, rel.target_table); + } + + dagre.layout(g); + + const nodes: Node[] = data.tables.map((table) => { + const pos = g.node(table.name); + const tableKey = `${table.schema}.${table.name}`; + return { + id: table.name, + type: "erdTable", + position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - pos.height / 2 }, + data: { + label: table.name, + schema: table.schema, + columns: table.columns, + fkColumnNames: fkColumnsPerTable.get(tableKey) ?? [], + } satisfies ErdTableNodeData, + }; + }); + + const edges: Edge[] = data.relationships.map((rel) => ({ + id: rel.constraint_name, + source: rel.source_table, + target: rel.target_table, + type: "smoothstep", + label: rel.constraint_name, + labelStyle: { fontSize: 10, fill: "var(--muted-foreground)" }, + labelBgStyle: { fill: "var(--card)", fillOpacity: 0.8 }, + labelBgPadding: [4, 2] as [number, number], + markerEnd: { + type: MarkerType.ArrowClosed, + width: 16, + height: 16, + color: "var(--muted-foreground)", + }, + style: { stroke: "var(--muted-foreground)", strokeWidth: 1.5 }, + })); + + return { nodes, edges }; +} + +interface Props { + connectionId: string; + schema: string; +} + +export function ErdDiagram({ connectionId, schema }: Props) { + const { data: erdData, isLoading, error } = useSchemaErd(connectionId, schema); + + const layout = useMemo(() => { + if (!erdData) return null; + return buildLayout(erdData); + }, [erdData]); + + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + + useEffect(() => { + if (layout) { + setNodes(layout.nodes); + setEdges(layout.edges); + } + }, [layout]); + + const onNodesChange = useCallback( + (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), + [], + ); + + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)), + [], + ); + + const onInit = useCallback((instance: { fitView: () => void }) => { + setTimeout(() => instance.fitView(), 50); + }, []); + + if (isLoading) { + return ( +
+ Loading ER diagram... +
+ ); + } + + if (error) { + return ( +
+ Error loading ER diagram: {String(error)} +
+ ); + } + + if (!erdData || erdData.tables.length === 0) { + return ( +
+ No tables found in schema "{schema}". +
+ ); + } + + return ( +
+ + + + + +
+ ); +} diff --git a/src/components/erd/ErdTableNode.tsx b/src/components/erd/ErdTableNode.tsx new file mode 100644 index 0000000..76cc61a --- /dev/null +++ b/src/components/erd/ErdTableNode.tsx @@ -0,0 +1,54 @@ +import { memo } from "react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import type { ErdColumn } from "@/types"; +import { KeyRound, Link } from "lucide-react"; + +export interface ErdTableNodeData { + label: string; + schema: string; + columns: ErdColumn[]; + fkColumnNames: string[]; + [key: string]: unknown; +} + +function ErdTableNodeComponent({ data }: NodeProps) { + const { label, columns, fkColumnNames } = data as unknown as ErdTableNodeData; + + return ( +
+
+ {label} +
+
+ {(columns as ErdColumn[]).map((col, i) => ( +
+ {col.is_primary_key ? ( + + ) : (fkColumnNames as string[]).includes(col.name) ? ( + + ) : ( + + )} + {col.name} + {col.data_type} + {col.is_nullable && ( + ? + )} +
+ ))} +
+ + +
+ ); +} + +export const ErdTableNode = memo(ErdTableNodeComponent); diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index db606e2..f357d46 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -1,7 +1,7 @@ import { useAppStore } from "@/stores/app-store"; import { useConnections } from "@/hooks/use-connections"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { X, Table2, Code, Columns, Users, Activity, Search } from "lucide-react"; +import { X, Table2, Code, Columns, Users, Activity, Search, GitFork } from "lucide-react"; export function TabBar() { const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore(); @@ -16,6 +16,7 @@ export function TabBar() { roles: , sessions: , lookup: , + erd: , }; return ( diff --git a/src/components/schema/SchemaTree.tsx b/src/components/schema/SchemaTree.tsx index 32d8087..9b00ee2 100644 --- a/src/components/schema/SchemaTree.tsx +++ b/src/components/schema/SchemaTree.tsx @@ -136,6 +136,17 @@ export function SchemaTree() { }; addTab(tab); }} + onViewErd={(schema) => { + const tab: Tab = { + id: crypto.randomUUID(), + type: "erd", + title: `${schema} (ER Diagram)`, + connectionId: activeConnectionId, + database: currentDatabase ?? undefined, + schema, + }; + addTab(tab); + }} /> ))} @@ -150,6 +161,7 @@ function DatabaseNode({ isSwitching, onOpenTable, onViewStructure, + onViewErd, }: { name: string; isActive: boolean; @@ -158,6 +170,7 @@ function DatabaseNode({ isSwitching: boolean; onOpenTable: (schema: string, table: string) => void; onViewStructure: (schema: string, table: string) => void; + onViewErd: (schema: string) => void; }) { const [expanded, setExpanded] = useState(false); const readOnlyMap = useAppStore((s) => s.readOnlyMap); @@ -234,6 +247,7 @@ function DatabaseNode({ connectionId={connectionId} onOpenTable={onOpenTable} onViewStructure={onViewStructure} + onViewErd={onViewErd} /> )} @@ -250,10 +264,12 @@ function SchemasForCurrentDb({ connectionId, onOpenTable, onViewStructure, + onViewErd, }: { connectionId: string; onOpenTable: (schema: string, table: string) => void; onViewStructure: (schema: string, table: string) => void; + onViewErd: (schema: string) => void; }) { const { data: schemas } = useSchemas(connectionId); @@ -272,6 +288,7 @@ function SchemasForCurrentDb({ connectionId={connectionId} onOpenTable={(table) => onOpenTable(schema, table)} onViewStructure={(table) => onViewStructure(schema, table)} + onViewErd={() => onViewErd(schema)} /> ))} @@ -283,32 +300,43 @@ function SchemaNode({ connectionId, onOpenTable, onViewStructure, + onViewErd, }: { schema: string; connectionId: string; onOpenTable: (table: string) => void; onViewStructure: (table: string) => void; + onViewErd: () => void; }) { const [expanded, setExpanded] = useState(false); return (
-
setExpanded(!expanded)} - > - {expanded ? ( - - ) : ( - - )} - {expanded ? ( - - ) : ( - - )} - {schema} -
+ + +
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + {expanded ? ( + + ) : ( + + )} + {schema} +
+
+ + + View ER Diagram + + +
{expanded && (
getTableIndexes(connectionId, schema, table), }); + const { data: triggers } = useQuery({ + queryKey: ["table-triggers", connectionId, schema, table], + queryFn: () => getTableTriggers(connectionId, schema, table), + }); + return ( @@ -50,6 +56,9 @@ export function TableStructure({ connectionId, schema, table }: Props) { Indexes{indexes ? ` (${indexes.length})` : ""} + + Triggers{triggers ? ` (${triggers.length})` : ""} + @@ -63,6 +72,7 @@ export function TableStructure({ connectionId, schema, table }: Props) { Nullable Default Key + Comment @@ -84,7 +94,7 @@ export function TableStructure({ connectionId, schema, table }: Props) { {col.is_nullable ? "YES" : "NO"} - {col.column_default ?? "—"} + {col.column_default ?? "\u2014"} {col.is_primary_key && ( @@ -93,6 +103,9 @@ export function TableStructure({ connectionId, schema, table }: Props) { )} + + {col.comment ?? "\u2014"} + ))} @@ -108,6 +121,9 @@ export function TableStructure({ connectionId, schema, table }: Props) { Name Type Columns + References + On Update + On Delete @@ -124,6 +140,17 @@ export function TableStructure({ connectionId, schema, table }: Props) { {c.columns.join(", ")} + + {c.referenced_table + ? `${c.referenced_schema}.${c.referenced_table}(${c.referenced_columns?.join(", ")})` + : "\u2014"} + + + {c.update_rule ?? "\u2014"} + + + {c.delete_rule ?? "\u2014"} + ))} @@ -163,6 +190,49 @@ export function TableStructure({ connectionId, schema, table }: Props) { + + + + + + + Name + Timing + Event + Level + Function + Enabled + + + + {triggers?.map((t) => ( + + + {t.name} + + + + {t.timing} + + + + {t.event} + + + {t.orientation} + + + {t.function_name} + + + {t.is_enabled ? "YES" : "NO"} + + + ))} + +
+
+
); } diff --git a/src/components/workspace/TabContent.tsx b/src/components/workspace/TabContent.tsx index 8ccff8c..7ac0b29 100644 --- a/src/components/workspace/TabContent.tsx +++ b/src/components/workspace/TabContent.tsx @@ -5,6 +5,7 @@ import { TableStructure } from "@/components/table-viewer/TableStructure"; import { RoleManagerView } from "@/components/management/RoleManagerView"; import { SessionsView } from "@/components/management/SessionsView"; import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel"; +import { ErdDiagram } from "@/components/erd/ErdDiagram"; export function TabContent() { const { tabs, activeTabId, updateTab } = useAppStore(); @@ -72,6 +73,14 @@ export function TabContent() { /> ); break; + case "erd": + content = ( + + ); + break; default: content = null; } diff --git a/src/hooks/use-schema.ts b/src/hooks/use-schema.ts index d3b6a8c..47dba3e 100644 --- a/src/hooks/use-schema.ts +++ b/src/hooks/use-schema.ts @@ -8,6 +8,7 @@ import { listSequences, switchDatabase, getColumnDetails, + getSchemaErd, } from "@/lib/tauri"; import type { ConnectionConfig } from "@/types"; @@ -88,3 +89,12 @@ export function useColumnDetails(connectionId: string | null, schema: string | n staleTime: 5 * 60 * 1000, }); } + +export function useSchemaErd(connectionId: string | null, schema: string | null) { + return useQuery({ + queryKey: ["schema-erd", connectionId, schema], + queryFn: () => getSchemaErd(connectionId!, schema!), + enabled: !!connectionId && !!schema, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 8af6968..c944f86 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -11,6 +11,8 @@ import type { ColumnInfo, ConstraintInfo, IndexInfo, + TriggerInfo, + ErdData, HistoryEntry, SavedQuery, SessionInfo, @@ -115,6 +117,15 @@ export const getTableIndexes = ( table: string ) => invoke("get_table_indexes", { connectionId, schema, table }); +export const getTableTriggers = ( + connectionId: string, + schema: string, + table: string +) => invoke("get_table_triggers", { connectionId, schema, table }); + +export const getSchemaErd = (connectionId: string, schema: string) => + invoke("get_schema_erd", { connectionId, schema }); + // Data export const getTableData = (params: { connectionId: string; diff --git a/src/types/index.ts b/src/types/index.ts index f26897d..99f0c81 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,12 +56,18 @@ export interface ColumnInfo { ordinal_position: number; character_maximum_length: number | null; is_primary_key: boolean; + comment: string | null; } export interface ConstraintInfo { name: string; constraint_type: string; columns: string[]; + referenced_schema: string | null; + referenced_table: string | null; + referenced_columns: string[] | null; + update_rule: string | null; + delete_rule: string | null; } export interface IndexInfo { @@ -223,8 +229,13 @@ export interface SavedQuery { created_at: string; } +export type AiProvider = "ollama" | "openai" | "anthropic"; + export interface AiSettings { + provider: AiProvider; ollama_url: string; + openai_api_key?: string; + anthropic_api_key?: string; model: string; } @@ -272,7 +283,47 @@ export interface LookupProgress { total: number; } -export type TabType = "query" | "table" | "structure" | "roles" | "sessions" | "lookup"; +export interface TriggerInfo { + name: string; + event: string; + timing: string; + orientation: string; + function_name: string; + is_enabled: boolean; + definition: string; +} + +export interface ErdColumn { + name: string; + data_type: string; + is_nullable: boolean; + is_primary_key: boolean; +} + +export interface ErdTable { + schema: string; + name: string; + columns: ErdColumn[]; +} + +export interface ErdRelationship { + constraint_name: string; + source_schema: string; + source_table: string; + source_columns: string[]; + target_schema: string; + target_table: string; + target_columns: string[]; + update_rule: string; + delete_rule: string; +} + +export interface ErdData { + tables: ErdTable[]; + relationships: ErdRelationship[]; +} + +export type TabType = "query" | "table" | "structure" | "roles" | "sessions" | "lookup" | "erd"; export interface Tab { id: string;