Skip to content

Commit 4a098fc

Browse files
committed
feat: Add ER diagram visualization for database schemas
- Right-click a schema node → "Show ER Diagram" opens an interactive React Flow-based diagram showing tables, columns, PK/FK markers, and foreign key relationships with column-level connections - Dagre layout engine for automatic positioning that minimizes edge crossings - Per-table color coding with colored relationship edges - Focus mode: click a table or use the search field to highlight it and its connected tables, dimming everything else - Foreign key queries added for PostgreSQL, MySQL, MSSQL, and SQLite - Fixed pre-existing PG driver notice listener leak
1 parent f66b415 commit 4a098fc

20 files changed

Lines changed: 1244 additions & 7 deletions

File tree

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757
"source-map-support": "^0.5.11",
5858
"typescript": "~4.8.3",
5959
"@vscode/vsce": "^2.19.0",
60-
"ts-node": "^9.1.1"
60+
"ts-node": "^9.1.1",
61+
"react-flow-renderer": "^10.3.17",
62+
"dagre": "^0.8.5",
63+
"@types/dagre": "^0.7.52"
6164
}
6265
}

packages/base-driver/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,24 @@ export default abstract class AbstractDriver<ConnectionType extends any, DriverO
115115
return Promise.resolve([]);
116116
}
117117

118+
public async getERDiagramData(schema: NSDatabase.ISchema): Promise<{ tables: NSDatabase.ITable[], columns: { [tableName: string]: NSDatabase.IColumn[] }, foreignKeys: NSDatabase.IForeignKey[] }> {
119+
const tables: NSDatabase.ITable[] = await this.queryResults(this.queries.fetchTables(schema));
120+
const columns: { [tableName: string]: NSDatabase.IColumn[] } = {};
121+
for (const table of tables) {
122+
const cols: NSDatabase.IColumn[] = await this.queryResults(this.queries.fetchColumns(table));
123+
columns[table.label] = cols;
124+
}
125+
let foreignKeys: NSDatabase.IForeignKey[] = [];
126+
if (this.queries.fetchForeignKeys) {
127+
try {
128+
foreignKeys = await this.queryResults(this.queries.fetchForeignKeys(schema));
129+
} catch (e) {
130+
this.log.warn('Failed to fetch foreign keys: %O', e);
131+
}
132+
}
133+
return { tables, columns, foreignKeys };
134+
}
135+
118136
public async toAbsolutePath(fsPath: string) {
119137
if (!path.isAbsolute(fsPath) && /\$\{workspaceFolder:(.+)}/g.test(fsPath)) {
120138
const workspaceName = fsPath.match(/\$\{workspaceFolder:(.+)}/)[1];

packages/base-driver/src/lib/factory.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,44 @@ describe(`query generator`, () => {
1717

1818
const expected = `SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'table' AND TABLE_CATALOG = 'catalog' AND TABLE_SCHEMA = 'schema' AND something = 'smtg'`;
1919
expect(result).toBe(expected);
20-
})
20+
});
21+
22+
it(`should generate foreign key queries with schema and database params`, () => {
23+
const fetchForeignKeys = queryFactory<{ schema: string; database: string }>`
24+
SELECT
25+
tc.constraint_name AS "constraintName",
26+
tc.table_schema AS "sourceTableSchema",
27+
tc.table_name AS "sourceTableName",
28+
kcu.column_name AS "sourceColumnName",
29+
ccu.table_schema AS "targetTableSchema",
30+
ccu.table_name AS "targetTableName",
31+
ccu.column_name AS "targetColumnName"
32+
FROM information_schema.table_constraints AS tc
33+
WHERE
34+
tc.constraint_type = 'FOREIGN KEY'
35+
AND tc.table_schema = '${p => p.schema}'
36+
AND tc.table_catalog = '${p => p.database}'
37+
`;
38+
const result = fetchForeignKeys({ schema: 'public', database: 'mydb' });
39+
40+
expect(result).toContain("tc.table_schema = 'public'");
41+
expect(result).toContain("tc.table_catalog = 'mydb'");
42+
expect(result).toContain("tc.constraint_type = 'FOREIGN KEY'");
43+
expect(result).toContain('"constraintName"');
44+
expect(result).toContain('"sourceTableName"');
45+
expect(result).toContain('"targetTableName"');
46+
});
47+
48+
it(`should handle queries with no dynamic params`, () => {
49+
const staticQuery = queryFactory`SELECT 1 AS result`;
50+
expect(staticQuery({})).toBe('SELECT 1 AS result');
51+
});
52+
53+
it(`should expose raw query template`, () => {
54+
const query = queryFactory<{ name: string }>`
55+
SELECT * FROM tables WHERE name = '${p => p.name}'
56+
`;
57+
expect(query.raw).toBeDefined();
58+
expect(query.raw).toContain('${');
59+
});
2160
});

