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.
@@ -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
+ }