@tejasanik/postgres-mcp-server 2.0.0 → 2.1.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 +335 -2
- package/dist/__tests__/server-tools.test.js +24 -0
- package/dist/__tests__/server-tools.test.js.map +1 -1
- package/dist/__tests__/sql-tools.test.js +713 -0
- package/dist/__tests__/sql-tools.test.js.map +1 -1
- package/dist/db-manager.d.ts.map +1 -1
- package/dist/db-manager.js +1 -0
- package/dist/db-manager.js.map +1 -1
- package/dist/index.js +89 -2
- package/dist/index.js.map +1 -1
- package/dist/tools/server-tools.d.ts +1 -0
- package/dist/tools/server-tools.d.ts.map +1 -1
- package/dist/tools/server-tools.js +5 -4
- package/dist/tools/server-tools.js.map +1 -1
- package/dist/tools/sql-tools.d.ts +82 -1
- package/dist/tools/sql-tools.d.ts.map +1 -1
- package/dist/tools/sql-tools.js +741 -7
- package/dist/tools/sql-tools.js.map +1 -1
- package/dist/types.d.ts +158 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/utils/masking.d.ts +0 -18
- package/dist/utils/masking.d.ts.map +0 -1
- package/dist/utils/masking.js +0 -68
- package/dist/utils/masking.js.map +0 -1
package/dist/tools/sql-tools.js
CHANGED
|
@@ -10,6 +10,156 @@ const MAX_ROWS_LIMIT = 100000; // Absolute maximum rows
|
|
|
10
10
|
const DEFAULT_SQL_LENGTH_LIMIT = 100000; // Default SQL query length limit (100KB)
|
|
11
11
|
const MAX_PARAMS = 100; // Maximum number of query parameters
|
|
12
12
|
const MAX_SQL_FILE_SIZE = 50 * 1024 * 1024; // Maximum SQL file size (50MB)
|
|
13
|
+
const MAX_DRY_RUN_SAMPLE_ROWS = 10; // Maximum sample rows to return in dry-run
|
|
14
|
+
/**
|
|
15
|
+
* Extract detailed error information from a PostgreSQL error.
|
|
16
|
+
* Captures all available fields to help AI quickly identify and fix issues.
|
|
17
|
+
*/
|
|
18
|
+
function extractDryRunError(error) {
|
|
19
|
+
const result = {
|
|
20
|
+
message: error instanceof Error ? error.message : String(error)
|
|
21
|
+
};
|
|
22
|
+
// PostgreSQL errors have additional properties
|
|
23
|
+
if (error && typeof error === 'object') {
|
|
24
|
+
const pgError = error;
|
|
25
|
+
if (pgError.code)
|
|
26
|
+
result.code = String(pgError.code);
|
|
27
|
+
if (pgError.severity)
|
|
28
|
+
result.severity = String(pgError.severity);
|
|
29
|
+
if (pgError.detail)
|
|
30
|
+
result.detail = String(pgError.detail);
|
|
31
|
+
if (pgError.hint)
|
|
32
|
+
result.hint = String(pgError.hint);
|
|
33
|
+
if (pgError.position)
|
|
34
|
+
result.position = Number(pgError.position);
|
|
35
|
+
if (pgError.internalPosition)
|
|
36
|
+
result.internalPosition = Number(pgError.internalPosition);
|
|
37
|
+
if (pgError.internalQuery)
|
|
38
|
+
result.internalQuery = String(pgError.internalQuery);
|
|
39
|
+
if (pgError.where)
|
|
40
|
+
result.where = String(pgError.where);
|
|
41
|
+
if (pgError.schema)
|
|
42
|
+
result.schema = String(pgError.schema);
|
|
43
|
+
if (pgError.table)
|
|
44
|
+
result.table = String(pgError.table);
|
|
45
|
+
if (pgError.column)
|
|
46
|
+
result.column = String(pgError.column);
|
|
47
|
+
if (pgError.dataType)
|
|
48
|
+
result.dataType = String(pgError.dataType);
|
|
49
|
+
if (pgError.constraint)
|
|
50
|
+
result.constraint = String(pgError.constraint);
|
|
51
|
+
if (pgError.file)
|
|
52
|
+
result.file = String(pgError.file);
|
|
53
|
+
if (pgError.line)
|
|
54
|
+
result.line = String(pgError.line);
|
|
55
|
+
if (pgError.routine)
|
|
56
|
+
result.routine = String(pgError.routine);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Check if a SQL statement contains operations that cannot be fully rolled back
|
|
62
|
+
* or have side effects even within a transaction.
|
|
63
|
+
*/
|
|
64
|
+
function detectNonRollbackableOperations(sql, statementIndex, lineNumber) {
|
|
65
|
+
const warnings = [];
|
|
66
|
+
const upperSql = sql.toUpperCase().trim();
|
|
67
|
+
// Operations that cannot run inside a transaction at all - MUST SKIP
|
|
68
|
+
if (upperSql.match(/\bVACUUM\b/)) {
|
|
69
|
+
warnings.push({
|
|
70
|
+
operation: 'VACUUM',
|
|
71
|
+
message: 'VACUUM cannot run inside a transaction block. Statement skipped.',
|
|
72
|
+
statementIndex,
|
|
73
|
+
lineNumber,
|
|
74
|
+
mustSkip: true
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (upperSql.match(/\bCLUSTER\b/) && !upperSql.includes('CREATE')) {
|
|
78
|
+
warnings.push({
|
|
79
|
+
operation: 'CLUSTER',
|
|
80
|
+
message: 'CLUSTER cannot run inside a transaction block. Statement skipped.',
|
|
81
|
+
statementIndex,
|
|
82
|
+
lineNumber,
|
|
83
|
+
mustSkip: true
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (upperSql.match(/\bREINDEX\b.*\bCONCURRENTLY\b/)) {
|
|
87
|
+
warnings.push({
|
|
88
|
+
operation: 'REINDEX_CONCURRENTLY',
|
|
89
|
+
message: 'REINDEX CONCURRENTLY cannot run inside a transaction block. Statement skipped.',
|
|
90
|
+
statementIndex,
|
|
91
|
+
lineNumber,
|
|
92
|
+
mustSkip: true
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (upperSql.match(/\bCREATE\s+INDEX\b.*\bCONCURRENTLY\b/)) {
|
|
96
|
+
warnings.push({
|
|
97
|
+
operation: 'CREATE_INDEX_CONCURRENTLY',
|
|
98
|
+
message: 'CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Statement skipped.',
|
|
99
|
+
statementIndex,
|
|
100
|
+
lineNumber,
|
|
101
|
+
mustSkip: true
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (upperSql.match(/\bCREATE\s+DATABASE\b/)) {
|
|
105
|
+
warnings.push({
|
|
106
|
+
operation: 'CREATE_DATABASE',
|
|
107
|
+
message: 'CREATE DATABASE cannot run inside a transaction block. Statement skipped.',
|
|
108
|
+
statementIndex,
|
|
109
|
+
lineNumber,
|
|
110
|
+
mustSkip: true
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (upperSql.match(/\bDROP\s+DATABASE\b/)) {
|
|
114
|
+
warnings.push({
|
|
115
|
+
operation: 'DROP_DATABASE',
|
|
116
|
+
message: 'DROP DATABASE cannot run inside a transaction block. Statement skipped.',
|
|
117
|
+
statementIndex,
|
|
118
|
+
lineNumber,
|
|
119
|
+
mustSkip: true
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Operations that have side effects even when rolled back - MUST SKIP
|
|
123
|
+
if (upperSql.match(/\bNEXTVAL\s*\(/)) {
|
|
124
|
+
warnings.push({
|
|
125
|
+
operation: 'SEQUENCE',
|
|
126
|
+
message: 'NEXTVAL increments sequence even when transaction is rolled back. Statement skipped to prevent sequence consumption.',
|
|
127
|
+
statementIndex,
|
|
128
|
+
lineNumber,
|
|
129
|
+
mustSkip: true
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (upperSql.match(/\bSETVAL\s*\(/)) {
|
|
133
|
+
warnings.push({
|
|
134
|
+
operation: 'SEQUENCE',
|
|
135
|
+
message: 'SETVAL modifies sequence. Statement skipped to prevent side effects.',
|
|
136
|
+
statementIndex,
|
|
137
|
+
lineNumber,
|
|
138
|
+
mustSkip: true
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// INSERT with SERIAL/BIGSERIAL columns may consume sequence values - WARNING ONLY (not skipped)
|
|
142
|
+
if (upperSql.match(/\bINSERT\s+INTO\b/)) {
|
|
143
|
+
warnings.push({
|
|
144
|
+
operation: 'SEQUENCE',
|
|
145
|
+
message: 'INSERT may consume sequence values (for SERIAL/BIGSERIAL columns) even when rolled back.',
|
|
146
|
+
statementIndex,
|
|
147
|
+
lineNumber,
|
|
148
|
+
mustSkip: false // Warning only, do not skip
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// NOTIFY only sends on commit, so safe in dry-run (rollback prevents notification)
|
|
152
|
+
if (upperSql.match(/\bNOTIFY\b/)) {
|
|
153
|
+
warnings.push({
|
|
154
|
+
operation: 'NOTIFY',
|
|
155
|
+
message: 'NOTIFY sends notifications on commit. Since dry-run rolls back, notifications will NOT be sent.',
|
|
156
|
+
statementIndex,
|
|
157
|
+
lineNumber,
|
|
158
|
+
mustSkip: false // Safe to execute in dry-run since we rollback
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return warnings;
|
|
162
|
+
}
|
|
13
163
|
export async function executeSql(args) {
|
|
14
164
|
// Validate SQL input
|
|
15
165
|
if (args.sql === undefined || args.sql === null) {
|
|
@@ -283,6 +433,37 @@ export async function explainQuery(args) {
|
|
|
283
433
|
client.release();
|
|
284
434
|
}
|
|
285
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* Preprocess SQL content by removing patterns.
|
|
438
|
+
* Supports both literal string matching and regex patterns.
|
|
439
|
+
*
|
|
440
|
+
* @param sql - The SQL content to preprocess
|
|
441
|
+
* @param patterns - Array of patterns to remove from SQL content
|
|
442
|
+
* @param isRegex - If true, patterns are treated as regex; if false, as literal strings
|
|
443
|
+
*/
|
|
444
|
+
function preprocessSqlContent(sql, patterns, isRegex = false) {
|
|
445
|
+
let result = sql;
|
|
446
|
+
for (const pattern of patterns) {
|
|
447
|
+
try {
|
|
448
|
+
if (isRegex) {
|
|
449
|
+
// Treat as regex pattern (multiline by default)
|
|
450
|
+
const regex = new RegExp(pattern, 'gm');
|
|
451
|
+
result = result.replace(regex, '');
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
// Treat as literal string - escape and match on its own line
|
|
455
|
+
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
456
|
+
const regex = new RegExp(`^\\s*${escapedPattern}\\s*$`, 'gm');
|
|
457
|
+
result = result.replace(regex, '');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
// Invalid regex - skip this pattern
|
|
462
|
+
console.error(`Warning: Invalid pattern "${pattern}": ${error instanceof Error ? error.message : String(error)}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
286
467
|
/**
|
|
287
468
|
* Execute a SQL file from the filesystem.
|
|
288
469
|
* Supports transaction mode for atomic execution.
|
|
@@ -316,17 +497,16 @@ export async function executeSqlFile(args) {
|
|
|
316
497
|
throw new Error('File is empty');
|
|
317
498
|
}
|
|
318
499
|
// Read file content
|
|
319
|
-
|
|
500
|
+
let sqlContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
501
|
+
// Preprocess SQL content if patterns specified
|
|
502
|
+
if (args.stripPatterns && args.stripPatterns.length > 0) {
|
|
503
|
+
sqlContent = preprocessSqlContent(sqlContent, args.stripPatterns, args.stripAsRegex === true);
|
|
504
|
+
}
|
|
320
505
|
const dbManager = getDbManager();
|
|
321
506
|
const useTransaction = args.useTransaction !== false; // Default to true
|
|
322
507
|
const stopOnError = args.stopOnError !== false; // Default to true
|
|
508
|
+
const validateOnly = args.validateOnly === true; // Default to false
|
|
323
509
|
const startTime = process.hrtime.bigint();
|
|
324
|
-
const client = await dbManager.getClient();
|
|
325
|
-
let statementsExecuted = 0;
|
|
326
|
-
let statementsFailed = 0;
|
|
327
|
-
let totalRowsAffected = 0;
|
|
328
|
-
let rolledBack = false;
|
|
329
|
-
const collectedErrors = [];
|
|
330
510
|
// Split SQL into statements with line number tracking
|
|
331
511
|
const parsedStatements = splitSqlStatementsWithLineNumbers(sqlContent);
|
|
332
512
|
const executableStatements = parsedStatements.filter(stmt => {
|
|
@@ -337,6 +517,36 @@ export async function executeSqlFile(args) {
|
|
|
337
517
|
return withoutComments.length > 0;
|
|
338
518
|
});
|
|
339
519
|
const totalStatements = executableStatements.length;
|
|
520
|
+
// If validateOnly mode, return preview without execution
|
|
521
|
+
if (validateOnly) {
|
|
522
|
+
const endTime = process.hrtime.bigint();
|
|
523
|
+
const executionTimeMs = Number(endTime - startTime) / 1_000_000;
|
|
524
|
+
// Create preview of statements
|
|
525
|
+
const preview = executableStatements.map((stmt, idx) => ({
|
|
526
|
+
index: idx + 1,
|
|
527
|
+
lineNumber: stmt.lineNumber,
|
|
528
|
+
sql: stmt.sql.length > 300 ? stmt.sql.substring(0, 300) + '...' : stmt.sql,
|
|
529
|
+
type: detectStatementType(stmt.sql)
|
|
530
|
+
}));
|
|
531
|
+
return {
|
|
532
|
+
success: true,
|
|
533
|
+
filePath: resolvedPath,
|
|
534
|
+
fileSize: stats.size,
|
|
535
|
+
totalStatements,
|
|
536
|
+
statementsExecuted: 0,
|
|
537
|
+
statementsFailed: 0,
|
|
538
|
+
executionTimeMs: Math.round(executionTimeMs * 100) / 100,
|
|
539
|
+
rowsAffected: 0,
|
|
540
|
+
validateOnly: true,
|
|
541
|
+
preview
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const client = await dbManager.getClient();
|
|
545
|
+
let statementsExecuted = 0;
|
|
546
|
+
let statementsFailed = 0;
|
|
547
|
+
let totalRowsAffected = 0;
|
|
548
|
+
let rolledBack = false;
|
|
549
|
+
const collectedErrors = [];
|
|
340
550
|
try {
|
|
341
551
|
if (useTransaction) {
|
|
342
552
|
await client.query('BEGIN');
|
|
@@ -423,6 +633,338 @@ export async function executeSqlFile(args) {
|
|
|
423
633
|
client.release();
|
|
424
634
|
}
|
|
425
635
|
}
|
|
636
|
+
/**
|
|
637
|
+
* Detect the type of SQL statement (SELECT, INSERT, UPDATE, DELETE, CREATE, etc.)
|
|
638
|
+
*/
|
|
639
|
+
function detectStatementType(sql) {
|
|
640
|
+
const trimmed = stripLeadingComments(sql).toUpperCase();
|
|
641
|
+
// Common statement types
|
|
642
|
+
const types = [
|
|
643
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP',
|
|
644
|
+
'TRUNCATE', 'GRANT', 'REVOKE', 'BEGIN', 'COMMIT', 'ROLLBACK',
|
|
645
|
+
'SET', 'SHOW', 'EXPLAIN', 'ANALYZE', 'VACUUM', 'REINDEX',
|
|
646
|
+
'COMMENT', 'WITH', 'DO', 'CALL', 'EXECUTE'
|
|
647
|
+
];
|
|
648
|
+
for (const type of types) {
|
|
649
|
+
if (trimmed.startsWith(type + ' ') || trimmed.startsWith(type + '\n') ||
|
|
650
|
+
trimmed.startsWith(type + '\t') || trimmed === type) {
|
|
651
|
+
// Special case for WITH - check if it's a CTE followed by SELECT/INSERT/UPDATE/DELETE
|
|
652
|
+
if (type === 'WITH') {
|
|
653
|
+
if (trimmed.includes('SELECT'))
|
|
654
|
+
return 'WITH SELECT';
|
|
655
|
+
if (trimmed.includes('INSERT'))
|
|
656
|
+
return 'WITH INSERT';
|
|
657
|
+
if (trimmed.includes('UPDATE'))
|
|
658
|
+
return 'WITH UPDATE';
|
|
659
|
+
if (trimmed.includes('DELETE'))
|
|
660
|
+
return 'WITH DELETE';
|
|
661
|
+
return 'WITH';
|
|
662
|
+
}
|
|
663
|
+
return type;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return 'UNKNOWN';
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Preview a SQL file without executing.
|
|
670
|
+
* Similar to mutation_preview but for SQL files - shows what would happen if executed.
|
|
671
|
+
*/
|
|
672
|
+
export async function previewSqlFile(args) {
|
|
673
|
+
// Validate file path
|
|
674
|
+
if (!args.filePath || typeof args.filePath !== 'string') {
|
|
675
|
+
throw new Error('filePath parameter is required');
|
|
676
|
+
}
|
|
677
|
+
const filePath = args.filePath.trim();
|
|
678
|
+
// Security: Validate file extension
|
|
679
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
680
|
+
if (ext !== '.sql') {
|
|
681
|
+
throw new Error('Only .sql files are allowed');
|
|
682
|
+
}
|
|
683
|
+
// Security: Prevent path traversal attacks
|
|
684
|
+
const resolvedPath = path.resolve(filePath);
|
|
685
|
+
// Check file exists
|
|
686
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
687
|
+
throw new Error(`File not found: ${filePath}`);
|
|
688
|
+
}
|
|
689
|
+
// Check file stats
|
|
690
|
+
const stats = fs.statSync(resolvedPath);
|
|
691
|
+
if (!stats.isFile()) {
|
|
692
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
693
|
+
}
|
|
694
|
+
if (stats.size > MAX_SQL_FILE_SIZE) {
|
|
695
|
+
throw new Error(`File too large. Maximum size is ${MAX_SQL_FILE_SIZE / (1024 * 1024)}MB`);
|
|
696
|
+
}
|
|
697
|
+
if (stats.size === 0) {
|
|
698
|
+
throw new Error('File is empty');
|
|
699
|
+
}
|
|
700
|
+
// Read file content
|
|
701
|
+
let sqlContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
702
|
+
// Preprocess SQL content if patterns specified
|
|
703
|
+
if (args.stripPatterns && args.stripPatterns.length > 0) {
|
|
704
|
+
sqlContent = preprocessSqlContent(sqlContent, args.stripPatterns, args.stripAsRegex === true);
|
|
705
|
+
}
|
|
706
|
+
const maxStatements = Math.min(args.maxStatements || 20, 100);
|
|
707
|
+
// Split SQL into statements with line number tracking
|
|
708
|
+
const parsedStatements = splitSqlStatementsWithLineNumbers(sqlContent);
|
|
709
|
+
const executableStatements = parsedStatements.filter(stmt => {
|
|
710
|
+
const trimmed = stmt.sql.trim();
|
|
711
|
+
if (!trimmed)
|
|
712
|
+
return false;
|
|
713
|
+
const withoutComments = stripLeadingComments(trimmed);
|
|
714
|
+
return withoutComments.length > 0;
|
|
715
|
+
});
|
|
716
|
+
const totalStatements = executableStatements.length;
|
|
717
|
+
// Count statements by type
|
|
718
|
+
const statementsByType = {};
|
|
719
|
+
const warnings = [];
|
|
720
|
+
executableStatements.forEach((stmt, idx) => {
|
|
721
|
+
const type = detectStatementType(stmt.sql);
|
|
722
|
+
statementsByType[type] = (statementsByType[type] || 0) + 1;
|
|
723
|
+
// Check for potentially dangerous operations
|
|
724
|
+
const sqlUpper = stmt.sql.toUpperCase();
|
|
725
|
+
if (type === 'DROP') {
|
|
726
|
+
warnings.push(`Statement ${idx + 1} (line ${stmt.lineNumber}): DROP statement detected - will permanently remove database object`);
|
|
727
|
+
}
|
|
728
|
+
else if (type === 'TRUNCATE') {
|
|
729
|
+
warnings.push(`Statement ${idx + 1} (line ${stmt.lineNumber}): TRUNCATE statement detected - will delete all rows from table`);
|
|
730
|
+
}
|
|
731
|
+
else if (type === 'DELETE' && !sqlUpper.includes('WHERE')) {
|
|
732
|
+
warnings.push(`Statement ${idx + 1} (line ${stmt.lineNumber}): DELETE without WHERE clause - will delete ALL rows from table`);
|
|
733
|
+
}
|
|
734
|
+
else if (type === 'UPDATE' && !sqlUpper.includes('WHERE')) {
|
|
735
|
+
warnings.push(`Statement ${idx + 1} (line ${stmt.lineNumber}): UPDATE without WHERE clause - will update ALL rows in table`);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
// Create statement previews (limited to maxStatements)
|
|
739
|
+
const statements = executableStatements.slice(0, maxStatements).map((stmt, idx) => ({
|
|
740
|
+
index: idx + 1,
|
|
741
|
+
lineNumber: stmt.lineNumber,
|
|
742
|
+
sql: stmt.sql.length > 300 ? stmt.sql.substring(0, 300) + '...' : stmt.sql,
|
|
743
|
+
type: detectStatementType(stmt.sql)
|
|
744
|
+
}));
|
|
745
|
+
// Format file size
|
|
746
|
+
const fileSizeFormatted = stats.size < 1024
|
|
747
|
+
? `${stats.size} bytes`
|
|
748
|
+
: stats.size < 1024 * 1024
|
|
749
|
+
? `${(stats.size / 1024).toFixed(1)} KB`
|
|
750
|
+
: `${(stats.size / (1024 * 1024)).toFixed(2)} MB`;
|
|
751
|
+
// Generate summary
|
|
752
|
+
const typeEntries = Object.entries(statementsByType).sort((a, b) => b[1] - a[1]);
|
|
753
|
+
const typeSummary = typeEntries.map(([type, count]) => `${count} ${type}`).join(', ');
|
|
754
|
+
const summary = `File contains ${totalStatements} statement${totalStatements !== 1 ? 's' : ''}: ${typeSummary || 'none'}`;
|
|
755
|
+
return {
|
|
756
|
+
filePath: resolvedPath,
|
|
757
|
+
fileSize: stats.size,
|
|
758
|
+
fileSizeFormatted,
|
|
759
|
+
totalStatements,
|
|
760
|
+
statementsByType,
|
|
761
|
+
statements,
|
|
762
|
+
warnings,
|
|
763
|
+
summary
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Execute a SQL file in dry-run mode.
|
|
768
|
+
* Actually executes all statements within a transaction, captures real results,
|
|
769
|
+
* then rolls back so no changes are persisted.
|
|
770
|
+
*
|
|
771
|
+
* This provides accurate results including:
|
|
772
|
+
* - Exact row counts for each statement
|
|
773
|
+
* - Actual errors with full PostgreSQL error details
|
|
774
|
+
* - Line numbers for easy debugging
|
|
775
|
+
* - Detection of non-rollbackable operations
|
|
776
|
+
*/
|
|
777
|
+
export async function dryRunSqlFile(args) {
|
|
778
|
+
// Validate file path
|
|
779
|
+
if (!args.filePath || typeof args.filePath !== 'string') {
|
|
780
|
+
throw new Error('filePath parameter is required');
|
|
781
|
+
}
|
|
782
|
+
const filePath = args.filePath.trim();
|
|
783
|
+
// Security: Validate file extension
|
|
784
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
785
|
+
if (ext !== '.sql') {
|
|
786
|
+
throw new Error('Only .sql files are allowed');
|
|
787
|
+
}
|
|
788
|
+
// Security: Prevent path traversal attacks
|
|
789
|
+
const resolvedPath = path.resolve(filePath);
|
|
790
|
+
// Check file exists
|
|
791
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
792
|
+
throw new Error(`File not found: ${filePath}`);
|
|
793
|
+
}
|
|
794
|
+
// Check file stats
|
|
795
|
+
const stats = fs.statSync(resolvedPath);
|
|
796
|
+
if (!stats.isFile()) {
|
|
797
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
798
|
+
}
|
|
799
|
+
if (stats.size > MAX_SQL_FILE_SIZE) {
|
|
800
|
+
throw new Error(`File too large. Maximum size is ${MAX_SQL_FILE_SIZE / (1024 * 1024)}MB`);
|
|
801
|
+
}
|
|
802
|
+
if (stats.size === 0) {
|
|
803
|
+
throw new Error('File is empty');
|
|
804
|
+
}
|
|
805
|
+
// Read file content
|
|
806
|
+
let sqlContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
807
|
+
// Preprocess SQL content if patterns specified
|
|
808
|
+
if (args.stripPatterns && args.stripPatterns.length > 0) {
|
|
809
|
+
sqlContent = preprocessSqlContent(sqlContent, args.stripPatterns, args.stripAsRegex === true);
|
|
810
|
+
}
|
|
811
|
+
const maxStatements = Math.min(args.maxStatements || 50, 200);
|
|
812
|
+
const stopOnError = args.stopOnError === true;
|
|
813
|
+
// Split SQL into statements with line number tracking
|
|
814
|
+
const parsedStatements = splitSqlStatementsWithLineNumbers(sqlContent);
|
|
815
|
+
const executableStatements = parsedStatements.filter(stmt => {
|
|
816
|
+
const trimmed = stmt.sql.trim();
|
|
817
|
+
if (!trimmed)
|
|
818
|
+
return false;
|
|
819
|
+
const withoutComments = stripLeadingComments(trimmed);
|
|
820
|
+
return withoutComments.length > 0;
|
|
821
|
+
});
|
|
822
|
+
const totalStatements = executableStatements.length;
|
|
823
|
+
// Detect all non-rollbackable operations upfront
|
|
824
|
+
const nonRollbackableWarnings = [];
|
|
825
|
+
executableStatements.forEach((stmt, idx) => {
|
|
826
|
+
const warnings = detectNonRollbackableOperations(stmt.sql, idx + 1, stmt.lineNumber);
|
|
827
|
+
nonRollbackableWarnings.push(...warnings);
|
|
828
|
+
});
|
|
829
|
+
// Format file size
|
|
830
|
+
const fileSizeFormatted = stats.size < 1024
|
|
831
|
+
? `${stats.size} bytes`
|
|
832
|
+
: stats.size < 1024 * 1024
|
|
833
|
+
? `${(stats.size / 1024).toFixed(1)} KB`
|
|
834
|
+
: `${(stats.size / (1024 * 1024)).toFixed(2)} MB`;
|
|
835
|
+
const dbManager = getDbManager();
|
|
836
|
+
const client = await dbManager.getClient();
|
|
837
|
+
const startTime = process.hrtime.bigint();
|
|
838
|
+
const statementResults = [];
|
|
839
|
+
const statementsByType = {};
|
|
840
|
+
let successCount = 0;
|
|
841
|
+
let failureCount = 0;
|
|
842
|
+
let skippedCount = 0;
|
|
843
|
+
let totalRowsAffected = 0;
|
|
844
|
+
let aborted = false;
|
|
845
|
+
try {
|
|
846
|
+
// Start transaction
|
|
847
|
+
await client.query('BEGIN');
|
|
848
|
+
for (let idx = 0; idx < executableStatements.length && !aborted; idx++) {
|
|
849
|
+
const stmt = executableStatements[idx];
|
|
850
|
+
const stmtType = detectStatementType(stmt.sql);
|
|
851
|
+
statementsByType[stmtType] = (statementsByType[stmtType] || 0) + 1;
|
|
852
|
+
const stmtStartTime = process.hrtime.bigint();
|
|
853
|
+
const result = {
|
|
854
|
+
index: idx + 1,
|
|
855
|
+
lineNumber: stmt.lineNumber,
|
|
856
|
+
sql: stmt.sql.length > 300 ? stmt.sql.substring(0, 300) + '...' : stmt.sql,
|
|
857
|
+
type: stmtType,
|
|
858
|
+
success: false
|
|
859
|
+
};
|
|
860
|
+
// Check for non-rollbackable warnings specific to this statement
|
|
861
|
+
const stmtWarnings = nonRollbackableWarnings
|
|
862
|
+
.filter(w => w.statementIndex === idx + 1);
|
|
863
|
+
const mustSkipWarnings = stmtWarnings.filter(w => w.mustSkip);
|
|
864
|
+
// Only skip if there are mustSkip warnings; otherwise just warn
|
|
865
|
+
if (mustSkipWarnings.length > 0) {
|
|
866
|
+
result.skipped = true;
|
|
867
|
+
result.skipReason = mustSkipWarnings.map(w => w.message).join('; ');
|
|
868
|
+
result.warnings = stmtWarnings.map(w => w.message);
|
|
869
|
+
result.success = true; // Not a failure, just skipped
|
|
870
|
+
skippedCount++;
|
|
871
|
+
// For DML statements with NEXTVAL/SETVAL, run EXPLAIN to show query plan
|
|
872
|
+
const isDML = ['INSERT', 'UPDATE', 'DELETE', 'SELECT'].includes(stmtType);
|
|
873
|
+
const hasSequenceSkip = mustSkipWarnings.some(w => w.operation === 'SEQUENCE');
|
|
874
|
+
if (isDML && hasSequenceSkip) {
|
|
875
|
+
try {
|
|
876
|
+
const explainResult = await client.query(`EXPLAIN (FORMAT JSON) ${stmt.sql}`);
|
|
877
|
+
if (explainResult.rows && explainResult.rows.length > 0) {
|
|
878
|
+
result.explainPlan = explainResult.rows[0]['QUERY PLAN'];
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
// Ignore EXPLAIN errors - just skip without plan
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
// Include non-mustSkip warnings if any
|
|
888
|
+
if (stmtWarnings.length > 0) {
|
|
889
|
+
result.warnings = stmtWarnings.map(w => w.message);
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
const queryResult = await client.query(stmt.sql);
|
|
893
|
+
result.success = true;
|
|
894
|
+
result.rowCount = queryResult.rowCount || 0;
|
|
895
|
+
totalRowsAffected += result.rowCount;
|
|
896
|
+
successCount++;
|
|
897
|
+
// Include sample rows for SELECT or RETURNING statements
|
|
898
|
+
if (queryResult.rows && queryResult.rows.length > 0) {
|
|
899
|
+
result.rows = queryResult.rows.slice(0, MAX_DRY_RUN_SAMPLE_ROWS);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch (e) {
|
|
903
|
+
result.success = false;
|
|
904
|
+
result.error = extractDryRunError(e);
|
|
905
|
+
failureCount++;
|
|
906
|
+
if (stopOnError) {
|
|
907
|
+
aborted = true;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const stmtEndTime = process.hrtime.bigint();
|
|
912
|
+
result.executionTimeMs = Math.round(Number(stmtEndTime - stmtStartTime) / 1_000_000 * 100) / 100;
|
|
913
|
+
// Only include results up to maxStatements
|
|
914
|
+
if (statementResults.length < maxStatements) {
|
|
915
|
+
statementResults.push(result);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Always rollback - this is a dry run
|
|
919
|
+
await client.query('ROLLBACK');
|
|
920
|
+
}
|
|
921
|
+
catch (e) {
|
|
922
|
+
// Ensure rollback on any error
|
|
923
|
+
try {
|
|
924
|
+
await client.query('ROLLBACK');
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
// Ignore rollback errors
|
|
928
|
+
}
|
|
929
|
+
throw e;
|
|
930
|
+
}
|
|
931
|
+
finally {
|
|
932
|
+
client.release();
|
|
933
|
+
}
|
|
934
|
+
const endTime = process.hrtime.bigint();
|
|
935
|
+
const executionTimeMs = Math.round(Number(endTime - startTime) / 1_000_000 * 100) / 100;
|
|
936
|
+
// Generate summary
|
|
937
|
+
const typeEntries = Object.entries(statementsByType).sort((a, b) => b[1] - a[1]);
|
|
938
|
+
const typeSummary = typeEntries.map(([type, count]) => `${count} ${type}`).join(', ');
|
|
939
|
+
let summary = `Dry-run of ${totalStatements} statement${totalStatements !== 1 ? 's' : ''}: `;
|
|
940
|
+
summary += `${successCount} succeeded, ${failureCount} failed`;
|
|
941
|
+
if (skippedCount > 0) {
|
|
942
|
+
summary += `, ${skippedCount} skipped (non-rollbackable)`;
|
|
943
|
+
}
|
|
944
|
+
summary += '. ';
|
|
945
|
+
if (typeSummary) {
|
|
946
|
+
summary += `Types: ${typeSummary}. `;
|
|
947
|
+
}
|
|
948
|
+
summary += `Total rows affected: ${totalRowsAffected}. `;
|
|
949
|
+
summary += 'All changes rolled back.';
|
|
950
|
+
return {
|
|
951
|
+
success: failureCount === 0,
|
|
952
|
+
filePath: resolvedPath,
|
|
953
|
+
fileSize: stats.size,
|
|
954
|
+
fileSizeFormatted,
|
|
955
|
+
totalStatements,
|
|
956
|
+
successCount,
|
|
957
|
+
failureCount,
|
|
958
|
+
skippedCount,
|
|
959
|
+
totalRowsAffected,
|
|
960
|
+
statementsByType,
|
|
961
|
+
executionTimeMs,
|
|
962
|
+
statementResults,
|
|
963
|
+
nonRollbackableWarnings,
|
|
964
|
+
summary,
|
|
965
|
+
rolledBack: true
|
|
966
|
+
};
|
|
967
|
+
}
|
|
426
968
|
/**
|
|
427
969
|
* Strips leading line comments and block comments from SQL to check if there's actual SQL.
|
|
428
970
|
* Returns empty string if the entire content is just comments.
|
|
@@ -796,6 +1338,198 @@ export async function mutationPreview(args) {
|
|
|
796
1338
|
}
|
|
797
1339
|
return result;
|
|
798
1340
|
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Execute a mutation (INSERT/UPDATE/DELETE) in dry-run mode.
|
|
1343
|
+
* Actually executes the SQL within a transaction, captures real results,
|
|
1344
|
+
* then rolls back so no changes are persisted.
|
|
1345
|
+
*
|
|
1346
|
+
* This provides accurate results including:
|
|
1347
|
+
* - Exact row counts (not estimates)
|
|
1348
|
+
* - Actual errors (constraint violations, triggers, etc.)
|
|
1349
|
+
* - Before/after row states
|
|
1350
|
+
*/
|
|
1351
|
+
export async function mutationDryRun(args) {
|
|
1352
|
+
if (!args.sql || typeof args.sql !== 'string') {
|
|
1353
|
+
throw new Error('sql parameter is required');
|
|
1354
|
+
}
|
|
1355
|
+
const sql = args.sql.trim();
|
|
1356
|
+
const sampleSize = Math.min(args.sampleSize || MAX_DRY_RUN_SAMPLE_ROWS, 20);
|
|
1357
|
+
// Detect mutation type
|
|
1358
|
+
const upperSql = sql.toUpperCase();
|
|
1359
|
+
let mutationType = 'UNKNOWN';
|
|
1360
|
+
if (upperSql.startsWith('UPDATE') || upperSql.match(/^WITH\b.*\bUPDATE\b/s)) {
|
|
1361
|
+
mutationType = 'UPDATE';
|
|
1362
|
+
}
|
|
1363
|
+
else if (upperSql.startsWith('DELETE') || upperSql.match(/^WITH\b.*\bDELETE\b/s)) {
|
|
1364
|
+
mutationType = 'DELETE';
|
|
1365
|
+
}
|
|
1366
|
+
else if (upperSql.startsWith('INSERT') || upperSql.match(/^WITH\b.*\bINSERT\b/s)) {
|
|
1367
|
+
mutationType = 'INSERT';
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
throw new Error('SQL must be an INSERT, UPDATE, or DELETE statement');
|
|
1371
|
+
}
|
|
1372
|
+
// Check for non-rollbackable operations
|
|
1373
|
+
const nonRollbackableWarnings = detectNonRollbackableOperations(sql);
|
|
1374
|
+
const mustSkipWarnings = nonRollbackableWarnings.filter(w => w.mustSkip);
|
|
1375
|
+
// Skip only if there are mustSkip warnings
|
|
1376
|
+
if (mustSkipWarnings.length > 0) {
|
|
1377
|
+
const skipReason = mustSkipWarnings.map(w => w.message).join('; ');
|
|
1378
|
+
// Run EXPLAIN to show query plan without executing
|
|
1379
|
+
let explainPlan;
|
|
1380
|
+
const dbManager = getDbManager();
|
|
1381
|
+
try {
|
|
1382
|
+
const explainResult = await dbManager.query(`EXPLAIN (FORMAT JSON) ${sql}`);
|
|
1383
|
+
if (explainResult.rows && explainResult.rows.length > 0) {
|
|
1384
|
+
explainPlan = explainResult.rows[0]['QUERY PLAN'];
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
// Ignore EXPLAIN errors
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
mutationType,
|
|
1392
|
+
success: true, // Not a failure, just skipped
|
|
1393
|
+
skipped: true,
|
|
1394
|
+
skipReason,
|
|
1395
|
+
rowsAffected: 0,
|
|
1396
|
+
nonRollbackableWarnings,
|
|
1397
|
+
explainPlan,
|
|
1398
|
+
warnings: nonRollbackableWarnings.map(w => w.message)
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
// Extract table and WHERE clause
|
|
1402
|
+
let targetTable;
|
|
1403
|
+
let whereClause;
|
|
1404
|
+
if (mutationType === 'UPDATE') {
|
|
1405
|
+
const updateMatch = sql.match(/UPDATE\s+(["`]?[\w.]+["`]?)\s+SET/i);
|
|
1406
|
+
const whereMatch = sql.match(/\bWHERE\s+(.+?)(?:RETURNING|$)/is);
|
|
1407
|
+
targetTable = updateMatch?.[1]?.replace(/["`]/g, '');
|
|
1408
|
+
whereClause = whereMatch?.[1]?.trim();
|
|
1409
|
+
}
|
|
1410
|
+
else if (mutationType === 'DELETE') {
|
|
1411
|
+
const deleteMatch = sql.match(/DELETE\s+FROM\s+(["`]?[\w.]+["`]?)/i);
|
|
1412
|
+
const whereMatch = sql.match(/\bWHERE\s+(.+?)(?:RETURNING|$)/is);
|
|
1413
|
+
targetTable = deleteMatch?.[1]?.replace(/["`]/g, '');
|
|
1414
|
+
whereClause = whereMatch?.[1]?.trim();
|
|
1415
|
+
}
|
|
1416
|
+
else if (mutationType === 'INSERT') {
|
|
1417
|
+
const insertMatch = sql.match(/INSERT\s+INTO\s+(["`]?[\w.]+["`]?)/i);
|
|
1418
|
+
targetTable = insertMatch?.[1]?.replace(/["`]/g, '');
|
|
1419
|
+
}
|
|
1420
|
+
const dbManager = getDbManager();
|
|
1421
|
+
const client = await dbManager.getClient();
|
|
1422
|
+
const startTime = process.hrtime.bigint();
|
|
1423
|
+
let beforeRows;
|
|
1424
|
+
let affectedRows = [];
|
|
1425
|
+
let rowsAffected = 0;
|
|
1426
|
+
let error;
|
|
1427
|
+
let success = false;
|
|
1428
|
+
const warnings = [];
|
|
1429
|
+
try {
|
|
1430
|
+
// Start transaction
|
|
1431
|
+
await client.query('BEGIN');
|
|
1432
|
+
// For UPDATE/DELETE, capture "before" state
|
|
1433
|
+
if ((mutationType === 'UPDATE' || mutationType === 'DELETE') && targetTable) {
|
|
1434
|
+
try {
|
|
1435
|
+
const beforeSql = whereClause
|
|
1436
|
+
? `SELECT * FROM ${targetTable} WHERE ${whereClause} LIMIT ${sampleSize}`
|
|
1437
|
+
: `SELECT * FROM ${targetTable} LIMIT ${sampleSize}`;
|
|
1438
|
+
const beforeResult = await client.query(beforeSql);
|
|
1439
|
+
beforeRows = beforeResult.rows;
|
|
1440
|
+
}
|
|
1441
|
+
catch (e) {
|
|
1442
|
+
// Couldn't get before rows, continue anyway
|
|
1443
|
+
warnings.push(`Could not capture before state: ${e instanceof Error ? e.message : String(e)}`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
// Execute the actual mutation
|
|
1447
|
+
// Add RETURNING * if not already present to get affected rows
|
|
1448
|
+
let executeSql = sql;
|
|
1449
|
+
const hasReturning = upperSql.includes('RETURNING');
|
|
1450
|
+
if (!hasReturning && targetTable) {
|
|
1451
|
+
executeSql = `${sql} RETURNING *`;
|
|
1452
|
+
}
|
|
1453
|
+
try {
|
|
1454
|
+
const result = await client.query(executeSql);
|
|
1455
|
+
rowsAffected = result.rowCount || 0;
|
|
1456
|
+
if (result.rows && result.rows.length > 0) {
|
|
1457
|
+
affectedRows = result.rows.slice(0, sampleSize);
|
|
1458
|
+
}
|
|
1459
|
+
success = true;
|
|
1460
|
+
}
|
|
1461
|
+
catch (e) {
|
|
1462
|
+
// If RETURNING failed, try without it
|
|
1463
|
+
if (!hasReturning) {
|
|
1464
|
+
try {
|
|
1465
|
+
const result = await client.query(sql);
|
|
1466
|
+
rowsAffected = result.rowCount || 0;
|
|
1467
|
+
success = true;
|
|
1468
|
+
// Try to get affected rows for UPDATE/DELETE
|
|
1469
|
+
if ((mutationType === 'UPDATE' || mutationType === 'DELETE') && targetTable && whereClause) {
|
|
1470
|
+
const afterSql = `SELECT * FROM ${targetTable} WHERE ${whereClause} LIMIT ${sampleSize}`;
|
|
1471
|
+
const afterResult = await client.query(afterSql);
|
|
1472
|
+
affectedRows = afterResult.rows;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
catch (innerError) {
|
|
1476
|
+
error = extractDryRunError(innerError);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
error = extractDryRunError(e);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
// Always rollback - this is a dry run
|
|
1484
|
+
await client.query('ROLLBACK');
|
|
1485
|
+
}
|
|
1486
|
+
catch (e) {
|
|
1487
|
+
// Ensure rollback on any error
|
|
1488
|
+
try {
|
|
1489
|
+
await client.query('ROLLBACK');
|
|
1490
|
+
}
|
|
1491
|
+
catch {
|
|
1492
|
+
// Ignore rollback errors
|
|
1493
|
+
}
|
|
1494
|
+
error = extractDryRunError(e);
|
|
1495
|
+
}
|
|
1496
|
+
finally {
|
|
1497
|
+
client.release();
|
|
1498
|
+
}
|
|
1499
|
+
const endTime = process.hrtime.bigint();
|
|
1500
|
+
const executionTimeMs = Number(endTime - startTime) / 1_000_000;
|
|
1501
|
+
const result = {
|
|
1502
|
+
mutationType,
|
|
1503
|
+
success,
|
|
1504
|
+
rowsAffected,
|
|
1505
|
+
executionTimeMs: Math.round(executionTimeMs * 100) / 100
|
|
1506
|
+
};
|
|
1507
|
+
if (beforeRows && beforeRows.length > 0) {
|
|
1508
|
+
result.beforeRows = beforeRows;
|
|
1509
|
+
}
|
|
1510
|
+
if (affectedRows.length > 0) {
|
|
1511
|
+
result.affectedRows = affectedRows;
|
|
1512
|
+
}
|
|
1513
|
+
if (targetTable) {
|
|
1514
|
+
result.targetTable = targetTable;
|
|
1515
|
+
}
|
|
1516
|
+
if (whereClause) {
|
|
1517
|
+
result.whereClause = whereClause;
|
|
1518
|
+
}
|
|
1519
|
+
else if (mutationType !== 'INSERT') {
|
|
1520
|
+
warnings.push('No WHERE clause - ALL rows in the table would be affected!');
|
|
1521
|
+
}
|
|
1522
|
+
if (error) {
|
|
1523
|
+
result.error = error;
|
|
1524
|
+
}
|
|
1525
|
+
if (nonRollbackableWarnings.length > 0) {
|
|
1526
|
+
result.nonRollbackableWarnings = nonRollbackableWarnings;
|
|
1527
|
+
}
|
|
1528
|
+
if (warnings.length > 0) {
|
|
1529
|
+
result.warnings = warnings;
|
|
1530
|
+
}
|
|
1531
|
+
return result;
|
|
1532
|
+
}
|
|
799
1533
|
/**
|
|
800
1534
|
* Execute multiple SQL queries in parallel.
|
|
801
1535
|
* Returns results keyed by query name.
|