packages/driver.mssql/src/ls/queries.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,33 @@ FETCH NEXT ${p => p.limit || 100} ROWS ONLY
188188
`;
189189

190190

191+
export const fetchForeignKeys: IBaseQueries['fetchForeignKeys'] = queryFactory`
192+
SELECT
193+
FK.CONSTRAINT_NAME AS "constraintName",
194+
FK.TABLE_SCHEMA AS "sourceTableSchema",
195+
FK.TABLE_NAME AS "sourceTableName",
196+
CU.COLUMN_NAME AS "sourceColumnName",
197+
PK.TABLE_SCHEMA AS "targetTableSchema",
198+
PK.TABLE_NAME AS "targetTableName",
199+
PT.COLUMN_NAME AS "targetColumnName"
200+
FROM
201+
${p => p.database ? `${escapeTableName({ database: p.database, schema: "INFORMATION_SCHEMA", label: "REFERENTIAL_CONSTRAINTS" })}` : 'INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS'} AS C
202+
JOIN ${p => p.database ? `${escapeTableName({ database: p.database, schema: "INFORMATION_SCHEMA", label: "TABLE_CONSTRAINTS" })}` : 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS'} AS FK
203+
ON C.CONSTRAINT_NAME = FK.CONSTRAINT_NAME AND C.CONSTRAINT_SCHEMA = FK.CONSTRAINT_SCHEMA
204+
JOIN ${p => p.database ? `${escapeTableName({ database: p.database, schema: "INFORMATION_SCHEMA", label: "TABLE_CONSTRAINTS" })}` : 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS'} AS PK
205+
ON C.UNIQUE_CONSTRAINT_NAME = PK.CONSTRAINT_NAME AND C.UNIQUE_CONSTRAINT_SCHEMA = PK.CONSTRAINT_SCHEMA
206+
JOIN ${p => p.database ? `${escapeTableName({ database: p.database, schema: "INFORMATION_SCHEMA", label: "KEY_COLUMN_USAGE" })}` : 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE'} AS CU
207+
ON C.CONSTRAINT_NAME = CU.CONSTRAINT_NAME AND C.CONSTRAINT_SCHEMA = CU.CONSTRAINT_SCHEMA
208+
JOIN ${p => p.database ? `${escapeTableName({ database: p.database, schema: "INFORMATION_SCHEMA", label: "KEY_COLUMN_USAGE" })}` : 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE'} AS PT
209+
ON C.UNIQUE_CONSTRAINT_NAME = PT.CONSTRAINT_NAME AND C.UNIQUE_CONSTRAINT_SCHEMA = PT.CONSTRAINT_SCHEMA
210+
WHERE
211+
FK.TABLE_SCHEMA = '${p => p.schema}'
212+
AND FK.TABLE_CATALOG = '${p => p.database}'
213+
ORDER BY
214+
FK.TABLE_NAME,
215+
CU.COLUMN_NAME
216+
`;
217+
191218
// export default {
192219
// fetchFunctions: `
193220
// SELECT

packages/driver.mysql/src/ls/queries.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,25 @@ ORDER BY
182182
LIMIT ${p => p.limit || 100}
183183
184184
`;
185+
export const fetchForeignKeys: IBaseQueries['fetchForeignKeys'] = queryFactory`
186+
SELECT
187+
KCU.CONSTRAINT_NAME AS "constraintName",
188+
KCU.TABLE_SCHEMA AS "sourceTableSchema",
189+
KCU.TABLE_NAME AS "sourceTableName",
190+
KCU.COLUMN_NAME AS "sourceColumnName",
191+
KCU.REFERENCED_TABLE_SCHEMA AS "targetTableSchema",
192+
KCU.REFERENCED_TABLE_NAME AS "targetTableName",
193+
KCU.REFERENCED_COLUMN_NAME AS "targetColumnName"
194+
FROM
195+
INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS KCU
196+
WHERE
197+
KCU.REFERENCED_TABLE_NAME IS NOT NULL
198+
AND KCU.TABLE_SCHEMA = '${p => p.schema}'
199+
ORDER BY
200+
KCU.TABLE_NAME,
201+
KCU.COLUMN_NAME
202+
`;
203+
185204
// export default {
186205
// fetchFunctions: `
187206
// SELECT

packages/driver.pg/src/ls/driver.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,15 @@ export default class PostgreSQL extends AbstractDriver<Pool, PoolConfig> impleme
104104
public query: (typeof AbstractDriver)['prototype']['query'] = (query, opt = {}) => {
105105
const messages = [];
106106
let cli : PoolClient;
107+
let noticeHandler: (notice: any) => void;
107108
const { requestId } = opt;
108109
return this.open()
109110
.then(async (pool) => {
110111
cli = await pool.connect();
111-
cli.on('notice', notice => messages.push(this.prepareMessage(`${notice.name.toUpperCase()}: ${notice.message}`)));
112+
noticeHandler = notice => messages.push(this.prepareMessage(`${notice.name.toUpperCase()}: ${notice.message}`));
113+
cli.on('notice', noticeHandler);
112114
const results = await cli.query({ text: query.toString(), rowMode: 'array' });
115+
cli.removeListener('notice', noticeHandler);
113116
cli.release();
114117
return results;
115118
})
@@ -137,6 +140,7 @@ export default class PostgreSQL extends AbstractDriver<Pool, PoolConfig> impleme
137140
});
138141
})
139142
.catch(err => {
143+
if (cli && noticeHandler) cli.removeListener('notice', noticeHandler);
140144
cli && cli.release();
141145
return [<NSDatabase.IResult>{
142146
connId: this.getId(),

packages/driver.pg/src/ls/queries.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,31 @@ ORDER BY
225225
schema_name;
226226
`;
227227

228+
const fetchForeignKeys: IBaseQueries['fetchForeignKeys'] = queryFactory`
229+
SELECT
230+
tc.constraint_name AS "constraintName",
231+
tc.table_schema AS "sourceTableSchema",
232+
tc.table_name AS "sourceTableName",
233+
kcu.column_name AS "sourceColumnName",
234+
ccu.table_schema AS "targetTableSchema",
235+
ccu.table_name AS "targetTableName",
236+
ccu.column_name AS "targetColumnName"
237+
FROM information_schema.table_constraints AS tc
238+
JOIN information_schema.key_column_usage AS kcu
239+
ON tc.constraint_name = kcu.constraint_name
240+
AND tc.table_schema = kcu.table_schema
241+
JOIN information_schema.constraint_column_usage AS ccu
242+
ON ccu.constraint_name = tc.constraint_name
243+
AND ccu.table_schema = tc.table_schema
244+
WHERE
245+
tc.constraint_type = 'FOREIGN KEY'
246+
AND tc.table_schema = '${p => p.schema}'
247+
AND tc.table_catalog = '${p => p.database}'
248+
ORDER BY
249+
tc.table_name,
250+
kcu.column_name
251+
`;
252+
228253
export default {
229254
describeTable,
230255
countRecords,
@@ -238,4 +263,5 @@ export default {
238263
fetchMaterializedViews,
239264
searchTables,
240265
searchColumns,
266+
fetchForeignKeys,
241267
};

packages/driver.sqlite/src/ls/queries.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,22 @@ ORDER BY C.name ASC,
7979
LIMIT ${p => p.limit || 100}
8080
`;
8181

82+
const fetchForeignKeys: IBaseQueries['fetchForeignKeys'] = queryFactory`
83+
SELECT
84+
'' AS "constraintName",
85+
'' AS "sourceTableSchema",
86+
m.name AS "sourceTableName",
87+
fk."from" AS "sourceColumnName",
88+
'' AS "targetTableSchema",
89+
fk."table" AS "targetTableName",
90+
fk."to" AS "targetColumnName"
91+
FROM sqlite_master AS m
92+
JOIN pragma_foreign_key_list(m.name) AS fk ON 1 = 1
93+
WHERE m.type = 'table'
94+
AND m.name NOT LIKE 'sqlite_%'
95+
ORDER BY m.name, fk.seq
96+
`;
97+
8298
export default {
8399
describeTable,
84100
countRecords,
@@ -87,7 +103,8 @@ export default {
87103
fetchTables,
88104
fetchViews,
89105
searchTables,
90-
searchColumns
106+
searchColumns,
107+
fetchForeignKeys,
91108
}
92109
// export default {
93110
// listFks: `PRAGMA foreign_key_list(\':table\');`

packages/extension/build.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"entries": {
1717
"Settings": "../plugins/connection-manager/webview/ui/screens/Settings/index.tsx",
1818
"Results": "../plugins/connection-manager/webview/ui/screens/Results/index.tsx",
19+
"ERDiagram": "../plugins/connection-manager/webview/ui/screens/ERDiagram/index.tsx",
1920
"theme": "../plugins/connection-manager/webview/ui/sass/theme.scss"
2021
},
2122
"type": "webview"

packages/extension/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,15 @@
353353
"title": "Open Docs",
354354
"command": "sqltools.openDocs",
355355
"category": "SQLTools"
356+
},
357+
{
358+
"title": "Show ER Diagram",
359+
"command": "sqltools.showERDiagram",
360+
"category": "SQLTools Connection",
361+
"icon": {
362+
"light": "icons/show-light.svg",
363+
"dark": "icons/show-dark.svg"
364+
}
356365
}
357366
],
358367
"keybindings": [
@@ -1046,6 +1055,10 @@
10461055
"command": "sqltools.refreshTree",
10471056
"when": "false"
10481057
},
1058+
{
1059+
"command": "sqltools.showERDiagram",
1060+
"when": "false"
1061+
},
10491062
{
10501063
"command": "sqltools.generateInsertQuery",
10511064
"when": "false"
@@ -1147,6 +1160,11 @@
11471160
"when": "view == sqltoolsViewConnectionExplorer && viewItem =~ /^connection\\.(table|view|materializedView)$/",
11481161
"group": "navigation@1"
11491162
},
1163+
{
1164+
"command": "sqltools.showERDiagram",
1165+
"when": "view == sqltoolsViewConnectionExplorer && viewItem == connection.schema",
1166+
"group": "navigation@1"
1167+
},
11501168
{
11511169
"command": "sqltools.describeTable",
11521170
"when": "view == sqltoolsViewConnectionExplorer && viewItem =~ /^connection\\.(table|view)$/",

0 commit comments

Comments
 (0)