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