diff --git a/apps/tauri/package.json b/apps/tauri/package.json index 28796961..b4caab7b 100644 --- a/apps/tauri/package.json +++ b/apps/tauri/package.json @@ -18,10 +18,14 @@ "@codemirror/language": "^6.13.1", "@codemirror/state": "^6.7.6", "@codemirror/view": "^6.43.15", + "@dagrejs/dagre": "^3.1.3", "@glideapps/glide-data-grid": "^6.0.3", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.8.4", "@tauri-apps/plugin-opener": "^2", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", "codemirror": "^6.0.2", "lodash": "^4.18.1", "marked": "^4.3.0", @@ -33,6 +37,7 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@types/dagre": "^0.7.54", "@types/lodash": "^4.17.27", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", diff --git a/apps/tauri/pnpm-lock.yaml b/apps/tauri/pnpm-lock.yaml index 0b40b7a5..5461865c 100644 --- a/apps/tauri/pnpm-lock.yaml +++ b/apps/tauri/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@codemirror/view': specifier: ^6.43.15 version: 6.43.15 + '@dagrejs/dagre': + specifier: ^3.1.3 + version: 3.1.3 '@glideapps/glide-data-grid': specifier: ^6.0.3 version: 6.0.3(lodash@4.18.1)(marked@4.3.0)(react-dom@18.3.1(react@18.3.1))(react-responsive-carousel@3.2.23)(react@18.3.1) @@ -35,6 +38,15 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.7.3 + '@vue-flow/background': + specifier: ^1.3.2 + version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.45(typescript@5.8.3)))(vue@3.5.45(typescript@5.8.3)) + '@vue-flow/controls': + specifier: ^1.1.3 + version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.45(typescript@5.8.3)))(vue@3.5.45(typescript@5.8.3)) + '@vue-flow/core': + specifier: ^1.48.2 + version: 1.48.2(vue@3.5.45(typescript@5.8.3)) codemirror: specifier: ^6.0.2 version: 6.0.2 @@ -63,6 +75,9 @@ importers: '@tauri-apps/cli': specifier: ^2 version: 2.12.4 + '@types/dagre': + specifier: ^0.7.54 + version: 0.7.54 '@types/lodash': specifier: ^4.17.27 version: 4.17.27 @@ -211,6 +226,12 @@ packages: '@codemirror/view@6.43.15': resolution: {integrity: sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==} + '@dagrejs/dagre@3.1.3': + resolution: {integrity: sha512-X4aUTwOReZvthaZWOVHqlJ52/ZdhZFEM4+Lh/uR4XooUSLE6p71bcy7MPGzYnNUcNh+nj+i/qJQpCMIdQnSmrg==} + + '@dagrejs/graphlib@4.0.6': + resolution: {integrity: sha512-Hjv7hbfmSBMP4nVXMFt4gusqWWHghQF0qNT4V5KHo4Jatvd+2L5VHKFnnIjXVuDnaHIRz7ZuHSnlf0RuZkOwWA==} + '@emotion/is-prop-valid@1.4.0': resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} @@ -666,6 +687,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/dagre@0.7.54': + resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -686,6 +710,9 @@ packages: '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@vitejs/plugin-vue@6.0.11': resolution: {integrity: sha512-m48lskIUJGlB6qUiYhDMI8nd2ngeidz2U4nKTjqI13cFMu5rH8JeEILPkDtK9Fk/TjBA2pkj8AlbenqDaKu+ww==} engines: {node: ^20.19.0 || >=22.12.0} @@ -731,6 +758,23 @@ packages: '@volar/typescript@2.4.15': resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + '@vue-flow/background@1.3.2': + resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/controls@1.1.3': + resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/core@1.48.2': + resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==} + peerDependencies: + vue: ^3.3.0 + '@vue/compiler-core@3.5.45': resolution: {integrity: sha512-XGHJrM6ufZARAc/w+tyOswStw9tdKiqCKBScI4gGaNxD+aaGu/HQWgKJ+JItjRLPqenQ7iE7G8AJC7T5ew1p+g==} @@ -778,6 +822,15 @@ packages: '@vue/shared@3.5.45': resolution: {integrity: sha512-ue9fgHmVNPlP7iW3+cxoBX/Cnlw53dHlVSw0JKGY6z1lObg8pHoj928M2nzG4/yVsMZ3NaRZ2s2z61uC16NDwQ==} + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + alien-signals@1.0.13: resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} @@ -847,6 +900,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.1: + resolution: {integrity: sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.2: + resolution: {integrity: sha512-sAzH4Y4nsLo8eSqw5jKiZt+LU2wZmL/qS+OkxQGjhwazB/D7TY34R/QK1eshfGr7cZEKymKZ/Haem7m+lu98Sg==} + engines: {node: '>=12'} + + d3-drag@3.0.1: + resolution: {integrity: sha512-OWeoayCq8J69hUIjvL+Mb6Q6Oaj/fyHV5XaXByEUAG+JUjIfTGDnk3qGpcXrD3VmQ3BSV96fcbM6fqq7C0tbPA==} + engines: {node: '>=12'} + + d3-ease@3.0.2: + resolution: {integrity: sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.2: + resolution: {integrity: sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==} + engines: {node: '>=12'} + + d3-selection@3.0.1: + resolution: {integrity: sha512-ODSf35vqCrlCfp7hrm0eZjvOosSXSYLjqvIlhMBV9q9VOx7EBJG6pc+uXBswEZNJgCGJ+qnKYcWutbZDIxe/Xg==} + engines: {node: '>=12'} + + d3-timer@3.0.2: + resolution: {integrity: sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==} + engines: {node: '>=12'} + + d3-transition@3.0.2: + resolution: {integrity: sha512-RcjCsdFgyFoDB+QXLC5m60ZkTN0k9zH+ovoLlTdJnDS1AbfbcuowiM4pCKSTOclNibS4f4J4cbv2m7O7y8NTOQ==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.1: + resolution: {integrity: sha512-kfjD3BDQu/FOatpZ6JdMSyIoDwTxIpI29ySTI2J5ZlDaH1WbuJGQJ2Q9oJkWTEZEW98j6idezoomSD3Cn2zwiw==} + engines: {node: '>=12'} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -1245,6 +1336,17 @@ packages: vscode-uri@3.2.0: resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-tsc@2.2.12: resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} hasBin: true @@ -1460,6 +1562,12 @@ snapshots: style-mod: 4.1.4 w3c-keyname: 2.2.8 + '@dagrejs/dagre@3.1.3': + dependencies: + '@dagrejs/graphlib': 4.0.6 + + '@dagrejs/graphlib@4.0.6': {} + '@emotion/is-prop-valid@1.4.0': dependencies: '@emotion/memoize': 0.9.0 @@ -1783,6 +1891,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/dagre@0.7.54': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.10': {} @@ -1800,6 +1910,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/web-bluetooth@0.0.20': {} + '@vitejs/plugin-vue@6.0.11(vite@6.4.6)(vue@3.5.45(typescript@5.8.3))': dependencies: '@rolldown/pluginutils': 1.1.0 @@ -1860,6 +1972,27 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.2.0 + '@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.45(typescript@5.8.3)))(vue@3.5.45(typescript@5.8.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.45(typescript@5.8.3)) + vue: 3.5.45(typescript@5.8.3) + + '@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.45(typescript@5.8.3)))(vue@3.5.45(typescript@5.8.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.45(typescript@5.8.3)) + vue: 3.5.45(typescript@5.8.3) + + '@vue-flow/core@1.48.2(vue@3.5.45(typescript@5.8.3))': + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.45(typescript@5.8.3)) + d3-drag: 3.0.1 + d3-interpolate: 3.0.2 + d3-selection: 3.0.1 + d3-zoom: 3.0.1 + vue: 3.5.45(typescript@5.8.3) + transitivePeerDependencies: + - '@vue/composition-api' + '@vue/compiler-core@3.5.45': dependencies: '@babel/parser': 7.29.14 @@ -1950,6 +2083,25 @@ snapshots: '@vue/shared@3.5.45': {} + '@vueuse/core@10.11.1(vue@3.5.45(typescript@5.8.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.45(typescript@5.8.3)) + vue-demi: 0.14.10(vue@3.5.45(typescript@5.8.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/shared@10.11.1(vue@3.5.45(typescript@5.8.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.45(typescript@5.8.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + alien-signals@1.0.13: {} assertion-error@2.0.1: {} @@ -2014,6 +2166,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.1: {} + + d3-dispatch@3.0.2: {} + + d3-drag@3.0.1: + dependencies: + d3-dispatch: 3.0.2 + d3-selection: 3.0.1 + + d3-ease@3.0.2: {} + + d3-interpolate@3.0.2: + dependencies: + d3-color: 3.1.1 + + d3-selection@3.0.1: {} + + d3-timer@3.0.2: {} + + d3-transition@3.0.2(d3-selection@3.0.1): + dependencies: + d3-color: 3.1.1 + d3-dispatch: 3.0.2 + d3-ease: 3.0.2 + d3-interpolate: 3.0.2 + d3-selection: 3.0.1 + d3-timer: 3.0.2 + + d3-zoom@3.0.1: + dependencies: + d3-dispatch: 3.0.2 + d3-drag: 3.0.1 + d3-interpolate: 3.0.2 + d3-selection: 3.0.1 + d3-transition: 3.0.2(d3-selection@3.0.1) + de-indent@1.0.2: {} debug@4.4.3: @@ -2383,6 +2571,10 @@ snapshots: vscode-uri@3.2.0: {} + vue-demi@0.14.10(vue@3.5.45(typescript@5.8.3)): + dependencies: + vue: 3.5.45(typescript@5.8.3) + vue-tsc@2.2.12(typescript@5.8.3): dependencies: '@volar/typescript': 2.4.15 diff --git a/apps/tauri/pnpm-workspace.yaml b/apps/tauri/pnpm-workspace.yaml index 09a02ca1..1bdd797c 100644 --- a/apps/tauri/pnpm-workspace.yaml +++ b/apps/tauri/pnpm-workspace.yaml @@ -1,4 +1,6 @@ allowBuilds: esbuild: true + vue-demi: true onlyBuiltDependencies: - esbuild + - vue-demi diff --git a/apps/tauri/src-tauri/src/commands.rs b/apps/tauri/src-tauri/src/commands.rs index 744659ae..a441651d 100644 --- a/apps/tauri/src-tauri/src/commands.rs +++ b/apps/tauri/src-tauri/src/commands.rs @@ -1,6 +1,6 @@ use crate::connection::{ ConnectionConfig, ConnectionError, ConnectionId, ConnectionManager, ConnectionRegistry, - PostgresObjectDetail, SchemaObject, SchemaObjectKind, SqlObjectKind, SqlObjectNode, + ErdSchema, PostgresObjectDetail, SchemaObject, SchemaObjectKind, SqlObjectKind, SqlObjectNode, SqliteOverview, TableSchema, }; use crate::profiles::{ConnectionProfile, ProfileStore, ProfileStoreError}; @@ -345,6 +345,23 @@ pub fn db_sqlite_overview( registry.with_sqlite(&id, |s| s.database_overview()) } +/// ER diagram catalog: tables/views + FK edges (optional neighbourhood). +#[tauri::command] +pub fn db_erd_schema( + id: ConnectionId, + schema: Option, + focus: Option, + depth: Option, + registry: State<'_, ConnectionRegistry>, +) -> Result { + registry.erd_schema( + &id, + schema.as_deref(), + focus.as_deref(), + depth, + ) +} + /// Execute SQL and stream `QueryEvent` batches over `on_event`. /// Failures return as the invoke `Err` only (no dual channel `Error` event). #[tauri::command] diff --git a/apps/tauri/src-tauri/src/connection/erd.rs b/apps/tauri/src-tauri/src/connection/erd.rs new file mode 100644 index 00000000..ef728b5c --- /dev/null +++ b/apps/tauri/src-tauri/src/connection/erd.rs @@ -0,0 +1,229 @@ +//! ER diagram catalog DTOs — mirrors Flutter `ErdSchema` / `ErdTable` / `ErdRelation`. + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +/// Column on an ER table card. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErdColumn { + pub name: String, + pub data_type: String, + pub is_primary_key: bool, + pub is_foreign_key: bool, + pub is_nullable: bool, + #[serde(default)] + pub is_unique: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_value: Option, + #[serde(default)] + pub is_identity: bool, +} + +/// Table (or view) node on the diagram. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErdTable { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub columns: Vec, + #[serde(default)] + pub is_view: bool, +} + +/// Foreign key edge: `from_table.from_column` → `to_table.to_column`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErdRelation { + pub from_table: String, + pub from_column: String, + pub to_table: String, + pub to_column: String, + /// Referencing column may be NULL. + #[serde(default)] + pub optional: bool, + /// Referencing side is unique (1:1). + #[serde(default)] + pub one_to_one: bool, +} + +/// Full (or neighbourhood) schema for the ER canvas. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErdSchema { + pub tables: Vec, + pub relations: Vec, + #[serde(default)] + pub truncated: bool, +} + +impl ErdSchema { + pub fn empty() -> Self { + Self { + tables: Vec::new(), + relations: Vec::new(), + truncated: false, + } + } + + /// Mark FK flags on columns that appear as relation sources. + pub fn mark_foreign_keys(&mut self) { + let mut fk_cols: HashSet<(String, String)> = HashSet::new(); + for r in &self.relations { + fk_cols.insert((r.from_table.clone(), r.from_column.clone())); + } + for t in &mut self.tables { + for c in &mut t.columns { + if fk_cols.contains(&(t.name.clone(), c.name.clone())) { + c.is_foreign_key = true; + } + } + } + } + + /// Fill `optional` / `one_to_one` from column nullability / uniqueness / PK. + pub fn annotate_relations(&mut self) { + let mut by_name: HashMap<&str, &ErdTable> = HashMap::new(); + for t in &self.tables { + by_name.insert(t.name.as_str(), t); + } + for r in &mut self.relations { + let Some(src) = by_name.get(r.from_table.as_str()) else { + continue; + }; + let Some(col) = src.columns.iter().find(|c| c.name == r.from_column) else { + continue; + }; + r.optional = col.is_nullable; + r.one_to_one = col.is_unique + || (col.is_primary_key + && src + .columns + .iter() + .filter(|c| c.is_primary_key) + .count() + == 1); + } + } + + /// Keep only tables in `names` and relations whose both ends remain. + pub fn filter_tables(&mut self, names: &HashSet) { + self.tables.retain(|t| names.contains(&t.name)); + self.relations + .retain(|r| names.contains(&r.from_table) && names.contains(&r.to_table)); + } +} + +/// Undirected BFS around `focus` over FK edges (Flutter `ErdCatalog.neighbourhood`). +pub fn neighbourhood(relations: &[ErdRelation], focus: &str, depth: usize) -> HashSet { + let mut adjacent: HashMap<&str, HashSet<&str>> = HashMap::new(); + for r in relations { + adjacent + .entry(r.from_table.as_str()) + .or_default() + .insert(r.to_table.as_str()); + adjacent + .entry(r.to_table.as_str()) + .or_default() + .insert(r.from_table.as_str()); + } + let mut seen: HashSet = HashSet::from([focus.to_owned()]); + let mut frontier: HashSet = HashSet::from([focus.to_owned()]); + for _ in 0..depth { + if frontier.is_empty() { + break; + } + let mut next = HashSet::new(); + for t in &frontier { + if let Some(ns) = adjacent.get(t.as_str()) { + for n in ns { + if seen.insert((*n).to_owned()) { + next.insert((*n).to_owned()); + } + } + } + } + frontier = next; + } + seen +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn neighbourhood_depth_one() { + let rels = vec![ + ErdRelation { + from_table: "orders".into(), + from_column: "user_id".into(), + to_table: "users".into(), + to_column: "id".into(), + optional: false, + one_to_one: false, + }, + ErdRelation { + from_table: "order_items".into(), + from_column: "order_id".into(), + to_table: "orders".into(), + to_column: "id".into(), + optional: false, + one_to_one: false, + }, + ]; + let n = neighbourhood(&rels, "orders", 1); + assert!(n.contains("orders")); + assert!(n.contains("users")); + assert!(n.contains("order_items")); + assert_eq!(n.len(), 3); + } + + #[test] + fn mark_fk_and_annotate() { + let mut schema = ErdSchema { + tables: vec![ErdTable { + name: "orders".into(), + schema: None, + is_view: false, + columns: vec![ + ErdColumn { + name: "id".into(), + data_type: "INTEGER".into(), + is_primary_key: true, + is_foreign_key: false, + is_nullable: false, + is_unique: false, + default_value: None, + is_identity: true, + }, + ErdColumn { + name: "user_id".into(), + data_type: "INTEGER".into(), + is_primary_key: false, + is_foreign_key: false, + is_nullable: true, + is_unique: false, + default_value: None, + is_identity: false, + }, + ], + }], + relations: vec![ErdRelation { + from_table: "orders".into(), + from_column: "user_id".into(), + to_table: "users".into(), + to_column: "id".into(), + optional: false, + one_to_one: false, + }], + truncated: false, + }; + schema.mark_foreign_keys(); + schema.annotate_relations(); + assert!(schema.tables[0].columns[1].is_foreign_key); + assert!(schema.relations[0].optional); + assert!(!schema.relations[0].one_to_one); + } +} diff --git a/apps/tauri/src-tauri/src/connection/manager.rs b/apps/tauri/src-tauri/src/connection/manager.rs index cf7c6f1f..38577f98 100644 --- a/apps/tauri/src-tauri/src/connection/manager.rs +++ b/apps/tauri/src-tauri/src/connection/manager.rs @@ -275,6 +275,32 @@ impl ConnectionRegistry { ) -> ConnectionResult { self.object_tree(id)?.describe(object_id) } + + /// ER diagram catalog (tables + FK edges). Optional neighbourhood around `focus`. + pub fn erd_schema( + &self, + id: &ConnectionId, + schema: Option<&str>, + focus: Option<&str>, + depth: Option, + ) -> ConnectionResult { + let depth = depth.unwrap_or(1) as usize; + match self.driver_of(id).ok_or_else(|| ConnectionError::NotFound { + id: id.clone(), + })? { + DriverKind::Sqlite => self.with_sqlite(id, |s| { + s.with_connection(|c| { + super::sqlite_erd::load_schema(c, focus, depth) + }) + }), + DriverKind::Postgres => self.with_postgres(id, |s| { + s.with_pool(|pool| { + super::postgres_erd::load_schema(pool, schema, focus, depth) + }) + }), + other => Err(ConnectionError::UnsupportedDriver { driver: other }), + } + } } impl ConnectionManager for ConnectionRegistry { diff --git a/apps/tauri/src-tauri/src/connection/mod.rs b/apps/tauri/src-tauri/src/connection/mod.rs index 205733c3..a4853eee 100644 --- a/apps/tauri/src-tauri/src/connection/mod.rs +++ b/apps/tauri/src-tauri/src/connection/mod.rs @@ -1,11 +1,13 @@ //! Shared connection layer for Querya drivers (SQLite first, then PG/MySQL/…). +mod erd; mod error; mod id; mod introspect; mod manager; mod object_tree; mod postgres; +mod postgres_erd; mod postgres_object_detail; mod postgres_object_tree; pub(crate) mod postgres_query; @@ -14,10 +16,12 @@ mod runtime; mod smoke_slice; mod sql_guard; mod sqlite; +mod sqlite_erd; mod sqlite_introspect; mod sqlite_object_tree; pub(crate) mod sqlite_query; +pub use erd::{ErdColumn, ErdRelation, ErdSchema, ErdTable}; pub use error::ConnectionError; pub use id::{ConnectionId, DriverKind}; pub use introspect::{ColumnMeta, SchemaObject, SchemaObjectKind, TableSchema}; diff --git a/apps/tauri/src-tauri/src/connection/postgres_erd.rs b/apps/tauri/src-tauri/src/connection/postgres_erd.rs new file mode 100644 index 00000000..0696ef09 --- /dev/null +++ b/apps/tauri/src-tauri/src/connection/postgres_erd.rs @@ -0,0 +1,294 @@ +//! PostgreSQL ER catalog — mirrors Flutter `ErdCatalog` pg_catalog queries. + +use super::erd::{ErdColumn, ErdRelation, ErdSchema, ErdTable}; +use super::error::{ConnectionError, ConnectionResult}; +use super::id::DriverKind; +use super::runtime; +use sqlx::postgres::PgRow; +use sqlx::{PgPool, Row}; +use std::collections::HashSet; + +/// Load tables of `schema`, or **all user schemas** when `schema` is `None`. +/// +/// When `focus` is set, only the neighbourhood within `depth` FK hops is kept. +/// Focus may be `table` or `schema.table`. +pub fn load_schema( + pool: &PgPool, + schema: Option<&str>, + focus: Option<&str>, + depth: usize, +) -> ConnectionResult { + let schema_name = schema.map(str::to_owned); + let relations = load_foreign_keys(pool)?; + let mut tables = load_tables(pool, schema_name.as_deref())?; + + // Neighbourhood may pull tables from other schemas via qualified names. + if let Some(focus) = focus { + let all_rels = relations.clone(); + let names = super::erd::neighbourhood(&all_rels, focus, depth); + let mut names = names; + names.insert(focus.to_owned()); + // Also try bare name if focus was qualified and unused. + if let Some((_, bare)) = focus.split_once('.') { + if !all_rels.iter().any(|r| r.from_table == focus || r.to_table == focus) + && all_rels + .iter() + .any(|r| r.from_table == bare || r.to_table == bare) + { + names = super::erd::neighbourhood(&all_rels, bare, depth); + names.insert(bare.to_owned()); + } + } + // Fetch missing tables that live outside the requested schema. + let have: HashSet = tables.iter().map(|t| t.name.clone()).collect(); + for name in &names { + if have.contains(name) { + continue; + } + if let Some(extra) = load_one_table(pool, name)? { + tables.push(extra); + } + } + let mut schema = ErdSchema { + tables, + relations, + truncated: false, + }; + schema.mark_foreign_keys(); + schema.annotate_relations(); + schema.filter_tables(&names); + return Ok(schema); + } + + let filtered = filter_relations_to_tables(&relations, &tables); + let mut schema = ErdSchema { + tables, + relations: filtered, + truncated: false, + }; + schema.mark_foreign_keys(); + schema.annotate_relations(); + Ok(schema) +} + +fn filter_relations_to_tables( + relations: &[ErdRelation], + tables: &[ErdTable], +) -> Vec { + let names: HashSet<&str> = tables.iter().map(|t| t.name.as_str()).collect(); + relations + .iter() + .filter(|r| names.contains(r.from_table.as_str()) && names.contains(r.to_table.as_str())) + .cloned() + .collect() +} + +fn load_tables(pool: &PgPool, schema: Option<&str>) -> ConnectionResult> { + runtime::block_on(async { + // No schema → every user schema (public/shop/…); search_path alone + // left multi-schema DBs looking empty on the connection ER tab. + let rows = if let Some(schema) = schema { + sqlx::query( + "SELECT CASE WHEN ns.nspname = current_schema() THEN c.relname + ELSE ns.nspname || '.' || c.relname END AS table_name, + c.relname AS bare_name, + ns.nspname AS schema_name, + c.relkind::text AS relkind + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace + WHERE ns.nspname = $1 + AND c.relkind IN ('r', 'p', 'v', 'm') + ORDER BY c.relkind, table_name", + ) + .bind(schema) + .fetch_all(pool) + .await + } else { + sqlx::query( + "SELECT CASE WHEN ns.nspname = current_schema() THEN c.relname + ELSE ns.nspname || '.' || c.relname END AS table_name, + c.relname AS bare_name, + ns.nspname AS schema_name, + c.relkind::text AS relkind + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace + WHERE ns.nspname NOT IN ('pg_catalog', 'information_schema') + AND ns.nspname NOT LIKE 'pg_toast%' + AND ns.nspname NOT LIKE 'pg_temp_%' + AND c.relkind IN ('r', 'p', 'v', 'm') + ORDER BY ns.nspname, c.relkind, c.relname", + ) + .fetch_all(pool) + .await + } + .map_err(pg_err)?; + + let mut tables = Vec::with_capacity(rows.len()); + for r in rows { + let table_name: String = r.get("table_name"); + let bare: String = r.get("bare_name"); + let schema_name: String = r.get("schema_name"); + let relkind: String = r.get("relkind"); + let is_view = relkind == "v" || relkind == "m"; + let columns = load_columns(pool, &schema_name, &bare, is_view).await?; + tables.push(ErdTable { + name: table_name, + schema: Some(schema_name), + columns, + is_view, + }); + } + Ok(tables) + }) +} + +fn load_one_table(pool: &PgPool, qualified_or_bare: &str) -> ConnectionResult> { + let (schema, bare) = match qualified_or_bare.split_once('.') { + Some((s, t)) => (s.to_owned(), t.to_owned()), + None => { + // Resolve against current_schema. + let current: String = runtime::block_on(async { + sqlx::query_scalar("SELECT current_schema()") + .fetch_one(pool) + .await + .map_err(pg_err) + })?; + (current, qualified_or_bare.to_owned()) + } + }; + + runtime::block_on(async { + let row = sqlx::query( + "SELECT CASE WHEN ns.nspname = current_schema() THEN c.relname + ELSE ns.nspname || '.' || c.relname END AS table_name, + c.relkind::text AS relkind + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace + WHERE ns.nspname = $1 AND c.relname = $2 + AND c.relkind IN ('r', 'p', 'v', 'm') + LIMIT 1", + ) + .bind(&schema) + .bind(&bare) + .fetch_optional(pool) + .await + .map_err(pg_err)?; + + let Some(r) = row else { + return Ok(None); + }; + let table_name: String = r.get("table_name"); + let relkind: String = r.get("relkind"); + let is_view = relkind == "v" || relkind == "m"; + let columns = load_columns(pool, &schema, &bare, is_view).await?; + Ok(Some(ErdTable { + name: table_name, + schema: Some(schema), + columns, + is_view, + })) + }) +} + +async fn load_columns( + pool: &PgPool, + schema: &str, + table: &str, + is_view: bool, +) -> ConnectionResult> { + let rows = sqlx::query( + "SELECT a.attname AS column_name, + format_type(a.atttypid, a.atttypmod) AS data_type, + CASE WHEN pk.oid IS NULL THEN 0 ELSE 1 END AS is_pk, + CASE WHEN a.attnotnull THEN 0 ELSE 1 END AS is_nullable, + CASE WHEN EXISTS ( + SELECT 1 FROM pg_catalog.pg_index ui + WHERE ui.indrelid = c.oid AND ui.indisunique AND NOT ui.indisprimary + AND ui.indnatts = 1 AND ui.indkey[0] = a.attnum + AND ui.indpred IS NULL + ) THEN 1 ELSE 0 END AS is_unique, + pg_get_expr(df.adbin, df.adrelid) AS column_default, + CASE WHEN a.attidentity <> '' + OR pg_get_expr(df.adbin, df.adrelid) LIKE 'nextval(%' + THEN 1 ELSE 0 END AS is_identity + FROM pg_catalog.pg_attribute a + JOIN pg_catalog.pg_class c ON c.oid = a.attrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace + LEFT JOIN pg_catalog.pg_constraint pk + ON pk.conrelid = c.oid AND pk.contype = 'p' AND a.attnum = ANY (pk.conkey) + LEFT JOIN pg_catalog.pg_attrdef df + ON df.adrelid = c.oid AND df.adnum = a.attnum + WHERE ns.nspname = $1 + AND c.relname = $2 + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum", + ) + .bind(schema) + .bind(table) + .fetch_all(pool) + .await + .map_err(pg_err)?; + + Ok(rows + .into_iter() + .map(|r: PgRow| { + let is_pk = !is_view && r.get::("is_pk") == 1; + ErdColumn { + name: r.get("column_name"), + data_type: r.get("data_type"), + is_primary_key: is_pk, + is_foreign_key: false, + is_nullable: is_view || r.get::("is_nullable") == 1, + is_unique: r.get::("is_unique") == 1, + default_value: r.get("column_default"), + is_identity: !is_view && r.get::("is_identity") == 1, + } + }) + .collect()) +} + +fn load_foreign_keys(pool: &PgPool) -> ConnectionResult> { + runtime::block_on(async { + let rows = sqlx::query( + "SELECT CASE WHEN ns.nspname = current_schema() THEN src.relname + ELSE ns.nspname || '.' || src.relname END AS table_name, + sa.attname AS column_name, + CASE WHEN rn.nspname = current_schema() THEN ref.relname + ELSE rn.nspname || '.' || ref.relname END AS ref_table, + ra.attname AS ref_column + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class src ON src.oid = con.conrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid = src.relnamespace + JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid + JOIN pg_catalog.pg_namespace rn ON rn.oid = ref.relnamespace + CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(src_attnum, ref_attnum) + JOIN pg_catalog.pg_attribute sa + ON sa.attrelid = con.conrelid AND sa.attnum = k.src_attnum + JOIN pg_catalog.pg_attribute ra + ON ra.attrelid = con.confrelid AND ra.attnum = k.ref_attnum + WHERE con.contype = 'f' + AND ns.nspname NOT IN ('pg_catalog', 'information_schema') + AND ns.nspname NOT LIKE 'pg_toast%' + AND rn.nspname NOT IN ('pg_catalog', 'information_schema')", + ) + .fetch_all(pool) + .await + .map_err(pg_err)?; + + Ok(rows + .into_iter() + .map(|r| ErdRelation { + from_table: r.get("table_name"), + from_column: r.get("column_name"), + to_table: r.get("ref_table"), + to_column: r.get("ref_column"), + optional: false, + one_to_one: false, + }) + .collect()) + }) +} + +fn pg_err(e: impl ToString) -> ConnectionError { + ConnectionError::driver(DriverKind::Postgres, e.to_string()) +} diff --git a/apps/tauri/src-tauri/src/connection/sqlite_erd.rs b/apps/tauri/src-tauri/src/connection/sqlite_erd.rs new file mode 100644 index 00000000..b710768a --- /dev/null +++ b/apps/tauri/src-tauri/src/connection/sqlite_erd.rs @@ -0,0 +1,242 @@ +//! SQLite ER catalog — mirrors Flutter `ErdCatalog` SQLite queries. + +use super::erd::{ErdColumn, ErdRelation, ErdSchema, ErdTable}; +use super::error::ConnectionResult; +use rusqlite::Connection; +use std::collections::HashSet; + +/// Load tables + views + foreign keys for the ER canvas. +/// +/// When `focus` is set, only the neighbourhood within `depth` FK hops is kept. +pub fn load_schema( + conn: &Connection, + focus: Option<&str>, + depth: usize, +) -> ConnectionResult { + let relations = load_foreign_keys(conn)?; + let mut schema = ErdSchema { + tables: load_tables(conn)?, + relations, + truncated: false, + }; + schema.mark_foreign_keys(); + schema.annotate_relations(); + + if let Some(focus) = focus { + let names = super::erd::neighbourhood(&schema.relations, focus, depth); + // Always include focus even with zero FKs. + let mut names = names; + names.insert(focus.to_owned()); + schema.filter_tables(&names); + } + + Ok(schema) +} + +fn load_tables(conn: &Connection) -> ConnectionResult> { + let mut stmt = conn.prepare( + "SELECT name, type FROM sqlite_master \ + WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' \ + ORDER BY type ASC, name ASC", + )?; + let rows = stmt.query_map([], |row| { + let name: String = row.get(0)?; + let kind: String = row.get(1)?; + Ok((name, kind)) + })?; + + let mut tables = Vec::new(); + for row in rows { + let (name, kind) = row?; + let is_view = kind == "view"; + let columns = load_columns(conn, &name, is_view)?; + tables.push(ErdTable { + name, + schema: Some("main".into()), + columns, + is_view, + }); + } + Ok(tables) +} + +fn load_columns( + conn: &Connection, + table: &str, + is_view: bool, +) -> ConnectionResult> { + let sql = format!("PRAGMA table_info({})", quote_ident(table)); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + let name: String = row.get(1)?; + let data_type: String = row.get::<_, Option>(2)?.unwrap_or_default(); + let notnull: i32 = row.get(3)?; + let dflt: Option = row.get(4)?; + let pk: i32 = row.get(5)?; + Ok((name, data_type, notnull, dflt, pk)) + })?; + + let mut columns = Vec::new(); + let mut pk_count = 0i32; + let mut collected: Vec<(String, String, i32, Option, i32)> = Vec::new(); + for row in rows { + let tup = row?; + if tup.4 > 0 { + pk_count += 1; + } + collected.push(tup); + } + + // Unique single-column indexes (non-PK) for 1:1 annotation. + let unique_cols = if is_view { + HashSet::new() + } else { + unique_column_names(conn, table)? + }; + + for (name, data_type, notnull, dflt, pk) in collected { + let is_pk = !is_view && pk > 0; + let is_identity = is_pk + && pk_count == 1 + && data_type.eq_ignore_ascii_case("INTEGER"); + let is_unique = unique_cols.contains(&name); + columns.push(ErdColumn { + name, + data_type, + is_primary_key: is_pk, + is_foreign_key: false, + is_nullable: is_view || notnull == 0, + is_unique, + default_value: dflt, + is_identity, + }); + } + Ok(columns) +} + +fn unique_column_names(conn: &Connection, table: &str) -> ConnectionResult> { + let sql = format!("PRAGMA index_list({})", quote_ident(table)); + let mut stmt = conn.prepare(&sql)?; + let indexes: Vec<(String, i32, String)> = stmt + .query_map([], |row| { + let name: String = row.get(1)?; + let unique: i32 = row.get(2)?; + let origin: String = row.get(3)?; + Ok((name, unique, origin)) + })? + .collect::>()?; + + let mut out = HashSet::new(); + for (idx_name, unique, origin) in indexes { + if unique != 1 || origin == "pk" { + continue; + } + let info_sql = format!("PRAGMA index_info({})", quote_ident(&idx_name)); + let mut info = conn.prepare(&info_sql)?; + let cols: Vec = info + .query_map([], |row| { + let name: Option = row.get(2)?; + Ok(name.unwrap_or_default()) + })? + .collect::>()?; + if cols.len() == 1 && !cols[0].is_empty() { + out.insert(cols[0].clone()); + } + } + Ok(out) +} + +fn load_foreign_keys(conn: &Connection) -> ConnectionResult> { + let mut stmt = conn.prepare( + "SELECT name FROM sqlite_master \ + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \ + ORDER BY name", + )?; + let tables: Vec = stmt + .query_map([], |row| row.get(0))? + .collect::>()?; + + let mut relations = Vec::new(); + for table in tables { + let sql = format!("PRAGMA foreign_key_list({})", quote_ident(&table)); + let mut fk_stmt = conn.prepare(&sql)?; + let rows = fk_stmt.query_map([], |row| { + let from_col: String = row.get(3)?; + let to_table: String = row.get(2)?; + let to_col: String = row.get(4)?; + Ok((from_col, to_table, to_col)) + })?; + for row in rows { + let (from_column, to_table, to_column) = row?; + relations.push(ErdRelation { + from_table: table.clone(), + from_column, + to_table, + to_column, + optional: false, + one_to_one: false, + }); + } + } + Ok(relations) +} + +fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn fixture() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE + ); + CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + total REAL + ); + CREATE VIEW v_users AS SELECT id, email FROM users;", + ) + .unwrap(); + conn + } + + #[test] + fn loads_tables_views_and_fks() { + let conn = fixture(); + let schema = load_schema(&conn, None, 1).unwrap(); + assert_eq!(schema.tables.len(), 3); + let orders = schema + .tables + .iter() + .find(|t| t.name == "orders") + .expect("orders"); + assert!( + orders + .columns + .iter() + .any(|c| c.name == "user_id" && c.is_foreign_key) + ); + assert_eq!(schema.relations.len(), 1); + assert_eq!(schema.relations[0].from_table, "orders"); + assert_eq!(schema.relations[0].to_table, "users"); + } + + #[test] + fn neighbourhood_focus() { + let conn = fixture(); + let schema = load_schema(&conn, Some("users"), 1).unwrap(); + let names: HashSet<_> = schema.tables.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains("users")); + assert!(names.contains("orders")); + assert!(!names.contains("v_users")); + } +} diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 9da0fd80..5bec6301 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -5,16 +5,17 @@ mod secrets; pub use connection::{ ColumnMeta, ConnectionConfig, ConnectionError, ConnectionId, ConnectionManager, - ConnectionRegistry, DriverKind, ObjectTreeProvider, PostgresObjectDetail, QueryEvent, - SchemaObject, SchemaObjectKind, SqlObjectKind, SqlObjectNode, SqliteOverview, TableSchema, + ConnectionRegistry, DriverKind, ErdColumn, ErdRelation, ErdSchema, ErdTable, + ObjectTreeProvider, PostgresObjectDetail, QueryEvent, SchemaObject, SchemaObjectKind, + SqlObjectKind, SqlObjectNode, SqliteOverview, TableSchema, }; pub use profiles::{ConnectionProfile, ProfileStore, ProfileStoreError}; pub use secrets::{KeyringSecretStore, MemorySecretStore, SecretError, SecretStore, SharedSecretStore}; use commands::{ - db_connect_postgres, db_connect_sqlite, db_describe_object, db_disconnect, db_execute_query, - db_is_connected, db_list_children, db_list_columns, db_list_schemas, db_list_tables, - db_postgres_object_detail, db_sqlite_overview, profiles_connect_postgres, + db_connect_postgres, db_connect_sqlite, db_describe_object, db_disconnect, db_erd_schema, + db_execute_query, db_is_connected, db_list_children, db_list_columns, db_list_schemas, + db_list_tables, db_postgres_object_detail, db_sqlite_overview, profiles_connect_postgres, profiles_connect_sqlite, profiles_delete, profiles_list, profiles_upsert, }; @@ -68,6 +69,7 @@ pub fn run() { db_describe_object, db_postgres_object_detail, db_sqlite_overview, + db_erd_schema, db_execute_query, profiles_list, profiles_upsert, diff --git a/apps/tauri/src/api/connection.ts b/apps/tauri/src/api/connection.ts index 68cd3aa8..e046db37 100644 --- a/apps/tauri/src/api/connection.ts +++ b/apps/tauri/src/api/connection.ts @@ -289,6 +289,56 @@ export async function dbSqliteOverview(id: string): Promise { return invoke("db_sqlite_overview", { id }); } +/** ER diagram catalog (mirrors Rust `ErdSchema`). */ +export type ErdColumn = { + name: string; + dataType: string; + isPrimaryKey: boolean; + isForeignKey: boolean; + isNullable: boolean; + isUnique?: boolean; + defaultValue?: string | null; + isIdentity?: boolean; +}; + +export type ErdTable = { + name: string; + schema?: string | null; + columns: ErdColumn[]; + isView?: boolean; +}; + +export type ErdRelation = { + fromTable: string; + fromColumn: string; + toTable: string; + toColumn: string; + optional?: boolean; + oneToOne?: boolean; +}; + +export type ErdSchema = { + tables: ErdTable[]; + relations: ErdRelation[]; + truncated?: boolean; +}; + +export async function dbErdSchema( + id: string, + opts?: { + schema?: string | null; + focus?: string | null; + depth?: number | null; + }, +): Promise { + return invoke("db_erd_schema", { + id, + schema: opts?.schema ?? null, + focus: opts?.focus ?? null, + depth: opts?.depth ?? null, + }); +} + export function isConnectionError(err: unknown): err is ConnectionError { return ( !!err && diff --git a/apps/tauri/src/mock/demoCatalog.ts b/apps/tauri/src/mock/demoCatalog.ts index 63f78be9..e2c9b7b5 100644 --- a/apps/tauri/src/mock/demoCatalog.ts +++ b/apps/tauri/src/mock/demoCatalog.ts @@ -1,6 +1,6 @@ /** Offline demo catalog for UI-core review (no live DB). */ -import type { SqlObjectNode } from "../api/connection"; +import type { ErdSchema, SqlObjectNode } from "../api/connection"; export type DemoColumn = { name: string; @@ -102,6 +102,68 @@ export function findDemoObject(name: string): DemoObject | undefined { return DEMO_CONNECTION.objects.find((o) => o.name === name); } +/** Offline ER schema for the demo connection (orders → users). */ +export function demoErdSchema(opts?: { + focus?: string | null; + depth?: number; +}): ErdSchema { + const tables = DEMO_CONNECTION.objects.map((o) => ({ + name: o.name, + schema: "main", + isView: o.kind === "view", + columns: o.columns.map((c) => ({ + name: c.name, + dataType: c.data_type, + isPrimaryKey: c.is_primary_key, + isForeignKey: o.name === "orders" && c.name === "user_id", + isNullable: c.is_nullable, + })), + })); + const relations = [ + { + fromTable: "orders", + fromColumn: "user_id", + toTable: "users", + toColumn: "id", + optional: false, + oneToOne: false, + }, + ]; + const focus = opts?.focus; + if (!focus) { + return { tables, relations, truncated: false }; + } + const depth = opts?.depth ?? 1; + const adjacent = new Map>(); + for (const r of relations) { + if (!adjacent.has(r.fromTable)) adjacent.set(r.fromTable, new Set()); + if (!adjacent.has(r.toTable)) adjacent.set(r.toTable, new Set()); + adjacent.get(r.fromTable)!.add(r.toTable); + adjacent.get(r.toTable)!.add(r.fromTable); + } + const seen = new Set([focus]); + let frontier = new Set([focus]); + for (let i = 0; i < depth; i++) { + const next = new Set(); + for (const t of frontier) { + for (const n of adjacent.get(t) ?? []) { + if (!seen.has(n)) { + seen.add(n); + next.add(n); + } + } + } + frontier = next; + } + return { + tables: tables.filter((t) => seen.has(t.name)), + relations: relations.filter( + (r) => seen.has(r.fromTable) && seen.has(r.toTable), + ), + truncated: false, + }; +} + const MAIN_SCHEMA_ID = "sqlite:schema:main"; /** Root schemas for the demo object tree (same shape as live IPC). */ diff --git a/apps/tauri/src/stores/shellNav.ts b/apps/tauri/src/stores/shellNav.ts index 831046eb..a534a9c3 100644 --- a/apps/tauri/src/stores/shellNav.ts +++ b/apps/tauri/src/stores/shellNav.ts @@ -9,7 +9,7 @@ export type WorkspaceSurface = | "tableObject" | "pgObject"; -export type HomeStripTab = "overview" | "sql"; +export type HomeStripTab = "overview" | "sql" | "erd"; export type ObjectStripTab = "data" | "relations"; export type RelationKind = "table" | "view" | "materialized_view"; diff --git a/apps/tauri/src/ui/ConnectionHome.vue b/apps/tauri/src/ui/ConnectionHome.vue index 53ab5d43..f5c2d2f0 100644 --- a/apps/tauri/src/ui/ConnectionHome.vue +++ b/apps/tauri/src/ui/ConnectionHome.vue @@ -11,6 +11,7 @@ import { useShellNavStore } from "../stores/shellNav"; import { useObjectTreeStore } from "../stores/objectTree"; import QueryaButton from "./QueryaButton.vue"; import SqlWorkspace from "./SqlWorkspace.vue"; +import ErdView from "./erd/ErdView.vue"; const connections = useConnectionsStore(); const nav = useShellNavStore(); @@ -162,6 +163,17 @@ function openRelation(o: { id: string; name: string; kind: string; schema?: stri > SQL +
-
+
+ +
+ +
@@ -575,7 +594,8 @@ h1 { line-height: 1; } -.sql-park { +.sql-park, +.erd-park { flex: 1; min-height: 0; } diff --git a/apps/tauri/src/ui/TableObjectView.vue b/apps/tauri/src/ui/TableObjectView.vue index 567881fe..5d42d2c3 100644 --- a/apps/tauri/src/ui/TableObjectView.vue +++ b/apps/tauri/src/ui/TableObjectView.vue @@ -9,6 +9,7 @@ import { findDemoObject } from "../mock/demoCatalog"; import { dbExecuteQuery } from "../api/query"; import { formatConnectionError } from "../api/connection"; import ResultGrid from "./ResultGrid.vue"; +import ErdView from "./erd/ErdView.vue"; const nav = useShellNavStore(); const connections = useConnectionsStore(); @@ -139,10 +140,14 @@ watch(
-

- Relations / ER for this table lands with the diagram epic. Chrome only - for now. -

+
@@ -259,12 +264,8 @@ watch( .relations { flex: 1; - padding: 24px; -} - -.empty { - margin: 16px 0; - font-size: 12px; - color: var(--q-muted-foreground); + min-height: 0; + display: flex; + flex-direction: column; } diff --git a/apps/tauri/src/ui/erd/ErdCanvas.vue b/apps/tauri/src/ui/erd/ErdCanvas.vue new file mode 100644 index 00000000..aa1b9974 --- /dev/null +++ b/apps/tauri/src/ui/erd/ErdCanvas.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/apps/tauri/src/ui/erd/ErdTableNode.vue b/apps/tauri/src/ui/erd/ErdTableNode.vue new file mode 100644 index 00000000..3c238d86 --- /dev/null +++ b/apps/tauri/src/ui/erd/ErdTableNode.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/apps/tauri/src/ui/erd/ErdView.vue b/apps/tauri/src/ui/erd/ErdView.vue new file mode 100644 index 00000000..9ddc1c55 --- /dev/null +++ b/apps/tauri/src/ui/erd/ErdView.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/apps/tauri/src/ui/erd/layout.test.ts b/apps/tauri/src/ui/erd/layout.test.ts new file mode 100644 index 00000000..c31603a0 --- /dev/null +++ b/apps/tauri/src/ui/erd/layout.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { ErdSchema } from "../../api/connection"; +import { schemaToFlow } from "./layout"; + +const fixture: ErdSchema = { + tables: [ + { + name: "users", + columns: [ + { + name: "id", + dataType: "INTEGER", + isPrimaryKey: true, + isForeignKey: false, + isNullable: false, + }, + ], + }, + { + name: "orders", + columns: [ + { + name: "id", + dataType: "INTEGER", + isPrimaryKey: true, + isForeignKey: false, + isNullable: false, + }, + { + name: "user_id", + dataType: "INTEGER", + isPrimaryKey: false, + isForeignKey: true, + isNullable: false, + }, + ], + }, + ], + relations: [ + { + fromTable: "orders", + fromColumn: "user_id", + toTable: "users", + toColumn: "id", + }, + ], +}; + +describe("schemaToFlow", () => { + it("maps tables to erdTable nodes and FK edges", () => { + const { nodes, edges } = schemaToFlow(fixture); + expect(nodes).toHaveLength(2); + expect(nodes.every((n) => n.type === "erdTable")).toBe(true); + expect(nodes.map((n) => n.id).sort()).toEqual(["orders", "users"]); + expect(edges).toHaveLength(1); + expect(edges[0].source).toBe("users"); + expect(edges[0].target).toBe("orders"); + expect(Number.isFinite(nodes[0].position.x)).toBe(true); + expect(Number.isFinite(nodes[0].position.y)).toBe(true); + }); +}); diff --git a/apps/tauri/src/ui/erd/layout.ts b/apps/tauri/src/ui/erd/layout.ts new file mode 100644 index 00000000..16265307 --- /dev/null +++ b/apps/tauri/src/ui/erd/layout.ts @@ -0,0 +1,68 @@ +import dagre from "@dagrejs/dagre"; +import type { Edge, Node } from "@vue-flow/core"; +import type { ErdSchema, ErdTable } from "../../api/connection"; + +export type ErdTableNodeData = { + table: ErdTable; +}; + +const NODE_WIDTH = 260; +const COL_ROW_H = 22; +const HEADER_H = 36; + +export function estimateTableHeight(table: ErdTable): number { + return HEADER_H + Math.max(1, table.columns.length) * COL_ROW_H + 8; +} + +/** Map catalog → Vue Flow nodes/edges with dagre layout (LR). */ +export function schemaToFlow(schema: ErdSchema): { + nodes: Node[]; + edges: Edge[]; +} { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: "LR", nodesep: 40, ranksep: 80, marginx: 24, marginy: 24 }); + + for (const table of schema.tables) { + g.setNode(table.name, { + width: NODE_WIDTH, + height: estimateTableHeight(table), + }); + } + for (const rel of schema.relations) { + if (!g.hasNode(rel.fromTable) || !g.hasNode(rel.toTable)) continue; + // Edge direction: referenced ← referencing for left-to-right parents→children feel. + g.setEdge(rel.toTable, rel.fromTable); + } + dagre.layout(g); + + const nodes: Node[] = schema.tables.map((table) => { + const pos = g.node(table.name); + const h = estimateTableHeight(table); + return { + id: table.name, + type: "erdTable", + position: { + x: (pos?.x ?? 0) - NODE_WIDTH / 2, + y: (pos?.y ?? 0) - h / 2, + }, + data: { table }, + draggable: true, + }; + }); + + const edges: Edge[] = schema.relations.map((rel, i) => ({ + id: `fk-${i}-${rel.fromTable}.${rel.fromColumn}->${rel.toTable}.${rel.toColumn}`, + source: rel.toTable, + target: rel.fromTable, + sourceHandle: `out-${rel.toColumn}`, + targetHandle: `in-${rel.fromColumn}`, + type: "smoothstep", + animated: false, + label: rel.oneToOne ? "1:1" : rel.optional ? "0..*" : "1..*", + style: { stroke: "var(--q-primary)", strokeWidth: 1.5 }, + labelStyle: { fill: "var(--q-muted-foreground)", fontSize: 10 }, + })); + + return { nodes, edges }; +}