mcp-db-analyzer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/build/analyzers/bloat.js +150 -0
- package/build/analyzers/connections.js +183 -0
- package/build/analyzers/indexes.js +251 -0
- package/build/analyzers/query.js +241 -0
- package/build/analyzers/relationships.js +210 -0
- package/build/analyzers/schema.js +259 -0
- package/build/analyzers/slow-queries.js +127 -0
- package/build/analyzers/suggestions.js +166 -0
- package/build/analyzers/vacuum.js +187 -0
- package/build/db-mysql.js +80 -0
- package/build/db-postgres.js +73 -0
- package/build/db-sqlite.js +36 -0
- package/build/db.js +41 -0
- package/build/errors.js +13 -0
- package/build/index.js +380 -0
- package/build/license.js +114 -0
- package/package.json +65 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table relationship analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Queries foreign key constraints to build a dependency graph.
|
|
5
|
+
* Detects:
|
|
6
|
+
* - Orphan tables (no FK relationships at all)
|
|
7
|
+
* - Cascading delete chains (CASCADE rules that could cause unexpected data loss)
|
|
8
|
+
* - Highly connected tables (many FKs pointing in/out — central entities)
|
|
9
|
+
* - Missing reciprocal relationships
|
|
10
|
+
*/
|
|
11
|
+
import { query, getDriverType } from "../db.js";
|
|
12
|
+
export async function analyzeTableRelationships(schema = "public") {
|
|
13
|
+
const driver = getDriverType();
|
|
14
|
+
if (driver === "sqlite") {
|
|
15
|
+
return analyzeSqliteRelationships();
|
|
16
|
+
}
|
|
17
|
+
if (driver === "mysql") {
|
|
18
|
+
return analyzeMysqlRelationships(schema);
|
|
19
|
+
}
|
|
20
|
+
return analyzePostgresRelationships(schema);
|
|
21
|
+
}
|
|
22
|
+
async function analyzePostgresRelationships(schema) {
|
|
23
|
+
// Get all tables
|
|
24
|
+
const tablesResult = await query(`SELECT table_name FROM information_schema.tables
|
|
25
|
+
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
|
|
26
|
+
ORDER BY table_name`, [schema]);
|
|
27
|
+
if (tablesResult.rows.length === 0) {
|
|
28
|
+
return "## Table Relationships\n\nNo tables found in schema.";
|
|
29
|
+
}
|
|
30
|
+
// Get all foreign keys
|
|
31
|
+
const fkResult = await query(`SELECT
|
|
32
|
+
kcu.table_name AS source_table,
|
|
33
|
+
kcu.column_name AS source_column,
|
|
34
|
+
ccu.table_name AS target_table,
|
|
35
|
+
ccu.column_name AS target_column,
|
|
36
|
+
tc.constraint_name,
|
|
37
|
+
rc.delete_rule AS on_delete,
|
|
38
|
+
rc.update_rule AS on_update
|
|
39
|
+
FROM information_schema.table_constraints tc
|
|
40
|
+
JOIN information_schema.key_column_usage kcu
|
|
41
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
42
|
+
AND tc.table_schema = kcu.table_schema
|
|
43
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
44
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
45
|
+
AND ccu.table_schema = tc.table_schema
|
|
46
|
+
JOIN information_schema.referential_constraints rc
|
|
47
|
+
ON rc.constraint_name = tc.constraint_name
|
|
48
|
+
AND rc.constraint_schema = tc.table_schema
|
|
49
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
50
|
+
AND tc.table_schema = $1
|
|
51
|
+
ORDER BY kcu.table_name, kcu.column_name`, [schema]);
|
|
52
|
+
return formatRelationshipReport(tablesResult.rows.map(r => r.table_name), fkResult.rows);
|
|
53
|
+
}
|
|
54
|
+
async function analyzeMysqlRelationships(schema) {
|
|
55
|
+
const tablesResult = await query(`SELECT TABLE_NAME AS table_name FROM information_schema.TABLES
|
|
56
|
+
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
|
|
57
|
+
ORDER BY TABLE_NAME`, [schema]);
|
|
58
|
+
if (tablesResult.rows.length === 0) {
|
|
59
|
+
return "## Table Relationships\n\nNo tables found in schema.";
|
|
60
|
+
}
|
|
61
|
+
const fkResult = await query(`SELECT
|
|
62
|
+
kcu.TABLE_NAME AS source_table,
|
|
63
|
+
kcu.COLUMN_NAME AS source_column,
|
|
64
|
+
kcu.REFERENCED_TABLE_NAME AS target_table,
|
|
65
|
+
kcu.REFERENCED_COLUMN_NAME AS target_column,
|
|
66
|
+
kcu.CONSTRAINT_NAME AS constraint_name,
|
|
67
|
+
rc.DELETE_RULE AS on_delete,
|
|
68
|
+
rc.UPDATE_RULE AS on_update
|
|
69
|
+
FROM information_schema.KEY_COLUMN_USAGE kcu
|
|
70
|
+
JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
|
|
71
|
+
ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
|
72
|
+
AND kcu.TABLE_SCHEMA = rc.CONSTRAINT_SCHEMA
|
|
73
|
+
WHERE kcu.TABLE_SCHEMA = ?
|
|
74
|
+
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
|
75
|
+
ORDER BY kcu.TABLE_NAME, kcu.COLUMN_NAME`, [schema]);
|
|
76
|
+
return formatRelationshipReport(tablesResult.rows.map(r => r.table_name), fkResult.rows);
|
|
77
|
+
}
|
|
78
|
+
async function analyzeSqliteRelationships() {
|
|
79
|
+
// Get all tables
|
|
80
|
+
const tablesResult = await query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`);
|
|
81
|
+
if (tablesResult.rows.length === 0) {
|
|
82
|
+
return "## Table Relationships\n\nNo tables found.";
|
|
83
|
+
}
|
|
84
|
+
// Get FK info per table
|
|
85
|
+
const fks = [];
|
|
86
|
+
for (const table of tablesResult.rows) {
|
|
87
|
+
const fkInfo = await query(`PRAGMA foreign_key_list('${table.name}')`);
|
|
88
|
+
for (const fk of fkInfo.rows) {
|
|
89
|
+
fks.push({
|
|
90
|
+
source_table: table.name,
|
|
91
|
+
source_column: fk.from,
|
|
92
|
+
target_table: fk.table,
|
|
93
|
+
target_column: fk.to,
|
|
94
|
+
constraint_name: `fk_${table.name}_${fk.from}`,
|
|
95
|
+
on_delete: fk.on_delete,
|
|
96
|
+
on_update: fk.on_update,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return formatRelationshipReport(tablesResult.rows.map(r => r.name), fks);
|
|
101
|
+
}
|
|
102
|
+
function formatRelationshipReport(allTables, foreignKeys) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
lines.push("## Table Relationships\n");
|
|
105
|
+
// Build graph
|
|
106
|
+
const graph = new Map();
|
|
107
|
+
for (const t of allTables) {
|
|
108
|
+
graph.set(t, { name: t, outgoing: [], incoming: [] });
|
|
109
|
+
}
|
|
110
|
+
for (const fk of foreignKeys) {
|
|
111
|
+
const source = graph.get(fk.source_table);
|
|
112
|
+
const target = graph.get(fk.target_table);
|
|
113
|
+
if (source)
|
|
114
|
+
source.outgoing.push(fk);
|
|
115
|
+
if (target)
|
|
116
|
+
target.incoming.push(fk);
|
|
117
|
+
}
|
|
118
|
+
// Summary
|
|
119
|
+
lines.push(`**Tables**: ${allTables.length}`);
|
|
120
|
+
lines.push(`**Foreign Keys**: ${foreignKeys.length}`);
|
|
121
|
+
lines.push("");
|
|
122
|
+
// Relationship map
|
|
123
|
+
if (foreignKeys.length > 0) {
|
|
124
|
+
lines.push("### Foreign Key Map\n");
|
|
125
|
+
lines.push("| Source Table | Column | Target Table | Column | ON DELETE | ON UPDATE |");
|
|
126
|
+
lines.push("|-------------|--------|--------------|--------|-----------|-----------|");
|
|
127
|
+
for (const fk of foreignKeys) {
|
|
128
|
+
lines.push(`| ${fk.source_table} | ${fk.source_column} | ${fk.target_table} | ${fk.target_column} | ${fk.on_delete} | ${fk.on_update} |`);
|
|
129
|
+
}
|
|
130
|
+
lines.push("");
|
|
131
|
+
}
|
|
132
|
+
// Central entities (most connections)
|
|
133
|
+
const connectivity = allTables
|
|
134
|
+
.map(t => {
|
|
135
|
+
const node = graph.get(t);
|
|
136
|
+
return { name: t, total: node.outgoing.length + node.incoming.length, incoming: node.incoming.length, outgoing: node.outgoing.length };
|
|
137
|
+
})
|
|
138
|
+
.filter(t => t.total > 0)
|
|
139
|
+
.sort((a, b) => b.total - a.total);
|
|
140
|
+
if (connectivity.length > 0) {
|
|
141
|
+
lines.push("### Entity Connectivity\n");
|
|
142
|
+
lines.push("| Table | Incoming FKs | Outgoing FKs | Total |");
|
|
143
|
+
lines.push("|-------|-------------|-------------|-------|");
|
|
144
|
+
for (const c of connectivity) {
|
|
145
|
+
const marker = c.total >= 5 ? " **hub**" : "";
|
|
146
|
+
lines.push(`| ${c.name}${marker} | ${c.incoming} | ${c.outgoing} | ${c.total} |`);
|
|
147
|
+
}
|
|
148
|
+
lines.push("");
|
|
149
|
+
}
|
|
150
|
+
// Orphan tables (no FK relationships at all)
|
|
151
|
+
const orphans = allTables.filter(t => {
|
|
152
|
+
const node = graph.get(t);
|
|
153
|
+
return node.outgoing.length === 0 && node.incoming.length === 0;
|
|
154
|
+
});
|
|
155
|
+
if (orphans.length > 0) {
|
|
156
|
+
lines.push("### Orphan Tables (no FK relationships)\n");
|
|
157
|
+
for (const o of orphans) {
|
|
158
|
+
lines.push(`- \`${o}\``);
|
|
159
|
+
}
|
|
160
|
+
lines.push("");
|
|
161
|
+
lines.push("*Orphan tables may be lookup tables, denormalized tables, or tables missing FK constraints.*");
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
// Cascading delete chains
|
|
165
|
+
const cascadeDeletes = foreignKeys.filter(fk => fk.on_delete === "CASCADE");
|
|
166
|
+
if (cascadeDeletes.length > 0) {
|
|
167
|
+
lines.push("### Cascading Delete Chains\n");
|
|
168
|
+
lines.push("Deleting a row from these parent tables will cascade-delete rows in child tables:\n");
|
|
169
|
+
// Group by target (parent) table
|
|
170
|
+
const cascadeByParent = new Map();
|
|
171
|
+
for (const fk of cascadeDeletes) {
|
|
172
|
+
const existing = cascadeByParent.get(fk.target_table) || [];
|
|
173
|
+
existing.push(fk);
|
|
174
|
+
cascadeByParent.set(fk.target_table, existing);
|
|
175
|
+
}
|
|
176
|
+
for (const [parent, fks] of cascadeByParent) {
|
|
177
|
+
const children = fks.map(f => f.source_table).join(", ");
|
|
178
|
+
lines.push(`- **${parent}** → cascades to: ${children}`);
|
|
179
|
+
// Check for deep chains (cascade through multiple levels)
|
|
180
|
+
for (const fk of fks) {
|
|
181
|
+
const grandchildren = cascadeDeletes.filter(gfk => gfk.target_table === fk.source_table);
|
|
182
|
+
if (grandchildren.length > 0) {
|
|
183
|
+
const gcNames = grandchildren.map(g => g.source_table).join(", ");
|
|
184
|
+
lines.push(` - **${fk.source_table}** → further cascades to: ${gcNames}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
lines.push("");
|
|
189
|
+
lines.push("**WARNING**: Cascading deletes can cause unexpected data loss. Ensure all CASCADE rules are intentional.\n");
|
|
190
|
+
}
|
|
191
|
+
// Recommendations
|
|
192
|
+
const issues = [];
|
|
193
|
+
if (orphans.length > 0 && orphans.length > allTables.length * 0.5) {
|
|
194
|
+
issues.push(`${orphans.length}/${allTables.length} tables have no FK relationships — consider adding foreign key constraints for data integrity`);
|
|
195
|
+
}
|
|
196
|
+
if (cascadeDeletes.length > 3) {
|
|
197
|
+
issues.push(`${cascadeDeletes.length} CASCADE DELETE rules — review each one to ensure intended behavior`);
|
|
198
|
+
}
|
|
199
|
+
const hubs = connectivity.filter(c => c.total >= 5);
|
|
200
|
+
if (hubs.length > 0) {
|
|
201
|
+
issues.push(`Hub tables (${hubs.map(h => h.name).join(", ")}) have 5+ FK connections — changes to these tables affect many others`);
|
|
202
|
+
}
|
|
203
|
+
if (issues.length > 0) {
|
|
204
|
+
lines.push("### Recommendations\n");
|
|
205
|
+
for (const issue of issues) {
|
|
206
|
+
lines.push(`- ${issue}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return lines.join("\n");
|
|
210
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { query, getDriverType } from "../db.js";
|
|
2
|
+
/**
|
|
3
|
+
* List all user tables with row estimates and sizes.
|
|
4
|
+
*/
|
|
5
|
+
export async function listTables(schema = "public") {
|
|
6
|
+
const driver = getDriverType();
|
|
7
|
+
if (driver === "sqlite") {
|
|
8
|
+
return listTablesSqlite();
|
|
9
|
+
}
|
|
10
|
+
if (driver === "mysql") {
|
|
11
|
+
return listTablesMysql(schema);
|
|
12
|
+
}
|
|
13
|
+
const result = await query(`
|
|
14
|
+
SELECT
|
|
15
|
+
t.table_name,
|
|
16
|
+
t.table_schema,
|
|
17
|
+
COALESCE(s.n_live_tup, 0)::text AS row_estimate,
|
|
18
|
+
pg_size_pretty(pg_total_relation_size(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))) AS total_size
|
|
19
|
+
FROM information_schema.tables t
|
|
20
|
+
LEFT JOIN pg_stat_user_tables s
|
|
21
|
+
ON s.schemaname = t.table_schema AND s.relname = t.table_name
|
|
22
|
+
WHERE t.table_schema = $1
|
|
23
|
+
AND t.table_type = 'BASE TABLE'
|
|
24
|
+
ORDER BY COALESCE(s.n_live_tup, 0) DESC
|
|
25
|
+
`, [schema]);
|
|
26
|
+
return formatTableList(result.rows, schema);
|
|
27
|
+
}
|
|
28
|
+
async function listTablesMysql(schema) {
|
|
29
|
+
const result = await query(`
|
|
30
|
+
SELECT
|
|
31
|
+
TABLE_NAME AS table_name,
|
|
32
|
+
TABLE_SCHEMA AS table_schema,
|
|
33
|
+
CAST(TABLE_ROWS AS CHAR) AS row_estimate,
|
|
34
|
+
CONCAT(
|
|
35
|
+
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2),
|
|
36
|
+
' MB'
|
|
37
|
+
) AS total_size
|
|
38
|
+
FROM information_schema.TABLES
|
|
39
|
+
WHERE TABLE_SCHEMA = ?
|
|
40
|
+
AND TABLE_TYPE = 'BASE TABLE'
|
|
41
|
+
ORDER BY TABLE_ROWS DESC
|
|
42
|
+
`, [schema]);
|
|
43
|
+
return formatTableList(result.rows, schema);
|
|
44
|
+
}
|
|
45
|
+
async function listTablesSqlite() {
|
|
46
|
+
const result = await query(`
|
|
47
|
+
SELECT name FROM sqlite_master
|
|
48
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
49
|
+
ORDER BY name
|
|
50
|
+
`);
|
|
51
|
+
if (result.rows.length === 0) {
|
|
52
|
+
return `No tables found in database.`;
|
|
53
|
+
}
|
|
54
|
+
const lines = [`## Tables in schema 'main'\n`];
|
|
55
|
+
lines.push("| Table | Rows (est.) | Total Size |");
|
|
56
|
+
lines.push("|-------|-------------|------------|");
|
|
57
|
+
for (const row of result.rows) {
|
|
58
|
+
// SQLite doesn't have built-in row count or size — use count
|
|
59
|
+
const countResult = await query(`SELECT count(*) as cnt FROM "${row.name}"`);
|
|
60
|
+
const cnt = countResult.rows[0]?.cnt ?? 0;
|
|
61
|
+
lines.push(`| ${row.name} | ${cnt} | - |`);
|
|
62
|
+
}
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
function formatTableList(rows, schema) {
|
|
66
|
+
if (rows.length === 0) {
|
|
67
|
+
return `No tables found in schema '${schema}'.`;
|
|
68
|
+
}
|
|
69
|
+
const lines = [`## Tables in schema '${schema}'\n`];
|
|
70
|
+
lines.push("| Table | Rows (est.) | Total Size |");
|
|
71
|
+
lines.push("|-------|-------------|------------|");
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
lines.push(`| ${row.table_name} | ${row.row_estimate} | ${row.total_size} |`);
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get detailed schema information for a specific table.
|
|
79
|
+
*/
|
|
80
|
+
export async function inspectTable(tableName, schema = "public") {
|
|
81
|
+
const driver = getDriverType();
|
|
82
|
+
if (driver === "sqlite") {
|
|
83
|
+
return inspectTableSqlite(tableName);
|
|
84
|
+
}
|
|
85
|
+
// Columns — information_schema works for both PG and MySQL
|
|
86
|
+
const colResult = await query(`
|
|
87
|
+
SELECT column_name, data_type, is_nullable, column_default,
|
|
88
|
+
character_maximum_length, ordinal_position
|
|
89
|
+
FROM information_schema.columns
|
|
90
|
+
WHERE table_schema = ${driver === "mysql" ? "?" : "$1"}
|
|
91
|
+
AND table_name = ${driver === "mysql" ? "?" : "$2"}
|
|
92
|
+
ORDER BY ordinal_position
|
|
93
|
+
`, [schema, tableName]);
|
|
94
|
+
if (colResult.rows.length === 0) {
|
|
95
|
+
return `Table '${schema}.${tableName}' not found.`;
|
|
96
|
+
}
|
|
97
|
+
const lines = [`## Table: ${schema}.${tableName}\n`];
|
|
98
|
+
// Stats — driver-specific
|
|
99
|
+
if (driver === "mysql") {
|
|
100
|
+
const statsResult = await query(`
|
|
101
|
+
SELECT
|
|
102
|
+
CAST(TABLE_ROWS AS CHAR) AS row_estimate,
|
|
103
|
+
CONCAT(ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2), ' MB') AS total_size,
|
|
104
|
+
CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 2), ' MB') AS table_size,
|
|
105
|
+
CONCAT(ROUND(INDEX_LENGTH / 1024 / 1024, 2), ' MB') AS index_size
|
|
106
|
+
FROM information_schema.TABLES
|
|
107
|
+
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
|
108
|
+
`, [schema, tableName]);
|
|
109
|
+
const stats = statsResult.rows[0];
|
|
110
|
+
if (stats) {
|
|
111
|
+
lines.push(`- **Rows (est.)**: ${stats.row_estimate}`);
|
|
112
|
+
lines.push(`- **Total size**: ${stats.total_size}`);
|
|
113
|
+
lines.push(`- **Table size**: ${stats.table_size}`);
|
|
114
|
+
lines.push(`- **Index size**: ${stats.index_size}`);
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const statsResult = await query(`
|
|
120
|
+
SELECT
|
|
121
|
+
COALESCE(n_live_tup, 0)::text AS row_estimate,
|
|
122
|
+
pg_size_pretty(pg_total_relation_size(quote_ident($1) || '.' || quote_ident($2))) AS total_size,
|
|
123
|
+
pg_size_pretty(pg_table_size(quote_ident($1) || '.' || quote_ident($2))) AS table_size,
|
|
124
|
+
pg_size_pretty(pg_indexes_size(quote_ident($1) || '.' || quote_ident($2))) AS index_size
|
|
125
|
+
FROM pg_stat_user_tables
|
|
126
|
+
WHERE schemaname = $1 AND relname = $2
|
|
127
|
+
`, [schema, tableName]);
|
|
128
|
+
const stats = statsResult.rows[0];
|
|
129
|
+
if (stats) {
|
|
130
|
+
lines.push(`- **Rows (est.)**: ${stats.row_estimate}`);
|
|
131
|
+
lines.push(`- **Total size**: ${stats.total_size}`);
|
|
132
|
+
lines.push(`- **Table size**: ${stats.table_size}`);
|
|
133
|
+
lines.push(`- **Index size**: ${stats.index_size}`);
|
|
134
|
+
lines.push("");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Columns
|
|
138
|
+
lines.push("### Columns\n");
|
|
139
|
+
lines.push("| # | Column | Type | Nullable | Default |");
|
|
140
|
+
lines.push("|---|--------|------|----------|---------|");
|
|
141
|
+
for (const col of colResult.rows) {
|
|
142
|
+
const type = col.character_maximum_length
|
|
143
|
+
? `${col.data_type}(${col.character_maximum_length})`
|
|
144
|
+
: col.data_type;
|
|
145
|
+
lines.push(`| ${col.ordinal_position} | ${col.column_name} | ${type} | ${col.is_nullable} | ${col.column_default ?? "-"} |`);
|
|
146
|
+
}
|
|
147
|
+
// Constraints — information_schema works for both, but FK detection differs
|
|
148
|
+
if (driver === "mysql") {
|
|
149
|
+
const constraintResult = await query(`
|
|
150
|
+
SELECT
|
|
151
|
+
tc.CONSTRAINT_NAME AS constraint_name,
|
|
152
|
+
tc.CONSTRAINT_TYPE AS constraint_type,
|
|
153
|
+
kcu.COLUMN_NAME AS column_name,
|
|
154
|
+
kcu.REFERENCED_TABLE_NAME AS foreign_table_name,
|
|
155
|
+
kcu.REFERENCED_COLUMN_NAME AS foreign_column_name
|
|
156
|
+
FROM information_schema.TABLE_CONSTRAINTS tc
|
|
157
|
+
JOIN information_schema.KEY_COLUMN_USAGE kcu
|
|
158
|
+
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
|
159
|
+
AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA
|
|
160
|
+
AND tc.TABLE_NAME = kcu.TABLE_NAME
|
|
161
|
+
WHERE tc.TABLE_SCHEMA = ? AND tc.TABLE_NAME = ?
|
|
162
|
+
ORDER BY tc.CONSTRAINT_TYPE, tc.CONSTRAINT_NAME
|
|
163
|
+
`, [schema, tableName]);
|
|
164
|
+
appendConstraints(constraintResult.rows, lines);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const constraintResult = await query(`
|
|
168
|
+
SELECT
|
|
169
|
+
tc.constraint_name,
|
|
170
|
+
tc.constraint_type,
|
|
171
|
+
kcu.column_name,
|
|
172
|
+
ccu.table_name AS foreign_table_name,
|
|
173
|
+
ccu.column_name AS foreign_column_name
|
|
174
|
+
FROM information_schema.table_constraints tc
|
|
175
|
+
JOIN information_schema.key_column_usage kcu
|
|
176
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
177
|
+
AND tc.table_schema = kcu.table_schema
|
|
178
|
+
LEFT JOIN information_schema.constraint_column_usage ccu
|
|
179
|
+
ON tc.constraint_name = ccu.constraint_name
|
|
180
|
+
AND tc.table_schema = ccu.table_schema
|
|
181
|
+
AND tc.constraint_type = 'FOREIGN KEY'
|
|
182
|
+
WHERE tc.table_schema = $1 AND tc.table_name = $2
|
|
183
|
+
ORDER BY tc.constraint_type, tc.constraint_name
|
|
184
|
+
`, [schema, tableName]);
|
|
185
|
+
appendConstraints(constraintResult.rows, lines);
|
|
186
|
+
}
|
|
187
|
+
return lines.join("\n");
|
|
188
|
+
}
|
|
189
|
+
async function inspectTableSqlite(tableName) {
|
|
190
|
+
const cols = await query(`PRAGMA table_info("${tableName}")`);
|
|
191
|
+
if (cols.rows.length === 0) {
|
|
192
|
+
return `Table '${tableName}' not found.`;
|
|
193
|
+
}
|
|
194
|
+
const lines = [`## Table: main.${tableName}\n`];
|
|
195
|
+
// Row count
|
|
196
|
+
const countResult = await query(`SELECT count(*) as cnt FROM "${tableName}"`);
|
|
197
|
+
lines.push(`- **Rows**: ${countResult.rows[0]?.cnt ?? 0}`);
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push("### Columns\n");
|
|
200
|
+
lines.push("| # | Column | Type | Nullable | Default | PK |");
|
|
201
|
+
lines.push("|---|--------|------|----------|---------|-----|");
|
|
202
|
+
for (const col of cols.rows) {
|
|
203
|
+
lines.push(`| ${col.cid + 1} | ${col.name} | ${col.type || 'ANY'} | ${col.notnull ? 'NO' : 'YES'} | ${col.dflt_value ?? '-'} | ${col.pk ? 'YES' : '-'} |`);
|
|
204
|
+
}
|
|
205
|
+
// Foreign keys
|
|
206
|
+
const fks = await query(`PRAGMA foreign_key_list("${tableName}")`);
|
|
207
|
+
if (fks.rows.length > 0) {
|
|
208
|
+
lines.push("\n### Foreign Keys\n");
|
|
209
|
+
lines.push("| Column | References |");
|
|
210
|
+
lines.push("|--------|------------|");
|
|
211
|
+
for (const fk of fks.rows) {
|
|
212
|
+
lines.push(`| ${fk.from} | ${fk.table}(${fk.to}) |`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Indexes
|
|
216
|
+
const indexes = await query(`PRAGMA index_list("${tableName}")`);
|
|
217
|
+
if (indexes.rows.length > 0) {
|
|
218
|
+
lines.push("\n### Indexes\n");
|
|
219
|
+
lines.push("| Name | Unique | Columns |");
|
|
220
|
+
lines.push("|------|--------|---------|");
|
|
221
|
+
for (const idx of indexes.rows) {
|
|
222
|
+
const idxCols = await query(`PRAGMA index_info("${idx.name}")`);
|
|
223
|
+
const colNames = idxCols.rows.map(c => c.name).join(", ");
|
|
224
|
+
lines.push(`| ${idx.name} | ${idx.unique ? 'YES' : 'NO'} | ${colNames} |`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return lines.join("\n");
|
|
228
|
+
}
|
|
229
|
+
function appendConstraints(rows, lines) {
|
|
230
|
+
const constraintMap = new Map();
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
let c = constraintMap.get(row.constraint_name);
|
|
233
|
+
if (!c) {
|
|
234
|
+
c = {
|
|
235
|
+
constraint_name: row.constraint_name,
|
|
236
|
+
constraint_type: row.constraint_type,
|
|
237
|
+
columns: [],
|
|
238
|
+
foreign_table: row.foreign_table_name,
|
|
239
|
+
foreign_columns: row.foreign_column_name ? [] : null,
|
|
240
|
+
};
|
|
241
|
+
constraintMap.set(row.constraint_name, c);
|
|
242
|
+
}
|
|
243
|
+
c.columns.push(row.column_name);
|
|
244
|
+
if (row.foreign_column_name) {
|
|
245
|
+
c.foreign_columns?.push(row.foreign_column_name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (constraintMap.size > 0) {
|
|
249
|
+
lines.push("\n### Constraints\n");
|
|
250
|
+
lines.push("| Name | Type | Columns | References |");
|
|
251
|
+
lines.push("|------|------|---------|------------|");
|
|
252
|
+
for (const c of constraintMap.values()) {
|
|
253
|
+
const ref = c.foreign_table
|
|
254
|
+
? `${c.foreign_table}(${c.foreign_columns?.join(", ")})`
|
|
255
|
+
: "-";
|
|
256
|
+
lines.push(`| ${c.constraint_name} | ${c.constraint_type} | ${c.columns.join(", ")} | ${ref} |`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slow query analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Queries pg_stat_statements (if available) for the slowest queries
|
|
5
|
+
* and suggests optimization strategies.
|
|
6
|
+
*/
|
|
7
|
+
import { query, getDriverType } from "../db.js";
|
|
8
|
+
export async function analyzeSlowQueries(schema, limit = 10) {
|
|
9
|
+
const driver = getDriverType();
|
|
10
|
+
if (driver === "sqlite") {
|
|
11
|
+
return "## Slow Query Analysis\n\nNot available for SQLite — no query statistics tracking.";
|
|
12
|
+
}
|
|
13
|
+
if (driver === "mysql") {
|
|
14
|
+
return analyzeMysqlSlowQueries(limit);
|
|
15
|
+
}
|
|
16
|
+
return analyzePostgresSlowQueries(schema, limit);
|
|
17
|
+
}
|
|
18
|
+
async function analyzePostgresSlowQueries(schema, limit) {
|
|
19
|
+
// Check if pg_stat_statements is available
|
|
20
|
+
try {
|
|
21
|
+
const extCheck = await query("SELECT extname FROM pg_extension WHERE extname = 'pg_stat_statements'");
|
|
22
|
+
if (extCheck.rows.length === 0) {
|
|
23
|
+
return [
|
|
24
|
+
"## Slow Query Analysis",
|
|
25
|
+
"",
|
|
26
|
+
"**pg_stat_statements extension not installed.**",
|
|
27
|
+
"",
|
|
28
|
+
"To enable slow query tracking:",
|
|
29
|
+
"```sql",
|
|
30
|
+
"CREATE EXTENSION pg_stat_statements;",
|
|
31
|
+
"```",
|
|
32
|
+
"",
|
|
33
|
+
"Also add to `postgresql.conf`:",
|
|
34
|
+
"```",
|
|
35
|
+
"shared_preload_libraries = 'pg_stat_statements'",
|
|
36
|
+
"pg_stat_statements.track = all",
|
|
37
|
+
"```",
|
|
38
|
+
"",
|
|
39
|
+
"Restart PostgreSQL and queries will be tracked automatically.",
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "## Slow Query Analysis\n\nUnable to check pg_stat_statements. Ensure the database user has permissions.";
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const result = await query(`SELECT
|
|
48
|
+
query,
|
|
49
|
+
calls,
|
|
50
|
+
total_exec_time,
|
|
51
|
+
mean_exec_time,
|
|
52
|
+
min_exec_time,
|
|
53
|
+
max_exec_time,
|
|
54
|
+
rows
|
|
55
|
+
FROM pg_stat_statements
|
|
56
|
+
WHERE query NOT LIKE '%pg_stat_statements%'
|
|
57
|
+
AND query NOT LIKE 'BEGIN%'
|
|
58
|
+
AND query NOT LIKE 'COMMIT%'
|
|
59
|
+
AND query NOT LIKE 'SET %'
|
|
60
|
+
ORDER BY mean_exec_time DESC
|
|
61
|
+
LIMIT $1`, [limit]);
|
|
62
|
+
if (result.rows.length === 0) {
|
|
63
|
+
return "## Slow Query Analysis\n\nNo query statistics found. Run some queries first.";
|
|
64
|
+
}
|
|
65
|
+
const sections = [];
|
|
66
|
+
sections.push("## Slow Query Analysis (by avg execution time)");
|
|
67
|
+
sections.push("");
|
|
68
|
+
sections.push("| # | Avg Time | Total Time | Calls | Avg Rows | Query |");
|
|
69
|
+
sections.push("|---|----------|------------|-------|----------|-------|");
|
|
70
|
+
for (let i = 0; i < result.rows.length; i++) {
|
|
71
|
+
const r = result.rows[i];
|
|
72
|
+
const avgMs = r.mean_exec_time.toFixed(1);
|
|
73
|
+
const totalMs = r.total_exec_time.toFixed(0);
|
|
74
|
+
const truncatedQuery = r.query.length > 80 ? r.query.slice(0, 77) + "..." : r.query;
|
|
75
|
+
const avgRows = Math.round(r.rows / Math.max(r.calls, 1));
|
|
76
|
+
sections.push(`| ${i + 1} | ${avgMs}ms | ${totalMs}ms | ${r.calls} | ${avgRows} | \`${truncatedQuery.replace(/\|/g, "\\|")}\` |`);
|
|
77
|
+
}
|
|
78
|
+
// Recommendations
|
|
79
|
+
sections.push("");
|
|
80
|
+
sections.push("### Recommendations");
|
|
81
|
+
const highCallSlowQueries = result.rows.filter((r) => r.calls > 100 && r.mean_exec_time > 100);
|
|
82
|
+
if (highCallSlowQueries.length > 0) {
|
|
83
|
+
sections.push(`- **${highCallSlowQueries.length} high-impact queries** — called >100 times with >100ms avg. Prioritize these for optimization.`);
|
|
84
|
+
}
|
|
85
|
+
const seqScanCandidates = result.rows.filter((r) => r.mean_exec_time > 50 && r.rows / Math.max(r.calls, 1) < 10);
|
|
86
|
+
if (seqScanCandidates.length > 0) {
|
|
87
|
+
sections.push(`- **${seqScanCandidates.length} queries returning few rows but slow** — likely missing indexes. Use \`explain_query\` to check.`);
|
|
88
|
+
}
|
|
89
|
+
return sections.join("\n");
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return `## Slow Query Analysis\n\nError querying pg_stat_statements: ${err instanceof Error ? err.message : String(err)}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function analyzeMysqlSlowQueries(limit) {
|
|
96
|
+
try {
|
|
97
|
+
const result = await query(`SELECT
|
|
98
|
+
DIGEST_TEXT,
|
|
99
|
+
COUNT_STAR,
|
|
100
|
+
SUM_TIMER_WAIT / 1000000000 as SUM_TIMER_WAIT,
|
|
101
|
+
AVG_TIMER_WAIT / 1000000000 as AVG_TIMER_WAIT,
|
|
102
|
+
SUM_ROWS_EXAMINED,
|
|
103
|
+
SUM_ROWS_SENT
|
|
104
|
+
FROM performance_schema.events_statements_summary_by_digest
|
|
105
|
+
WHERE DIGEST_TEXT IS NOT NULL
|
|
106
|
+
AND DIGEST_TEXT NOT LIKE '%performance_schema%'
|
|
107
|
+
ORDER BY AVG_TIMER_WAIT DESC
|
|
108
|
+
LIMIT ?`, [limit]);
|
|
109
|
+
if (result.rows.length === 0) {
|
|
110
|
+
return "## Slow Query Analysis\n\nNo query statistics found in performance_schema.";
|
|
111
|
+
}
|
|
112
|
+
const sections = [];
|
|
113
|
+
sections.push("## Slow Query Analysis (MySQL — by avg execution time)");
|
|
114
|
+
sections.push("");
|
|
115
|
+
sections.push("| # | Avg Time | Total Time | Calls | Query |");
|
|
116
|
+
sections.push("|---|----------|------------|-------|-------|");
|
|
117
|
+
for (let i = 0; i < result.rows.length; i++) {
|
|
118
|
+
const r = result.rows[i];
|
|
119
|
+
const truncated = r.DIGEST_TEXT.length > 80 ? r.DIGEST_TEXT.slice(0, 77) + "..." : r.DIGEST_TEXT;
|
|
120
|
+
sections.push(`| ${i + 1} | ${r.AVG_TIMER_WAIT.toFixed(1)}ms | ${r.SUM_TIMER_WAIT.toFixed(0)}ms | ${r.COUNT_STAR} | \`${truncated.replace(/\|/g, "\\|")}\` |`);
|
|
121
|
+
}
|
|
122
|
+
return sections.join("\n");
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "## Slow Query Analysis\n\nUnable to query performance_schema. Ensure it is enabled and the user has SELECT permission.";
|
|
126
|
+
}
|
|
127
|
+
}
|