mcp-database-inspector 2.0.1
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/README.md +197 -0
- package/dist/database/connection.d.ts +13 -0
- package/dist/database/connection.d.ts.map +1 -0
- package/dist/database/connection.js +155 -0
- package/dist/database/connection.js.map +1 -0
- package/dist/database/manager.d.ts +28 -0
- package/dist/database/manager.d.ts.map +1 -0
- package/dist/database/manager.js +621 -0
- package/dist/database/manager.js.map +1 -0
- package/dist/database/postgres-connection.d.ts +10 -0
- package/dist/database/postgres-connection.d.ts.map +1 -0
- package/dist/database/postgres-connection.js +113 -0
- package/dist/database/postgres-connection.js.map +1 -0
- package/dist/database/types.d.ts +84 -0
- package/dist/database/types.d.ts.map +1 -0
- package/dist/database/types.js +6 -0
- package/dist/database/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +186 -0
- package/dist/server.js.map +1 -0
- package/dist/test-defaults.d.ts +2 -0
- package/dist/test-defaults.d.ts.map +1 -0
- package/dist/test-defaults.js +57 -0
- package/dist/test-defaults.js.map +1 -0
- package/dist/tools/analyze-query.d.ts +27 -0
- package/dist/tools/analyze-query.d.ts.map +1 -0
- package/dist/tools/analyze-query.js +71 -0
- package/dist/tools/analyze-query.js.map +1 -0
- package/dist/tools/execute-query.d.ts +33 -0
- package/dist/tools/execute-query.d.ts.map +1 -0
- package/dist/tools/execute-query.js +57 -0
- package/dist/tools/execute-query.js.map +1 -0
- package/dist/tools/get-foreign-keys.d.ts +38 -0
- package/dist/tools/get-foreign-keys.d.ts.map +1 -0
- package/dist/tools/get-foreign-keys.js +391 -0
- package/dist/tools/get-foreign-keys.js.map +1 -0
- package/dist/tools/get-indexes.d.ts +38 -0
- package/dist/tools/get-indexes.d.ts.map +1 -0
- package/dist/tools/get-indexes.js +472 -0
- package/dist/tools/get-indexes.js.map +1 -0
- package/dist/tools/information-schema-query.d.ts +33 -0
- package/dist/tools/information-schema-query.d.ts.map +1 -0
- package/dist/tools/information-schema-query.js +76 -0
- package/dist/tools/information-schema-query.js.map +1 -0
- package/dist/tools/inspect-table.d.ts +38 -0
- package/dist/tools/inspect-table.d.ts.map +1 -0
- package/dist/tools/inspect-table.js +351 -0
- package/dist/tools/inspect-table.js.map +1 -0
- package/dist/tools/list-databases.d.ts +14 -0
- package/dist/tools/list-databases.d.ts.map +1 -0
- package/dist/tools/list-databases.js +83 -0
- package/dist/tools/list-databases.js.map +1 -0
- package/dist/tools/list-tables.d.ts +19 -0
- package/dist/tools/list-tables.d.ts.map +1 -0
- package/dist/tools/list-tables.js +130 -0
- package/dist/tools/list-tables.js.map +1 -0
- package/dist/utils/errors.d.ts +32 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +98 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.d.ts +28 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +132 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/validators/input-validator.d.ts +76 -0
- package/dist/validators/input-validator.d.ts.map +1 -0
- package/dist/validators/input-validator.js +295 -0
- package/dist/validators/input-validator.js.map +1 -0
- package/dist/validators/query-validator.d.ts +19 -0
- package/dist/validators/query-validator.d.ts.map +1 -0
- package/dist/validators/query-validator.js +229 -0
- package/dist/validators/query-validator.js.map +1 -0
- package/enhanced_sql_prompt.md +324 -0
- package/examples/claude-config.json +23 -0
- package/examples/roo-config.json +16 -0
- package/package.json +42 -0
- package/src/database/connection.ts +165 -0
- package/src/database/manager.ts +682 -0
- package/src/database/postgres-connection.ts +123 -0
- package/src/database/types.ts +93 -0
- package/src/index.ts +136 -0
- package/src/server.ts +254 -0
- package/src/test-defaults.ts +63 -0
- package/src/tools/analyze-query.test.ts +100 -0
- package/src/tools/analyze-query.ts +112 -0
- package/src/tools/execute-query.ts +91 -0
- package/src/tools/get-foreign-keys.test.ts +51 -0
- package/src/tools/get-foreign-keys.ts +488 -0
- package/src/tools/get-indexes.test.ts +51 -0
- package/src/tools/get-indexes.ts +570 -0
- package/src/tools/information-schema-query.ts +125 -0
- package/src/tools/inspect-table.test.ts +59 -0
- package/src/tools/inspect-table.ts +440 -0
- package/src/tools/list-databases.ts +119 -0
- package/src/tools/list-tables.ts +181 -0
- package/src/utils/errors.ts +103 -0
- package/src/utils/logger.ts +158 -0
- package/src/validators/input-validator.ts +318 -0
- package/src/validators/query-validator.ts +267 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { DatabaseConfig, DatabaseInfo, QueryResult, TableInfo, ColumnInfo, ForeignKeyInfo, IndexInfo, DatabaseType } from './types.js';
|
|
4
|
+
import { DatabaseConnection } from './connection.js';
|
|
5
|
+
import { QueryValidator } from '../validators/query-validator.js';
|
|
6
|
+
import { Logger } from '../utils/logger.js';
|
|
7
|
+
import { DatabaseError, ValidationError } from '../utils/errors.js';
|
|
8
|
+
|
|
9
|
+
export class DatabaseManager {
|
|
10
|
+
private databases: Map<string, DatabaseConfig> = new Map();
|
|
11
|
+
private readonly connectionTimeout: number = 30000;
|
|
12
|
+
private readonly maxRowLimit: number = 1000;
|
|
13
|
+
|
|
14
|
+
async addDatabase(url: string, name?: string): Promise<string> {
|
|
15
|
+
try {
|
|
16
|
+
const type = DatabaseConnection.detectDatabaseType(url);
|
|
17
|
+
const connectionOptions = DatabaseConnection.parseConnectionUrl(url);
|
|
18
|
+
const dbName = name || DatabaseConnection.extractDatabaseName(url);
|
|
19
|
+
|
|
20
|
+
// Test the connection first
|
|
21
|
+
const isConnectable = await DatabaseConnection.testConnection(connectionOptions);
|
|
22
|
+
if (!isConnectable) {
|
|
23
|
+
throw new DatabaseError(`Cannot connect to database: ${dbName}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config: DatabaseConfig = {
|
|
27
|
+
name: dbName,
|
|
28
|
+
url,
|
|
29
|
+
type,
|
|
30
|
+
connection: null,
|
|
31
|
+
lastUsed: new Date(),
|
|
32
|
+
host: connectionOptions.host,
|
|
33
|
+
port: connectionOptions.port,
|
|
34
|
+
username: connectionOptions.user,
|
|
35
|
+
password: connectionOptions.password,
|
|
36
|
+
database: connectionOptions.database,
|
|
37
|
+
ssl: connectionOptions.ssl
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
this.databases.set(dbName, config);
|
|
41
|
+
Logger.info(`Added ${type} database configuration: ${dbName}`);
|
|
42
|
+
return dbName;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
Logger.error(`Failed to add database: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async removeDatabase(name: string): Promise<void> {
|
|
50
|
+
const config = this.databases.get(name);
|
|
51
|
+
if (!config) {
|
|
52
|
+
throw new DatabaseError(`Database not found: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (config.connection) {
|
|
56
|
+
await DatabaseConnection.closeConnection(config.connection, config.type);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.databases.delete(name);
|
|
60
|
+
Logger.info(`Removed database: ${name}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
listDatabases(): DatabaseInfo[] {
|
|
64
|
+
return Array.from(this.databases.values()).map(config => ({
|
|
65
|
+
name: config.name,
|
|
66
|
+
type: config.type,
|
|
67
|
+
connected: config.connection !== null,
|
|
68
|
+
lastUsed: config.lastUsed,
|
|
69
|
+
host: config.host,
|
|
70
|
+
database: config.database
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getDatabaseType(dbName: string): DatabaseType {
|
|
75
|
+
const config = this.databases.get(dbName);
|
|
76
|
+
if (!config) {
|
|
77
|
+
throw new DatabaseError(`Database not found: ${dbName}`);
|
|
78
|
+
}
|
|
79
|
+
return config.type;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async getConnection(dbName: string): Promise<any> {
|
|
83
|
+
const config = this.databases.get(dbName);
|
|
84
|
+
if (!config) {
|
|
85
|
+
throw new DatabaseError(`Database not found: ${dbName}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const connectionOptions = DatabaseConnection.parseConnectionUrl(config.url);
|
|
89
|
+
const connection = await DatabaseConnection.createConnection(connectionOptions);
|
|
90
|
+
|
|
91
|
+
config.lastUsed = new Date();
|
|
92
|
+
return connection;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async executeQuery(dbName: string, query: string, params?: any[]): Promise<QueryResult> {
|
|
96
|
+
const config = this.databases.get(dbName);
|
|
97
|
+
if (!config) throw new DatabaseError(`Database not found: ${dbName}`);
|
|
98
|
+
|
|
99
|
+
// Validate query is read-only
|
|
100
|
+
const validation = QueryValidator.validateQuery(query);
|
|
101
|
+
if (!validation.isValid) {
|
|
102
|
+
throw new ValidationError(`Query validation failed: ${validation.error}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let connection: any = null;
|
|
106
|
+
try {
|
|
107
|
+
connection = await this.getConnection(dbName);
|
|
108
|
+
|
|
109
|
+
// Add row limit to SELECT queries if not already present
|
|
110
|
+
const limitedQuery = this.addRowLimitToQuery(query, config.type);
|
|
111
|
+
|
|
112
|
+
const result = await DatabaseConnection.executeQuery(connection, limitedQuery, params, config.type);
|
|
113
|
+
return result;
|
|
114
|
+
} finally {
|
|
115
|
+
if (connection) {
|
|
116
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private addRowLimitToQuery(query: string, type: DatabaseType): string {
|
|
122
|
+
const trimmedQuery = query.trim().toUpperCase();
|
|
123
|
+
|
|
124
|
+
if (trimmedQuery.startsWith('SELECT') && !trimmedQuery.includes('LIMIT')) {
|
|
125
|
+
if (type === DatabaseType.PostgreSQL) {
|
|
126
|
+
return `${query.trim()} LIMIT ${this.maxRowLimit}`;
|
|
127
|
+
}
|
|
128
|
+
return `${query.trim()} LIMIT ${this.maxRowLimit}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return query;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async getTables(dbName: string): Promise<TableInfo[]> {
|
|
135
|
+
const config = this.databases.get(dbName);
|
|
136
|
+
if (!config) throw new DatabaseError(`Database not found: ${dbName}`);
|
|
137
|
+
|
|
138
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
139
|
+
return this.getTablesPostgres(dbName);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const query = `
|
|
143
|
+
SELECT
|
|
144
|
+
TABLE_NAME as tableName,
|
|
145
|
+
TABLE_TYPE as tableType,
|
|
146
|
+
ENGINE as engine,
|
|
147
|
+
TABLE_ROWS as tableRows,
|
|
148
|
+
TABLE_COMMENT as tableComment
|
|
149
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
150
|
+
WHERE TABLE_SCHEMA = ?
|
|
151
|
+
ORDER BY TABLE_NAME
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
let connection: any = null;
|
|
155
|
+
try {
|
|
156
|
+
connection = await this.getConnection(dbName);
|
|
157
|
+
const result = await DatabaseConnection.executeQuery(connection, query, [config.database], config.type);
|
|
158
|
+
|
|
159
|
+
return result.rows.map(row => ({
|
|
160
|
+
tableName: row.tableName,
|
|
161
|
+
tableType: row.tableType,
|
|
162
|
+
engine: row.engine,
|
|
163
|
+
tableRows: row.tableRows ? parseInt(row.tableRows) : undefined,
|
|
164
|
+
tableComment: row.tableComment || undefined
|
|
165
|
+
}));
|
|
166
|
+
} finally {
|
|
167
|
+
if (connection) {
|
|
168
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async getTablesPostgres(dbName: string): Promise<TableInfo[]> {
|
|
174
|
+
const config = this.databases.get(dbName)!;
|
|
175
|
+
const query = `
|
|
176
|
+
SELECT
|
|
177
|
+
table_name as "tableName",
|
|
178
|
+
table_type as "tableType",
|
|
179
|
+
NULL as engine,
|
|
180
|
+
NULL as "tableRows",
|
|
181
|
+
NULL as "tableComment"
|
|
182
|
+
FROM information_schema.tables
|
|
183
|
+
WHERE table_schema = 'public'
|
|
184
|
+
OR table_schema = $1
|
|
185
|
+
ORDER BY table_name
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
let connection: any = null;
|
|
189
|
+
try {
|
|
190
|
+
connection = await this.getConnection(dbName);
|
|
191
|
+
const result = await DatabaseConnection.executeQuery(connection, query, [config.database], config.type);
|
|
192
|
+
|
|
193
|
+
return result.rows.map(row => ({
|
|
194
|
+
tableName: row.tableName,
|
|
195
|
+
tableType: row.tableType,
|
|
196
|
+
engine: undefined,
|
|
197
|
+
tableRows: undefined,
|
|
198
|
+
tableComment: undefined
|
|
199
|
+
}));
|
|
200
|
+
} finally {
|
|
201
|
+
if (connection) {
|
|
202
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async getTableSchema(dbName: string, tableName: string): Promise<ColumnInfo[]> {
|
|
208
|
+
const config = this.databases.get(dbName);
|
|
209
|
+
if (!config) throw new DatabaseError(`Database not found: ${dbName}`);
|
|
210
|
+
|
|
211
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
212
|
+
return this.getTableSchemaPostgres(dbName, tableName);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const query = `
|
|
216
|
+
SELECT
|
|
217
|
+
c.COLUMN_NAME as columnName,
|
|
218
|
+
c.DATA_TYPE as dataType,
|
|
219
|
+
c.IS_NULLABLE as isNullable,
|
|
220
|
+
c.COLUMN_DEFAULT as columnDefault,
|
|
221
|
+
c.EXTRA as extra,
|
|
222
|
+
c.COLUMN_COMMENT as columnComment,
|
|
223
|
+
c.CHARACTER_MAXIMUM_LENGTH as characterMaximumLength,
|
|
224
|
+
c.NUMERIC_PRECISION as numericPrecision,
|
|
225
|
+
c.NUMERIC_SCALE as numericScale,
|
|
226
|
+
CASE WHEN k.COLUMN_NAME IS NOT NULL THEN true ELSE false END as isPrimaryKey
|
|
227
|
+
FROM INFORMATION_SCHEMA.COLUMNS c
|
|
228
|
+
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k
|
|
229
|
+
ON c.TABLE_SCHEMA = k.TABLE_SCHEMA
|
|
230
|
+
AND c.TABLE_NAME = k.TABLE_NAME
|
|
231
|
+
AND c.COLUMN_NAME = k.COLUMN_NAME
|
|
232
|
+
AND k.CONSTRAINT_NAME = 'PRIMARY'
|
|
233
|
+
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ?
|
|
234
|
+
ORDER BY c.ORDINAL_POSITION
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
let connection: any = null;
|
|
238
|
+
try {
|
|
239
|
+
connection = await this.getConnection(dbName);
|
|
240
|
+
const result = await DatabaseConnection.executeQuery(connection, query, [config.database, tableName], config.type);
|
|
241
|
+
|
|
242
|
+
return result.rows.map(row => ({
|
|
243
|
+
columnName: row.columnName,
|
|
244
|
+
dataType: row.dataType,
|
|
245
|
+
isNullable: row.isNullable,
|
|
246
|
+
columnDefault: row.columnDefault,
|
|
247
|
+
isPrimaryKey: Boolean(row.isPrimaryKey),
|
|
248
|
+
isAutoIncrement: row.extra && row.extra.toLowerCase().includes('auto_increment'),
|
|
249
|
+
columnComment: row.columnComment || undefined,
|
|
250
|
+
characterMaximumLength: row.characterMaximumLength,
|
|
251
|
+
numericPrecision: row.numericPrecision,
|
|
252
|
+
numericScale: row.numericScale
|
|
253
|
+
}));
|
|
254
|
+
} finally {
|
|
255
|
+
if (connection) {
|
|
256
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async getTableSchemaPostgres(dbName: string, tableName: string): Promise<ColumnInfo[]> {
|
|
262
|
+
const config = this.databases.get(dbName)!;
|
|
263
|
+
const query = `
|
|
264
|
+
SELECT
|
|
265
|
+
c.column_name as "columnName",
|
|
266
|
+
c.data_type as "dataType",
|
|
267
|
+
c.is_nullable as "isNullable",
|
|
268
|
+
c.column_default as "columnDefault",
|
|
269
|
+
NULL as extra,
|
|
270
|
+
NULL as "columnComment",
|
|
271
|
+
c.character_maximum_length as "characterMaximumLength",
|
|
272
|
+
c.numeric_precision as "numericPrecision",
|
|
273
|
+
c.numeric_scale as "numericScale",
|
|
274
|
+
EXISTS (
|
|
275
|
+
SELECT 1 FROM information_schema.table_constraints tc
|
|
276
|
+
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
|
|
277
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
278
|
+
AND tc.table_name = c.table_name
|
|
279
|
+
AND kcu.column_name = c.column_name
|
|
280
|
+
) as "isPrimaryKey"
|
|
281
|
+
FROM information_schema.columns c
|
|
282
|
+
WHERE c.table_name = $2 AND (c.table_schema = 'public' OR c.table_schema = $1)
|
|
283
|
+
ORDER BY c.ordinal_position
|
|
284
|
+
`;
|
|
285
|
+
|
|
286
|
+
let connection: any = null;
|
|
287
|
+
try {
|
|
288
|
+
connection = await this.getConnection(dbName);
|
|
289
|
+
const result = await DatabaseConnection.executeQuery(connection, query, [config.database, tableName], config.type);
|
|
290
|
+
|
|
291
|
+
return result.rows.map(row => ({
|
|
292
|
+
columnName: row.columnName,
|
|
293
|
+
dataType: row.dataType,
|
|
294
|
+
isNullable: row.isNullable,
|
|
295
|
+
columnDefault: row.columnDefault,
|
|
296
|
+
isPrimaryKey: Boolean(row.isPrimaryKey),
|
|
297
|
+
isAutoIncrement: row.columnDefault && row.columnDefault.includes('nextval'),
|
|
298
|
+
columnComment: undefined,
|
|
299
|
+
characterMaximumLength: row.characterMaximumLength,
|
|
300
|
+
numericPrecision: row.numericPrecision,
|
|
301
|
+
numericScale: row.numericScale
|
|
302
|
+
}));
|
|
303
|
+
} finally {
|
|
304
|
+
if (connection) {
|
|
305
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async getForeignKeys(dbName: string, tableName?: string): Promise<ForeignKeyInfo[]> {
|
|
311
|
+
const config = this.databases.get(dbName);
|
|
312
|
+
if (!config) throw new DatabaseError(`Database not found: ${dbName}`);
|
|
313
|
+
|
|
314
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
315
|
+
return this.getForeignKeysPostgres(dbName, tableName);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let query = `
|
|
319
|
+
SELECT
|
|
320
|
+
rc.CONSTRAINT_NAME as constraintName,
|
|
321
|
+
kcu.TABLE_NAME as tableName,
|
|
322
|
+
kcu.COLUMN_NAME as columnName,
|
|
323
|
+
kcu.REFERENCED_TABLE_NAME as referencedTableName,
|
|
324
|
+
kcu.REFERENCED_COLUMN_NAME as referencedColumnName,
|
|
325
|
+
rc.UPDATE_RULE as updateRule,
|
|
326
|
+
rc.DELETE_RULE as deleteRule
|
|
327
|
+
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
|
328
|
+
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
|
329
|
+
ON rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
|
330
|
+
AND rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
|
331
|
+
WHERE rc.CONSTRAINT_SCHEMA = ?
|
|
332
|
+
`;
|
|
333
|
+
|
|
334
|
+
const params: string[] = [];
|
|
335
|
+
params.push(config.database);
|
|
336
|
+
|
|
337
|
+
if (tableName) {
|
|
338
|
+
query += ' AND kcu.TABLE_NAME = ?';
|
|
339
|
+
params.push(tableName);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
query += ' ORDER BY kcu.TABLE_NAME, rc.CONSTRAINT_NAME';
|
|
343
|
+
|
|
344
|
+
let connection: any = null;
|
|
345
|
+
try {
|
|
346
|
+
connection = await this.getConnection(dbName);
|
|
347
|
+
const result = await DatabaseConnection.executeQuery(connection, query, params, config.type);
|
|
348
|
+
|
|
349
|
+
return result.rows.map(row => ({
|
|
350
|
+
constraintName: row.constraintName,
|
|
351
|
+
tableName: row.tableName,
|
|
352
|
+
columnName: row.columnName,
|
|
353
|
+
referencedTableName: row.referencedTableName,
|
|
354
|
+
referencedColumnName: row.referencedColumnName,
|
|
355
|
+
updateRule: row.updateRule,
|
|
356
|
+
deleteRule: row.deleteRule
|
|
357
|
+
}));
|
|
358
|
+
} finally {
|
|
359
|
+
if (connection) {
|
|
360
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async getForeignKeysPostgres(dbName: string, tableName?: string): Promise<ForeignKeyInfo[]> {
|
|
366
|
+
const config = this.databases.get(dbName)!;
|
|
367
|
+
let query = `
|
|
368
|
+
SELECT
|
|
369
|
+
tc.constraint_name as "constraintName",
|
|
370
|
+
tc.table_name as "tableName",
|
|
371
|
+
kcu.column_name as "columnName",
|
|
372
|
+
ccu.table_name AS "referencedTableName",
|
|
373
|
+
ccu.column_name AS "referencedColumnName"
|
|
374
|
+
FROM information_schema.table_constraints AS tc
|
|
375
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
376
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
377
|
+
AND tc.table_schema = kcu.table_schema
|
|
378
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
379
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
380
|
+
AND ccu.table_schema = tc.table_schema
|
|
381
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
382
|
+
AND (tc.table_schema = 'public' OR tc.table_schema = $1)
|
|
383
|
+
`;
|
|
384
|
+
|
|
385
|
+
const params: string[] = [config.database];
|
|
386
|
+
if (tableName) {
|
|
387
|
+
query += ' AND tc.table_name = $2';
|
|
388
|
+
params.push(tableName);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let connection: any = null;
|
|
392
|
+
try {
|
|
393
|
+
connection = await this.getConnection(dbName);
|
|
394
|
+
const result = await DatabaseConnection.executeQuery(connection, query, params, config.type);
|
|
395
|
+
|
|
396
|
+
return result.rows.map(row => ({
|
|
397
|
+
constraintName: row.constraintName,
|
|
398
|
+
tableName: row.tableName,
|
|
399
|
+
columnName: row.columnName,
|
|
400
|
+
referencedTableName: row.referencedTableName,
|
|
401
|
+
referencedColumnName: row.referencedColumnName,
|
|
402
|
+
updateRule: undefined,
|
|
403
|
+
deleteRule: undefined
|
|
404
|
+
}));
|
|
405
|
+
} finally {
|
|
406
|
+
if (connection) {
|
|
407
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async getIndexes(dbName: string, tableName: string): Promise<IndexInfo[]> {
|
|
413
|
+
const config = this.databases.get(dbName);
|
|
414
|
+
if (!config) throw new DatabaseError(`Database not found: ${dbName}`);
|
|
415
|
+
|
|
416
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
417
|
+
return this.getIndexesPostgres(dbName, tableName);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const query = `
|
|
421
|
+
SELECT
|
|
422
|
+
TABLE_NAME as tableName,
|
|
423
|
+
INDEX_NAME as indexName,
|
|
424
|
+
COLUMN_NAME as columnName,
|
|
425
|
+
NON_UNIQUE as nonUnique,
|
|
426
|
+
INDEX_TYPE as indexType,
|
|
427
|
+
CARDINALITY as cardinality,
|
|
428
|
+
SUB_PART as subPart,
|
|
429
|
+
NULLABLE as nullable
|
|
430
|
+
FROM INFORMATION_SCHEMA.STATISTICS
|
|
431
|
+
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
|
432
|
+
ORDER BY INDEX_NAME, SEQ_IN_INDEX
|
|
433
|
+
`;
|
|
434
|
+
|
|
435
|
+
let connection: any = null;
|
|
436
|
+
try {
|
|
437
|
+
connection = await this.getConnection(dbName);
|
|
438
|
+
const result = await DatabaseConnection.executeQuery(connection, query, [config.database, tableName], config.type);
|
|
439
|
+
|
|
440
|
+
return result.rows.map(row => ({
|
|
441
|
+
tableName: row.tableName,
|
|
442
|
+
indexName: row.indexName,
|
|
443
|
+
columnName: row.columnName,
|
|
444
|
+
nonUnique: Boolean(row.nonUnique),
|
|
445
|
+
indexType: row.indexType,
|
|
446
|
+
cardinality: row.cardinality,
|
|
447
|
+
subPart: row.subPart,
|
|
448
|
+
nullable: row.nullable === 'YES',
|
|
449
|
+
isPrimary: row.indexName === 'PRIMARY'
|
|
450
|
+
}));
|
|
451
|
+
} finally {
|
|
452
|
+
if (connection) {
|
|
453
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private async getIndexesPostgres(dbName: string, tableName: string): Promise<IndexInfo[]> {
|
|
459
|
+
const config = this.databases.get(dbName)!;
|
|
460
|
+
const query = `
|
|
461
|
+
SELECT
|
|
462
|
+
t.relname as "tableName",
|
|
463
|
+
i.relname as "indexName",
|
|
464
|
+
a.attname as "columnName",
|
|
465
|
+
NOT ix.indisunique as "nonUnique",
|
|
466
|
+
ix.indisprimary as "isPrimary",
|
|
467
|
+
am.amname as "indexType"
|
|
468
|
+
FROM pg_class t
|
|
469
|
+
JOIN pg_index ix ON t.oid = ix.indrelid
|
|
470
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
471
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
472
|
+
JOIN pg_am am ON i.relam = am.oid
|
|
473
|
+
WHERE t.relname = $1
|
|
474
|
+
ORDER BY i.relname
|
|
475
|
+
`;
|
|
476
|
+
|
|
477
|
+
let connection: any = null;
|
|
478
|
+
try {
|
|
479
|
+
connection = await this.getConnection(dbName);
|
|
480
|
+
const result = await DatabaseConnection.executeQuery(connection, query, [tableName], config.type);
|
|
481
|
+
|
|
482
|
+
return result.rows.map(row => ({
|
|
483
|
+
tableName: row.tableName,
|
|
484
|
+
indexName: row.indexName,
|
|
485
|
+
columnName: row.columnName,
|
|
486
|
+
nonUnique: Boolean(row.nonUnique),
|
|
487
|
+
indexType: row.indexType,
|
|
488
|
+
cardinality: undefined,
|
|
489
|
+
subPart: undefined,
|
|
490
|
+
nullable: true, // pg_attribute doesn't directly tell us nullable here, but irrelevant for simple index list
|
|
491
|
+
isPrimary: Boolean(row.isPrimary)
|
|
492
|
+
}));
|
|
493
|
+
} finally {
|
|
494
|
+
if (connection) {
|
|
495
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async cleanup(): Promise<void> {
|
|
501
|
+
Logger.info('Cleaning up database connections...');
|
|
502
|
+
|
|
503
|
+
for (const [name, config] of this.databases) {
|
|
504
|
+
if (config.connection) {
|
|
505
|
+
try {
|
|
506
|
+
await DatabaseConnection.closeConnection(config.connection, config.type);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
Logger.warn(`Error closing connection for ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
this.databases.clear();
|
|
514
|
+
Logger.info('Database cleanup completed');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async queryInformationSchema(
|
|
518
|
+
dbName: string,
|
|
519
|
+
table: 'COLUMNS' | 'TABLES' | 'ROUTINES',
|
|
520
|
+
filters?: Record<string, string>,
|
|
521
|
+
limit: number = 100
|
|
522
|
+
): Promise<QueryResult> {
|
|
523
|
+
const config = this.databases.get(dbName);
|
|
524
|
+
if (!config) throw new DatabaseError(`Database not found: ${dbName}`);
|
|
525
|
+
|
|
526
|
+
const allowedTables = ['COLUMNS', 'TABLES', 'ROUTINES'];
|
|
527
|
+
if (!allowedTables.includes(table)) {
|
|
528
|
+
throw new ValidationError(`Table '${table}' is not allowed for INFORMATION_SCHEMA queries.`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let whereClauses: string[] = [];
|
|
532
|
+
let params: any[] = [];
|
|
533
|
+
|
|
534
|
+
if (filters) {
|
|
535
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
536
|
+
if (!/^[A-Z_]+$/.test(key)) {
|
|
537
|
+
throw new ValidationError(`Invalid filter key: ${key}. Only uppercase letters and underscores allowed.`);
|
|
538
|
+
}
|
|
539
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
540
|
+
whereClauses.push(`${key.toLowerCase()} = $${params.length + 1}`);
|
|
541
|
+
} else {
|
|
542
|
+
whereClauses.push(`${key} = ?`);
|
|
543
|
+
}
|
|
544
|
+
params.push(value);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
549
|
+
whereClauses.unshift(`table_schema = $${params.length + 1}`);
|
|
550
|
+
params.push('public'); // Default for PG
|
|
551
|
+
} else {
|
|
552
|
+
whereClauses.unshift('TABLE_SCHEMA = ?');
|
|
553
|
+
params.unshift(config.database);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let sql = `SELECT * FROM INFORMATION_SCHEMA.${table}`;
|
|
557
|
+
if (whereClauses.length > 0) {
|
|
558
|
+
sql += ' WHERE ' + whereClauses.join(' AND ');
|
|
559
|
+
}
|
|
560
|
+
sql += ` LIMIT ${Math.min(Math.max(limit, 1), 1000)}`;
|
|
561
|
+
|
|
562
|
+
let connection: any = null;
|
|
563
|
+
try {
|
|
564
|
+
connection = await this.getConnection(dbName);
|
|
565
|
+
const result = await DatabaseConnection.executeQuery(connection, sql, params, config.type);
|
|
566
|
+
return result;
|
|
567
|
+
} finally {
|
|
568
|
+
if (connection) {
|
|
569
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async analyzeQuery(dbName: string, query: string): Promise<any> {
|
|
575
|
+
const config = this.databases.get(dbName);
|
|
576
|
+
if (!config) throw new DatabaseError(`Database not found: ${dbName}`);
|
|
577
|
+
|
|
578
|
+
// Validate query is read-only
|
|
579
|
+
const validation = QueryValidator.validateQuery(query);
|
|
580
|
+
if (!validation.isValid) {
|
|
581
|
+
throw new ValidationError(`Query validation failed: ${validation.error}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
let explainQuery: string;
|
|
585
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
586
|
+
explainQuery = `EXPLAIN (FORMAT JSON, VERBOSE, ANALYZE FALSE) ${query}`;
|
|
587
|
+
} else {
|
|
588
|
+
explainQuery = `EXPLAIN FORMAT=JSON ${query}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let connection: any = null;
|
|
592
|
+
try {
|
|
593
|
+
connection = await this.getConnection(dbName);
|
|
594
|
+
const result = await DatabaseConnection.executeQuery(connection, explainQuery, [], config.type);
|
|
595
|
+
|
|
596
|
+
let rawPlan: any;
|
|
597
|
+
if (config.type === DatabaseType.PostgreSQL) {
|
|
598
|
+
// PG returns [{ "QUERY PLAN": [...] }] or similar
|
|
599
|
+
const firstRow = result.rows[0];
|
|
600
|
+
const firstKey = Object.keys(firstRow)[0];
|
|
601
|
+
const val = firstRow[firstKey];
|
|
602
|
+
rawPlan = Array.isArray(val) ? val[0] : val;
|
|
603
|
+
} else {
|
|
604
|
+
// MySQL returns [{ EXPLAIN: "json_string" }] or similar
|
|
605
|
+
const firstRow = result.rows[0];
|
|
606
|
+
const firstKey = Object.keys(firstRow)[0];
|
|
607
|
+
try {
|
|
608
|
+
rawPlan = typeof firstRow[firstKey] === 'string' ? JSON.parse(firstRow[firstKey]) : firstRow[firstKey];
|
|
609
|
+
} catch (e) {
|
|
610
|
+
rawPlan = firstRow[firstKey];
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
database: dbName,
|
|
616
|
+
type: config.type,
|
|
617
|
+
query,
|
|
618
|
+
plan: rawPlan,
|
|
619
|
+
summary: this.summarizePlan(rawPlan, config.type)
|
|
620
|
+
};
|
|
621
|
+
} finally {
|
|
622
|
+
if (connection) {
|
|
623
|
+
await DatabaseConnection.closeConnection(connection, config.type);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private summarizePlan(plan: any, type: DatabaseType): any {
|
|
629
|
+
const summary: any = {
|
|
630
|
+
cost: 0,
|
|
631
|
+
potentialIssues: [] as string[],
|
|
632
|
+
operations: [] as string[]
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
if (type === DatabaseType.PostgreSQL) {
|
|
636
|
+
const rootPlan = plan?.Plan || plan?.[0]?.Plan;
|
|
637
|
+
if (rootPlan) {
|
|
638
|
+
summary.cost = rootPlan['Total Cost'];
|
|
639
|
+
this.traversePostgresPlan(rootPlan, summary);
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
// MySQL JSON format is deeply nested under query_block
|
|
643
|
+
const queryBlock = plan?.query_block;
|
|
644
|
+
if (queryBlock) {
|
|
645
|
+
summary.cost = queryBlock.cost_info?.query_cost;
|
|
646
|
+
this.traverseMySQLPlan(queryBlock, summary);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return summary;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private traversePostgresPlan(plan: any, summary: any) {
|
|
654
|
+
const nodeType = plan['Node Type'];
|
|
655
|
+
summary.operations.push(nodeType);
|
|
656
|
+
|
|
657
|
+
if (nodeType === 'Seq Scan') {
|
|
658
|
+
summary.potentialIssues.push(`Full table scan on ${plan['Relation Name']}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (plan.Plans) {
|
|
662
|
+
for (const subPlan of plan.Plans) {
|
|
663
|
+
this.traversePostgresPlan(subPlan, summary);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private traverseMySQLPlan(node: any, summary: any) {
|
|
669
|
+
if (node.table) {
|
|
670
|
+
summary.operations.push(node.table.access_type);
|
|
671
|
+
if (node.table.access_type === 'ALL') {
|
|
672
|
+
summary.potentialIssues.push(`Full table scan on ${node.table.table_name}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
for (const key in node) {
|
|
677
|
+
if (typeof node[key] === 'object' && node[key] !== null) {
|
|
678
|
+
this.traverseMySQLPlan(node[key], summary);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|