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:
255
package-lock.json
generated
255
package-lock.json
generated
@@ -15,10 +15,13 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -4469,6 +4472,61 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"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"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -5269,6 +5387,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||||
@@ -5607,6 +5731,122 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
@@ -6757,6 +6997,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/graphql": {
|
||||||
"version": "16.12.0",
|
"version": "16.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
|
||||||
@@ -7609,6 +7858,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|||||||
@@ -18,10 +18,13 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
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 crate::state::{AppState, DbFlavor};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -271,7 +274,13 @@ pub async fn get_table_columns_core(
|
|||||||
AND tc.table_name = $2 \
|
AND tc.table_name = $2 \
|
||||||
AND kcu.column_name = c.column_name \
|
AND kcu.column_name = c.column_name \
|
||||||
LIMIT 1 \
|
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 \
|
FROM information_schema.columns c \
|
||||||
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||||
ORDER BY c.ordinal_position",
|
ORDER BY c.ordinal_position",
|
||||||
@@ -292,6 +301,7 @@ pub async fn get_table_columns_core(
|
|||||||
ordinal_position: r.get::<i32, _>(4),
|
ordinal_position: r.get::<i32, _>(4),
|
||||||
character_maximum_length: r.get::<Option<i32>, _>(5),
|
character_maximum_length: r.get::<Option<i32>, _>(5),
|
||||||
is_primary_key: r.get::<bool, _>(6),
|
is_primary_key: r.get::<bool, _>(6),
|
||||||
|
comment: r.get::<Option<String>, _>(7),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -320,16 +330,49 @@ pub async fn get_table_constraints(
|
|||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT \
|
"SELECT \
|
||||||
tc.constraint_name, \
|
c.conname AS constraint_name, \
|
||||||
tc.constraint_type, \
|
CASE c.contype \
|
||||||
array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] as columns \
|
WHEN 'p' THEN 'PRIMARY KEY' \
|
||||||
FROM information_schema.table_constraints tc \
|
WHEN 'f' THEN 'FOREIGN KEY' \
|
||||||
JOIN information_schema.key_column_usage kcu \
|
WHEN 'u' THEN 'UNIQUE' \
|
||||||
ON tc.constraint_name = kcu.constraint_name \
|
WHEN 'c' THEN 'CHECK' \
|
||||||
AND tc.table_schema = kcu.table_schema \
|
WHEN 'x' THEN 'EXCLUDE' \
|
||||||
WHERE tc.table_schema = $1 AND tc.table_name = $2 \
|
END AS constraint_type, \
|
||||||
GROUP BY tc.constraint_name, tc.constraint_type \
|
ARRAY( \
|
||||||
ORDER BY tc.constraint_type, tc.constraint_name",
|
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(&schema)
|
||||||
.bind(&table)
|
.bind(&table)
|
||||||
@@ -343,6 +386,11 @@ pub async fn get_table_constraints(
|
|||||||
name: r.get::<String, _>(0),
|
name: r.get::<String, _>(0),
|
||||||
constraint_type: r.get::<String, _>(1),
|
constraint_type: r.get::<String, _>(1),
|
||||||
columns: r.get::<Vec<String>, _>(2),
|
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())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -483,3 +531,186 @@ pub async fn get_column_details(
|
|||||||
})
|
})
|
||||||
.collect())
|
.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_table_indexes,
|
||||||
commands::schema::get_completion_schema,
|
commands::schema::get_completion_schema,
|
||||||
commands::schema::get_column_details,
|
commands::schema::get_column_details,
|
||||||
|
commands::schema::get_table_triggers,
|
||||||
|
commands::schema::get_schema_erd,
|
||||||
// data
|
// data
|
||||||
commands::data::get_table_data,
|
commands::data::get_table_data,
|
||||||
commands::data::update_row,
|
commands::data::update_row,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub struct ColumnInfo {
|
|||||||
pub ordinal_position: i32,
|
pub ordinal_position: i32,
|
||||||
pub character_maximum_length: Option<i32>,
|
pub character_maximum_length: Option<i32>,
|
||||||
pub is_primary_key: bool,
|
pub is_primary_key: bool,
|
||||||
|
pub comment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -34,6 +35,11 @@ pub struct ConstraintInfo {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub constraint_type: String,
|
pub constraint_type: String,
|
||||||
pub columns: Vec<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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -43,3 +49,48 @@ pub struct IndexInfo {
|
|||||||
pub is_unique: bool,
|
pub is_unique: bool,
|
||||||
pub is_primary: 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>,
|
||||||
|
}
|
||||||
|
|||||||
186
src/components/erd/ErdDiagram.tsx
Normal file
186
src/components/erd/ErdDiagram.tsx
Normal file
@@ -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<string, string[]>();
|
||||||
|
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<Node[]>([]);
|
||||||
|
const [edges, setEdges] = useState<Edge[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Loading ER diagram...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-destructive">
|
||||||
|
Error loading ER diagram: {String(error)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!erdData || erdData.tables.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No tables found in schema "{schema}".
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onInit={onInit}
|
||||||
|
fitView
|
||||||
|
colorMode="dark"
|
||||||
|
minZoom={0.05}
|
||||||
|
maxZoom={3}
|
||||||
|
zoomOnScroll
|
||||||
|
zoomOnPinch
|
||||||
|
panOnScroll
|
||||||
|
panOnScrollMode={PanOnScrollMode.Free}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background gap={16} size={1} />
|
||||||
|
<Controls className="!bg-card !border !shadow-sm [&>button]:!bg-card [&>button]:!border-border [&>button]:!text-foreground" />
|
||||||
|
<MiniMap
|
||||||
|
className="!bg-card !border"
|
||||||
|
nodeColor="var(--muted)"
|
||||||
|
maskColor="rgba(0, 0, 0, 0.7)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/erd/ErdTableNode.tsx
Normal file
54
src/components/erd/ErdTableNode.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-w-[220px] rounded-lg border border-border bg-card text-card-foreground shadow-md">
|
||||||
|
<div className="rounded-t-lg border-b bg-primary/10 px-3 py-2 text-xs font-bold tracking-wide text-primary">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{(columns as ErdColumn[]).map((col, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 px-3 py-1 text-[11px]">
|
||||||
|
{col.is_primary_key ? (
|
||||||
|
<KeyRound className="h-3 w-3 shrink-0 text-amber-500" />
|
||||||
|
) : (fkColumnNames as string[]).includes(col.name) ? (
|
||||||
|
<Link className="h-3 w-3 shrink-0 text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<span className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">{col.name}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground">{col.data_type}</span>
|
||||||
|
{col.is_nullable && (
|
||||||
|
<span className="text-muted-foreground/60">?</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!w-1.5 !h-1.5 !bg-primary !opacity-0 hover:!opacity-100 !border-none !min-w-0 !min-h-0"
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!w-1.5 !h-1.5 !bg-primary !opacity-0 hover:!opacity-100 !border-none !min-w-0 !min-h-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErdTableNode = memo(ErdTableNodeComponent);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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() {
|
export function TabBar() {
|
||||||
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
||||||
@@ -16,6 +16,7 @@ export function TabBar() {
|
|||||||
roles: <Users className="h-3 w-3" />,
|
roles: <Users className="h-3 w-3" />,
|
||||||
sessions: <Activity className="h-3 w-3" />,
|
sessions: <Activity className="h-3 w-3" />,
|
||||||
lookup: <Search className="h-3 w-3" />,
|
lookup: <Search className="h-3 w-3" />,
|
||||||
|
erd: <GitFork className="h-3 w-3" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -136,6 +136,17 @@ export function SchemaTree() {
|
|||||||
};
|
};
|
||||||
addTab(tab);
|
addTab(tab);
|
||||||
}}
|
}}
|
||||||
|
onViewErd={(schema) => {
|
||||||
|
const tab: Tab = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "erd",
|
||||||
|
title: `${schema} (ER Diagram)`,
|
||||||
|
connectionId: activeConnectionId,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
|
schema,
|
||||||
|
};
|
||||||
|
addTab(tab);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,6 +161,7 @@ function DatabaseNode({
|
|||||||
isSwitching,
|
isSwitching,
|
||||||
onOpenTable,
|
onOpenTable,
|
||||||
onViewStructure,
|
onViewStructure,
|
||||||
|
onViewErd,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -158,6 +170,7 @@ function DatabaseNode({
|
|||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
onOpenTable: (schema: string, table: string) => void;
|
onOpenTable: (schema: string, table: string) => void;
|
||||||
onViewStructure: (schema: string, table: string) => void;
|
onViewStructure: (schema: string, table: string) => void;
|
||||||
|
onViewErd: (schema: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
|
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
|
||||||
@@ -234,6 +247,7 @@ function DatabaseNode({
|
|||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
onOpenTable={onOpenTable}
|
onOpenTable={onOpenTable}
|
||||||
onViewStructure={onViewStructure}
|
onViewStructure={onViewStructure}
|
||||||
|
onViewErd={onViewErd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -250,10 +264,12 @@ function SchemasForCurrentDb({
|
|||||||
connectionId,
|
connectionId,
|
||||||
onOpenTable,
|
onOpenTable,
|
||||||
onViewStructure,
|
onViewStructure,
|
||||||
|
onViewErd,
|
||||||
}: {
|
}: {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
onOpenTable: (schema: string, table: string) => void;
|
onOpenTable: (schema: string, table: string) => void;
|
||||||
onViewStructure: (schema: string, table: string) => void;
|
onViewStructure: (schema: string, table: string) => void;
|
||||||
|
onViewErd: (schema: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data: schemas } = useSchemas(connectionId);
|
const { data: schemas } = useSchemas(connectionId);
|
||||||
|
|
||||||
@@ -272,6 +288,7 @@ function SchemasForCurrentDb({
|
|||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
onOpenTable={(table) => onOpenTable(schema, table)}
|
onOpenTable={(table) => onOpenTable(schema, table)}
|
||||||
onViewStructure={(table) => onViewStructure(schema, table)}
|
onViewStructure={(table) => onViewStructure(schema, table)}
|
||||||
|
onViewErd={() => onViewErd(schema)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -283,32 +300,43 @@ function SchemaNode({
|
|||||||
connectionId,
|
connectionId,
|
||||||
onOpenTable,
|
onOpenTable,
|
||||||
onViewStructure,
|
onViewStructure,
|
||||||
|
onViewErd,
|
||||||
}: {
|
}: {
|
||||||
schema: string;
|
schema: string;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
onOpenTable: (table: string) => void;
|
onOpenTable: (table: string) => void;
|
||||||
onViewStructure: (table: string) => void;
|
onViewStructure: (table: string) => void;
|
||||||
|
onViewErd: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<ContextMenu>
|
||||||
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium"
|
<ContextMenuTrigger>
|
||||||
onClick={() => setExpanded(!expanded)}
|
<div
|
||||||
>
|
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium"
|
||||||
{expanded ? (
|
onClick={() => setExpanded(!expanded)}
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
>
|
||||||
) : (
|
{expanded ? (
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
)}
|
) : (
|
||||||
{expanded ? (
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
)}
|
||||||
) : (
|
{expanded ? (
|
||||||
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
)}
|
) : (
|
||||||
<span>{schema}</span>
|
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</div>
|
)}
|
||||||
|
<span>{schema}</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={onViewErd}>
|
||||||
|
View ER Diagram
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<CategoryNode
|
<CategoryNode
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getTableColumns,
|
getTableColumns,
|
||||||
getTableConstraints,
|
getTableConstraints,
|
||||||
getTableIndexes,
|
getTableIndexes,
|
||||||
|
getTableTriggers,
|
||||||
} from "@/lib/tauri";
|
} from "@/lib/tauri";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,11 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
queryFn: () => getTableIndexes(connectionId, schema, table),
|
queryFn: () => getTableIndexes(connectionId, schema, table),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: triggers } = useQuery({
|
||||||
|
queryKey: ["table-triggers", connectionId, schema, table],
|
||||||
|
queryFn: () => getTableTriggers(connectionId, schema, table),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="columns" className="flex h-full flex-col">
|
<Tabs defaultValue="columns" className="flex h-full flex-col">
|
||||||
<TabsList className="mx-2 mt-2 w-fit">
|
<TabsList className="mx-2 mt-2 w-fit">
|
||||||
@@ -50,6 +56,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
<TabsTrigger value="indexes" className="text-xs">
|
<TabsTrigger value="indexes" className="text-xs">
|
||||||
Indexes{indexes ? ` (${indexes.length})` : ""}
|
Indexes{indexes ? ` (${indexes.length})` : ""}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="triggers" className="text-xs">
|
||||||
|
Triggers{triggers ? ` (${triggers.length})` : ""}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="columns" className="flex-1 overflow-hidden mt-0">
|
<TabsContent value="columns" className="flex-1 overflow-hidden mt-0">
|
||||||
@@ -63,6 +72,7 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
<TableHead className="text-xs">Nullable</TableHead>
|
<TableHead className="text-xs">Nullable</TableHead>
|
||||||
<TableHead className="text-xs">Default</TableHead>
|
<TableHead className="text-xs">Default</TableHead>
|
||||||
<TableHead className="text-xs">Key</TableHead>
|
<TableHead className="text-xs">Key</TableHead>
|
||||||
|
<TableHead className="text-xs">Comment</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -84,7 +94,7 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
{col.is_nullable ? "YES" : "NO"}
|
{col.is_nullable ? "YES" : "NO"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
|
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||||
{col.column_default ?? "—"}
|
{col.column_default ?? "\u2014"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{col.is_primary_key && (
|
{col.is_primary_key && (
|
||||||
@@ -93,6 +103,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||||
|
{col.comment ?? "\u2014"}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -108,6 +121,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
<TableHead className="text-xs">Name</TableHead>
|
<TableHead className="text-xs">Name</TableHead>
|
||||||
<TableHead className="text-xs">Type</TableHead>
|
<TableHead className="text-xs">Type</TableHead>
|
||||||
<TableHead className="text-xs">Columns</TableHead>
|
<TableHead className="text-xs">Columns</TableHead>
|
||||||
|
<TableHead className="text-xs">References</TableHead>
|
||||||
|
<TableHead className="text-xs">On Update</TableHead>
|
||||||
|
<TableHead className="text-xs">On Delete</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -124,6 +140,17 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
{c.columns.join(", ")}
|
{c.columns.join(", ")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{c.referenced_table
|
||||||
|
? `${c.referenced_schema}.${c.referenced_table}(${c.referenced_columns?.join(", ")})`
|
||||||
|
: "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{c.update_rule ?? "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{c.delete_rule ?? "\u2014"}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -163,6 +190,49 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="triggers" className="flex-1 overflow-hidden mt-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs">Name</TableHead>
|
||||||
|
<TableHead className="text-xs">Timing</TableHead>
|
||||||
|
<TableHead className="text-xs">Event</TableHead>
|
||||||
|
<TableHead className="text-xs">Level</TableHead>
|
||||||
|
<TableHead className="text-xs">Function</TableHead>
|
||||||
|
<TableHead className="text-xs">Enabled</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{triggers?.map((t) => (
|
||||||
|
<TableRow key={t.name}>
|
||||||
|
<TableCell className="text-xs font-medium">
|
||||||
|
{t.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{t.timing}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{t.event}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{t.orientation}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{t.function_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{t.is_enabled ? "YES" : "NO"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TableStructure } from "@/components/table-viewer/TableStructure";
|
|||||||
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
||||||
import { SessionsView } from "@/components/management/SessionsView";
|
import { SessionsView } from "@/components/management/SessionsView";
|
||||||
import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel";
|
import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel";
|
||||||
|
import { ErdDiagram } from "@/components/erd/ErdDiagram";
|
||||||
|
|
||||||
export function TabContent() {
|
export function TabContent() {
|
||||||
const { tabs, activeTabId, updateTab } = useAppStore();
|
const { tabs, activeTabId, updateTab } = useAppStore();
|
||||||
@@ -72,6 +73,14 @@ export function TabContent() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "erd":
|
||||||
|
content = (
|
||||||
|
<ErdDiagram
|
||||||
|
connectionId={tab.connectionId}
|
||||||
|
schema={tab.schema!}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
content = null;
|
content = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
listSequences,
|
listSequences,
|
||||||
switchDatabase,
|
switchDatabase,
|
||||||
getColumnDetails,
|
getColumnDetails,
|
||||||
|
getSchemaErd,
|
||||||
} from "@/lib/tauri";
|
} from "@/lib/tauri";
|
||||||
import type { ConnectionConfig } from "@/types";
|
import type { ConnectionConfig } from "@/types";
|
||||||
|
|
||||||
@@ -88,3 +89,12 @@ export function useColumnDetails(connectionId: string | null, schema: string | n
|
|||||||
staleTime: 5 * 60 * 1000,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
ColumnInfo,
|
ColumnInfo,
|
||||||
ConstraintInfo,
|
ConstraintInfo,
|
||||||
IndexInfo,
|
IndexInfo,
|
||||||
|
TriggerInfo,
|
||||||
|
ErdData,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
SavedQuery,
|
SavedQuery,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
@@ -115,6 +117,15 @@ export const getTableIndexes = (
|
|||||||
table: string
|
table: string
|
||||||
) => invoke<IndexInfo[]>("get_table_indexes", { connectionId, schema, table });
|
) => invoke<IndexInfo[]>("get_table_indexes", { connectionId, schema, table });
|
||||||
|
|
||||||
|
export const getTableTriggers = (
|
||||||
|
connectionId: string,
|
||||||
|
schema: string,
|
||||||
|
table: string
|
||||||
|
) => invoke<TriggerInfo[]>("get_table_triggers", { connectionId, schema, table });
|
||||||
|
|
||||||
|
export const getSchemaErd = (connectionId: string, schema: string) =>
|
||||||
|
invoke<ErdData>("get_schema_erd", { connectionId, schema });
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
export const getTableData = (params: {
|
export const getTableData = (params: {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
|
|||||||
@@ -56,12 +56,18 @@ export interface ColumnInfo {
|
|||||||
ordinal_position: number;
|
ordinal_position: number;
|
||||||
character_maximum_length: number | null;
|
character_maximum_length: number | null;
|
||||||
is_primary_key: boolean;
|
is_primary_key: boolean;
|
||||||
|
comment: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConstraintInfo {
|
export interface ConstraintInfo {
|
||||||
name: string;
|
name: string;
|
||||||
constraint_type: string;
|
constraint_type: string;
|
||||||
columns: 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 {
|
export interface IndexInfo {
|
||||||
@@ -223,8 +229,13 @@ export interface SavedQuery {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiProvider = "ollama" | "openai" | "anthropic";
|
||||||
|
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
|
provider: AiProvider;
|
||||||
ollama_url: string;
|
ollama_url: string;
|
||||||
|
openai_api_key?: string;
|
||||||
|
anthropic_api_key?: string;
|
||||||
model: string;
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +283,47 @@ export interface LookupProgress {
|
|||||||
total: number;
|
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 {
|
export interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user