sqlcipher-mcp-server 1.0.4 → 2.0.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/README.md +433 -210
- package/package.json +1 -1
- package/src/config/constants.js +18 -40
- package/src/definitions/prompts.js +124 -0
- package/src/definitions/tools.js +363 -0
- package/src/handlers/http-handlers.js +576 -4
- package/src/handlers/mcp-handlers.js +558 -69
- package/src/handlers/prompt-handlers.js +601 -0
- package/src/server/http-server.js +52 -2
- package/src/server/mcp-server.js +208 -95
- package/src/services/database-service.js +395 -55
- package/src/utils/database-operations.js +967 -0
- package/src/utils/detectors.js +55 -0
- package/src/utils/formatters.js +470 -64
- package/src/utils/validators.js +147 -58
- package/lib/database.js +0 -216
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
import sqlcipher from '@journeyapps/sqlcipher';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
// Extract Database from the sqlcipher module object
|
|
5
|
+
const Database = sqlcipher.Database;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Connects to a SQLite database (encrypted or unencrypted)
|
|
9
|
+
* Supports both SQLCipher-encrypted and plain SQLite databases
|
|
10
|
+
*
|
|
11
|
+
* @param {string} dbPath - Path to the database file
|
|
12
|
+
* @param {string} [password] - Optional database password (for encrypted databases)
|
|
13
|
+
* @returns {Promise<Database>} Database connection instance
|
|
14
|
+
* @throws {Error} If database file doesn't exist or connection fails
|
|
15
|
+
*/
|
|
16
|
+
export function connectDatabase(dbPath, password) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
// Validate database path exists
|
|
19
|
+
if (!fs.existsSync(dbPath)) {
|
|
20
|
+
return reject(new Error(`Database file not found: ${dbPath}`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let db = null;
|
|
24
|
+
// Open database connection with callback
|
|
25
|
+
db = new Database(dbPath, (err) => {
|
|
26
|
+
if (err) {
|
|
27
|
+
return reject(new Error(`Failed to open database: ${err.message}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// If no password provided, treat as unencrypted SQLite database
|
|
31
|
+
if (!password || password.trim() === '') {
|
|
32
|
+
// Verify the database is accessible by running a simple query
|
|
33
|
+
db.get('SELECT 1', (getErr, row) => {
|
|
34
|
+
if (getErr) {
|
|
35
|
+
db.close((closeErr) => {
|
|
36
|
+
// Ignore close errors
|
|
37
|
+
});
|
|
38
|
+
return reject(new Error(`Failed to verify database: ${getErr.message}`));
|
|
39
|
+
}
|
|
40
|
+
resolve(db);
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Password provided - treat as encrypted SQLCipher database
|
|
46
|
+
// Explicitly set SQLCipher 3 compatibility mode
|
|
47
|
+
// This ensures SQLCipher 3 defaults are used:
|
|
48
|
+
// - Page size: 1024 bytes
|
|
49
|
+
// - PBKDF2 iterations: 64,000
|
|
50
|
+
// - KDF algorithm: PBKDF2-HMAC-SHA1
|
|
51
|
+
// - HMAC algorithm: HMAC-SHA1
|
|
52
|
+
db.exec('PRAGMA cipher_compatibility = 3', (compatErr) => {
|
|
53
|
+
if (compatErr) {
|
|
54
|
+
db.close((closeErr) => {
|
|
55
|
+
// Ignore close errors
|
|
56
|
+
});
|
|
57
|
+
return reject(new Error(`Failed to set SQLCipher 3 compatibility: ${compatErr.message}`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Set SQLCipher 3 default encryption settings
|
|
61
|
+
// PRAGMA key sets the encryption key using SQLCipher 3 defaults
|
|
62
|
+
// PRAGMA key does NOT support parameterized queries, so we must embed the password directly
|
|
63
|
+
// Escape single quotes in password for SQL (double them) and escape backslashes
|
|
64
|
+
const escapedPassword = password.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
|
65
|
+
|
|
66
|
+
// Use db.exec() with callback for PRAGMA key
|
|
67
|
+
db.exec(`PRAGMA key = '${escapedPassword}'`, (execErr) => {
|
|
68
|
+
if (execErr) {
|
|
69
|
+
db.close((closeErr) => {
|
|
70
|
+
// Ignore close errors
|
|
71
|
+
});
|
|
72
|
+
return reject(new Error(`Failed to set encryption key: ${execErr.message}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Verify the database is accessible by running a simple query
|
|
76
|
+
// This will throw an error if the password is incorrect
|
|
77
|
+
db.get('SELECT 1', (getErr, row) => {
|
|
78
|
+
if (getErr) {
|
|
79
|
+
db.close((closeErr) => {
|
|
80
|
+
// Ignore close errors
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (getErr.message.includes('file is not a database') ||
|
|
84
|
+
getErr.message.includes('malformed database') ||
|
|
85
|
+
getErr.code === 'SQLITE_NOTADB') {
|
|
86
|
+
return reject(new Error('Invalid password or database is corrupted'));
|
|
87
|
+
}
|
|
88
|
+
return reject(new Error(`Failed to verify database: ${getErr.message}`));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
resolve(db);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validates that a SQL query is a SELECT query (read-only)
|
|
101
|
+
*
|
|
102
|
+
* @param {string} query - SQL query string
|
|
103
|
+
* @returns {boolean} True if query is a SELECT query
|
|
104
|
+
* @throws {Error} If query is not a SELECT query
|
|
105
|
+
*/
|
|
106
|
+
function validateSelectQuery(query) {
|
|
107
|
+
if (!query || typeof query !== 'string') {
|
|
108
|
+
throw new Error('Query must be a non-empty string');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Trim and normalize whitespace
|
|
112
|
+
const normalizedQuery = query.trim().replace(/\s+/g, ' ');
|
|
113
|
+
|
|
114
|
+
// Check if query starts with SELECT (case-insensitive)
|
|
115
|
+
if (!normalizedQuery.match(/^SELECT\s+/i)) {
|
|
116
|
+
throw new Error('Only SELECT queries are allowed (read-only mode)');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Additional check: ensure no DDL or DML keywords are present
|
|
120
|
+
const forbiddenKeywords = [
|
|
121
|
+
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER',
|
|
122
|
+
'TRUNCATE', 'REPLACE', 'PRAGMA'
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const upperQuery = normalizedQuery.toUpperCase();
|
|
126
|
+
for (const keyword of forbiddenKeywords) {
|
|
127
|
+
// Check for keyword followed by space or semicolon (to avoid false positives)
|
|
128
|
+
const regex = new RegExp(`\\b${keyword}\\s+`, 'i');
|
|
129
|
+
if (regex.test(normalizedQuery) && keyword !== 'SELECT') {
|
|
130
|
+
throw new Error(`Query contains forbidden keyword: ${keyword}. Only SELECT queries are allowed.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Executes a SELECT query on the database and returns results
|
|
139
|
+
*
|
|
140
|
+
* @param {Database} db - Database connection instance
|
|
141
|
+
* @param {string} query - SQL SELECT query to execute
|
|
142
|
+
* @returns {Promise<Object>} Query results with columns, rows, and rowCount
|
|
143
|
+
* @throws {Error} If query is invalid or execution fails
|
|
144
|
+
*/
|
|
145
|
+
export function executeQuery(db, query) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
// Validate query is a SELECT query
|
|
148
|
+
try {
|
|
149
|
+
validateSelectQuery(query);
|
|
150
|
+
} catch (validationError) {
|
|
151
|
+
return reject(validationError);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Prepare and execute the query with callback
|
|
155
|
+
const statement = db.prepare(query, (prepareErr) => {
|
|
156
|
+
if (prepareErr) {
|
|
157
|
+
return reject(new Error(`Query preparation failed: ${prepareErr.message}`));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Execute query with callback - statement.all() requires a callback
|
|
161
|
+
statement.all((allErr, rows) => {
|
|
162
|
+
if (allErr) {
|
|
163
|
+
statement.finalize();
|
|
164
|
+
if (allErr.message.includes('no such table')) {
|
|
165
|
+
return reject(new Error(`Table not found: ${allErr.message}`));
|
|
166
|
+
} else if (allErr.message.includes('no such column')) {
|
|
167
|
+
return reject(new Error(`Column not found: ${allErr.message}`));
|
|
168
|
+
} else if (allErr.message.includes('syntax error')) {
|
|
169
|
+
return reject(new Error(`SQL syntax error: ${allErr.message}`));
|
|
170
|
+
}
|
|
171
|
+
return reject(new Error(`Query execution failed: ${allErr.message}`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Get column names from the first row
|
|
175
|
+
let columns = [];
|
|
176
|
+
if (rows && rows.length > 0) {
|
|
177
|
+
columns = Object.keys(rows[0]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Finalize the statement
|
|
181
|
+
statement.finalize();
|
|
182
|
+
|
|
183
|
+
resolve({
|
|
184
|
+
columns: columns,
|
|
185
|
+
rows: rows || [],
|
|
186
|
+
rowCount: rows ? rows.length : 0
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Closes a database connection
|
|
195
|
+
* Ensures all statements are finalized before closing
|
|
196
|
+
*
|
|
197
|
+
* @param {Database} db - Database connection instance
|
|
198
|
+
*/
|
|
199
|
+
export function closeConnection(db) {
|
|
200
|
+
if (!db || typeof db.close !== 'function') {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
// Close with callback to handle any errors gracefully
|
|
206
|
+
db.close((err) => {
|
|
207
|
+
if (err) {
|
|
208
|
+
// Log but don't throw - closing should be best effort
|
|
209
|
+
console.error('Error closing database connection:', err.message);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
} catch (error) {
|
|
213
|
+
// Log but don't throw - closing should be best effort
|
|
214
|
+
console.error('Error closing database connection:', error.message);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get list of tables in the database
|
|
220
|
+
* @param {Database} db - Database connection instance
|
|
221
|
+
* @param {string[]} [tableNames] - Optional array of table names to filter
|
|
222
|
+
* @returns {Promise<Array>} Array of table objects with metadata
|
|
223
|
+
*/
|
|
224
|
+
export function getTableList(db, tableNames = null) {
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
let query = `
|
|
227
|
+
SELECT
|
|
228
|
+
name,
|
|
229
|
+
type,
|
|
230
|
+
sql
|
|
231
|
+
FROM sqlite_master
|
|
232
|
+
WHERE type IN ('table', 'view')
|
|
233
|
+
AND name NOT LIKE 'sqlite_%'
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
// Add filter for specific table names if provided
|
|
237
|
+
if (tableNames && Array.isArray(tableNames) && tableNames.length > 0) {
|
|
238
|
+
const placeholders = tableNames.map(() => '?').join(',');
|
|
239
|
+
query += ` AND name IN (${placeholders})`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
query += ' ORDER BY name';
|
|
243
|
+
|
|
244
|
+
const params = tableNames && Array.isArray(tableNames) && tableNames.length > 0 ? tableNames : [];
|
|
245
|
+
|
|
246
|
+
db.all(query, params, (err, rows) => {
|
|
247
|
+
if (err) {
|
|
248
|
+
return reject(new Error(`Failed to get table list: ${err.message}`));
|
|
249
|
+
}
|
|
250
|
+
resolve(rows || []);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get table schema information using PRAGMA table_info
|
|
257
|
+
* @param {Database} db - Database connection instance
|
|
258
|
+
* @param {string} tableName - Name of the table
|
|
259
|
+
* @returns {Promise<Object>} Table schema information
|
|
260
|
+
*/
|
|
261
|
+
export function getTableSchema(db, tableName) {
|
|
262
|
+
return new Promise((resolve, reject) => {
|
|
263
|
+
// First verify table exists
|
|
264
|
+
db.get(
|
|
265
|
+
'SELECT name FROM sqlite_master WHERE type IN ("table", "view") AND name = ?',
|
|
266
|
+
[tableName],
|
|
267
|
+
(err, row) => {
|
|
268
|
+
if (err) {
|
|
269
|
+
return reject(new Error(`Failed to verify table: ${err.message}`));
|
|
270
|
+
}
|
|
271
|
+
if (!row) {
|
|
272
|
+
return reject(new Error(`Table "${tableName}" does not exist`));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Get column information
|
|
276
|
+
db.all(`PRAGMA table_info("${tableName.replace(/"/g, '""')}")`, (pragmaErr, columns) => {
|
|
277
|
+
if (pragmaErr) {
|
|
278
|
+
return reject(new Error(`Failed to get table info: ${pragmaErr.message}`));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
resolve({
|
|
282
|
+
tableName: tableName,
|
|
283
|
+
columns: columns || []
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get foreign key information for a table
|
|
293
|
+
* @param {Database} db - Database connection instance
|
|
294
|
+
* @param {string} [tableName] - Optional table name (if not provided, gets all foreign keys)
|
|
295
|
+
* @returns {Promise<Array>} Array of foreign key relationships
|
|
296
|
+
*/
|
|
297
|
+
export function getForeignKeys(db, tableName = null) {
|
|
298
|
+
return new Promise(async (resolve, reject) => {
|
|
299
|
+
if (tableName) {
|
|
300
|
+
// Get foreign keys for specific table
|
|
301
|
+
db.all(`PRAGMA foreign_key_list("${tableName.replace(/"/g, '""')}")`, (err, fks) => {
|
|
302
|
+
if (err) {
|
|
303
|
+
return reject(new Error(`Failed to get foreign keys: ${err.message}`));
|
|
304
|
+
}
|
|
305
|
+
resolve(fks.map(fk => ({ ...fk, table: tableName })) || []);
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
// Get foreign keys for all tables
|
|
309
|
+
try {
|
|
310
|
+
const tables = await getTableList(db);
|
|
311
|
+
const allForeignKeys = [];
|
|
312
|
+
|
|
313
|
+
let completed = 0;
|
|
314
|
+
const total = tables.length;
|
|
315
|
+
|
|
316
|
+
if (total === 0) {
|
|
317
|
+
return resolve([]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
tables.forEach(table => {
|
|
321
|
+
db.all(`PRAGMA foreign_key_list("${table.name.replace(/"/g, '""')}")`, (err, fks) => {
|
|
322
|
+
if (!err && fks) {
|
|
323
|
+
fks.forEach(fk => {
|
|
324
|
+
allForeignKeys.push({ ...fk, table: table.name });
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
completed++;
|
|
329
|
+
if (completed === total) {
|
|
330
|
+
resolve(allForeignKeys);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
} catch (error) {
|
|
335
|
+
reject(error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get index information for a table or database
|
|
343
|
+
* @param {Database} db - Database connection instance
|
|
344
|
+
* @param {string} [tableName] - Optional table name
|
|
345
|
+
* @returns {Promise<Array>} Array of index information
|
|
346
|
+
*/
|
|
347
|
+
export function getIndexes(db, tableName = null) {
|
|
348
|
+
return new Promise(async (resolve, reject) => {
|
|
349
|
+
if (tableName) {
|
|
350
|
+
// Get indexes for specific table
|
|
351
|
+
db.all(`PRAGMA index_list("${tableName.replace(/"/g, '""')}")`, (err, indexes) => {
|
|
352
|
+
if (err) {
|
|
353
|
+
return reject(new Error(`Failed to get indexes: ${err.message}`));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!indexes || indexes.length === 0) {
|
|
357
|
+
return resolve([]);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Get detailed info for each index
|
|
361
|
+
const indexDetails = [];
|
|
362
|
+
let completed = 0;
|
|
363
|
+
|
|
364
|
+
indexes.forEach(index => {
|
|
365
|
+
db.all(`PRAGMA index_info("${index.name.replace(/"/g, '""')}")`, (infoErr, columns) => {
|
|
366
|
+
if (!infoErr && columns) {
|
|
367
|
+
indexDetails.push({
|
|
368
|
+
...index,
|
|
369
|
+
table: tableName,
|
|
370
|
+
columns: columns
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
completed++;
|
|
375
|
+
if (completed === indexes.length) {
|
|
376
|
+
resolve(indexDetails);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
} else {
|
|
382
|
+
// Get indexes for all tables
|
|
383
|
+
try {
|
|
384
|
+
const tables = await getTableList(db);
|
|
385
|
+
const allIndexes = [];
|
|
386
|
+
|
|
387
|
+
let completed = 0;
|
|
388
|
+
const total = tables.length;
|
|
389
|
+
|
|
390
|
+
if (total === 0) {
|
|
391
|
+
return resolve([]);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
tables.forEach(table => {
|
|
395
|
+
db.all(`PRAGMA index_list("${table.name.replace(/"/g, '""')}")`, (err, indexes) => {
|
|
396
|
+
if (!err && indexes && indexes.length > 0) {
|
|
397
|
+
let indexCompleted = 0;
|
|
398
|
+
indexes.forEach(index => {
|
|
399
|
+
db.all(`PRAGMA index_info("${index.name.replace(/"/g, '""')}")`, (infoErr, columns) => {
|
|
400
|
+
if (!infoErr && columns) {
|
|
401
|
+
allIndexes.push({
|
|
402
|
+
...index,
|
|
403
|
+
table: table.name,
|
|
404
|
+
columns: columns
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
indexCompleted++;
|
|
409
|
+
if (indexCompleted === indexes.length) {
|
|
410
|
+
completed++;
|
|
411
|
+
if (completed === total) {
|
|
412
|
+
resolve(allIndexes);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
} else {
|
|
418
|
+
completed++;
|
|
419
|
+
if (completed === total) {
|
|
420
|
+
resolve(allIndexes);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
} catch (error) {
|
|
426
|
+
reject(error);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Get database metadata information
|
|
434
|
+
* @param {Database} db - Database connection instance
|
|
435
|
+
* @param {string} dbPath - Path to database file
|
|
436
|
+
* @returns {Promise<Object>} Database metadata
|
|
437
|
+
*/
|
|
438
|
+
export function getDatabaseInfo(db, dbPath) {
|
|
439
|
+
return new Promise(async (resolve, reject) => {
|
|
440
|
+
const info = { path: dbPath };
|
|
441
|
+
const pragmas = [
|
|
442
|
+
'user_version',
|
|
443
|
+
'application_id',
|
|
444
|
+
'page_size',
|
|
445
|
+
'page_count',
|
|
446
|
+
'encoding',
|
|
447
|
+
'freelist_count',
|
|
448
|
+
'schema_version'
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
let completed = 0;
|
|
452
|
+
let hasError = false;
|
|
453
|
+
|
|
454
|
+
// Get file size
|
|
455
|
+
try {
|
|
456
|
+
const fs = await import('fs');
|
|
457
|
+
const stats = fs.statSync(dbPath);
|
|
458
|
+
info.size_bytes = stats.size;
|
|
459
|
+
} catch (e) {
|
|
460
|
+
// Ignore file stat errors
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Get SQLite version
|
|
464
|
+
db.get('SELECT sqlite_version() as version', (err, row) => {
|
|
465
|
+
if (!err && row) {
|
|
466
|
+
info.sqlite_version = row.version;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
pragmas.forEach(pragma => {
|
|
471
|
+
db.get(`PRAGMA ${pragma}`, (err, row) => {
|
|
472
|
+
if (!hasError) {
|
|
473
|
+
if (err) {
|
|
474
|
+
hasError = true;
|
|
475
|
+
return reject(new Error(`Failed to get database info: ${err.message}`));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (row) {
|
|
479
|
+
const key = Object.keys(row)[0];
|
|
480
|
+
info[pragma] = row[key];
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
completed++;
|
|
485
|
+
if (completed === pragmas.length && !hasError) {
|
|
486
|
+
resolve(info);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Get table information including row count
|
|
495
|
+
* @param {Database} db - Database connection instance
|
|
496
|
+
* @param {string} tableName - Name of the table
|
|
497
|
+
* @returns {Promise<Object>} Table information
|
|
498
|
+
*/
|
|
499
|
+
export function getTableInfo(db, tableName) {
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
// Verify table exists
|
|
502
|
+
db.get(
|
|
503
|
+
'SELECT name, type, sql FROM sqlite_master WHERE type IN ("table", "view") AND name = ?',
|
|
504
|
+
[tableName],
|
|
505
|
+
(err, table) => {
|
|
506
|
+
if (err) {
|
|
507
|
+
return reject(new Error(`Failed to get table info: ${err.message}`));
|
|
508
|
+
}
|
|
509
|
+
if (!table) {
|
|
510
|
+
return reject(new Error(`Table "${tableName}" does not exist`));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Get row count
|
|
514
|
+
db.get(`SELECT COUNT(*) as count FROM "${tableName.replace(/"/g, '""')}"`, (countErr, countRow) => {
|
|
515
|
+
if (countErr) {
|
|
516
|
+
return reject(new Error(`Failed to count rows: ${countErr.message}`));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Get column count
|
|
520
|
+
db.all(`PRAGMA table_info("${tableName.replace(/"/g, '""')}")`, (pragmaErr, columns) => {
|
|
521
|
+
if (pragmaErr) {
|
|
522
|
+
return reject(new Error(`Failed to get column info: ${pragmaErr.message}`));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
resolve({
|
|
526
|
+
name: table.name,
|
|
527
|
+
type: table.type,
|
|
528
|
+
row_count: countRow ? countRow.count : 0,
|
|
529
|
+
column_count: columns ? columns.length : 0,
|
|
530
|
+
sql: table.sql
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Test database connection
|
|
541
|
+
* @param {Database} db - Database connection instance
|
|
542
|
+
* @returns {Promise<boolean>} True if connection is valid
|
|
543
|
+
*/
|
|
544
|
+
export function testConnection(db) {
|
|
545
|
+
return new Promise((resolve, reject) => {
|
|
546
|
+
db.get('SELECT 1 as test', (err, row) => {
|
|
547
|
+
if (err) {
|
|
548
|
+
return reject(new Error(`Connection test failed: ${err.message}`));
|
|
549
|
+
}
|
|
550
|
+
resolve(true);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Explain query execution plan
|
|
557
|
+
* @param {Database} db - Database connection instance
|
|
558
|
+
* @param {string} query - SQL query to explain
|
|
559
|
+
* @returns {Promise<Array>} Query execution plan
|
|
560
|
+
*/
|
|
561
|
+
export function explainQueryPlan(db, query) {
|
|
562
|
+
return new Promise((resolve, reject) => {
|
|
563
|
+
// Validate query is a SELECT query
|
|
564
|
+
try {
|
|
565
|
+
validateSelectQuery(query);
|
|
566
|
+
} catch (validationError) {
|
|
567
|
+
return reject(validationError);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
db.all(`EXPLAIN QUERY PLAN ${query}`, (err, rows) => {
|
|
571
|
+
if (err) {
|
|
572
|
+
return reject(new Error(`Failed to explain query: ${err.message}`));
|
|
573
|
+
}
|
|
574
|
+
resolve(rows || []);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Get statistics for a table
|
|
581
|
+
* @param {Database} db - Database connection instance
|
|
582
|
+
* @param {string} tableName - Name of the table
|
|
583
|
+
* @param {number} maxSampleSize - Maximum number of rows to sample
|
|
584
|
+
* @returns {Promise<Object>} Table statistics
|
|
585
|
+
*/
|
|
586
|
+
export function getTableStatistics(db, tableName, maxSampleSize = 10000) {
|
|
587
|
+
return new Promise(async (resolve, reject) => {
|
|
588
|
+
try {
|
|
589
|
+
// Get table schema first
|
|
590
|
+
const schema = await getTableSchema(db, tableName);
|
|
591
|
+
const columns = schema.columns;
|
|
592
|
+
|
|
593
|
+
// Get row count
|
|
594
|
+
db.get(`SELECT COUNT(*) as total_rows FROM "${tableName.replace(/"/g, '""')}"`, async (err, countRow) => {
|
|
595
|
+
if (err) {
|
|
596
|
+
return reject(new Error(`Failed to get row count: ${err.message}`));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const totalRows = countRow ? countRow.total_rows : 0;
|
|
600
|
+
const statistics = {
|
|
601
|
+
table_name: tableName,
|
|
602
|
+
total_rows: totalRows,
|
|
603
|
+
column_count: columns.length,
|
|
604
|
+
columns: []
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
if (columns.length === 0 || totalRows === 0) {
|
|
608
|
+
return resolve(statistics);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// For each column, get basic statistics
|
|
612
|
+
let completed = 0;
|
|
613
|
+
|
|
614
|
+
columns.forEach(column => {
|
|
615
|
+
const columnName = column.name;
|
|
616
|
+
const columnType = column.type;
|
|
617
|
+
const escapedColumn = `"${columnName.replace(/"/g, '""')}"`;
|
|
618
|
+
|
|
619
|
+
// Get distinct count and null count
|
|
620
|
+
const statsQuery = `
|
|
621
|
+
SELECT
|
|
622
|
+
COUNT(DISTINCT ${escapedColumn}) as distinct_count,
|
|
623
|
+
COUNT(*) - COUNT(${escapedColumn}) as null_count
|
|
624
|
+
FROM "${tableName.replace(/"/g, '""')}"
|
|
625
|
+
`;
|
|
626
|
+
|
|
627
|
+
db.get(statsQuery, (statsErr, statsRow) => {
|
|
628
|
+
const columnStats = {
|
|
629
|
+
name: columnName,
|
|
630
|
+
type: columnType,
|
|
631
|
+
distinct_count: 0,
|
|
632
|
+
null_count: 0
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
if (!statsErr && statsRow) {
|
|
636
|
+
columnStats.distinct_count = statsRow.distinct_count || 0;
|
|
637
|
+
columnStats.null_count = statsRow.null_count || 0;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// For numeric columns, get min/max/avg
|
|
641
|
+
if (columnType && (columnType.toUpperCase().includes('INT') ||
|
|
642
|
+
columnType.toUpperCase().includes('REAL') ||
|
|
643
|
+
columnType.toUpperCase().includes('NUMERIC') ||
|
|
644
|
+
columnType.toUpperCase().includes('FLOAT') ||
|
|
645
|
+
columnType.toUpperCase().includes('DOUBLE'))) {
|
|
646
|
+
|
|
647
|
+
const numericQuery = `
|
|
648
|
+
SELECT
|
|
649
|
+
MIN(${escapedColumn}) as min_value,
|
|
650
|
+
MAX(${escapedColumn}) as max_value,
|
|
651
|
+
AVG(${escapedColumn}) as avg_value
|
|
652
|
+
FROM "${tableName.replace(/"/g, '""')}"
|
|
653
|
+
`;
|
|
654
|
+
|
|
655
|
+
db.get(numericQuery, (numErr, numRow) => {
|
|
656
|
+
if (!numErr && numRow) {
|
|
657
|
+
columnStats.min_value = numRow.min_value;
|
|
658
|
+
columnStats.max_value = numRow.max_value;
|
|
659
|
+
columnStats.avg_value = numRow.avg_value;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
statistics.columns.push(columnStats);
|
|
663
|
+
completed++;
|
|
664
|
+
|
|
665
|
+
if (completed === columns.length) {
|
|
666
|
+
resolve(statistics);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
} else {
|
|
670
|
+
statistics.columns.push(columnStats);
|
|
671
|
+
completed++;
|
|
672
|
+
|
|
673
|
+
if (completed === columns.length) {
|
|
674
|
+
resolve(statistics);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
} catch (error) {
|
|
681
|
+
reject(error);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Sample table data
|
|
688
|
+
* @param {Database} db - Database connection instance
|
|
689
|
+
* @param {string} tableName - Name of the table
|
|
690
|
+
* @param {number} limit - Number of rows to sample
|
|
691
|
+
* @param {number} offset - Offset for sampling
|
|
692
|
+
* @param {string[]} columns - Optional array of column names to include
|
|
693
|
+
* @returns {Promise<Object>} Sample data
|
|
694
|
+
*/
|
|
695
|
+
export function sampleTableData(db, tableName, limit = 10, offset = 0, columns = null) {
|
|
696
|
+
return new Promise((resolve, reject) => {
|
|
697
|
+
// Build column list
|
|
698
|
+
let columnList = '*';
|
|
699
|
+
if (columns && Array.isArray(columns) && columns.length > 0) {
|
|
700
|
+
columnList = columns.map(col => `"${col.replace(/"/g, '""')}"`).join(', ');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const query = `SELECT ${columnList} FROM "${tableName.replace(/"/g, '""')}" LIMIT ? OFFSET ?`;
|
|
704
|
+
|
|
705
|
+
db.all(query, [limit, offset], (err, rows) => {
|
|
706
|
+
if (err) {
|
|
707
|
+
if (err.message.includes('no such table')) {
|
|
708
|
+
return reject(new Error(`Table "${tableName}" does not exist`));
|
|
709
|
+
} else if (err.message.includes('no such column')) {
|
|
710
|
+
return reject(new Error(`One or more specified columns do not exist`));
|
|
711
|
+
}
|
|
712
|
+
return reject(new Error(`Failed to sample table data: ${err.message}`));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Get column names
|
|
716
|
+
let columnNames = [];
|
|
717
|
+
if (rows && rows.length > 0) {
|
|
718
|
+
columnNames = Object.keys(rows[0]);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
resolve({
|
|
722
|
+
table_name: tableName,
|
|
723
|
+
columns: columnNames,
|
|
724
|
+
rows: rows || [],
|
|
725
|
+
row_count: rows ? rows.length : 0,
|
|
726
|
+
limit: limit,
|
|
727
|
+
offset: offset
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Get column statistics
|
|
735
|
+
* @param {Database} db - Database connection instance
|
|
736
|
+
* @param {string} tableName - Name of the table
|
|
737
|
+
* @param {string[]} columnNames - Array of column names
|
|
738
|
+
* @param {number} maxSampleSize - Maximum sample size
|
|
739
|
+
* @returns {Promise<Array>} Array of column statistics
|
|
740
|
+
*/
|
|
741
|
+
export function getColumnStatistics(db, tableName, columnNames, maxSampleSize = 10000) {
|
|
742
|
+
return new Promise(async (resolve, reject) => {
|
|
743
|
+
try {
|
|
744
|
+
// Verify table exists and get schema
|
|
745
|
+
const schema = await getTableSchema(db, tableName);
|
|
746
|
+
const allColumns = schema.columns.map(c => c.name);
|
|
747
|
+
|
|
748
|
+
// Verify requested columns exist
|
|
749
|
+
const invalidColumns = columnNames.filter(name => !allColumns.includes(name));
|
|
750
|
+
if (invalidColumns.length > 0) {
|
|
751
|
+
return reject(new Error(`Columns do not exist: ${invalidColumns.join(', ')}`));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const columnStats = [];
|
|
755
|
+
let completed = 0;
|
|
756
|
+
|
|
757
|
+
columnNames.forEach(columnName => {
|
|
758
|
+
const column = schema.columns.find(c => c.name === columnName);
|
|
759
|
+
const escapedColumn = `"${columnName.replace(/"/g, '""')}"`;
|
|
760
|
+
|
|
761
|
+
// Get basic statistics
|
|
762
|
+
const basicQuery = `
|
|
763
|
+
SELECT
|
|
764
|
+
COUNT(DISTINCT ${escapedColumn}) as distinct_count,
|
|
765
|
+
COUNT(*) - COUNT(${escapedColumn}) as null_count,
|
|
766
|
+
COUNT(${escapedColumn}) as non_null_count
|
|
767
|
+
FROM "${tableName.replace(/"/g, '""')}"
|
|
768
|
+
`;
|
|
769
|
+
|
|
770
|
+
db.get(basicQuery, (err, basicRow) => {
|
|
771
|
+
const stats = {
|
|
772
|
+
table_name: tableName,
|
|
773
|
+
column_name: columnName,
|
|
774
|
+
column_type: column ? column.type : 'UNKNOWN',
|
|
775
|
+
distinct_count: 0,
|
|
776
|
+
null_count: 0,
|
|
777
|
+
non_null_count: 0
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
if (!err && basicRow) {
|
|
781
|
+
stats.distinct_count = basicRow.distinct_count || 0;
|
|
782
|
+
stats.null_count = basicRow.null_count || 0;
|
|
783
|
+
stats.non_null_count = basicRow.non_null_count || 0;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// For numeric columns, get min/max/avg
|
|
787
|
+
const columnType = column ? column.type : '';
|
|
788
|
+
if (columnType && (columnType.toUpperCase().includes('INT') ||
|
|
789
|
+
columnType.toUpperCase().includes('REAL') ||
|
|
790
|
+
columnType.toUpperCase().includes('NUMERIC') ||
|
|
791
|
+
columnType.toUpperCase().includes('FLOAT') ||
|
|
792
|
+
columnType.toUpperCase().includes('DOUBLE'))) {
|
|
793
|
+
|
|
794
|
+
const numericQuery = `
|
|
795
|
+
SELECT
|
|
796
|
+
MIN(${escapedColumn}) as min_value,
|
|
797
|
+
MAX(${escapedColumn}) as max_value,
|
|
798
|
+
AVG(${escapedColumn}) as avg_value
|
|
799
|
+
FROM "${tableName.replace(/"/g, '""')}"
|
|
800
|
+
`;
|
|
801
|
+
|
|
802
|
+
db.get(numericQuery, (numErr, numRow) => {
|
|
803
|
+
if (!numErr && numRow) {
|
|
804
|
+
stats.min_value = numRow.min_value;
|
|
805
|
+
stats.max_value = numRow.max_value;
|
|
806
|
+
stats.avg_value = numRow.avg_value;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Get sample values
|
|
810
|
+
const sampleQuery = `
|
|
811
|
+
SELECT DISTINCT ${escapedColumn} as value
|
|
812
|
+
FROM "${tableName.replace(/"/g, '""')}"
|
|
813
|
+
WHERE ${escapedColumn} IS NOT NULL
|
|
814
|
+
LIMIT 5
|
|
815
|
+
`;
|
|
816
|
+
|
|
817
|
+
db.all(sampleQuery, (sampleErr, sampleRows) => {
|
|
818
|
+
if (!sampleErr && sampleRows) {
|
|
819
|
+
stats.sample_values = sampleRows.map(r => r.value);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
columnStats.push(stats);
|
|
823
|
+
completed++;
|
|
824
|
+
|
|
825
|
+
if (completed === columnNames.length) {
|
|
826
|
+
resolve(columnStats);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
} else {
|
|
831
|
+
// Get sample values for non-numeric columns
|
|
832
|
+
const sampleQuery = `
|
|
833
|
+
SELECT DISTINCT ${escapedColumn} as value
|
|
834
|
+
FROM "${tableName.replace(/"/g, '""')}"
|
|
835
|
+
WHERE ${escapedColumn} IS NOT NULL
|
|
836
|
+
LIMIT 5
|
|
837
|
+
`;
|
|
838
|
+
|
|
839
|
+
db.all(sampleQuery, (sampleErr, sampleRows) => {
|
|
840
|
+
if (!sampleErr && sampleRows) {
|
|
841
|
+
stats.sample_values = sampleRows.map(r => r.value);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
columnStats.push(stats);
|
|
845
|
+
completed++;
|
|
846
|
+
|
|
847
|
+
if (completed === columnNames.length) {
|
|
848
|
+
resolve(columnStats);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
} catch (error) {
|
|
855
|
+
reject(error);
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Search for tables by name pattern
|
|
862
|
+
* @param {Database} db - Database connection instance
|
|
863
|
+
* @param {string} pattern - SQL LIKE pattern
|
|
864
|
+
* @returns {Promise<Array>} Matching tables
|
|
865
|
+
*/
|
|
866
|
+
export function searchTables(db, pattern) {
|
|
867
|
+
return new Promise((resolve, reject) => {
|
|
868
|
+
const query = `
|
|
869
|
+
SELECT name, type, sql
|
|
870
|
+
FROM sqlite_master
|
|
871
|
+
WHERE type IN ('table', 'view')
|
|
872
|
+
AND name NOT LIKE 'sqlite_%'
|
|
873
|
+
AND name LIKE ?
|
|
874
|
+
ORDER BY name
|
|
875
|
+
`;
|
|
876
|
+
|
|
877
|
+
db.all(query, [pattern], (err, rows) => {
|
|
878
|
+
if (err) {
|
|
879
|
+
return reject(new Error(`Failed to search tables: ${err.message}`));
|
|
880
|
+
}
|
|
881
|
+
resolve(rows || []);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Search for columns across all tables
|
|
888
|
+
* @param {Database} db - Database connection instance
|
|
889
|
+
* @param {string} pattern - SQL LIKE pattern
|
|
890
|
+
* @returns {Promise<Array>} Matching columns with table names
|
|
891
|
+
*/
|
|
892
|
+
export function searchColumns(db, pattern) {
|
|
893
|
+
return new Promise(async (resolve, reject) => {
|
|
894
|
+
try {
|
|
895
|
+
const tables = await getTableList(db);
|
|
896
|
+
const matchingColumns = [];
|
|
897
|
+
|
|
898
|
+
if (tables.length === 0) {
|
|
899
|
+
return resolve([]);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
let completed = 0;
|
|
903
|
+
|
|
904
|
+
tables.forEach(table => {
|
|
905
|
+
db.all(`PRAGMA table_info("${table.name.replace(/"/g, '""')}")`, (err, columns) => {
|
|
906
|
+
if (!err && columns) {
|
|
907
|
+
columns.forEach(column => {
|
|
908
|
+
// SQLite LIKE is case-insensitive by default
|
|
909
|
+
const columnName = column.name;
|
|
910
|
+
const regex = new RegExp(pattern.replace(/%/g, '.*').replace(/_/g, '.'), 'i');
|
|
911
|
+
if (regex.test(columnName)) {
|
|
912
|
+
matchingColumns.push({
|
|
913
|
+
table_name: table.name,
|
|
914
|
+
column_name: column.name,
|
|
915
|
+
column_type: column.type,
|
|
916
|
+
is_primary_key: column.pk === 1,
|
|
917
|
+
is_nullable: column.notnull === 0
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
completed++;
|
|
924
|
+
if (completed === tables.length) {
|
|
925
|
+
resolve(matchingColumns);
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
} catch (error) {
|
|
930
|
+
reject(error);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Find tables related to a given table via foreign keys
|
|
937
|
+
* @param {Database} db - Database connection instance
|
|
938
|
+
* @param {string} tableName - Name of the table
|
|
939
|
+
* @returns {Promise<Object>} Related tables information
|
|
940
|
+
*/
|
|
941
|
+
export function findRelatedTables(db, tableName) {
|
|
942
|
+
return new Promise(async (resolve, reject) => {
|
|
943
|
+
try {
|
|
944
|
+
// Verify table exists
|
|
945
|
+
const schema = await getTableSchema(db, tableName);
|
|
946
|
+
|
|
947
|
+
// Get foreign keys from this table (outgoing relationships)
|
|
948
|
+
const outgoingFks = await getForeignKeys(db, tableName);
|
|
949
|
+
|
|
950
|
+
// Get foreign keys to this table (incoming relationships)
|
|
951
|
+
const allFks = await getForeignKeys(db);
|
|
952
|
+
const incomingFks = allFks.filter(fk => fk.table === tableName);
|
|
953
|
+
|
|
954
|
+
const relatedTables = {
|
|
955
|
+
table_name: tableName,
|
|
956
|
+
references_tables: [...new Set(outgoingFks.map(fk => fk.table))],
|
|
957
|
+
referenced_by_tables: [...new Set(incomingFks.map(fk => fk.table))],
|
|
958
|
+
outgoing_foreign_keys: outgoingFks,
|
|
959
|
+
incoming_foreign_keys: incomingFks
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
resolve(relatedTables);
|
|
963
|
+
} catch (error) {
|
|
964
|
+
reject(error);
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
}
|