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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection pool analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Queries pg_stat_activity (PostgreSQL), performance_schema (MySQL),
|
|
5
|
+
* or returns unavailable for SQLite.
|
|
6
|
+
*
|
|
7
|
+
* Detects:
|
|
8
|
+
* - Connection pool utilization
|
|
9
|
+
* - Idle-in-transaction connections (holding locks)
|
|
10
|
+
* - Long-running queries
|
|
11
|
+
* - Blocked connections waiting on locks
|
|
12
|
+
*/
|
|
13
|
+
import { query, getDriverType } from "../db.js";
|
|
14
|
+
export async function analyzeConnections() {
|
|
15
|
+
const driver = getDriverType();
|
|
16
|
+
if (driver === "sqlite") {
|
|
17
|
+
return "## Connection Analysis\n\nConnection analysis is not available for SQLite (single-process database).";
|
|
18
|
+
}
|
|
19
|
+
if (driver === "mysql") {
|
|
20
|
+
return analyzeMysqlConnections();
|
|
21
|
+
}
|
|
22
|
+
return analyzePostgresConnections();
|
|
23
|
+
}
|
|
24
|
+
async function analyzePostgresConnections() {
|
|
25
|
+
const lines = [`## Connection Analysis (PostgreSQL)\n`];
|
|
26
|
+
const summary = await query(`SELECT COALESCE(state, 'null') AS state, COUNT(*)::text AS count
|
|
27
|
+
FROM pg_stat_activity
|
|
28
|
+
WHERE backend_type = 'client backend'
|
|
29
|
+
GROUP BY state
|
|
30
|
+
ORDER BY COUNT(*) DESC`);
|
|
31
|
+
lines.push("### Connection States\n");
|
|
32
|
+
lines.push("| State | Count |");
|
|
33
|
+
lines.push("|-------|-------|");
|
|
34
|
+
let totalConnections = 0;
|
|
35
|
+
for (const row of summary.rows) {
|
|
36
|
+
lines.push(`| ${row.state} | ${row.count} |`);
|
|
37
|
+
totalConnections += parseInt(row.count, 10);
|
|
38
|
+
}
|
|
39
|
+
lines.push(`| **Total** | **${totalConnections}** |`);
|
|
40
|
+
lines.push("");
|
|
41
|
+
const maxConn = await query(`SELECT setting FROM pg_settings WHERE name = 'max_connections'`);
|
|
42
|
+
if (maxConn.rows.length > 0) {
|
|
43
|
+
const max = parseInt(maxConn.rows[0].setting, 10);
|
|
44
|
+
const utilization = totalConnections / max;
|
|
45
|
+
lines.push(`**Max connections**: ${max}`);
|
|
46
|
+
lines.push(`**Utilization**: ${(utilization * 100).toFixed(1)}%`);
|
|
47
|
+
if (utilization > 0.8) {
|
|
48
|
+
lines.push(`\n**WARNING**: Connection pool is ${(utilization * 100).toFixed(0)}% utilized. Consider increasing max_connections or using PgBouncer.`);
|
|
49
|
+
}
|
|
50
|
+
lines.push("");
|
|
51
|
+
}
|
|
52
|
+
const idleTxn = await query(`SELECT pid::text, usename, state,
|
|
53
|
+
(NOW() - state_change)::text AS duration,
|
|
54
|
+
LEFT(query, 100) AS query
|
|
55
|
+
FROM pg_stat_activity
|
|
56
|
+
WHERE state = 'idle in transaction'
|
|
57
|
+
AND backend_type = 'client backend'
|
|
58
|
+
ORDER BY state_change ASC
|
|
59
|
+
LIMIT 10`);
|
|
60
|
+
if (idleTxn.rows.length > 0) {
|
|
61
|
+
lines.push("### Idle-in-Transaction Connections\n");
|
|
62
|
+
lines.push("These connections hold locks and may block other operations.\n");
|
|
63
|
+
lines.push("| PID | User | Duration | Query |");
|
|
64
|
+
lines.push("|-----|------|----------|-------|");
|
|
65
|
+
for (const row of idleTxn.rows) {
|
|
66
|
+
lines.push(`| ${row.pid} | ${row.usename} | ${row.duration} | ${row.query} |`);
|
|
67
|
+
}
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
const longQueries = await query(`SELECT pid::text, usename,
|
|
71
|
+
(NOW() - query_start)::text AS duration,
|
|
72
|
+
wait_event_type,
|
|
73
|
+
LEFT(query, 120) AS query
|
|
74
|
+
FROM pg_stat_activity
|
|
75
|
+
WHERE state = 'active'
|
|
76
|
+
AND backend_type = 'client backend'
|
|
77
|
+
AND query_start < NOW() - INTERVAL '30 seconds'
|
|
78
|
+
AND pid != pg_backend_pid()
|
|
79
|
+
ORDER BY query_start ASC
|
|
80
|
+
LIMIT 10`);
|
|
81
|
+
if (longQueries.rows.length > 0) {
|
|
82
|
+
lines.push("### Long-Running Queries (> 30s)\n");
|
|
83
|
+
lines.push("| PID | User | Duration | Wait | Query |");
|
|
84
|
+
lines.push("|-----|------|----------|------|-------|");
|
|
85
|
+
for (const row of longQueries.rows) {
|
|
86
|
+
lines.push(`| ${row.pid} | ${row.usename} | ${row.duration} | ${row.wait_event_type || "-"} | ${row.query} |`);
|
|
87
|
+
}
|
|
88
|
+
lines.push("");
|
|
89
|
+
}
|
|
90
|
+
const blocked = await query(`SELECT blocked.pid::text AS blocked_pid,
|
|
91
|
+
blocking.pid::text AS blocking_pid,
|
|
92
|
+
LEFT(blocked.query, 80) AS blocked_query,
|
|
93
|
+
LEFT(blocking.query, 80) AS blocking_query
|
|
94
|
+
FROM pg_stat_activity blocked
|
|
95
|
+
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
|
|
96
|
+
JOIN pg_locks gl ON gl.locktype = bl.locktype
|
|
97
|
+
AND gl.database IS NOT DISTINCT FROM bl.database
|
|
98
|
+
AND gl.relation IS NOT DISTINCT FROM bl.relation
|
|
99
|
+
AND gl.page IS NOT DISTINCT FROM bl.page
|
|
100
|
+
AND gl.tuple IS NOT DISTINCT FROM bl.tuple
|
|
101
|
+
AND gl.virtualxid IS NOT DISTINCT FROM bl.virtualxid
|
|
102
|
+
AND gl.transactionid IS NOT DISTINCT FROM bl.transactionid
|
|
103
|
+
AND gl.classid IS NOT DISTINCT FROM bl.classid
|
|
104
|
+
AND gl.objid IS NOT DISTINCT FROM bl.objid
|
|
105
|
+
AND gl.objsubid IS NOT DISTINCT FROM bl.objsubid
|
|
106
|
+
AND gl.pid != bl.pid
|
|
107
|
+
AND gl.granted
|
|
108
|
+
JOIN pg_stat_activity blocking ON blocking.pid = gl.pid
|
|
109
|
+
LIMIT 10`);
|
|
110
|
+
if (blocked.rows.length > 0) {
|
|
111
|
+
lines.push("### Blocked Connections\n");
|
|
112
|
+
lines.push("| Blocked PID | Blocking PID | Blocked Query | Blocking Query |");
|
|
113
|
+
lines.push("|-------------|--------------|---------------|----------------|");
|
|
114
|
+
for (const row of blocked.rows) {
|
|
115
|
+
lines.push(`| ${row.blocked_pid} | ${row.blocking_pid} | ${row.blocked_query} | ${row.blocking_query} |`);
|
|
116
|
+
}
|
|
117
|
+
lines.push("");
|
|
118
|
+
}
|
|
119
|
+
// Summary
|
|
120
|
+
const issues = [];
|
|
121
|
+
if (idleTxn.rows.length > 0)
|
|
122
|
+
issues.push(`${idleTxn.rows.length} idle-in-transaction connection(s) holding locks`);
|
|
123
|
+
if (longQueries.rows.length > 0)
|
|
124
|
+
issues.push(`${longQueries.rows.length} long-running query/queries (> 30s)`);
|
|
125
|
+
if (blocked.rows.length > 0)
|
|
126
|
+
issues.push(`${blocked.rows.length} blocked connection(s) waiting on locks`);
|
|
127
|
+
if (issues.length > 0) {
|
|
128
|
+
lines.push("### Issues\n");
|
|
129
|
+
for (const issue of issues) {
|
|
130
|
+
lines.push(`- ${issue}`);
|
|
131
|
+
}
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push("### Recommendations\n");
|
|
134
|
+
if (idleTxn.rows.length > 0) {
|
|
135
|
+
lines.push("- Set `idle_in_transaction_session_timeout` to auto-kill stale transactions");
|
|
136
|
+
}
|
|
137
|
+
if (longQueries.rows.length > 0) {
|
|
138
|
+
lines.push("- Set `statement_timeout` to prevent runaway queries");
|
|
139
|
+
}
|
|
140
|
+
if (blocked.rows.length > 0) {
|
|
141
|
+
lines.push("- Investigate lock contention — consider `pg_terminate_backend()` for blocking PIDs");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
lines.push("### No connection issues detected.\n");
|
|
146
|
+
}
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
async function analyzeMysqlConnections() {
|
|
150
|
+
const lines = [`## Connection Analysis (MySQL)\n`];
|
|
151
|
+
const summary = await query(`SELECT COALESCE(COMMAND, 'Unknown') AS state, COUNT(*) AS count
|
|
152
|
+
FROM information_schema.PROCESSLIST
|
|
153
|
+
GROUP BY COMMAND
|
|
154
|
+
ORDER BY COUNT(*) DESC`);
|
|
155
|
+
lines.push("### Connection States\n");
|
|
156
|
+
lines.push("| State | Count |");
|
|
157
|
+
lines.push("|-------|-------|");
|
|
158
|
+
let total = 0;
|
|
159
|
+
for (const row of summary.rows) {
|
|
160
|
+
lines.push(`| ${row.state} | ${row.count} |`);
|
|
161
|
+
total += parseInt(row.count, 10);
|
|
162
|
+
}
|
|
163
|
+
lines.push(`| **Total** | **${total}** |`);
|
|
164
|
+
lines.push("");
|
|
165
|
+
const longQueries = await query(`SELECT ID AS id, USER AS user, TIME AS time, STATE AS state, LEFT(INFO, 100) AS info
|
|
166
|
+
FROM information_schema.PROCESSLIST
|
|
167
|
+
WHERE COMMAND = 'Query' AND TIME > 30
|
|
168
|
+
ORDER BY TIME DESC
|
|
169
|
+
LIMIT 10`);
|
|
170
|
+
if (longQueries.rows.length > 0) {
|
|
171
|
+
lines.push("### Long-Running Queries (> 30s)\n");
|
|
172
|
+
lines.push("| ID | User | Duration (s) | State | Query |");
|
|
173
|
+
lines.push("|-----|------|-------------|-------|-------|");
|
|
174
|
+
for (const row of longQueries.rows) {
|
|
175
|
+
lines.push(`| ${row.id} | ${row.user} | ${row.time} | ${row.state} | ${row.info} |`);
|
|
176
|
+
}
|
|
177
|
+
lines.push("");
|
|
178
|
+
}
|
|
179
|
+
if (longQueries.rows.length === 0) {
|
|
180
|
+
lines.push("### No connection issues detected.\n");
|
|
181
|
+
}
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { query, getDriverType } from "../db.js";
|
|
2
|
+
/**
|
|
3
|
+
* Analyze index usage and find unused indexes.
|
|
4
|
+
*/
|
|
5
|
+
export async function analyzeIndexUsage(schema = "public") {
|
|
6
|
+
const driver = getDriverType();
|
|
7
|
+
if (driver === "sqlite") {
|
|
8
|
+
return analyzeIndexUsageSqlite();
|
|
9
|
+
}
|
|
10
|
+
if (driver === "mysql") {
|
|
11
|
+
return analyzeIndexUsageMysql(schema);
|
|
12
|
+
}
|
|
13
|
+
const result = await query(`
|
|
14
|
+
SELECT
|
|
15
|
+
s.relname AS table_name,
|
|
16
|
+
s.indexrelname AS index_name,
|
|
17
|
+
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
|
|
18
|
+
s.idx_scan::text,
|
|
19
|
+
s.idx_tup_read::text,
|
|
20
|
+
s.idx_tup_fetch::text,
|
|
21
|
+
i.indexdef AS index_def
|
|
22
|
+
FROM pg_stat_user_indexes s
|
|
23
|
+
JOIN pg_indexes i
|
|
24
|
+
ON s.schemaname = i.schemaname
|
|
25
|
+
AND s.relname = i.tablename
|
|
26
|
+
AND s.indexrelname = i.indexname
|
|
27
|
+
WHERE s.schemaname = $1
|
|
28
|
+
ORDER BY s.idx_scan ASC, pg_relation_size(s.indexrelid) DESC
|
|
29
|
+
`, [schema]);
|
|
30
|
+
return formatIndexUsage(result.rows, schema);
|
|
31
|
+
}
|
|
32
|
+
async function analyzeIndexUsageSqlite() {
|
|
33
|
+
// SQLite doesn't track index usage stats — list all indexes with their definitions
|
|
34
|
+
const result = await query(`
|
|
35
|
+
SELECT tbl_name, name, sql FROM sqlite_master
|
|
36
|
+
WHERE type = 'index' AND name NOT LIKE 'sqlite_%'
|
|
37
|
+
ORDER BY tbl_name, name
|
|
38
|
+
`);
|
|
39
|
+
if (result.rows.length === 0) {
|
|
40
|
+
return "No user-created indexes found. SQLite does not track index usage statistics.";
|
|
41
|
+
}
|
|
42
|
+
const lines = [`## Index Usage Analysis (SQLite)\n`];
|
|
43
|
+
lines.push("SQLite does not track index scan statistics. Listing all indexes:\n");
|
|
44
|
+
lines.push("| Table | Index | Definition |");
|
|
45
|
+
lines.push("|-------|-------|------------|");
|
|
46
|
+
for (const idx of result.rows) {
|
|
47
|
+
lines.push(`| ${idx.tbl_name} | ${idx.name} | \`${idx.sql || 'auto-index'}\` |`);
|
|
48
|
+
}
|
|
49
|
+
lines.push("\n**Tip**: Use `EXPLAIN QUERY PLAN` via the `explain_query` tool to check if indexes are being used.");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
async function analyzeIndexUsageMysql(schema) {
|
|
53
|
+
try {
|
|
54
|
+
const result = await query(`
|
|
55
|
+
SELECT
|
|
56
|
+
s.OBJECT_NAME AS table_name,
|
|
57
|
+
s.INDEX_NAME AS index_name,
|
|
58
|
+
CONCAT(ROUND(stat.STAT_VALUE * @@innodb_page_size / 1024 / 1024, 2), ' MB') AS index_size,
|
|
59
|
+
CAST(s.COUNT_READ AS CHAR) AS idx_scan,
|
|
60
|
+
CAST(s.COUNT_FETCH AS CHAR) AS idx_tup_read,
|
|
61
|
+
CAST(s.COUNT_FETCH AS CHAR) AS idx_tup_fetch,
|
|
62
|
+
CONCAT('INDEX ', s.INDEX_NAME, ' ON ', s.OBJECT_NAME) AS index_def
|
|
63
|
+
FROM performance_schema.table_io_waits_summary_by_index_usage s
|
|
64
|
+
LEFT JOIN mysql.innodb_index_stats stat
|
|
65
|
+
ON stat.database_name = s.OBJECT_SCHEMA
|
|
66
|
+
AND stat.table_name = s.OBJECT_NAME
|
|
67
|
+
AND stat.index_name = s.INDEX_NAME
|
|
68
|
+
AND stat.stat_name = 'size'
|
|
69
|
+
WHERE s.OBJECT_SCHEMA = ?
|
|
70
|
+
AND s.INDEX_NAME IS NOT NULL
|
|
71
|
+
ORDER BY s.COUNT_READ ASC
|
|
72
|
+
`, [schema]);
|
|
73
|
+
return formatIndexUsage(result.rows, schema);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return `## Index Usage Analysis — schema '${schema}'\n\nUnable to query performance_schema. Ensure performance_schema is enabled (it is ON by default in MySQL 5.7+) and the user has SELECT privilege on performance_schema tables.`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function formatIndexUsage(rows, schema) {
|
|
80
|
+
if (rows.length === 0) {
|
|
81
|
+
return `No indexes found in schema '${schema}'.`;
|
|
82
|
+
}
|
|
83
|
+
const lines = [`## Index Usage Analysis — schema '${schema}'\n`];
|
|
84
|
+
const unused = rows.filter((r) => r.idx_scan === "0");
|
|
85
|
+
if (unused.length > 0) {
|
|
86
|
+
lines.push(`### Unused Indexes (${unused.length} found)\n`);
|
|
87
|
+
lines.push("These indexes have **zero scans** since the last stats reset. Consider dropping them to save space and speed up writes.\n");
|
|
88
|
+
lines.push("| Table | Index | Size | Definition |");
|
|
89
|
+
lines.push("|-------|-------|------|------------|");
|
|
90
|
+
for (const idx of unused) {
|
|
91
|
+
lines.push(`| ${idx.table_name} | ${idx.index_name} | ${idx.index_size} | \`${idx.index_def}\` |`);
|
|
92
|
+
}
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
lines.push("### No unused indexes found.\n");
|
|
97
|
+
}
|
|
98
|
+
lines.push("### All Indexes by Scan Count\n");
|
|
99
|
+
lines.push("| Table | Index | Scans | Rows Read | Size |");
|
|
100
|
+
lines.push("|-------|-------|-------|-----------|------|");
|
|
101
|
+
for (const idx of rows) {
|
|
102
|
+
lines.push(`| ${idx.table_name} | ${idx.index_name} | ${idx.idx_scan} | ${idx.idx_tup_read} | ${idx.index_size} |`);
|
|
103
|
+
}
|
|
104
|
+
return lines.join("\n");
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Find tables that might need indexes based on sequential scan patterns.
|
|
108
|
+
*/
|
|
109
|
+
export async function findMissingIndexes(schema = "public") {
|
|
110
|
+
const driver = getDriverType();
|
|
111
|
+
if (driver === "sqlite") {
|
|
112
|
+
return "SQLite does not provide sequential scan statistics. Use `explain_query` to analyze specific queries.";
|
|
113
|
+
}
|
|
114
|
+
if (driver === "mysql") {
|
|
115
|
+
return findMissingIndexesMysql(schema);
|
|
116
|
+
}
|
|
117
|
+
const result = await query(`
|
|
118
|
+
SELECT
|
|
119
|
+
relname AS table_name,
|
|
120
|
+
seq_scan::text,
|
|
121
|
+
seq_tup_read::text,
|
|
122
|
+
COALESCE(idx_scan, 0)::text AS idx_scan,
|
|
123
|
+
n_live_tup::text,
|
|
124
|
+
pg_size_pretty(pg_table_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS table_size
|
|
125
|
+
FROM pg_stat_user_tables
|
|
126
|
+
WHERE schemaname = $1
|
|
127
|
+
AND n_live_tup > 1000
|
|
128
|
+
AND seq_scan > COALESCE(idx_scan, 0)
|
|
129
|
+
ORDER BY seq_tup_read DESC
|
|
130
|
+
`, [schema]);
|
|
131
|
+
const lines = formatMissingIndexes(result.rows, schema);
|
|
132
|
+
// PostgreSQL-specific: check for unindexed foreign keys
|
|
133
|
+
const fkResult = await query(`
|
|
134
|
+
SELECT
|
|
135
|
+
kcu.table_name,
|
|
136
|
+
kcu.column_name,
|
|
137
|
+
tc.constraint_name,
|
|
138
|
+
ccu.table_name AS foreign_table
|
|
139
|
+
FROM information_schema.table_constraints tc
|
|
140
|
+
JOIN information_schema.key_column_usage kcu
|
|
141
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
142
|
+
AND tc.table_schema = kcu.table_schema
|
|
143
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
144
|
+
ON tc.constraint_name = ccu.constraint_name
|
|
145
|
+
AND tc.table_schema = ccu.table_schema
|
|
146
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
147
|
+
AND tc.table_schema = $1
|
|
148
|
+
AND NOT EXISTS (
|
|
149
|
+
SELECT 1 FROM pg_indexes pi
|
|
150
|
+
WHERE pi.schemaname = tc.table_schema
|
|
151
|
+
AND pi.tablename = kcu.table_name
|
|
152
|
+
AND pi.indexdef ~ ('\\m' || kcu.column_name || '\\M')
|
|
153
|
+
)
|
|
154
|
+
ORDER BY kcu.table_name
|
|
155
|
+
`, [schema]);
|
|
156
|
+
if (fkResult.rows.length > 0) {
|
|
157
|
+
lines.push(`\n### Unindexed Foreign Keys (${fkResult.rows.length} found)\n`);
|
|
158
|
+
lines.push("Foreign key columns without indexes cause slow JOINs and cascading deletes.\n");
|
|
159
|
+
lines.push("| Table | Column | FK → | Constraint |");
|
|
160
|
+
lines.push("|-------|--------|------|------------|");
|
|
161
|
+
for (const fk of fkResult.rows) {
|
|
162
|
+
lines.push(`| ${fk.table_name} | ${fk.column_name} | ${fk.foreign_table} | ${fk.constraint_name} |`);
|
|
163
|
+
}
|
|
164
|
+
lines.push("\n**Fix**: Create an index on each column above:\n```sql\nCREATE INDEX idx_<table>_<column> ON <table> (<column>);\n```");
|
|
165
|
+
}
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
async function findMissingIndexesMysql(schema) {
|
|
169
|
+
try {
|
|
170
|
+
// MySQL: use information_schema + performance_schema for scan stats
|
|
171
|
+
const result = await query(`
|
|
172
|
+
SELECT
|
|
173
|
+
t.TABLE_NAME AS table_name,
|
|
174
|
+
CAST(COALESCE(tio.COUNT_READ, 0) AS CHAR) AS seq_scan,
|
|
175
|
+
CAST(COALESCE(tio.COUNT_FETCH, 0) AS CHAR) AS seq_tup_read,
|
|
176
|
+
CAST(COALESCE(idx.idx_reads, 0) AS CHAR) AS idx_scan,
|
|
177
|
+
CAST(t.TABLE_ROWS AS CHAR) AS n_live_tup,
|
|
178
|
+
CONCAT(ROUND(t.DATA_LENGTH / 1024 / 1024, 2), ' MB') AS table_size
|
|
179
|
+
FROM information_schema.TABLES t
|
|
180
|
+
LEFT JOIN performance_schema.table_io_waits_summary_by_table tio
|
|
181
|
+
ON tio.OBJECT_SCHEMA = t.TABLE_SCHEMA AND tio.OBJECT_NAME = t.TABLE_NAME
|
|
182
|
+
LEFT JOIN (
|
|
183
|
+
SELECT OBJECT_SCHEMA, OBJECT_NAME, SUM(COUNT_READ) AS idx_reads
|
|
184
|
+
FROM performance_schema.table_io_waits_summary_by_index_usage
|
|
185
|
+
WHERE INDEX_NAME IS NOT NULL
|
|
186
|
+
GROUP BY OBJECT_SCHEMA, OBJECT_NAME
|
|
187
|
+
) idx ON idx.OBJECT_SCHEMA = t.TABLE_SCHEMA AND idx.OBJECT_NAME = t.TABLE_NAME
|
|
188
|
+
WHERE t.TABLE_SCHEMA = ?
|
|
189
|
+
AND t.TABLE_TYPE = 'BASE TABLE'
|
|
190
|
+
AND t.TABLE_ROWS > 1000
|
|
191
|
+
ORDER BY t.TABLE_ROWS DESC
|
|
192
|
+
`, [schema]);
|
|
193
|
+
const lines = formatMissingIndexes(result.rows, schema);
|
|
194
|
+
// MySQL: check for unindexed FK columns
|
|
195
|
+
const fkResult = await query(`
|
|
196
|
+
SELECT
|
|
197
|
+
kcu.TABLE_NAME AS table_name,
|
|
198
|
+
kcu.COLUMN_NAME AS column_name,
|
|
199
|
+
kcu.CONSTRAINT_NAME AS constraint_name,
|
|
200
|
+
kcu.REFERENCED_TABLE_NAME AS foreign_table
|
|
201
|
+
FROM information_schema.KEY_COLUMN_USAGE kcu
|
|
202
|
+
JOIN information_schema.TABLE_CONSTRAINTS tc
|
|
203
|
+
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
|
204
|
+
AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA
|
|
205
|
+
AND tc.TABLE_NAME = kcu.TABLE_NAME
|
|
206
|
+
WHERE tc.CONSTRAINT_TYPE = 'FOREIGN KEY'
|
|
207
|
+
AND kcu.TABLE_SCHEMA = ?
|
|
208
|
+
AND NOT EXISTS (
|
|
209
|
+
SELECT 1 FROM information_schema.STATISTICS s
|
|
210
|
+
WHERE s.TABLE_SCHEMA = kcu.TABLE_SCHEMA
|
|
211
|
+
AND s.TABLE_NAME = kcu.TABLE_NAME
|
|
212
|
+
AND s.COLUMN_NAME = kcu.COLUMN_NAME
|
|
213
|
+
AND s.INDEX_NAME != kcu.CONSTRAINT_NAME
|
|
214
|
+
)
|
|
215
|
+
ORDER BY kcu.TABLE_NAME
|
|
216
|
+
`, [schema]);
|
|
217
|
+
if (fkResult.rows.length > 0) {
|
|
218
|
+
lines.push(`\n### Unindexed Foreign Keys (${fkResult.rows.length} found)\n`);
|
|
219
|
+
lines.push("Foreign key columns without indexes cause slow JOINs and cascading deletes.\n");
|
|
220
|
+
lines.push("| Table | Column | FK → | Constraint |");
|
|
221
|
+
lines.push("|-------|--------|------|------------|");
|
|
222
|
+
for (const fk of fkResult.rows) {
|
|
223
|
+
lines.push(`| ${fk.table_name} | ${fk.column_name} | ${fk.foreign_table} | ${fk.constraint_name} |`);
|
|
224
|
+
}
|
|
225
|
+
lines.push("\n**Fix**: Create an index on each column above:\n```sql\nCREATE INDEX idx_<table>_<column> ON <table> (<column>);\n```");
|
|
226
|
+
}
|
|
227
|
+
return lines.join("\n");
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return "## Missing Index Analysis\n\nUnable to query performance_schema. Ensure performance_schema is enabled (it is ON by default in MySQL 5.7+) and the user has SELECT privilege on performance_schema tables.";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function formatMissingIndexes(rows, schema) {
|
|
234
|
+
if (rows.length === 0) {
|
|
235
|
+
return [
|
|
236
|
+
"No tables with suspicious sequential scan patterns found. Either all tables are well-indexed or too small to matter.",
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
const lines = [`## Potential Missing Indexes — schema '${schema}'\n`];
|
|
240
|
+
lines.push("Tables with more sequential scans than index scans (and >1000 rows).\n" +
|
|
241
|
+
"High seq_tup_read with low idx_scan suggests missing indexes on commonly queried columns.\n");
|
|
242
|
+
lines.push("| Table | Seq Scans | Seq Rows Read | Index Scans | Rows | Size |");
|
|
243
|
+
lines.push("|-------|-----------|---------------|-------------|------|------|");
|
|
244
|
+
for (const row of rows) {
|
|
245
|
+
lines.push(`| ${row.table_name} | ${row.seq_scan} | ${row.seq_tup_read} | ${row.idx_scan} | ${row.n_live_tup} | ${row.table_size} |`);
|
|
246
|
+
}
|
|
247
|
+
lines.push("\n### Recommendations\n");
|
|
248
|
+
lines.push("For each table above, check which columns are used in WHERE, JOIN, and ORDER BY clauses. " +
|
|
249
|
+
"Use the `explain_query` tool to analyze specific slow queries against these tables.");
|
|
250
|
+
return lines;
|
|
251
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { queryUnsafe, getDriverType } from "../db.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run EXPLAIN on a query and return a formatted analysis.
|
|
4
|
+
*/
|
|
5
|
+
export async function explainQuery(sql, analyze = false) {
|
|
6
|
+
// Safety: in ANALYZE mode, only allow pure SELECT statements.
|
|
7
|
+
// EXPLAIN ANALYZE actually executes the query, so we must reject anything
|
|
8
|
+
// that could modify data — including CTEs with write operations.
|
|
9
|
+
if (analyze) {
|
|
10
|
+
const upperSql = sql.trim().toUpperCase();
|
|
11
|
+
const DML_KEYWORDS = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE", "CREATE", "GRANT", "REVOKE", "COPY"];
|
|
12
|
+
const containsDml = DML_KEYWORDS.some((kw) => upperSql.includes(kw + " ") || upperSql.includes(kw + "\n") || upperSql.includes(kw + "\t") || upperSql.endsWith(kw));
|
|
13
|
+
if (containsDml) {
|
|
14
|
+
return "**Error**: EXPLAIN ANALYZE is only allowed on pure SELECT statements. The query contains write operations that would be executed.";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const driver = getDriverType();
|
|
18
|
+
if (driver === "sqlite") {
|
|
19
|
+
return explainQuerySqlite(sql);
|
|
20
|
+
}
|
|
21
|
+
if (driver === "mysql") {
|
|
22
|
+
return explainQueryMysql(sql, analyze);
|
|
23
|
+
}
|
|
24
|
+
const explainPrefix = analyze
|
|
25
|
+
? "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)"
|
|
26
|
+
: "EXPLAIN (FORMAT JSON)";
|
|
27
|
+
const result = await queryUnsafe(`${explainPrefix} ${sql}`);
|
|
28
|
+
const plan = result.rows[0]?.["QUERY PLAN"]?.[0];
|
|
29
|
+
if (!plan) {
|
|
30
|
+
return "Could not parse query plan.";
|
|
31
|
+
}
|
|
32
|
+
return formatPlan(plan, analyze);
|
|
33
|
+
}
|
|
34
|
+
async function explainQuerySqlite(sql) {
|
|
35
|
+
const result = await queryUnsafe(`EXPLAIN QUERY PLAN ${sql}`);
|
|
36
|
+
if (result.rows.length === 0) {
|
|
37
|
+
return "Could not parse query plan.";
|
|
38
|
+
}
|
|
39
|
+
const lines = ["## Query Plan Analysis (SQLite)\n"];
|
|
40
|
+
lines.push("```");
|
|
41
|
+
for (const row of result.rows) {
|
|
42
|
+
const indent = " ".repeat(Math.max(0, row.id));
|
|
43
|
+
lines.push(`${indent}${row.detail}`);
|
|
44
|
+
}
|
|
45
|
+
lines.push("```\n");
|
|
46
|
+
// Check for warnings
|
|
47
|
+
const warnings = [];
|
|
48
|
+
for (const row of result.rows) {
|
|
49
|
+
if (row.detail.includes("SCAN")) {
|
|
50
|
+
const match = row.detail.match(/SCAN (\w+)/);
|
|
51
|
+
if (match) {
|
|
52
|
+
warnings.push(`**Full table scan** on \`${match[1]}\`. Consider adding an index on the filtered columns.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (warnings.length > 0) {
|
|
57
|
+
lines.push("### Potential Issues\n");
|
|
58
|
+
for (const w of warnings) {
|
|
59
|
+
lines.push(`- ${w}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
async function explainQueryMysql(sql, analyze) {
|
|
65
|
+
const prefix = analyze ? "EXPLAIN ANALYZE" : "EXPLAIN FORMAT=JSON";
|
|
66
|
+
if (analyze) {
|
|
67
|
+
// MySQL EXPLAIN ANALYZE returns a text tree, not JSON
|
|
68
|
+
const result = await queryUnsafe(`${prefix} ${sql}`);
|
|
69
|
+
const output = result.rows[0]?.EXPLAIN;
|
|
70
|
+
if (!output) {
|
|
71
|
+
return "Could not parse query plan.";
|
|
72
|
+
}
|
|
73
|
+
const lines = ["## Query Plan Analysis (MySQL EXPLAIN ANALYZE)\n"];
|
|
74
|
+
lines.push("```");
|
|
75
|
+
lines.push(output);
|
|
76
|
+
lines.push("```");
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
// MySQL EXPLAIN FORMAT=JSON returns a single-column result
|
|
80
|
+
const result = await queryUnsafe(`${prefix} ${sql}`);
|
|
81
|
+
const raw = result.rows[0]?.EXPLAIN;
|
|
82
|
+
if (!raw) {
|
|
83
|
+
return "Could not parse query plan.";
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const plan = JSON.parse(raw);
|
|
87
|
+
return formatMysqlPlan(plan);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return `## Query Plan (raw)\n\n\`\`\`json\n${raw}\n\`\`\``;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function formatMysqlPlan(plan) {
|
|
94
|
+
const lines = ["## Query Plan Analysis (MySQL)\n"];
|
|
95
|
+
const qb = plan.query_block;
|
|
96
|
+
if (!qb) {
|
|
97
|
+
lines.push("```json");
|
|
98
|
+
lines.push(JSON.stringify(plan, null, 2));
|
|
99
|
+
lines.push("```");
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
if (qb.cost_info) {
|
|
103
|
+
const cost = qb.cost_info;
|
|
104
|
+
lines.push(`- **Query Cost**: ${cost.query_cost}`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
lines.push("### Plan Details\n");
|
|
108
|
+
lines.push("```json");
|
|
109
|
+
lines.push(JSON.stringify(plan, null, 2));
|
|
110
|
+
lines.push("```");
|
|
111
|
+
// Extract table info for warnings
|
|
112
|
+
const warnings = [];
|
|
113
|
+
extractMysqlWarnings(plan, warnings);
|
|
114
|
+
if (warnings.length > 0) {
|
|
115
|
+
lines.push("\n### Potential Issues\n");
|
|
116
|
+
for (const w of warnings) {
|
|
117
|
+
lines.push(`- ${w}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
function extractMysqlWarnings(obj, warnings) {
|
|
123
|
+
if (obj.table) {
|
|
124
|
+
const table = obj.table;
|
|
125
|
+
const accessType = table.access_type;
|
|
126
|
+
const tableName = table.table_name;
|
|
127
|
+
const rowsExamined = table.rows_examined_per_scan;
|
|
128
|
+
if (accessType === "ALL" && rowsExamined && rowsExamined > 10000) {
|
|
129
|
+
warnings.push(`**Full table scan** on \`${tableName}\` (~${rowsExamined} rows). Consider adding an index.`);
|
|
130
|
+
}
|
|
131
|
+
if (table.attached_condition) {
|
|
132
|
+
const filtered = table.filtered;
|
|
133
|
+
if (filtered && filtered < 10) {
|
|
134
|
+
warnings.push(`**Low selectivity** on \`${tableName}\`: only ${filtered}% of rows match the filter. An index would help.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Recurse into nested objects and arrays
|
|
139
|
+
for (const value of Object.values(obj)) {
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
for (const item of value) {
|
|
142
|
+
if (item && typeof item === "object") {
|
|
143
|
+
extractMysqlWarnings(item, warnings);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (value && typeof value === "object") {
|
|
148
|
+
extractMysqlWarnings(value, warnings);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function formatPlan(plan, analyzed) {
|
|
153
|
+
const lines = [];
|
|
154
|
+
lines.push("## Query Plan Analysis\n");
|
|
155
|
+
if (analyzed) {
|
|
156
|
+
lines.push(`- **Planning Time**: ${plan["Planning Time"]} ms`);
|
|
157
|
+
lines.push(`- **Execution Time**: ${plan["Execution Time"]} ms`);
|
|
158
|
+
}
|
|
159
|
+
lines.push(`- **Estimated Total Cost**: ${plan.Plan["Total Cost"]}`);
|
|
160
|
+
lines.push(`- **Estimated Rows**: ${plan.Plan["Plan Rows"]}`);
|
|
161
|
+
lines.push("");
|
|
162
|
+
lines.push("### Plan Tree\n");
|
|
163
|
+
lines.push("```");
|
|
164
|
+
formatNode(plan.Plan, lines, 0);
|
|
165
|
+
lines.push("```\n");
|
|
166
|
+
const warnings = collectWarnings(plan.Plan);
|
|
167
|
+
if (warnings.length > 0) {
|
|
168
|
+
lines.push("### Potential Issues\n");
|
|
169
|
+
for (const w of warnings) {
|
|
170
|
+
lines.push(`- ${w}`);
|
|
171
|
+
}
|
|
172
|
+
lines.push("");
|
|
173
|
+
}
|
|
174
|
+
return lines.join("\n");
|
|
175
|
+
}
|
|
176
|
+
function formatNode(node, lines, depth) {
|
|
177
|
+
const indent = " ".repeat(depth);
|
|
178
|
+
let line = `${indent}→ ${node["Node Type"]}`;
|
|
179
|
+
if (node["Relation Name"]) {
|
|
180
|
+
line += ` on ${node["Relation Name"]}`;
|
|
181
|
+
if (node["Alias"] && node["Alias"] !== node["Relation Name"]) {
|
|
182
|
+
line += ` (${node["Alias"]})`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (node["Index Name"]) {
|
|
186
|
+
line += ` using ${node["Index Name"]}`;
|
|
187
|
+
}
|
|
188
|
+
line += ` (cost=${node["Startup Cost"]}..${node["Total Cost"]} rows=${node["Plan Rows"]})`;
|
|
189
|
+
if (node["Actual Total Time"] !== undefined) {
|
|
190
|
+
line += ` (actual time=${node["Actual Startup Time"]}..${node["Actual Total Time"]} rows=${node["Actual Rows"]} loops=${node["Actual Loops"]})`;
|
|
191
|
+
}
|
|
192
|
+
lines.push(line);
|
|
193
|
+
if (node["Filter"]) {
|
|
194
|
+
lines.push(`${indent} Filter: ${node["Filter"]}`);
|
|
195
|
+
if (node["Rows Removed by Filter"]) {
|
|
196
|
+
lines.push(`${indent} Rows Removed by Filter: ${node["Rows Removed by Filter"]}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (node["Index Cond"]) {
|
|
200
|
+
lines.push(`${indent} Index Cond: ${node["Index Cond"]}`);
|
|
201
|
+
}
|
|
202
|
+
if (node["Hash Cond"]) {
|
|
203
|
+
lines.push(`${indent} Hash Cond: ${node["Hash Cond"]}`);
|
|
204
|
+
}
|
|
205
|
+
if (node["Sort Key"]) {
|
|
206
|
+
lines.push(`${indent} Sort Key: ${node["Sort Key"].join(", ")}`);
|
|
207
|
+
}
|
|
208
|
+
if (node["Shared Hit Blocks"] !== undefined || node["Shared Read Blocks"] !== undefined) {
|
|
209
|
+
lines.push(`${indent} Buffers: shared hit=${node["Shared Hit Blocks"] ?? 0} read=${node["Shared Read Blocks"] ?? 0}`);
|
|
210
|
+
}
|
|
211
|
+
if (node.Plans) {
|
|
212
|
+
for (const child of node.Plans) {
|
|
213
|
+
formatNode(child, lines, depth + 1);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function collectWarnings(node) {
|
|
218
|
+
const warnings = [];
|
|
219
|
+
if (node["Node Type"] === "Seq Scan" && node["Plan Rows"] > 10000) {
|
|
220
|
+
warnings.push(`**Sequential Scan** on \`${node["Relation Name"]}\` (~${node["Plan Rows"]} rows). Consider adding an index on the filtered columns.`);
|
|
221
|
+
}
|
|
222
|
+
if (node["Rows Removed by Filter"] &&
|
|
223
|
+
node["Actual Rows"] !== undefined &&
|
|
224
|
+
node["Rows Removed by Filter"] > node["Actual Rows"] * 10) {
|
|
225
|
+
warnings.push(`**High filter ratio** on \`${node["Relation Name"] ?? node["Node Type"]}\`: ${node["Rows Removed by Filter"]} rows removed vs ${node["Actual Rows"]} kept. An index on the filter column would eliminate this.`);
|
|
226
|
+
}
|
|
227
|
+
if (node["Node Type"] === "Nested Loop" &&
|
|
228
|
+
node["Actual Rows"] !== undefined &&
|
|
229
|
+
node["Actual Rows"] > 10000) {
|
|
230
|
+
warnings.push(`**Nested Loop** producing ${node["Actual Rows"]} rows. Consider if a Hash Join or Merge Join would be more efficient.`);
|
|
231
|
+
}
|
|
232
|
+
if (node["Sort Method"] === "external merge") {
|
|
233
|
+
warnings.push(`**Disk sort** detected. Increase \`work_mem\` or add an index to avoid sorting.`);
|
|
234
|
+
}
|
|
235
|
+
if (node.Plans) {
|
|
236
|
+
for (const child of node.Plans) {
|
|
237
|
+
warnings.push(...collectWarnings(child));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return warnings;
|
|
241
|
+
}
|