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,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
+ }