@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.
@@ -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
- const sqlContent = fs.readFileSync(resolvedPath, 'utf-8');
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.